diff --git a/cmd/pterm.go b/cmd/pterm.go index eeff4b79..28ef6c6e 100644 --- a/cmd/pterm.go +++ b/cmd/pterm.go @@ -2,16 +2,20 @@ package cmd import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "os" "os/exec" + "strings" "sync/atomic" + "time" "connectrpc.com/connect" - tea "github.com/charmbracelet/bubbletea" "github.com/overmindtech/cli/tracing" "github.com/overmindtech/sdp-go" + "github.com/overmindtech/sdp-go/auth" "github.com/pterm/pterm" log "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" @@ -27,7 +31,7 @@ func PTermSetup() { // disrupting bubbletea rendering (and potentially getting overwritten). // Otherwise, when TEABUG is set, log to a file. if len(os.Getenv("TEABUG")) > 0 { - f, err := tea.LogToFile("teabug.log", "debug") + f, err := os.OpenFile("teabug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:gomnd if err != nil { fmt.Println("fatal:", err) os.Exit(1) @@ -179,3 +183,125 @@ func RunApply(ctx context.Context, args []string) error { return nil } + +func snapshotDetail(state string, items, edges uint32) string { + itemStr := "" + if items == 0 { + itemStr = "0 items" + } else if items == 1 { + itemStr = "1 item" + } else { + itemStr = fmt.Sprintf("%d items", items) + } + + edgeStr := "" + if edges == 0 { + edgeStr = "0 edges" + } else if edges == 1 { + edgeStr = "1 edge" + } else { + edgeStr = fmt.Sprintf("%d edges", edges) + } + + detailStr := state + if itemStr != "" || edgeStr != "" { + detailStr = fmt.Sprintf("%s (%s, %s)", state, itemStr, edgeStr) + } + return detailStr +} + +func natsOptions(ctx context.Context, oi OvermindInstance, token *oauth2.Token) auth.NATSOptions { + hostname, err := os.Hostname() + if err != nil { + hostname = "localhost" + } + + natsNamePrefix := "overmind-cli" + + openapiUrl := *oi.ApiUrl + openapiUrl.Path = "/api" + tokenClient := auth.NewOAuthTokenClientWithContext( + ctx, + openapiUrl.String(), + "", + oauth2.StaticTokenSource(token), + ) + + return auth.NATSOptions{ + NumRetries: 3, + RetryDelay: 1 * time.Second, + Servers: []string{oi.NatsUrl.String()}, + ConnectionName: fmt.Sprintf("%v.%v", natsNamePrefix, hostname), + ConnectionTimeout: (10 * time.Second), // TODO: Make configurable + MaxReconnects: -1, + ReconnectWait: 1 * time.Second, + ReconnectJitter: 1 * time.Second, + TokenClient: tokenClient, + } +} + +func HasScopesFlexible(token *oauth2.Token, requiredScopes []string) (bool, string, error) { + if token == nil { + return false, "", errors.New("HasScopesFlexible: token is nil") + } + + claims, err := extractClaims(token.AccessToken) + if err != nil { + return false, "", fmt.Errorf("error extracting claims from token: %w", err) + } + + for _, scope := range requiredScopes { + if !claims.HasScope(scope) { + // If they don't have the *exact* scope, check to see if they have + // write access to the same service + sections := strings.Split(scope, ":") + var hasWriteInstead bool + + if len(sections) == 2 { + service, action := sections[0], sections[1] + + if action == "read" { + hasWriteInstead = claims.HasScope(fmt.Sprintf("%v:write", service)) + } + } + + if !hasWriteInstead { + return false, scope, nil + } + } + } + + return true, "", nil +} + +// extracts custom claims from a JWT token. Note that this does not verify the +// signature of the token, it just extracts the claims from the payload +func extractClaims(token string) (*sdp.CustomClaims, error) { + // We aren't interested in checking the signature of the token since + // the server will do that. All we need to do is make sure it + // contains the right scopes. Therefore we just parse the payload + // directly + sections := strings.Split(token, ".") + + if len(sections) != 3 { + return nil, errors.New("token is not a JWT") + } + + // Decode the payload + decodedPayload, err := base64.RawURLEncoding.DecodeString(sections[1]) + + if err != nil { + return nil, fmt.Errorf("error decoding token payload: %w", err) + } + + // Parse the payload + claims := new(sdp.CustomClaims) + + err = json.Unmarshal(decodedPayload, claims) + + if err != nil { + return nil, fmt.Errorf("error parsing token payload: %w", err) + } + + return claims, nil +} diff --git a/cmd/root.go b/cmd/root.go index 907ac58c..e7de6ac7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,13 +12,17 @@ import ( "os" "os/signal" "path" + "path/filepath" "strings" "syscall" "time" + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" "connectrpc.com/connect" - tea "github.com/charmbracelet/bubbletea" "github.com/getsentry/sentry-go" + "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/overmindtech/cli/tracing" "github.com/overmindtech/sdp-go" @@ -249,82 +253,6 @@ func Execute() { } } -type statusMsg int - -const ( - PromptUser statusMsg = 0 - WaitingForConfirmation statusMsg = 1 - Authenticated statusMsg = 2 - ErrorAuthenticating statusMsg = 3 -) - -type authenticateModel struct { - ctx context.Context - - status statusMsg - err error - deviceCode *oauth2.DeviceAuthResponse - config oauth2.Config - token *oauth2.Token - - width int -} - -func (m authenticateModel) Init() tea.Cmd { - return openBrowserCmd(m.deviceCode.VerificationURI) -} - -func (m authenticateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case tea.KeyMsg: - switch msg.String() { - default: - { - if m.status == Authenticated { - return m, tea.Quit - } - } - case "ctrl+c", "q": - return m, tea.Quit - } - - case *oauth2.Token: - m.status = Authenticated - m.token = msg - - case statusMsg: - switch msg { - case PromptUser: - cmds = append(cmds, openBrowserCmd(m.deviceCode.VerificationURI)) - case WaitingForConfirmation: - m.status = WaitingForConfirmation - cmds = append(cmds, awaitToken(m.ctx, m.config, m.deviceCode)) - case Authenticated: - case ErrorAuthenticating: - } - - case displayAuthorizationInstructionsMsg: - m.status = WaitingForConfirmation - cmds = append(cmds, awaitToken(m.ctx, m.config, m.deviceCode)) - - case failedToAuthenticateErrorMsg: - m.err = msg.err - m.status = ErrorAuthenticating - cmds = append(cmds, tea.Quit) - - case errMsg: - m.err = msg.err - cmds = append(cmds, tea.Quit) - } - - return m, tea.Batch(cmds...) -} - const beginAuthMessage string = `# Authenticate with a browser Attempting to automatically open the SSO authorization page in your default browser. @@ -337,47 +265,6 @@ Then enter the code: %v ` -func (m authenticateModel) View() string { - var output string - - switch m.status { - case PromptUser, WaitingForConfirmation: - prompt := fmt.Sprintf(beginAuthMessage, m.deviceCode.VerificationURI, m.deviceCode.UserCode) - output = markdownToString(m.width, prompt) - - case Authenticated: - output = wrap(RenderOk()+" Authenticated successfully. Press any key to continue.", m.width-4, 2) - case ErrorAuthenticating: - output = wrap(RenderErr()+" Unable to authenticate. Please try again.", m.width-4, 2) - } - - return containerStyle.Render(output) -} - -type errMsg struct{ err error } -type failedToAuthenticateErrorMsg struct{ err error } - -func openBrowserCmd(url string) tea.Cmd { - return func() tea.Msg { - err := browser.OpenURL(url) - if err != nil { - return displayAuthorizationInstructionsMsg{deviceCode: nil, err: err} - } - return WaitingForConfirmation - } -} - -func awaitToken(ctx context.Context, config oauth2.Config, deviceCode *oauth2.DeviceAuthResponse) tea.Cmd { - return func() tea.Msg { - token, err := config.DeviceAccessToken(ctx, deviceCode) - if err != nil { - return failedToAuthenticateErrorMsg{err} - } - - return token - } -} - // getChangeUuid returns the UUID of a change, as selected by --uuid or --change, or a state with the specified status and having --ticket-link func getChangeUuid(ctx context.Context, oi OvermindInstance, expectedStatus sdp.ChangeStatus, ticketLink string, errNotFound bool) (uuid.UUID, error) { var changeUuid uuid.UUID @@ -608,6 +495,261 @@ func login(ctx context.Context, cmd *cobra.Command, scopes []string, writer io.W return ctx, oi, token, nil } +func ensureToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (context.Context, *oauth2.Token, error) { + var token *oauth2.Token + var err error + + // get a token from the api key if present + if apiKey := viper.GetString("api-key"); apiKey != "" { + token, err = getAPIKeyToken(ctx, oi, apiKey, requiredScopes) + } else { + token, err = getOauthToken(ctx, oi, requiredScopes) + } + if err != nil { + return ctx, nil, fmt.Errorf("error getting token: %w", err) + } + + // Check that we actually got the claims we asked for. If you don't have + // permission auth0 will just not assign those scopes rather than fail + ok, missing, err := HasScopesFlexible(token, requiredScopes) + if err != nil { + return ctx, nil, fmt.Errorf("error checking token scopes: %w", err) + } + if !ok { + return ctx, nil, fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", missing) + } + + // store the token for later use by sdp-go's auth client. Note that this + // loses access to the RefreshToken and could be done better by using an + // oauth2.TokenSource, but this would require more work on updating sdp-go + // that is currently not scheduled + ctx = context.WithValue(ctx, sdp.UserTokenContextKey{}, token.AccessToken) + + return ctx, token, nil +} + +// Gets a token from Oauth with the required scopes. This method will also cache +// that token locally for use later, and will use the cached token if possible +func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (*oauth2.Token, error) { + var localScopes []string + + // Check for a locally saved token in ~/.overmind + if home, err := os.UserHomeDir(); err == nil { + var localToken *oauth2.Token + + localToken, localScopes, err = readLocalToken(home, requiredScopes) + + if err != nil { + log.WithContext(ctx).Debugf("Error reading local token, ignoring: %v", err) + } else { + // If we already have the right scopes, return the token + return localToken, nil + } + } + + // If we need to get a new token, request the required scopes on top of + // whatever ones the current local, valid token has so that we don't + // keep replacing it + requestScopes := append(requiredScopes, localScopes...) + + // Authenticate using the oauth device authorization flow + config := oauth2.Config{ + ClientID: oi.CLIClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://%v/authorize", oi.Auth0Domain), + TokenURL: fmt.Sprintf("https://%v/oauth/token", oi.Auth0Domain), + DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", oi.Auth0Domain), + }, + Scopes: requestScopes, + } + + deviceCode, err := config.DeviceAuth(ctx, + oauth2.SetAuthURLParam("audience", oi.Audience), + oauth2.AccessTypeOffline, + ) + if err != nil { + return nil, fmt.Errorf("error getting device code: %w", err) + } + + var token *oauth2.Token + + statusParagraph := pterm.DefaultParagraph.WithMaxWidth(MAX_TERMINAL_WIDTH) + err = browser.OpenURL(deviceCode.VerificationURI) + if err != nil { + statusParagraph.Println(RenderErr() + " Unable to open browser: " + err.Error()) + } + pterm.Print( + markdownToString(MAX_TERMINAL_WIDTH, fmt.Sprintf( + beginAuthMessage, + deviceCode.VerificationURI, + deviceCode.UserCode, + ))) + + token, err = config.DeviceAccessToken(ctx, deviceCode) + if err != nil { + statusParagraph.Println(RenderErr() + " Unable to authenticate. Please try again.") + log.WithContext(ctx).WithError(err).Error("Error getting device code") + os.Exit(1) + } + pterm.Println(RenderOk() + " Authenticated successfully. Press any key to continue.\n") + err = keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { + key := keyInfo.Code + if key == keys.CtrlC { + statusParagraph.Println(RenderErr() + " Cancelled") + os.Exit(1) + } + log.WithField("key", key).Debug("Received keyboard input") + return true, nil + }) + if err != nil { + statusParagraph.Println(RenderErr() + " Error reading keyboard input: " + err.Error()) + os.Exit(1) + } + + if token == nil { + statusParagraph.Println(RenderErr() + " Error running program: no token received") + os.Exit(1) + } + + tok, err := josejwt.ParseSigned(token.AccessToken, []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + statusParagraph.Println("Error running program: received invalid token:", err) + os.Exit(1) + } + out := josejwt.Claims{} + customClaims := sdp.CustomClaims{} + err = tok.UnsafeClaimsWithoutVerification(&out, &customClaims) + if err != nil { + statusParagraph.Println("Error running program: received unparsable token:", err) + os.Exit(1) + } + + if cmdSpan != nil { + cmdSpan.SetAttributes( + attribute.Bool("ovm.cli.authenticated", true), + attribute.String("ovm.cli.accountName", customClaims.AccountName), + attribute.String("ovm.cli.userId", out.Subject), + ) + } + + // Save the token locally + if home, err := os.UserHomeDir(); err == nil { + // Create the directory if it doesn't exist + err = os.MkdirAll(filepath.Join(home, ".overmind"), 0700) + if err != nil { + log.WithContext(ctx).WithError(err).Error("Failed to create ~/.overmind directory") + } + + // Write the token to a file + path := filepath.Join(home, ".overmind", "token.json") + file, err := os.Create(path) + if err != nil { + log.WithContext(ctx).WithError(err).Errorf("Failed to create token file at %v", path) + } + + // Encode the token + err = json.NewEncoder(file).Encode(token) + if err != nil { + log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path) + } + + log.WithContext(ctx).Debugf("Saved token to %v", path) + } + + return token, nil +} + +// Gets a token using an API key +func getAPIKeyToken(ctx context.Context, oi OvermindInstance, apiKey string, requiredScopes []string) (*oauth2.Token, error) { + log.WithContext(ctx).Debug("using provided token for authentication") + + var token *oauth2.Token + + if !strings.HasPrefix(apiKey, "ovm_api_") { + return nil, errors.New("OVM_API_KEY does not match pattern 'ovm_api_*'") + } + + // exchange api token for JWT + client := UnauthenticatedApiKeyClient(ctx, oi) + resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{ + Msg: &sdp.ExchangeKeyForTokenRequest{ + ApiKey: apiKey, + }, + }) + if err != nil { + return nil, fmt.Errorf("error authenticating the API token: %w", err) + } + log.WithContext(ctx).Debug("successfully got a token from the API key") + + token = &oauth2.Token{ + AccessToken: resp.Msg.GetAccessToken(), + TokenType: "Bearer", + } + + // Check that we actually got the claims we asked for. If you don't have + // permission auth0 will just not assign those scopes rather than fail + ok, missing, err := HasScopesFlexible(token, requiredScopes) + if err != nil { + return nil, fmt.Errorf("error checking token scopes: %w", err) + } + if !ok { + return nil, fmt.Errorf("authenticated successfully, but your API key is missing this permission: '%v'", missing) + } + + return token, nil +} + +func readLocalToken(homeDir string, requiredScopes []string) (*oauth2.Token, []string, error) { + // Read in the token JSON file + path := filepath.Join(homeDir, ".overmind", "token.json") + + token := new(oauth2.Token) + + // Check that the file exists + if _, err := os.Stat(path); err != nil { + return nil, nil, err + } + + // Read the file + file, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("error opening token file at %v: %w", path, err) + } + + // Decode the file + err = json.NewDecoder(file).Decode(token) + if err != nil { + return nil, nil, fmt.Errorf("error decoding token file at %v: %w", path, err) + } + + // Check to see if the token is still valid + if !token.Valid() { + return nil, nil, errors.New("token is no longer valid") + } + + claims, err := extractClaims(token.AccessToken) + if err != nil { + return nil, nil, fmt.Errorf("error extracting claims from token: %w", err) + } + if claims.Scope == "" { + return nil, nil, errors.New("token does not have any scopes") + } + + currentScopes := strings.Split(claims.Scope, " ") + + // Check that we actually got the claims we asked for. + ok, missing, err := HasScopesFlexible(token, requiredScopes) + if err != nil { + return nil, currentScopes, fmt.Errorf("error checking token scopes: %w", err) + } + if !ok { + return nil, currentScopes, fmt.Errorf("local token is missing this permission: '%v'", missing) + } + + log.Debugf("Using local token from %v", path) + return token, currentScopes, nil +} + func getAppUrl(frontend, app string) string { if frontend == "" && app == "" { return "https://app.overmind.tech" diff --git a/cmd/tea.go b/cmd/tea.go deleted file mode 100644 index 83715f34..00000000 --- a/cmd/tea.go +++ /dev/null @@ -1,131 +0,0 @@ -package cmd - -// this file contains a bunch of general helpers for building commands based on the bubbletea framework - -import ( - "context" - "fmt" - "net/url" - "os" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/overmindtech/cli/tracing" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "golang.org/x/oauth2" -) - -type OvermindCommandHandler func(ctx context.Context, args []string, oi OvermindInstance, token *oauth2.Token) error - -// viperGetApp fetches and validates the configured app url -func viperGetApp(ctx context.Context) (string, error) { - app := viper.GetString("app") - - // Check to see if the URL is secure - parsed, err := url.Parse(app) - if err != nil { - log.WithContext(ctx).WithError(err).Error("Failed to parse --app") - return "", fmt.Errorf("error parsing --app: %w", err) - } - - if !(parsed.Scheme == "wss" || parsed.Scheme == "https" || parsed.Hostname() == "localhost") { - return "", fmt.Errorf("target URL (%v) is insecure", parsed) - } - return app, nil -} - -type FinalReportingModel interface { - FinalReport() string -} - -func CmdWrapper(action string, requiredScopes []string, commandModel func(args []string, parent *cmdModel, width int) tea.Model) func(cmd *cobra.Command, args []string) { - return func(cmd *cobra.Command, args []string) { - // set up a context for the command - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() - - cmdName := fmt.Sprintf("CLI %v", cmd.CommandPath()) - defer tracing.LogRecoverToExit(ctx, cmdName) - - // ensure that only error messages are printed to the console, - // disrupting bubbletea rendering (and potentially getting overwritten). - // Otherwise, when TEABUG is set, log to a file. - if len(os.Getenv("TEABUG")) > 0 { - f, err := tea.LogToFile("teabug.log", "debug") - if err != nil { - fmt.Println("fatal:", err) - os.Exit(1) - } - // leave the log file open until the very last moment, so we capture everything - // defer f.Close() - log.SetOutput(f) - formatter := new(log.TextFormatter) - formatter.DisableTimestamp = false - log.SetFormatter(formatter) - viper.Set("log", "trace") - log.SetLevel(log.TraceLevel) - } else { - // avoid log messages from sources and others to interrupt bubbletea rendering - viper.Set("log", "fatal") - log.SetLevel(log.FatalLevel) - } - - // wrap the rest of the function in a closure to allow for cleaner error handling and deferring. - err := func() error { - timeout, err := time.ParseDuration(viper.GetString("timeout")) - if err != nil { - return flagError{usage: fmt.Sprintf("invalid --timeout value '%v'\n\n%v", viper.GetString("timeout"), cmd.UsageString())} - } - - app, err := viperGetApp(ctx) - if err != nil { - return err - } - - m := cmdModel{ - action: action, - ctx: ctx, - cancel: cancel, - timeout: timeout, - app: app, - requiredScopes: requiredScopes, - args: args, - apiKey: viper.GetString("api-key"), - tasks: map[string]tea.Model{}, - } - m.cmd = commandModel(args, &m, m.width) - - options := []tea.ProgramOption{} - if os.Getenv("CI") != "" { - // See https://github.com/charmbracelet/bubbletea/issues/761#issuecomment-1625863769 - options = append(options, tea.WithInput(nil)) - } - p := tea.NewProgram(&m, options...) - result, err := p.Run() - if err != nil { - return fmt.Errorf("could not start program: %w", err) - } - - cmd, ok := result.(*cmdModel) - if ok { - frm, ok := cmd.cmd.(FinalReportingModel) - if ok { - fmt.Println(frm.FinalReport()) - } - } - - return nil - }() - if err != nil { - log.WithContext(ctx).WithError(err).Error("Error running command") - // don't forget that os.Exit() does not wait for telemetry to be flushed - if cmdSpan != nil { - cmdSpan.End() - } - tracing.ShutdownTracer() - os.Exit(1) - } - } -} diff --git a/cmd/tea_ensuretoken.go b/cmd/tea_ensuretoken.go deleted file mode 100644 index a2ebda00..00000000 --- a/cmd/tea_ensuretoken.go +++ /dev/null @@ -1,766 +0,0 @@ -package cmd - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "atomicgo.dev/keyboard" - "atomicgo.dev/keyboard/keys" - "connectrpc.com/connect" - tea "github.com/charmbracelet/bubbletea" - "github.com/go-jose/go-jose/v4" - josejwt "github.com/go-jose/go-jose/v4/jwt" - "github.com/overmindtech/sdp-go" - "github.com/pkg/browser" - "github.com/pterm/pterm" - log "github.com/sirupsen/logrus" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" - "golang.org/x/oauth2" -) - -type tokenLoadedMsg struct{ token *oauth2.Token } -type displayAuthorizationInstructionsMsg struct { - config oauth2.Config - deviceCode *oauth2.DeviceAuthResponse - err error -} -type waitingForAuthorizationMsg struct { - config oauth2.Config - deviceCode *oauth2.DeviceAuthResponse -} -type tokenReceivedMsg struct { - token *oauth2.Token -} -type tokenStoredMsg struct { - tokenReceivedMsg - file string -} -type tokenAvailableMsg struct { - token *oauth2.Token -} - -// this tea.Model uses the apiKey to request a fresh auth0 token from the -// api-server. If no apiKey is available it either loads the auth0 token from a -// config file, or drives an interactive device authorization flow to get a new -// token. Results are delivered as either a tokenAvailableMsg or a fatalError. -type ensureTokenModel struct { - taskModel - - ctx context.Context - apiKey string - app string - oi OvermindInstance - requiredScopes []string - - errors []string - - deviceMessage string - deviceConfig oauth2.Config - deviceCode *oauth2.DeviceAuthResponse - deviceError error - - width int -} - -func NewEnsureTokenModel(ctx context.Context, app string, apiKey string, requiredScopes []string, width int) tea.Model { - return ensureTokenModel{ - ctx: ctx, - app: app, - apiKey: apiKey, - requiredScopes: requiredScopes, - - taskModel: NewTaskModel("Ensuring Token", width), - - errors: []string{}, - - width: width, - } -} - -func (m ensureTokenModel) TaskModel() taskModel { - return m.taskModel -} - -func (m ensureTokenModel) Init() tea.Cmd { - return m.taskModel.Init() -} - -func (m ensureTokenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case instanceLoadedMsg: - m.oi = msg.instance - m.status = taskStatusRunning - cmds = append(cmds, m.ensureTokenCmd(m.ctx)) - if os.Getenv("CI") == "" { - cmds = append(cmds, m.spinner.Tick) - } - case displayAuthorizationInstructionsMsg: - m.deviceMessage = "manual" - m.deviceConfig = msg.config - m.deviceCode = msg.deviceCode - m.deviceError = msg.err - - m.status = taskStatusDone // avoid console flickering to allow click to be registered - m.title = "Manual device authorization." - cmds = append(cmds, m.awaitTokenCmd) - case waitingForAuthorizationMsg: - m.deviceMessage = "browser" - m.deviceConfig = msg.config - m.deviceCode = msg.deviceCode - - m.title = "Waiting for device authorization, check your browser." - - cmds = append(cmds, m.awaitTokenCmd) - case tokenLoadedMsg: - m.status = taskStatusDone - m.title = "Using stored token" - m.deviceMessage = "" - cmds = append(cmds, m.tokenAvailable(msg.token)) - - if cmdSpan != nil { - cmdSpan.AddEvent("User Authenticated", trace.WithAttributes( - attribute.String("ovm.auth.mechanism", "Token"), - )) - } - case tokenReceivedMsg: - m.status = taskStatusDone - m.title = "Authentication successful, using API key" - m.deviceMessage = "" - cmds = append(cmds, m.tokenAvailable(msg.token)) - - if cmdSpan != nil { - cmdSpan.AddEvent("User Authenticated", trace.WithAttributes( - attribute.String("ovm.auth.mechanism", "API Key"), - )) - } - case tokenStoredMsg: - m.status = taskStatusDone - m.title = fmt.Sprintf("Authentication successful, token stored locally (%v)", msg.file) - m.deviceMessage = "" - cmds = append(cmds, m.tokenAvailable(msg.token)) - - if cmdSpan != nil { - cmdSpan.AddEvent("User Authenticated", trace.WithAttributes( - attribute.String("ovm.auth.mechanism", "Browser"), - )) - } - case otherError: - if msg.id == m.spinner.ID() { - m.errors = append(m.errors, fmt.Sprintf("Note: %v", msg.err)) - } - } - - var taskCmd tea.Cmd - m.taskModel, taskCmd = m.taskModel.Update(msg) - cmds = append(cmds, taskCmd) - - return m, tea.Batch(cmds...) -} - -func (m ensureTokenModel) View() string { - bits := []string{m.taskModel.View()} - - for _, err := range m.errors { - bits = append(bits, wrap(fmt.Sprintf(" %v", err), m.width, 2)) - } - switch m.deviceMessage { - case "manual": - beginAuthMessage := `# Authenticate with a browser - -Automatically opening the SSO authorization page in your default browser failed: %v - -Please open the following URL in your browser to authenticate: - -%v - -Then enter the code: - - %v -` - bits = append(bits, markdownToString(m.width, fmt.Sprintf(beginAuthMessage, m.deviceError, m.deviceCode.VerificationURI, m.deviceCode.UserCode))) - case "browser": - beginAuthMessage := `# Authenticate with a browser - -Attempting to automatically open the SSO authorization page in your default browser. -If the browser does not open or you wish to use a different device to authorize this request, open the following URL: - -%v - -Then enter the code: - - %v -` - bits = append(bits, markdownToString(m.width, fmt.Sprintf(beginAuthMessage, m.deviceCode.VerificationURI, m.deviceCode.UserCode))) - } - return strings.Join(bits, "\n") -} - -// ensureTokenCmd gets a token from the environment or from the user, and returns a -// context holding the token that can be used by sdp-go's helper functions to -// authenticate against the API -func (m ensureTokenModel) ensureTokenCmd(ctx context.Context) tea.Cmd { - if viper.GetString("ovm-test-fake") != "" { - return func() tea.Msg { - return displayAuthorizationInstructionsMsg{ - deviceCode: &oauth2.DeviceAuthResponse{ - DeviceCode: "test-device-code", - VerificationURI: "https://example.com/verify", - VerificationURIComplete: "https://example.com/verify-complete", - }, - err: errors.New("test error"), - } - } - } - - if m.apiKey == "" { - log.WithContext(ctx).Debug("getting token from Oauth") - return m.oauthTokenCmd - } else { - log.WithContext(ctx).Debug("getting token from API key") - return m.getAPIKeyTokenCmd - } -} - -// Gets a token from Oauth with the required scopes. This method will also cache -// that token locally for use later, and will use the cached token if possible -func (m ensureTokenModel) oauthTokenCmd() tea.Msg { - var localScopes []string - - // Check for a locally saved token in ~/.overmind - if home, err := os.UserHomeDir(); err == nil { - var localToken *oauth2.Token - - localToken, localScopes, err = readLocalToken(home, m.requiredScopes) - - if err != nil { - log.WithContext(m.ctx).Debugf("Error reading local token, ignoring: %v", err) - } else { - // If we already have the right scopes, return the token - return tokenLoadedMsg{token: localToken} - } - } - - if m.oi.CLIClientID == "" || m.oi.Auth0Domain == "" { - return fatalError{id: m.spinner.ID(), err: errors.New("missing client id or auth0 domain")} - } - - // If we need to get a new token, request the required scopes on top of - // whatever ones the current local, valid token has so that we don't - // keep replacing it - requestScopes := append(m.requiredScopes, localScopes...) - - // Authenticate using the oauth device authorization flow - config := oauth2.Config{ - ClientID: m.oi.CLIClientID, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("https://%v/authorize", m.oi.Auth0Domain), - TokenURL: fmt.Sprintf("https://%v/oauth/token", m.oi.Auth0Domain), - DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", m.oi.Auth0Domain), - }, - Scopes: requestScopes, - } - - deviceCode, err := config.DeviceAuth(m.ctx, - oauth2.SetAuthURLParam("audience", m.oi.Audience), - oauth2.AccessTypeOffline, - ) - if err != nil { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error getting device code: %w", err)} - } - - err = browser.OpenURL(deviceCode.VerificationURIComplete) - if err != nil { - return displayAuthorizationInstructionsMsg{config, deviceCode, err} - } - return waitingForAuthorizationMsg{config, deviceCode} -} - -func (m ensureTokenModel) awaitTokenCmd() tea.Msg { - ctx := m.ctx - if m.deviceCode == nil { - return fatalError{id: m.spinner.ID(), err: errors.New("device code is nil")} - } - - if viper.GetString("ovm-test-fake") != "" { - time.Sleep(500 * time.Millisecond) - token := oauth2.Token{ - AccessToken: "fake access token", - TokenType: "fake", - RefreshToken: "fake refresh token", - Expiry: time.Now().Add(1 * time.Hour), - } - path := "fake token file path" - return tokenStoredMsg{tokenReceivedMsg: tokenReceivedMsg{&token}, file: path} - } - - // if there is an actual expiry, limit the entire process to that time - if !m.deviceCode.Expiry.IsZero() { - var cancel context.CancelFunc - ctx, cancel = context.WithDeadline(ctx, m.deviceCode.Expiry) - defer cancel() - } - - // while the RFC requires the oauth2 library to use 5 as the default, Auth0 - // should be able to handle more. Hence we re-implement the - m.deviceCode.Interval = 1 - - var token *oauth2.Token - var err error - for { - log.Trace("attempting to get token from auth0") - // reset the deviceCode's expiry to at most 1.5 seconds - m.deviceCode.Expiry = time.Now().Add(1500 * time.Millisecond) - - token, err = m.deviceConfig.DeviceAccessToken(ctx, m.deviceCode) - if err == nil { - // we got a token, continue below. kthxbye - log.Trace("we got a token from auth0") - break - } - - // See https://github.com/golang/oauth2/issues/635, - // https://github.com/golang/oauth2/pull/636, - // https://go-review.googlesource.com/c/oauth2/+/476316 - if errors.Is(err, context.DeadlineExceeded) || strings.HasSuffix(err.Error(), "context deadline exceeded") { - // the context has expired, we need to retry - log.WithError(err).Trace("context.DeadlineExceeded - waiting for a second") - time.Sleep(time.Second) - continue - } - - // re-implement DeviceAccessToken's logic, but faster - e, isRetrieveError := err.(*oauth2.RetrieveError) // nolint:errorlint // we depend on DeviceAccessToken() returning an non-wrapped error - if !isRetrieveError { - log.WithError(err).Trace("error authorizing token") - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error authorizing token: %w", err)} - } - - switch e.ErrorCode { - case "slow_down": - // // https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 - // // "the interval MUST be increased by 5 seconds for this and all subsequent requests" - // interval += 5 - // ticker.Reset(time.Duration(interval) * time.Second) - case "authorization_pending": - // retry - case "expired_token": - default: - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error authorizing token (%v): %w", e.ErrorCode, err)} - } - } - - // Save the token locally - home, err := os.UserHomeDir() - if err != nil { - return otherError{id: m.spinner.ID(), err: fmt.Errorf("failed to get home directory: %w", err)} - } - - // Create the directory if it doesn't exist - err = os.MkdirAll(filepath.Join(home, ".overmind"), 0700) - if err != nil { - return otherError{id: m.spinner.ID(), err: fmt.Errorf("failed to create ~/.overmind directory: %w", err)} - } - - // Write the token to a file - path := filepath.Join(home, ".overmind", "token.json") - file, err := os.Create(path) - if err != nil { - return otherError{id: m.spinner.ID(), err: fmt.Errorf("failed to create token file at %v: %w", path, err)} - } - - // Encode the token - err = json.NewEncoder(file).Encode(token) - if err != nil { - return otherError{id: m.spinner.ID(), err: fmt.Errorf("failed to encode token file at %v: %w", path, err)} - } - - log.WithContext(ctx).Debugf("Saved token to %v", path) - return tokenStoredMsg{tokenReceivedMsg: tokenReceivedMsg{token}, file: path} -} - -// Gets a token using an API key -func (m ensureTokenModel) getAPIKeyTokenCmd() tea.Msg { - ctx := m.ctx - - var token *oauth2.Token - - if !strings.HasPrefix(m.apiKey, "ovm_api_") { - return fatalError{id: m.spinner.ID(), err: errors.New("OVM_API_KEY does not match pattern 'ovm_api_*'")} - } - - // exchange api token for JWT - client := UnauthenticatedApiKeyClient(ctx, m.oi) - resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{ - Msg: &sdp.ExchangeKeyForTokenRequest{ - ApiKey: m.apiKey, - }, - }) - if err != nil { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error authenticating the API token: %w", err)} - } - log.WithContext(ctx).Debug("successfully got a token from the API key") - - token = &oauth2.Token{ - AccessToken: resp.Msg.GetAccessToken(), - TokenType: "Bearer", - } - - return tokenReceivedMsg{token} -} - -func (m ensureTokenModel) tokenAvailable(token *oauth2.Token) tea.Cmd { - return func() tea.Msg { - return tokenAvailableMsg{token} - } -} - -///////////////////////////// -// "legacy" non-tea code // -///////////////////////////// - -// Gets a token from Oauth with the required scopes. This method will also cache -// that token locally for use later, and will use the cached token if possible -func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (*oauth2.Token, error) { - var localScopes []string - - // Check for a locally saved token in ~/.overmind - if home, err := os.UserHomeDir(); err == nil { - var localToken *oauth2.Token - - localToken, localScopes, err = readLocalToken(home, requiredScopes) - - if err != nil { - log.WithContext(ctx).Debugf("Error reading local token, ignoring: %v", err) - } else { - // If we already have the right scopes, return the token - return localToken, nil - } - } - - // If we need to get a new token, request the required scopes on top of - // whatever ones the current local, valid token has so that we don't - // keep replacing it - requestScopes := append(requiredScopes, localScopes...) - - // Authenticate using the oauth device authorization flow - config := oauth2.Config{ - ClientID: oi.CLIClientID, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("https://%v/authorize", oi.Auth0Domain), - TokenURL: fmt.Sprintf("https://%v/oauth/token", oi.Auth0Domain), - DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", oi.Auth0Domain), - }, - Scopes: requestScopes, - } - - deviceCode, err := config.DeviceAuth(ctx, - oauth2.SetAuthURLParam("audience", oi.Audience), - oauth2.AccessTypeOffline, - ) - if err != nil { - return nil, fmt.Errorf("error getting device code: %w", err) - } - - var token *oauth2.Token - - statusParagraph := pterm.DefaultParagraph.WithMaxWidth(MAX_TERMINAL_WIDTH) - err = browser.OpenURL(deviceCode.VerificationURI) - if err != nil { - statusParagraph.Println(RenderErr() + " Unable to open browser: " + err.Error()) - } - pterm.Print( - markdownToString(MAX_TERMINAL_WIDTH, fmt.Sprintf( - beginAuthMessage, - deviceCode.VerificationURI, - deviceCode.UserCode, - ))) - - token, err = config.DeviceAccessToken(ctx, deviceCode) - if err != nil { - statusParagraph.Println(RenderErr() + " Unable to authenticate. Please try again.") - log.WithContext(ctx).WithError(err).Error("Error getting device code") - os.Exit(1) - } - pterm.Println(RenderOk() + " Authenticated successfully. Press any key to continue.\n") - err = keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { - key := keyInfo.Code - if key == keys.CtrlC { - statusParagraph.Println(RenderErr() + " Cancelled") - os.Exit(1) - } - log.WithField("key", key).Debug("Received keyboard input") - return true, nil - }) - if err != nil { - statusParagraph.Println(RenderErr() + " Error reading keyboard input: " + err.Error()) - os.Exit(1) - } - - if token == nil { - statusParagraph.Println(RenderErr() + " Error running program: no token received") - os.Exit(1) - } - - tok, err := josejwt.ParseSigned(token.AccessToken, []jose.SignatureAlgorithm{jose.RS256}) - if err != nil { - statusParagraph.Println("Error running program: received invalid token:", err) - os.Exit(1) - } - out := josejwt.Claims{} - customClaims := sdp.CustomClaims{} - err = tok.UnsafeClaimsWithoutVerification(&out, &customClaims) - if err != nil { - statusParagraph.Println("Error running program: received unparsable token:", err) - os.Exit(1) - } - - if cmdSpan != nil { - cmdSpan.SetAttributes( - attribute.Bool("ovm.cli.authenticated", true), - attribute.String("ovm.cli.accountName", customClaims.AccountName), - attribute.String("ovm.cli.userId", out.Subject), - ) - } - - // Save the token locally - if home, err := os.UserHomeDir(); err == nil { - // Create the directory if it doesn't exist - err = os.MkdirAll(filepath.Join(home, ".overmind"), 0700) - if err != nil { - log.WithContext(ctx).WithError(err).Error("Failed to create ~/.overmind directory") - } - - // Write the token to a file - path := filepath.Join(home, ".overmind", "token.json") - file, err := os.Create(path) - if err != nil { - log.WithContext(ctx).WithError(err).Errorf("Failed to create token file at %v", path) - } - - // Encode the token - err = json.NewEncoder(file).Encode(token) - if err != nil { - log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path) - } - - log.WithContext(ctx).Debugf("Saved token to %v", path) - } - - return token, nil -} - -// ensureToken gets a token from the environment or from the user, and returns a -// context holding the token that can be used by sdp-go's helper functions to -// authenticate against the API -func ensureToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (context.Context, *oauth2.Token, error) { - var token *oauth2.Token - var err error - - // get a token from the api key if present - if apiKey := viper.GetString("api-key"); apiKey != "" { - token, err = getAPIKeyToken(ctx, oi, apiKey, requiredScopes) - } else { - token, err = getOauthToken(ctx, oi, requiredScopes) - } - if err != nil { - return ctx, nil, fmt.Errorf("error getting token: %w", err) - } - - // Check that we actually got the claims we asked for. If you don't have - // permission auth0 will just not assign those scopes rather than fail - ok, missing, err := HasScopesFlexible(token, requiredScopes) - if err != nil { - return ctx, nil, fmt.Errorf("error checking token scopes: %w", err) - } - if !ok { - return ctx, nil, fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", missing) - } - - // store the token for later use by sdp-go's auth client. Note that this - // loses access to the RefreshToken and could be done better by using an - // oauth2.TokenSource, but this would require more work on updating sdp-go - // that is currently not scheduled - ctx = context.WithValue(ctx, sdp.UserTokenContextKey{}, token.AccessToken) - - return ctx, token, nil -} - -// Returns whether a set of claims has all of the required scopes. It also -// accounts for when a user has write access but required read access, they -// aren't the same but the user will have access anyway so this will pass -// -// Returns true if the token has the required scopes. Otherwise, false and the missing permission for displaying or logging -func HasScopesFlexible(token *oauth2.Token, requiredScopes []string) (bool, string, error) { - if token == nil { - return false, "", errors.New("HasScopesFlexible: token is nil") - } - - claims, err := extractClaims(token.AccessToken) - if err != nil { - return false, "", fmt.Errorf("error extracting claims from token: %w", err) - } - - for _, scope := range requiredScopes { - if !claims.HasScope(scope) { - // If they don't have the *exact* scope, check to see if they have - // write access to the same service - sections := strings.Split(scope, ":") - var hasWriteInstead bool - - if len(sections) == 2 { - service, action := sections[0], sections[1] - - if action == "read" { - hasWriteInstead = claims.HasScope(fmt.Sprintf("%v:write", service)) - } - } - - if !hasWriteInstead { - return false, scope, nil - } - } - } - - return true, "", nil -} - -// extracts custom claims from a JWT token. Note that this does not verify the -// signature of the token, it just extracts the claims from the payload -func extractClaims(token string) (*sdp.CustomClaims, error) { - // We aren't interested in checking the signature of the token since - // the server will do that. All we need to do is make sure it - // contains the right scopes. Therefore we just parse the payload - // directly - sections := strings.Split(token, ".") - - if len(sections) != 3 { - return nil, errors.New("token is not a JWT") - } - - // Decode the payload - decodedPayload, err := base64.RawURLEncoding.DecodeString(sections[1]) - - if err != nil { - return nil, fmt.Errorf("error decoding token payload: %w", err) - } - - // Parse the payload - claims := new(sdp.CustomClaims) - - err = json.Unmarshal(decodedPayload, claims) - - if err != nil { - return nil, fmt.Errorf("error parsing token payload: %w", err) - } - - return claims, nil -} - -// reads the locally cached token if it exists and is valid returns the token, -// its scopes, and an error if any. The scopes are returned even if they are -// insufficient to allow cached tokens to be added to rather than constantly -// replaced -func readLocalToken(homeDir string, requiredScopes []string) (*oauth2.Token, []string, error) { - // Read in the token JSON file - path := filepath.Join(homeDir, ".overmind", "token.json") - - token := new(oauth2.Token) - - // Check that the file exists - if _, err := os.Stat(path); err != nil { - return nil, nil, err - } - - // Read the file - file, err := os.Open(path) - if err != nil { - return nil, nil, fmt.Errorf("error opening token file at %v: %w", path, err) - } - - // Decode the file - err = json.NewDecoder(file).Decode(token) - if err != nil { - return nil, nil, fmt.Errorf("error decoding token file at %v: %w", path, err) - } - - // Check to see if the token is still valid - if !token.Valid() { - return nil, nil, errors.New("token is no longer valid") - } - - claims, err := extractClaims(token.AccessToken) - if err != nil { - return nil, nil, fmt.Errorf("error extracting claims from token: %w", err) - } - if claims.Scope == "" { - return nil, nil, errors.New("token does not have any scopes") - } - - currentScopes := strings.Split(claims.Scope, " ") - - // Check that we actually got the claims we asked for. - ok, missing, err := HasScopesFlexible(token, requiredScopes) - if err != nil { - return nil, currentScopes, fmt.Errorf("error checking token scopes: %w", err) - } - if !ok { - return nil, currentScopes, fmt.Errorf("local token is missing this permission: '%v'", missing) - } - - log.Debugf("Using local token from %v", path) - return token, currentScopes, nil -} - -// Gets a token using an API key -func getAPIKeyToken(ctx context.Context, oi OvermindInstance, apiKey string, requiredScopes []string) (*oauth2.Token, error) { - log.WithContext(ctx).Debug("using provided token for authentication") - - var token *oauth2.Token - - if !strings.HasPrefix(apiKey, "ovm_api_") { - return nil, errors.New("OVM_API_KEY does not match pattern 'ovm_api_*'") - } - - // exchange api token for JWT - client := UnauthenticatedApiKeyClient(ctx, oi) - resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{ - Msg: &sdp.ExchangeKeyForTokenRequest{ - ApiKey: apiKey, - }, - }) - if err != nil { - return nil, fmt.Errorf("error authenticating the API token: %w", err) - } - log.WithContext(ctx).Debug("successfully got a token from the API key") - - token = &oauth2.Token{ - AccessToken: resp.Msg.GetAccessToken(), - TokenType: "Bearer", - } - - // Check that we actually got the claims we asked for. If you don't have - // permission auth0 will just not assign those scopes rather than fail - ok, missing, err := HasScopesFlexible(token, requiredScopes) - if err != nil { - return nil, fmt.Errorf("error checking token scopes: %w", err) - } - if !ok { - return nil, fmt.Errorf("authenticated successfully, but your API key is missing this permission: '%v'", missing) - } - - return token, nil -} diff --git a/cmd/tea_execcommand.go b/cmd/tea_execcommand.go deleted file mode 100644 index 8c3bea5b..00000000 --- a/cmd/tea_execcommand.go +++ /dev/null @@ -1,93 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "os/exec" - - tea "github.com/charmbracelet/bubbletea" -) - -// NewExecCommand returns a new ExecCommand that will print the last view from -// the parent cmdModel after bubbletea has released the terminal, but before the -// command is run. -func (m *cmdModel) NewExecCommand(c *exec.Cmd) tea.ExecCommand { - return &cliExecCommandModel{ - parent: m, - Cmd: c, - } -} - -// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand -// interface. It prints the last view from -// the parent cmdModel after bubbletea has released the terminal, but before the -// command is run. -type cliExecCommandModel struct { - parent *cmdModel - *exec.Cmd -} - -func (c cliExecCommandModel) Run() error { - _, err := c.Stdout.Write([]byte(c.parent.frozenView)) - if err != nil { - return fmt.Errorf("failed to write view to stdout: %w", err) - } - return c.Cmd.Run() -} - -// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader. -func (c *cliExecCommandModel) SetStdin(r io.Reader) { - // If unset, have the command use the same input as the terminal. - if c.Stdin == nil { - c.Stdin = r - } -} - -// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer. -func (c *cliExecCommandModel) SetStdout(w io.Writer) { - // If unset, have the command use the same output as the terminal. - if c.Stdout == nil { - c.Stdout = w - } -} - -// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer. -func (c *cliExecCommandModel) SetStderr(w io.Writer) { - // If unset, use stderr for the command's stderr - if c.Stderr == nil { - c.Stderr = w - } -} - -// interstitialCommand is a command that will print a string to stdout after -// bubbletea has released the terminal. -type interstitialCommand struct { - parent *cmdModel - text string - stdout io.Writer -} - -// assert that interstitialCommand implements tea.ExecCommand -var _ tea.ExecCommand = (*interstitialCommand)(nil) - -func (m *cmdModel) NewInterstitialCommand(text string) tea.ExecCommand { - return &interstitialCommand{ - parent: m, - text: text, - } -} - -func (c *interstitialCommand) Run() error { - _, err := c.stdout.Write([]byte(c.parent.frozenView)) - if err != nil { - return fmt.Errorf("failed to write view to stdout: %w", err) - } - _, err = fmt.Println(c.text) - return err -} - -func (c *interstitialCommand) SetStdin(io.Reader) {} -func (c *interstitialCommand) SetStdout(stdout io.Writer) { - c.stdout = stdout -} -func (c *interstitialCommand) SetStderr(io.Writer) {} diff --git a/cmd/tea_initialisesources.go b/cmd/tea_initialisesources.go deleted file mode 100644 index b7c183b3..00000000 --- a/cmd/tea_initialisesources.go +++ /dev/null @@ -1,323 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/overmindtech/aws-source/proc" - "github.com/overmindtech/cli/tfutils" - "github.com/overmindtech/sdp-go/auth" - stdlibsource "github.com/overmindtech/stdlib-source/sources" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" - "golang.org/x/oauth2" - "gopkg.in/yaml.v3" -) - -type loadSourcesConfigMsg struct { - ctx context.Context - oi OvermindInstance - action string - token *oauth2.Token - tfArgs []string -} - -type stdlibSourceInitialisedMsg struct{} -type awsSourceInitialisedMsg struct { - providers []tfutils.ProviderResult -} - -type sourcesInitialisedMsg struct{} -type sourceInitialisationFailedMsg struct{ err error } - -// this tea.Model either fetches the AWS auth config from the ConfigService or -// interrogates the user. Results get stored in the ConfigService. Send a -// loadSourcesConfigMsg to start the process. After the sourcesInitialisedMsg -// the viper config has been updated with the values from the ConfigService and -// the sources have successfully loaded and connected to overmind. -type initialiseSourcesModel struct { - taskModel - - ctx context.Context // note that this ctx is not initialized on NewGetConfigModel to instead get a modified context through the loadSourcesConfigMsg that has a timeout and cancelFunction configured - oi OvermindInstance - action string - token *oauth2.Token - - useManagedSources bool - awsSourceRunning bool - awsProviders []tfutils.ProviderResult - stdlibSourceRunning bool - - errorHints []string - - width int -} - -func NewInitialiseSourcesModel(width int) tea.Model { - return initialiseSourcesModel{ - taskModel: NewTaskModel("Configuring AWS Access", width), - - errorHints: []string{}, - } -} - -func (m initialiseSourcesModel) TaskModel() taskModel { - return m.taskModel -} - -func (m initialiseSourcesModel) Init() tea.Cmd { - return m.taskModel.Init() -} - -func (m initialiseSourcesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case loadSourcesConfigMsg: - m.ctx = msg.ctx - m.oi = msg.oi - m.action = msg.action - m.token = msg.token - - m.status = taskStatusRunning - if viper.GetBool("only-use-managed-sources") { - m.useManagedSources = true - cmds = append(cmds, func() tea.Msg { return sourcesInitialisedMsg{} }) - } else { - cmds = append(cmds, m.startStdlibSourceCmd(m.ctx, m.oi, m.token)) - cmds = append(cmds, m.startAwsSourceCmd(m.ctx, m.oi, m.token, msg.tfArgs)) - } - if os.Getenv("CI") == "" { - cmds = append(cmds, m.spinner.Tick) - } - case stdlibSourceInitialisedMsg: - m.stdlibSourceRunning = true - if cmdSpan != nil { - cmdSpan.AddEvent("stdlib source initialised") - } - if m.awsSourceRunning { - cmds = append(cmds, func() tea.Msg { return sourcesInitialisedMsg{} }) - } - case awsSourceInitialisedMsg: - m.awsSourceRunning = true - m.awsProviders = msg.providers - if cmdSpan != nil { - cmdSpan.AddEvent("aws source initialised", trace.WithAttributes( - attribute.Int("ovm.aws.providers", len(msg.providers)), - )) - } - if m.stdlibSourceRunning { - cmds = append(cmds, func() tea.Msg { return sourcesInitialisedMsg{} }) - } - case sourcesInitialisedMsg: - m.status = taskStatusDone - case sourceInitialisationFailedMsg: - m.status = taskStatusError - m.errorHints = append(m.errorHints, "Error initialising sources") - cmds = append(cmds, func() tea.Msg { - // create a fatalError for aborting the CLI and common error detail - // reporting, but don't pass in the spinner ID, to avoid double - // reporting in this model's View - return fatalError{err: fmt.Errorf("failed to initialise sources: %w", msg.err)} - }) - case otherError: - if msg.id == m.spinner.ID() { - m.errorHints = append(m.errorHints, fmt.Sprintf("Note: %v", msg.err)) - } - case fatalError: - if msg.id == m.spinner.ID() { - m.status = taskStatusError - m.errorHints = append(m.errorHints, fmt.Sprintf("Error: %v", msg.err)) - } - } - - var taskCmd tea.Cmd - m.taskModel, taskCmd = m.taskModel.Update(msg) - cmds = append(cmds, taskCmd) - - return m, tea.Batch(cmds...) -} - -func (m initialiseSourcesModel) View() string { - bits := []string{m.taskModel.View()} - for _, hint := range m.errorHints { - bits = append(bits, wrap(fmt.Sprintf(" %v %v", RenderErr(), hint), m.width, 2)) - } - if m.useManagedSources { - bits = append(bits, wrap(fmt.Sprintf(" %v Using managed sources", RenderOk()), m.width, 2)) - } else { - if m.awsSourceRunning { - bits = append(bits, wrap(fmt.Sprintf(" %v AWS Source: running with %v providers", RenderOk(), len(m.awsProviders)), m.width, 4)) - for _, p := range m.awsProviders { - bits = append(bits, renderProviderResult(p, 6)...) - } - } - if m.stdlibSourceRunning { - bits = append(bits, wrap(fmt.Sprintf(" %v stdlib Source: running", RenderOk()), m.width, 4)) - } - } - return strings.Join(bits, "\n") -} - -// Prints details of a provider with a given indent -func renderProviderResult(result tfutils.ProviderResult, indent int) []string { - output := make([]string, 0) - - indentString := strings.Repeat(" ", indent) - - style := lipgloss.NewStyle() - - if result.Error != nil { - style.Foreground(ColorPalette.BgDanger) - } - - var providerName string - - if result.Provider != nil { - if result.Provider.Alias != "" { - providerName = result.Provider.Alias - } else { - providerName = result.Provider.Name - } - } else { - providerName = "Unknown" - } - - // Print the heading i.e. name (from file.tf) - output = append(output, fmt.Sprintf("%v%v (%v)", indentString, style.Render(providerName), result.FilePath)) - - // Increase indent since everything should come under this heading - indent += 2 - indentString = strings.Repeat(" ", indent) - - if result.Error == nil { - if result.Provider != nil { - // Create a local copy of the provider so that we can redact - // sensitive information. Note that this won't be a deep copy, but - // there isn't anything to redact in the nested structs so this is - // okay - provider := *result.Provider - - if provider.SecretKey != "" { - provider.SecretKey = "REDACTED" - } - - out, err := yaml.Marshal(provider) - if err != nil { - output = append(output, fmt.Sprintf("%vFailed to marshal provider: %v", indentString, err)) - } else { - // Print the provider details with additional indentation - output = append(output, fmt.Sprintf("%v%v", indentString, strings.ReplaceAll(string(out), "\n", "\n"+indentString))) - } - } - } else { - output = append(output, fmt.Sprintf("%vError: %v", indentString, result.Error)) - } - - return output -} - -func natsOptions(ctx context.Context, oi OvermindInstance, token *oauth2.Token) auth.NATSOptions { - hostname, err := os.Hostname() - if err != nil { - hostname = "localhost" - } - - natsNamePrefix := "overmind-cli" - - openapiUrl := *oi.ApiUrl - openapiUrl.Path = "/api" - tokenClient := auth.NewOAuthTokenClientWithContext( - ctx, - openapiUrl.String(), - "", - oauth2.StaticTokenSource(token), - ) - - return auth.NATSOptions{ - NumRetries: 3, - RetryDelay: 1 * time.Second, - Servers: []string{oi.NatsUrl.String()}, - ConnectionName: fmt.Sprintf("%v.%v", natsNamePrefix, hostname), - ConnectionTimeout: (10 * time.Second), // TODO: Make configurable - MaxReconnects: -1, - ReconnectWait: 1 * time.Second, - ReconnectJitter: 1 * time.Second, - TokenClient: tokenClient, - } -} - -func (m initialiseSourcesModel) startStdlibSourceCmd(ctx context.Context, oi OvermindInstance, token *oauth2.Token) tea.Cmd { - return func() tea.Msg { - natsOptions := natsOptions(ctx, oi, token) - - // ignore returned context. Cancellation of sources is handled by the process exiting for now. - // should sources require more teardown, we'll have to figure something out. - - stdlibEngine, err := stdlibsource.InitializeEngine(natsOptions, 2_000, true) - if err != nil { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("failed to initialize stdlib source engine: %w", err)} - } - - // todo: pass in context with timeout to abort timely and allow Ctrl-C to work - err = stdlibEngine.Start() - - if err != nil { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("failed to start stdlib source engine: %w", err)} - } - return stdlibSourceInitialisedMsg{} - } -} - -func (m initialiseSourcesModel) startAwsSourceCmd(ctx context.Context, oi OvermindInstance, token *oauth2.Token, tfArgs []string) tea.Cmd { - return func() tea.Msg { - tfEval, err := tfutils.LoadEvalContext(tfArgs, os.Environ()) - if err != nil { - return sourceInitialisationFailedMsg{fmt.Errorf("failed to load variables from the environment: %w", err)} - } - - providers, err := tfutils.ParseAWSProviders(".", tfEval) - if err != nil { - return sourceInitialisationFailedMsg{fmt.Errorf("failed to parse providers: %w", err)} - } - configs := []aws.Config{} - - for _, p := range providers { - if p.Error != nil { - // skip providers that had errors. This allows us to use - // providers we _could_ detect, while still failing if there is - // a true syntax error and no providers are available at all. - continue - } - c, err := tfutils.ConfigFromProvider(ctx, *p.Provider) - if err != nil { - return sourceInitialisationFailedMsg{fmt.Errorf("error when converting provider to config: %w", err)} - } - configs = append(configs, c) - } - - natsOptions := natsOptions(ctx, oi, token) - - awsEngine, err := proc.InitializeAwsSourceEngine(ctx, natsOptions, 2_000, configs...) - if err != nil { - return sourceInitialisationFailedMsg{fmt.Errorf("failed to initialize AWS source engine: %w", err)} - } - - // todo: pass in context with timeout to abort timely and allow Ctrl-C to work - err = awsEngine.Start() - if err != nil { - return sourceInitialisationFailedMsg{fmt.Errorf("failed to start AWS source engine: %w", err)} - } - return awsSourceInitialisedMsg{providers: providers} - } -} diff --git a/cmd/tea_initialisesources_test.go b/cmd/tea_initialisesources_test.go deleted file mode 100644 index 6f99a055..00000000 --- a/cmd/tea_initialisesources_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package cmd - -import ( - "fmt" - "testing" - - "github.com/overmindtech/cli/tfutils" -) - -func TestPrintProviderResult(t *testing.T) { - results := []tfutils.ProviderResult{ - { - Provider: &tfutils.AWSProvider{ - Name: "aws", - AccessKey: "OOH SECRET", - SecretKey: "EVEN MORE SECRET", - Region: "us-west-2", - }, - FilePath: "/path/to/aws.tf", - }, - { - Error: fmt.Errorf("failed to parse provider"), - FilePath: "/path/to/bad.tf", - }, - { - Provider: &tfutils.AWSProvider{ - Alias: "dev", - Region: "us-west-2", - SharedConfigFiles: []string{ - "/path/to/credentials", - }, - AssumeRole: &tfutils.AssumeRole{ - Duration: "12h", - ExternalID: "external-id", - Policy: "policy", - RoleARN: "arn:aws:iam::123456789012:role/role-name", - }, - }, - }, - } - - for _, result := range results { - // This doesn't test anything, it's just used to visually confirm the - // results in the debug window - str := renderProviderResult(result, 0) - for _, line := range str { - fmt.Println(line) - } - } -} diff --git a/cmd/tea_instanceloader.go b/cmd/tea_instanceloader.go deleted file mode 100644 index 6623e045..00000000 --- a/cmd/tea_instanceloader.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "net/url" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/viper" -) - -type instanceLoadedMsg struct { - instance OvermindInstance -} - -type instanceLoaderModel struct { - taskModel - ctx context.Context - app string -} - -func NewInstanceLoaderModel(ctx context.Context, app string, width int) tea.Model { - result := instanceLoaderModel{ - taskModel: NewTaskModel("Connecting to Overmind", width), - ctx: ctx, - app: app, - } - result.status = taskStatusRunning - return result -} - -func (m instanceLoaderModel) TaskModel() taskModel { - return m.taskModel -} - -func (m instanceLoaderModel) Init() tea.Cmd { - return tea.Batch( - m.taskModel.Init(), - newOvermindInstanceCmd(m.ctx, m.app), - ) -} - -func (m instanceLoaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg.(type) { - case instanceLoadedMsg: - m.status = taskStatusDone - m.title = "Connected to Overmind" - } - - var cmd tea.Cmd - m.taskModel, cmd = m.taskModel.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func newOvermindInstanceCmd(ctx context.Context, app string) tea.Cmd { - if viper.GetString("ovm-test-fake") != "" { - mustParse := func(u string) *url.URL { - result, err := url.Parse(u) - if err != nil { - panic(err) - } - return result - } - - return func() tea.Msg { - return instanceLoadedMsg{instance: OvermindInstance{ - FrontendUrl: mustParse("http://localhost:3000"), - ApiUrl: mustParse("https://api.example.com"), - NatsUrl: mustParse("https://nats.example.com"), - Audience: "https://aud.example.com", - }} - } - } - return func() tea.Msg { - instance, err := NewOvermindInstance(ctx, app) - if err != nil { - return fatalError{err: fmt.Errorf("failed to get instance data from app: %w", err)} - } - - return instanceLoadedMsg{instance} - } -} diff --git a/cmd/tea_plan.go b/cmd/tea_plan.go deleted file mode 100644 index 8eaaf3c5..00000000 --- a/cmd/tea_plan.go +++ /dev/null @@ -1,158 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os/exec" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/overmindtech/cli/tracing" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -type runPlanModel struct { - ctx context.Context // note that this ctx is not initialized on NewTfPlanModel to instead get a modified context through the loadSourcesConfigMsg that has a timeout and cancelFunction configured - oi OvermindInstance - width int - - args []string - planFile string - - parent *cmdModel - revlinkTask revlinkWarmupModel - taskModel -} -type runPlanNowMsg struct{} -type runPlanFinishedMsg struct { - err error -} - -func NewRunPlanModel(args []string, planFile string, parent *cmdModel, width int) runPlanModel { - return runPlanModel{ - args: args, - planFile: planFile, - - parent: parent, - revlinkTask: NewRevlinkWarmupModel(width), - taskModel: NewTaskModel("Planning Changes", width), - } -} - -func (m runPlanModel) Init() tea.Cmd { - return tea.Batch( - m.revlinkTask.Init(), - m.taskModel.Init(), - ) -} - -func (m runPlanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case loadSourcesConfigMsg: - m.ctx = msg.ctx - m.oi = msg.oi - - case sourcesInitialisedMsg: - m.taskModel.status = taskStatusRunning - // since the taskModel will not be shown while `terraform plan` is running, - // there's no need to actually kick off the spinner - // if os.Getenv("CI") == "" { - // cmds = append(cmds, m.taskModel.spinner.Tick) - // } - - // defer the actual command to give the view a chance to show the header - cmds = append(cmds, func() tea.Msg { return runPlanNowMsg{} }) - - case runPlanNowMsg: - c := exec.CommandContext(m.ctx, "terraform", m.args...) // nolint:gosec // this is a user-provided command, let them do their thing - // remove go's default process cancel behaviour, so that terraform has a - // chance to gracefully shutdown when ^C is pressed. Otherwise the - // process would get killed immediately and leave locks lingering behind - c.Cancel = func() error { - return nil - } - - if viper.GetString("ovm-test-fake") != "" { - c = exec.CommandContext(m.ctx, "bash", "-c", "for i in $(seq 25); do echo fake terraform plan progress line $i of 25; sleep .1; done") - } - - _, span := tracing.Tracer().Start(m.ctx, "terraform plan") // nolint:spancheck // will be ended in the tea.Exec cleanup func - - cmds = append(cmds, Exec( - m.parent.NewExecCommand(c), - func(err error) tea.Msg { - defer span.End() - - if err != nil { - return runPlanFinishedMsg{err: fmt.Errorf("failed to run terraform plan: %w", err)} - } - return runPlanFinishedMsg{err: nil} - })) - - case runPlanFinishedMsg: - var attrs []attribute.KeyValue - var eventName string - - if msg.err != nil { - m.taskModel.status = taskStatusError - - // Tracing info - attrs = append(attrs, attribute.String("error", msg.err.Error())) - eventName = "Terraform plan failed" - } else { - m.taskModel.status = taskStatusDone - - // Tracing info - eventName = "Terraform plan finished" - } - - if cmdSpan != nil { - cmdSpan.AddEvent(eventName, trace.WithAttributes(attrs...)) - if msg.err != nil { - cmdSpan.SetStatus(codes.Error, msg.err.Error()) - } - } - } - - var cmd tea.Cmd - m.revlinkTask, cmd = m.revlinkTask.Update(msg) - cmds = append(cmds, cmd) - - m.taskModel, cmd = m.taskModel.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m runPlanModel) View() string { - bits := []string{} - - switch m.taskModel.status { - case taskStatusPending, taskStatusRunning: - bits = append(bits, - wrap(fmt.Sprintf("%v Running 'terraform %v'", - RenderOk(), - strings.Join(m.args, " "), - ), m.width, 2)) - case taskStatusDone: - bits = append(bits, m.taskModel.View()) - bits = append(bits, m.revlinkTask.View()) - case taskStatusError: - bits = append(bits, - wrap(fmt.Sprintf("%v Running 'terraform %v'", - RenderErr(), - strings.Join(m.args, " "), - ), m.width, 2)) - case taskStatusSkipped: - // handled by caller - } - return strings.Join(bits, "\n") -} diff --git a/cmd/tea_revlink.go b/cmd/tea_revlink.go deleted file mode 100644 index 7abdd877..00000000 --- a/cmd/tea_revlink.go +++ /dev/null @@ -1,200 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "os" - "time" - - "connectrpc.com/connect" - tea "github.com/charmbracelet/bubbletea" - "github.com/overmindtech/sdp-go" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type revlinkWarmupFinishedMsg struct{} - -type revlinkWarmupModel struct { - taskModel - - ctx context.Context // note that this ctx is not initialized on NewGetConfigModel to instead get a modified context through the loadSourcesConfigMsg that has a timeout and cancelFunction configured - oi OvermindInstance - // token *oauth2.Token - - status chan *sdp.RevlinkWarmupResponse - currentStatus *sdp.RevlinkWarmupResponse - - watchdogChan chan struct{} // a watchdog channel to keep the watchdog running - watchdogCancel context.CancelFunc // the cancel function that gets called if the watchdog detects a timeout -} - -func NewRevlinkWarmupModel(width int) revlinkWarmupModel { - return revlinkWarmupModel{ - taskModel: NewTaskModel("Discover and link all resources", width), - status: make(chan *sdp.RevlinkWarmupResponse, 3000), - currentStatus: &sdp.RevlinkWarmupResponse{ - Status: "pending", - Items: 0, - Edges: 0, - }, - } -} - -func (m revlinkWarmupModel) TaskModel() taskModel { - return m.taskModel -} - -func (m revlinkWarmupModel) Init() tea.Cmd { - return m.taskModel.Init() -} - -func (m revlinkWarmupModel) Update(msg tea.Msg) (revlinkWarmupModel, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case loadSourcesConfigMsg: - m.ctx = msg.ctx - m.oi = msg.oi - case sourcesInitialisedMsg: - m.taskModel.status = taskStatusRunning - // start the spinner - if os.Getenv("CI") == "" { - cmds = append(cmds, m.taskModel.spinner.Tick) - } - - // setup the watchdog infrastructure - ctx, cancel := context.WithCancel(m.ctx) - m.watchdogCancel = cancel - - // kick off a revlink warmup - cmds = append(cmds, m.revlinkWarmupCmd(ctx)) - // process status updates - cmds = append(cmds, m.waitForStatusActivity) - - case *sdp.RevlinkWarmupResponse: - m.currentStatus = msg - - switch m.taskModel.status { //nolint:exhaustive // we only care about running and done - case taskStatusRunning, taskStatusDone: - items := m.currentStatus.GetItems() - edges := m.currentStatus.GetEdges() - if items+edges > 0 { - m.taskModel.title = fmt.Sprintf("Discover and link all resources: %v (%v items, %v edges)", m.currentStatus.GetStatus(), items, edges) - } else { - m.taskModel.title = fmt.Sprintf("Discover and link all resources: %v", m.currentStatus.GetStatus()) - } - } - - // wait for the next status update - cmds = append(cmds, m.waitForStatusActivity) - - // tickle the watchdog when we get a response - if m.watchdogChan != nil { - go func() { - m.watchdogChan <- struct{}{} - }() - } - - case runPlanFinishedMsg: - if m.taskModel.status != taskStatusDone { - // start the watchdog once the plan is done - m.watchdogChan = make(chan struct{}, 1) - cmds = append(cmds, m.watchdogCmd()) - } - - case revlinkWarmupFinishedMsg: - m.taskModel.status = taskStatusDone - m.watchdogChan = nil - if m.watchdogCancel != nil { - m.watchdogCancel() - m.watchdogCancel = nil - } - - if cmdSpan != nil { - var attrs []attribute.KeyValue - - if m.currentStatus != nil { - attrs = append(attrs, - attribute.String("ovm.cli.revlinkWarmupStatus", m.currentStatus.GetStatus()), - attribute.Int("ovm.cli.revlinkWarmupItems", int(m.currentStatus.GetItems())), - attribute.Int("ovm.cli.revlinkWarmupEdges", int(m.currentStatus.GetEdges())), - ) - } - - cmdSpan.AddEvent("Revlink warmup finished", trace.WithAttributes(attrs...)) - } - default: - var taskCmd tea.Cmd - m.taskModel, taskCmd = m.taskModel.Update(msg) - cmds = append(cmds, taskCmd) - } - - return m, tea.Batch(cmds...) -} - -func (m revlinkWarmupModel) View() string { - return m.taskModel.View() -} - -// A command that waits for the activity on the status channel. -func (m revlinkWarmupModel) waitForStatusActivity() tea.Msg { - return <-m.status -} - -func (m revlinkWarmupModel) revlinkWarmupCmd(ctx context.Context) tea.Cmd { - return func() tea.Msg { - if viper.GetString("ovm-test-fake") != "" { - for i := 0; i < 10; i++ { - m.status <- &sdp.RevlinkWarmupResponse{ - Status: "running (test mode)", - Items: int32(i * 10), - Edges: int32(i*10) + 1, - } - time.Sleep(250 * time.Millisecond) - } - return revlinkWarmupFinishedMsg{} - } - - client := AuthenticatedManagementClient(ctx, m.oi) - - stream, err := client.RevlinkWarmup(ctx, &connect.Request[sdp.RevlinkWarmupRequest]{ - Msg: &sdp.RevlinkWarmupRequest{}, - }) - if err != nil { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error starting RevlinkWarmup: %w", err)} - } - - for stream.Receive() { - m.status <- stream.Msg() - } - - err = stream.Err() - if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { - return fatalError{id: m.spinner.ID(), err: fmt.Errorf("error warming up revlink: %w", stream.Err())} - } - - return revlinkWarmupFinishedMsg{} - } -} - -func (m revlinkWarmupModel) watchdogCmd() tea.Cmd { - return func() tea.Msg { - ticker := time.NewTimer(10 * time.Second) - for { - select { - case <-ticker.C: - m.watchdogCancel() - return nil - case <-m.ctx.Done(): - m.watchdogCancel() - return nil - case <-m.watchdogChan: - // extend the timeout everytime we get a message - ticker.Reset(10 * time.Second) - } - } - } -} diff --git a/cmd/tea_snapshot.go b/cmd/tea_snapshot.go deleted file mode 100644 index 4eb498ac..00000000 --- a/cmd/tea_snapshot.go +++ /dev/null @@ -1,186 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type snapshotModel struct { - overall taskModel - discovering taskModel - saving taskModel - - state string - items uint32 - edges uint32 -} - -type startSnapshotMsg struct { - id int -} -type progressSnapshotMsg struct { - id int - newState string - items uint32 - edges uint32 -} -type savingSnapshotMsg struct { - id int -} -type finishSnapshotMsg struct { - id int -} - -func NewSnapShotModel(header, title string, width int) snapshotModel { - return snapshotModel{ - overall: NewTaskModel(header, width), - discovering: NewTaskModel(title, width), - saving: NewTaskModel("Saving", width), - } -} - -func (m snapshotModel) Init() tea.Cmd { - return tea.Batch( - m.overall.Init(), - m.discovering.Init(), - m.saving.Init(), - ) -} - -func (m snapshotModel) Update(msg tea.Msg) (snapshotModel, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case startSnapshotMsg: - if m.overall.spinner.ID() != msg.id { - return m, nil - } - m.overall.status = taskStatusRunning - if os.Getenv("CI") == "" { - cmds = append(cmds, m.overall.spinner.Tick) - } - case progressSnapshotMsg: - if m.overall.spinner.ID() != msg.id { - return m, nil - } - m.state = msg.newState - m.items = msg.items - m.edges = msg.edges - - m.discovering.status = taskStatusRunning - if os.Getenv("CI") == "" { - cmds = append(cmds, m.discovering.spinner.Tick) - } - case savingSnapshotMsg: - if m.overall.spinner.ID() != msg.id { - return m, nil - } - - m.discovering.status = taskStatusDone - - m.saving.status = taskStatusRunning - if os.Getenv("CI") == "" { - cmds = append(cmds, m.saving.spinner.Tick) - } - - case finishSnapshotMsg: - if m.overall.spinner.ID() != msg.id { - return m, nil - } - m.overall.status = taskStatusDone - m.discovering.status = taskStatusDone - m.saving.status = taskStatusDone - } - - var cmd tea.Cmd - - m.overall, cmd = m.overall.Update(msg) - cmds = append(cmds, cmd) - - m.discovering, cmd = m.discovering.Update(msg) - cmds = append(cmds, cmd) - - m.saving, cmd = m.saving.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func snapshotDetail(state string, items, edges uint32) string { - itemStr := "" - if items == 0 { - itemStr = "0 items" - } else if items == 1 { - itemStr = "1 item" - } else { - itemStr = fmt.Sprintf("%d items", items) - } - - edgeStr := "" - if edges == 0 { - edgeStr = "0 edges" - } else if edges == 1 { - edgeStr = "1 edge" - } else { - edgeStr = fmt.Sprintf("%d edges", edges) - } - - detailStr := state - if itemStr != "" || edgeStr != "" { - detailStr = fmt.Sprintf("%s (%s, %s)", state, itemStr, edgeStr) - } - return detailStr -} - -func (m snapshotModel) View() string { - // TODO: add progressbar; complication: we do not have a expected number of - // items/edges to count towards for the progressbar - - // TODO: improve wrapping behaviour of the components. Currently skipped as - // all the taskModel titles are expected to be relatively short and because - // of the nesting of the components, the wrapping is more complex than the - // current code structure supports - bits := []string{} - bits = append(bits, m.overall.View()) - - bits = append(bits, fmt.Sprintf(" %v - %v", m.discovering.View(), snapshotDetail(m.state, m.items, m.edges))) - bits = append(bits, fmt.Sprintf(" %v", m.saving.View())) - return strings.Join(bits, "\n") -} - -func (m snapshotModel) ID() int { - return m.overall.spinner.ID() -} - -func (m snapshotModel) StartMsg() tea.Msg { - return startSnapshotMsg{ - id: m.overall.spinner.ID(), - } -} - -func (m snapshotModel) UpdateStatusMsg(newStatus taskStatus) tea.Msg { - return m.overall.UpdateStatusMsg(newStatus) -} - -func (m snapshotModel) ProgressMsg(newState string, items, edges uint32) tea.Msg { - return progressSnapshotMsg{ - id: m.overall.spinner.ID(), - newState: newState, - items: items, - edges: edges, - } -} -func (m snapshotModel) SavingMsg() tea.Msg { - return savingSnapshotMsg{ - id: m.overall.spinner.ID(), - } -} - -func (m snapshotModel) FinishMsg() tea.Msg { - return finishSnapshotMsg{ - id: m.overall.spinner.ID(), - } -} diff --git a/cmd/tea_submitplan.go b/cmd/tea_submitplan.go deleted file mode 100644 index 9207fd5f..00000000 --- a/cmd/tea_submitplan.go +++ /dev/null @@ -1,873 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "slices" - "strings" - "time" - - "connectrpc.com/connect" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/google/uuid" - "github.com/muesli/reflow/wordwrap" - "github.com/overmindtech/cli/tfutils" - "github.com/overmindtech/sdp-go" - log "github.com/sirupsen/logrus" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" -) - -type submitPlanModel struct { - ctx context.Context // note that this ctx is not initialized on NewTfPlanModel to instead get a modified context through the loadSourcesConfigMsg that has a timeout and cancelFunction configured - oi OvermindInstance - - planFile string - - // this channel transports updates from the submitPlan processing. The - // submitPlanUpdateMsg is a wrapper around the actual tea.Msg to make it - // simpler to pump the waitForSubmitPlanActivity command - processing chan submitPlanUpdateMsg - progress []string - changeUrl string - - removingSecretsTask taskModel - resourceExtractionTask taskModel - planMappingResult tfutils.PlanMappingResult - - uploadChangesTask taskModel - - blastRadiusTask snapshotModel - blastRadiusItems uint32 - blastRadiusEdges uint32 - - riskTask taskModel - risksStarted time.Time - riskMilestones []*sdp.RiskCalculationStatus_ProgressMilestone - riskMilestoneTasks []taskModel - risks []*sdp.Risk - - width int -} -type submitPlanNowMsg struct{} - -type submitPlanUpdateMsg struct{ wrapped tea.Msg } -type submitPlanFinishedMsg struct{ text string } - -type changeUpdatedMsg struct { - url string - riskMilestones []*sdp.RiskCalculationStatus_ProgressMilestone - risks []*sdp.Risk -} - -func NewSubmitPlanModel(planFile string, width int) submitPlanModel { - return submitPlanModel{ - planFile: planFile, - - processing: make(chan submitPlanUpdateMsg, 1000), // provide a buffer for sending updates, so we don't block the processing - progress: []string{}, - - removingSecretsTask: NewTaskModel("Removing secrets", width), - resourceExtractionTask: NewTaskModel("Extracting resources", width), - uploadChangesTask: NewTaskModel("Uploading planned changes", width), - - blastRadiusTask: NewSnapShotModel("Calculating Blast Radius", "Discovering dependencies", width), - riskTask: NewTaskModel("Calculating Risks", width), - } -} - -func (m submitPlanModel) Init() tea.Cmd { - return tea.Batch( - m.blastRadiusTask.Init(), - m.riskTask.Init(), - ) -} - -func (m submitPlanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := []tea.Cmd{} - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case loadSourcesConfigMsg: - m.ctx = msg.ctx - m.oi = msg.oi - - case submitPlanNowMsg: - cmds = append(cmds, - m.submitPlanCmd, - m.waitForSubmitPlanActivity, - ) - - if os.Getenv("CI") == "" { - cmds = append(cmds, - m.removingSecretsTask.spinner.Tick, - m.resourceExtractionTask.spinner.Tick, - m.uploadChangesTask.spinner.Tick, - ) - } - - case submitPlanUpdateMsg: - // ensure that the wrapped message is submitted before we wait for the - // next update. This is still not perfect, but there's currently no - // better idea on the table. - cmds = append(cmds, tea.Sequence(func() tea.Msg { return msg.wrapped }, m.waitForSubmitPlanActivity)) - - case *tfutils.PlanMappingResult: - m.planMappingResult = *msg - - case submitPlanFinishedMsg: - m.riskTask.status = taskStatusDone - m.progress = append(m.progress, msg.text) - case changeUpdatedMsg: - m.changeUrl = msg.url - m.riskMilestones = msg.riskMilestones - if len(m.riskMilestoneTasks) != len(msg.riskMilestones) { - m.riskMilestoneTasks = []taskModel{} - for _, ms := range msg.riskMilestones { - tm := NewTaskModel(ms.GetDescription(), m.width) - tm.indent = 4 - m.riskMilestoneTasks = append(m.riskMilestoneTasks, tm) - cmds = append(cmds, tm.Init()) - } - } - for i, ms := range msg.riskMilestones { - m.riskMilestoneTasks[i].title = ms.GetDescription() - switch ms.GetStatus() { - case sdp.RiskCalculationStatus_ProgressMilestone_STATUS_PENDING: - m.riskMilestoneTasks[i].status = taskStatusPending - case sdp.RiskCalculationStatus_ProgressMilestone_STATUS_ERROR: - m.riskMilestoneTasks[i].status = taskStatusError - case sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE: - m.riskMilestoneTasks[i].status = taskStatusDone - case sdp.RiskCalculationStatus_ProgressMilestone_STATUS_INPROGRESS: - m.riskMilestoneTasks[i].status = taskStatusRunning - if os.Getenv("CI") == "" { - cmds = append(cmds, m.riskMilestoneTasks[i].spinner.Tick) - } - case sdp.RiskCalculationStatus_ProgressMilestone_STATUS_SKIPPED: - m.riskMilestoneTasks[i].status = taskStatusSkipped - } - } - m.risks = msg.risks - - if len(m.riskMilestones) > 0 { - m.riskTask.status = taskStatusRunning - if os.Getenv("CI") == "" { - cmds = append(cmds, m.riskTask.spinner.Tick) - } - - var allSkipped = true - for _, ms := range m.riskMilestoneTasks { - if ms.status != taskStatusSkipped { - allSkipped = false - break - } - } - if allSkipped { - m.riskTask.status = taskStatusSkipped - } - - if m.risksStarted == (time.Time{}) { - m.risksStarted = time.Now() - } - } else if len(m.risks) > 0 { - m.riskTask.status = taskStatusDone - } - - case progressSnapshotMsg: - m.blastRadiusItems = msg.items - m.blastRadiusEdges = msg.edges - - } - - var cmd tea.Cmd - m.removingSecretsTask, cmd = m.removingSecretsTask.Update(msg) - cmds = append(cmds, cmd) - - m.resourceExtractionTask, cmd = m.resourceExtractionTask.Update(msg) - cmds = append(cmds, cmd) - - m.uploadChangesTask, cmd = m.uploadChangesTask.Update(msg) - cmds = append(cmds, cmd) - - m.blastRadiusTask, cmd = m.blastRadiusTask.Update(msg) - cmds = append(cmds, cmd) - - m.riskTask, cmd = m.riskTask.Update(msg) - cmds = append(cmds, cmd) - - for i, ms := range m.riskMilestoneTasks { - m.riskMilestoneTasks[i], cmd = ms.Update(msg) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m submitPlanModel) View() string { - bits := []string{} - - if m.removingSecretsTask.status != taskStatusPending { - bits = append(bits, m.removingSecretsTask.View()) - } - - if m.resourceExtractionTask.status != taskStatusPending { - bits = append(bits, m.resourceExtractionTask.View()) - for _, mapping := range m.planMappingResult.Results { - var icon string - switch mapping.Status { - case tfutils.MapStatusSuccess: - icon = RenderOk() - case tfutils.MapStatusNotEnoughInfo: - icon = RenderUnknown() - case tfutils.MapStatusUnsupported: - icon = RenderErr() - } - bits = append(bits, fmt.Sprintf(" %v %v (%v)", icon, mapping.TerraformName, mapping.Message)) - } - } - - if m.uploadChangesTask.status != taskStatusPending { - bits = append(bits, m.uploadChangesTask.View()) - } - - if m.blastRadiusTask.overall.status != taskStatusPending { - bits = append(bits, m.blastRadiusTask.View()) - } - - if m.riskTask.status != taskStatusPending { - bits = append(bits, m.riskTask.View()) - for _, t := range m.riskMilestoneTasks { - bits = append(bits, fmt.Sprintf(" %v", t.View())) - } - } - - if m.changeUrl != "" && m.riskTask.status != taskStatusDone && m.riskTask.status != taskStatusError && time.Since(m.risksStarted) > 1500*time.Millisecond { - bits = append(bits, fmt.Sprintf(" │ Check the blast radius graph while you wait:\n │ %v\n", m.changeUrl)) - } - - return strings.Join(bits, "\n") + "\n" -} - -func (m submitPlanModel) Status() taskStatus { - // return taskStatusPending when the first task is still pending - if m.removingSecretsTask.status != taskStatusDone { - return m.removingSecretsTask.status - } - - if m.removingSecretsTask.status != taskStatusPending && m.resourceExtractionTask.status != taskStatusDone { - return m.resourceExtractionTask.status - } - - if m.uploadChangesTask.status != taskStatusPending && m.uploadChangesTask.status != taskStatusDone { - return m.uploadChangesTask.status - } - - if m.blastRadiusTask.overall.status != taskStatusPending && m.blastRadiusTask.overall.status != taskStatusDone { - return m.blastRadiusTask.overall.status - } - - // return taskStatusDone when the last task is done - if m.riskTask.status != taskStatusPending { - return m.riskTask.status - } - - // return taskStatusRunning when no task has errored or skipped - return taskStatusRunning -} - -// A command that waits for the activity on the processing channel. -func (m submitPlanModel) waitForSubmitPlanActivity() tea.Msg { - return <-m.processing -} - -func (m submitPlanModel) risksError(msg string, err error) tea.Msg { - if m.changeUrl == "" { - if err == nil { - return fatalError{err: errors.New(msg)} - } - return fatalError{err: fmt.Errorf("%v: %w", msg, err)} - } else { - if err == nil { - return fatalError{err: fmt.Errorf("%v\nWe'll retry in the background. Find the results online when they're done: %v", msg, m.changeUrl)} - } - return fatalError{err: fmt.Errorf("%v: %w\nWe'll retry in the background. Find the results online when they're done: %v", msg, err, m.changeUrl)} - } -} - -func (m submitPlanModel) submitPlanCmd() tea.Msg { - ctx := m.ctx - span := trace.SpanFromContext(ctx) - - if viper.GetString("ovm-test-fake") != "" { - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusRunning)} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusDone)} - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateStatusMsg(taskStatusRunning)} - time.Sleep(time.Second) - - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateTitleMsg( - "Extracting 13 changing resources: 4 supported 9 unsupported", - )} - mappingResult := tfutils.PlanMappingResult{ - Results: []tfutils.PlannedChangeMapResult{ - { - TerraformName: "kubernetes_deployment.nats_box", - Status: tfutils.MapStatusSuccess, - Message: "mapped", - MappedItemDiff: &sdp.MappedItemDiff{}, - }, - { - TerraformName: "kubernetes_deployment.api_server", - Status: tfutils.MapStatusNotEnoughInfo, - Message: "missing arn", - MappedItemDiff: &sdp.MappedItemDiff{}, - }, - { - TerraformName: "aws_fake_resource", - Status: tfutils.MapStatusUnsupported, - Message: "unsupported", - MappedItemDiff: &sdp.MappedItemDiff{}, - }, - }, - RemovedSecrets: 12, - } - m.processing <- submitPlanUpdateMsg{&mappingResult} - - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateStatusMsg(taskStatusDone)} - time.Sleep(time.Second) - - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusRunning)} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateTitleMsg("Uploading planned changes (new/existing)")} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusDone)} - time.Sleep(time.Second) - - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.StartMsg()} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.ProgressMsg("fake processing", 1, 2)} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.ProgressMsg("fake processing", 3, 4)} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.SavingMsg()} - time.Sleep(time.Second) - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.FinishMsg()} - - // update local copy - m.changeUrl = "https://example.com/changes/abc" - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{url: "https://example.com/changes/abc"}} - time.Sleep(time.Second) - - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusRunning)} - time.Sleep(100 * time.Millisecond) - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: "https://example.com/changes/abc", - riskMilestones: []*sdp.RiskCalculationStatus_ProgressMilestone{ - { - Description: "fake done milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_INPROGRESS, - }, - { - Description: "fake inprogress milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_PENDING, - }, - { - Description: "fake pending milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_PENDING, - }, - }, - risks: []*sdp.Risk{}, - }} - time.Sleep(1500 * time.Millisecond) - - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: "https://example.com/changes/abc", - riskMilestones: []*sdp.RiskCalculationStatus_ProgressMilestone{ - { - Description: "fake done milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake inprogress milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_INPROGRESS, - }, - { - Description: "fake pending milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_PENDING, - }, - }, - risks: []*sdp.Risk{}, - }} - time.Sleep(1500 * time.Millisecond) - - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: "https://example.com/changes/abc", - riskMilestones: []*sdp.RiskCalculationStatus_ProgressMilestone{ - { - Description: "fake done milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake inprogress milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake pending milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_INPROGRESS, - }, - }, - risks: []*sdp.Risk{}, - }} - time.Sleep(1500 * time.Millisecond) - - if viper.GetString("ovm-test-fake") == "risks-error" { - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: "https://example.com/changes/abc", - riskMilestones: []*sdp.RiskCalculationStatus_ProgressMilestone{ - { - Description: "fake done milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake inprogress milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake errored milestone", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_ERROR, - }, - }, - risks: []*sdp.Risk{}, - }} - - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("initial risk calculation errored", nil)} - close(m.processing) - return nil - } - high := uuid.New() - medium := uuid.New() - low := uuid.New() - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: "https://example.com/changes/abc", - riskMilestones: []*sdp.RiskCalculationStatus_ProgressMilestone{ - { - Description: "fake done milestone - done", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake inprogress milestone - done", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - { - Description: "fake pending milestone - done", - Status: sdp.RiskCalculationStatus_ProgressMilestone_STATUS_DONE, - }, - }, - risks: []*sdp.Risk{ - { - UUID: high[:], - Title: "fake high risk titled risk", - Severity: sdp.Risk_SEVERITY_HIGH, - Description: TEST_RISK, - RelatedItems: []*sdp.Reference{}, - }, - { - UUID: medium[:], - Title: "fake medium risk titled risk", - Severity: sdp.Risk_SEVERITY_MEDIUM, - Description: TEST_RISK, - RelatedItems: []*sdp.Reference{}, - }, - { - UUID: low[:], - Title: "fake low risk titled risk", - Severity: sdp.Risk_SEVERITY_LOW, - Description: TEST_RISK, - RelatedItems: []*sdp.Reference{}, - }, - }, - }} - time.Sleep(time.Second) - - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusDone)} - m.processing <- submitPlanUpdateMsg{submitPlanFinishedMsg{"Fake done"}} - time.Sleep(time.Second) - return nil - } - - /////////////////////////////////////////////////////////////////// - // Convert provided plan into JSON for easier parsing - /////////////////////////////////////////////////////////////////// - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusRunning)} - tfPlanJsonCmd := exec.CommandContext(ctx, "terraform", "show", "-json", m.planFile) // nolint:gosec // this is the file `terraform plan` already wrote to, so it's safe enough - - tfPlanJsonCmd.Stderr = os.Stderr // TODO: capture and output this through the View() instead - - log.WithField("args", tfPlanJsonCmd.Args).Debug("converting plan to JSON") - planJson, err := tfPlanJsonCmd.Output() - if err != nil { - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to convert terraform plan to JSON", err)} - close(m.processing) - return nil - } - - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusDone)} - - /////////////////////////////////////////////////////////////////// - // Extract changes from the plan and created mapped item diffs - /////////////////////////////////////////////////////////////////// - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateStatusMsg(taskStatusRunning)} - time.Sleep(200 * time.Millisecond) // give the UI a little time to update - - // Map the terraform changes to Overmind queries - mappingResponse, err := tfutils.MappedItemDiffsFromPlan(ctx, planJson, m.planFile, log.Fields{}) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to parse terraform plan", err)} - close(m.processing) - return nil - } - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateTitleMsg( - fmt.Sprintf("Removed %v secrets", mappingResponse.RemovedSecrets), - )} - - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateTitleMsg( - fmt.Sprintf("Extracting %v changing resources: %v supported %v skipped %v unsupported", - mappingResponse.NumTotal(), - mappingResponse.NumSuccess(), - mappingResponse.NumNotEnoughInfo(), - mappingResponse.NumUnsupported(), - ))} - - // Sort the supported and unsupported changes so that they display nicely - slices.SortFunc(mappingResponse.Results, func(a, b tfutils.PlannedChangeMapResult) int { - return int(a.Status) - int(b.Status) - }) - m.processing <- submitPlanUpdateMsg{mappingResponse} - time.Sleep(200 * time.Millisecond) // give the UI a little time to update - m.processing <- submitPlanUpdateMsg{m.resourceExtractionTask.UpdateStatusMsg(taskStatusDone)} - - /////////////////////////////////////////////////////////////////// - // try to link up the plan with a Change and start submitting to the API - /////////////////////////////////////////////////////////////////// - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusRunning)} - ticketLink := viper.GetString("ticket-link") - if ticketLink == "" { - ticketLink, err = getTicketLinkFromPlan(m.planFile) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to get ticket link from plan", err)} - close(m.processing) - return nil - } - } - - client := AuthenticatedChangesClient(ctx, m.oi) - changeUuid, err := getChangeUuid(ctx, m.oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, ticketLink, false) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed searching for existing changes", err)} - close(m.processing) - return nil - } - - title := changeTitle(viper.GetString("title")) - tfPlanTextCmd := exec.CommandContext(ctx, "terraform", "show", m.planFile) // nolint:gosec // this is the file `terraform plan` already wrote to, so it's safe enough - - tfPlanTextCmd.Stderr = os.Stderr // TODO: capture and output this through the View() instead - - log.WithField("args", tfPlanTextCmd.Args).Debug("converting plan to JSON") - tfPlanOutput, err := tfPlanTextCmd.Output() - if err != nil { - m.processing <- submitPlanUpdateMsg{m.removingSecretsTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to convert terraform plan to JSON", err)} - close(m.processing) - return nil - } - codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) - - if changeUuid == uuid.Nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateTitleMsg("Uploading planned changes (new)")} - log.Debug("Creating a new change") - createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ - Msg: &sdp.CreateChangeRequest{ - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: ticketLink, - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: string(tfPlanOutput), - CodeChanges: codeChangesOutput, - }, - }, - }) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to create a new change", err)} - close(m.processing) - return nil - } - - maybeChangeUuid := createResponse.Msg.GetChange().GetMetadata().GetUUIDParsed() - if maybeChangeUuid == nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to read change id", err)} - close(m.processing) - return nil - } - - changeUuid = *maybeChangeUuid - span.SetAttributes( - attribute.String("ovm.change.uuid", changeUuid.String()), - attribute.Bool("ovm.change.new", true), - ) - } else { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateTitleMsg("Uploading planned changes (update)")} - log.WithField("change", changeUuid).Debug("Updating an existing change") - span.SetAttributes( - attribute.String("ovm.change.uuid", changeUuid.String()), - attribute.Bool("ovm.change.new", false), - ) - - _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ - Msg: &sdp.UpdateChangeRequest{ - UUID: changeUuid[:], - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: ticketLink, - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: string(tfPlanOutput), - CodeChanges: codeChangesOutput, - }, - }, - }) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to update change", err)} - close(m.processing) - return nil - } - } - - time.Sleep(200 * time.Millisecond) // give the UI a little time to update - m.processing <- submitPlanUpdateMsg{m.uploadChangesTask.UpdateStatusMsg(taskStatusDone)} - - /////////////////////////////////////////////////////////////////// - // calculate blast radius and risks - /////////////////////////////////////////////////////////////////// - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.StartMsg()} - log.WithField("change", changeUuid).Debug("Uploading planned changes") - - resultStream, err := client.UpdatePlannedChanges(ctx, &connect.Request[sdp.UpdatePlannedChangesRequest]{ - Msg: &sdp.UpdatePlannedChangesRequest{ - ChangeUUID: changeUuid[:], - ChangingItems: mappingResponse.GetItemDiffs(), - }, - }) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to update planned changes", err)} - close(m.processing) - return nil - } - - last_log := time.Now() - first_log := true - var msg *sdp.CalculateBlastRadiusResponse - for resultStream.Receive() { - msg = resultStream.Msg() - - // log the first message and at most every 250ms during discovery - // to avoid spanning the cli output - time_since_last_log := time.Since(last_log) - if first_log || msg.GetState() != sdp.CalculateBlastRadiusResponse_STATE_DISCOVERING || time_since_last_log > 250*time.Millisecond { - log.WithField("msg", msg).Trace("Status update") - last_log = time.Now() - first_log = false - } - stateLabel := "unknown" - switch msg.GetState() { - case sdp.CalculateBlastRadiusResponse_STATE_UNSPECIFIED: - stateLabel = "unknown" - case sdp.CalculateBlastRadiusResponse_STATE_DISCOVERING: - stateLabel = "discovering blast radius" - case sdp.CalculateBlastRadiusResponse_STATE_FINDING_APPS: - stateLabel = "finding apps" - case sdp.CalculateBlastRadiusResponse_STATE_SAVING, sdp.CalculateBlastRadiusResponse_STATE_DONE: - stateLabel = "done" - } - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.ProgressMsg(stateLabel, msg.GetNumItems(), msg.GetNumEdges())} - - // send a message when the blast radius is saved - if msg.GetState() == sdp.CalculateBlastRadiusResponse_STATE_SAVING { - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.SavingMsg()} - } - } - if resultStream.Err() != nil { - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("error streaming results", err)} - close(m.processing) - return nil - } - m.processing <- submitPlanUpdateMsg{m.blastRadiusTask.FinishMsg()} - - // Add tracing that the blast radius has finished - if cmdSpan != nil { - cmdSpan.AddEvent("Blast radius calculation finished", trace.WithAttributes( - attribute.Int("ovm.blast_radius.items", int(msg.GetNumItems())), - attribute.Int("ovm.blast_radius.edges", int(msg.GetNumEdges())), - attribute.String("ovm.blast_radius.state", msg.GetState().String()), - attribute.StringSlice("ovm.blast_radius.errors", msg.GetErrors()), - attribute.String("ovm.change.uuid", changeUuid.String()), - )) - } - - changeUrl := *m.oi.FrontendUrl - changeUrl.Path = fmt.Sprintf("%v/changes/%v/blast-radius", changeUrl.Path, changeUuid) - log.WithField("change-url", changeUrl.String()).Info("Change ready") - - // update local copy - m.changeUrl = changeUrl.String() - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{url: m.changeUrl}} - - /////////////////////////////////////////////////////////////////// - // wait for risk calculation to happen - /////////////////////////////////////////////////////////////////// - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusRunning)} - risksErrored := false - var riskRes *connect.Response[sdp.GetChangeRisksResponse] - for { - riskRes, err = client.GetChangeRisks(ctx, &connect.Request[sdp.GetChangeRisksRequest]{ - Msg: &sdp.GetChangeRisksRequest{ - UUID: changeUuid[:], - }, - }) - if err != nil { - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("failed to get change risks", err)} - close(m.processing) - return nil - } - - m.processing <- submitPlanUpdateMsg{changeUpdatedMsg{ - url: m.changeUrl, - riskMilestones: riskRes.Msg.GetChangeRiskMetadata().GetRiskCalculationStatus().GetProgressMilestones(), - risks: riskRes.Msg.GetChangeRiskMetadata().GetRisks(), - }} - - status := riskRes.Msg.GetChangeRiskMetadata().GetRiskCalculationStatus().GetStatus() - if status == sdp.RiskCalculationStatus_STATUS_UNSPECIFIED || status == sdp.RiskCalculationStatus_STATUS_INPROGRESS { - time.Sleep(time.Second) - // retry - } else if status == sdp.RiskCalculationStatus_STATUS_ERROR { - risksErrored = true - break - } else { - // it's done - break - } - - if ctx.Err() != nil { - err := ctx.Err() - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("context cancelled", err)} - close(m.processing) - return nil - } - } - - if risksErrored { - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusError)} - m.processing <- submitPlanUpdateMsg{m.risksError("initial risk calculation errored", nil)} - close(m.processing) - return nil - } - - // Submit milestone for tracing - if cmdSpan != nil { - cmdSpan.AddEvent("Risk calculation finished", trace.WithAttributes( - attribute.Int("ovm.risks.count", len(riskRes.Msg.GetChangeRiskMetadata().GetRisks())), - attribute.String("ovm.change.uuid", changeUuid.String()), - )) - } - - m.processing <- submitPlanUpdateMsg{m.riskTask.UpdateStatusMsg(taskStatusDone)} - m.processing <- submitPlanUpdateMsg{submitPlanFinishedMsg{"Done"}} - - return nil -} - -func (m submitPlanModel) FinalReport() string { - if m.Status() == taskStatusPending { - // hasn't started yet - return "" - } - - bits := []string{} - if m.blastRadiusItems > 0 { - bits = append(bits, styleH1().Render("Blast Radius")) - bits = append(bits, fmt.Sprintf("\nItems: %v\nEdges: %v\n", m.blastRadiusItems, m.blastRadiusEdges)) - } - if len(m.risks) == 0 { - bits = append(bits, styleH1().Render("Potential Risks")) - bits = append(bits, "") - bits = append(bits, "Overmind has not identified any risks associated with this change.") - bits = append(bits, "") - bits = append(bits, "This could be due to the change being low risk with no impact on other parts of the system, or involving resources that Overmind currently does not support.") - } else if m.changeUrl != "" { - bits = append(bits, styleH1().Render("Potential Risks")) - bits = append(bits, "") - for _, r := range m.risks { - severity := "" - switch r.GetSeverity() { - case sdp.Risk_SEVERITY_HIGH: - severity = lipgloss.NewStyle(). - Background(ColorPalette.BgDanger). - Foreground(ColorPalette.LabelTitle). - Padding(0, 1). - Bold(true). - Render("High ‼") - case sdp.Risk_SEVERITY_MEDIUM: - severity = lipgloss.NewStyle(). - Background(ColorPalette.BgWarning). - Foreground(ColorPalette.LabelTitle). - Padding(0, 1). - Render("Medium !") - case sdp.Risk_SEVERITY_LOW: - severity = lipgloss.NewStyle(). - Background(ColorPalette.LabelBase). - Foreground(ColorPalette.LabelTitle). - Padding(0, 1). - Render("Low ⓘ ") - case sdp.Risk_SEVERITY_UNSPECIFIED: - // do nothing - } - title := lipgloss.NewStyle(). - Foreground(ColorPalette.BgMain). - PaddingRight(1). - Bold(true). - Render(r.GetTitle()) - - bits = append(bits, (fmt.Sprintf("%v%v\n\n%v\n\n", - title, - severity, - wordwrap.String(r.GetDescription(), min(160, m.width-4))))) - } - bits = append(bits, fmt.Sprintf("\nCheck the blast radius graph and risks at:\n%v\n\n", m.changeUrl)) - } - - return strings.Join(bits, "\n") + "\n" -} diff --git a/cmd/tea_taskmodel.go b/cmd/tea_taskmodel.go deleted file mode 100644 index b07cd5e3..00000000 --- a/cmd/tea_taskmodel.go +++ /dev/null @@ -1,127 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type taskStatus int - -const ( - taskStatusPending taskStatus = 0 - taskStatusRunning taskStatus = 1 - taskStatusDone taskStatus = 2 - taskStatusError taskStatus = 3 - taskStatusSkipped taskStatus = 4 -) - -type taskModel struct { - status taskStatus - title string - spinner spinner.Model - - width int - indent int -} - -type WithTaskModel interface { - TaskModel() taskModel -} - -// assert that taskModel implements WithTaskModel -var _ WithTaskModel = (*taskModel)(nil) - -type updateTaskTitleMsg struct { - id int - title string -} - -type updateTaskStatusMsg struct { - id int - status taskStatus -} - -func NewTaskModel(title string, width int) taskModel { - return taskModel{ - status: taskStatusPending, - title: title, - spinner: spinner.New( - spinner.WithSpinner(PlatformSpinner()), - spinner.WithStyle(lipgloss.NewStyle().Foreground(ColorPalette.BgMain)), - ), - width: width, - indent: 2, - } -} - -func (m taskModel) Init() tea.Cmd { - if m.status == taskStatusRunning && os.Getenv("CI") == "" { - return m.spinner.Tick - } - return nil -} - -func (m taskModel) TaskModel() taskModel { - return m -} - -func (m taskModel) Update(msg tea.Msg) (taskModel, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - return m, nil - - case updateTaskTitleMsg: - if m.spinner.ID() == msg.id { - m.title = msg.title - } - case updateTaskStatusMsg: - if m.spinner.ID() == msg.id { - m.status = msg.status - } - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - return m, nil -} - -func (m taskModel) View() string { - label := "" - switch m.status { - case taskStatusPending: - label = lipgloss.NewStyle().Foreground(ColorPalette.LabelFaint).Render("+") - case taskStatusRunning: - label = m.spinner.View() - case taskStatusDone: - label = RenderOk() - case taskStatusError: - label = RenderErr() - case taskStatusSkipped: - label = lipgloss.NewStyle().Foreground(ColorPalette.LabelFaint).Render("-") - default: - label = lipgloss.NewStyle().Render("?") - } - - return wrap(fmt.Sprintf("%v %v", label, m.title), m.width, m.indent) -} - -func (m taskModel) UpdateTitleMsg(newTitle string) tea.Msg { - return updateTaskTitleMsg{ - id: m.spinner.ID(), - title: newTitle, - } -} - -func (m taskModel) UpdateStatusMsg(newStatus taskStatus) tea.Msg { - return updateTaskStatusMsg{ - id: m.spinner.ID(), - status: newStatus, - } -} diff --git a/cmd/tea_terraform.go b/cmd/tea_terraform.go deleted file mode 100644 index 3e4c829a..00000000 --- a/cmd/tea_terraform.go +++ /dev/null @@ -1,337 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "slices" - "sort" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/getsentry/sentry-go" - "github.com/go-jose/go-jose/v4" - josejwt "github.com/go-jose/go-jose/v4/jwt" - "github.com/overmindtech/sdp-go" - log "github.com/sirupsen/logrus" - "github.com/spf13/viper" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" - "golang.org/x/oauth2" -) - -type cmdModel struct { - action string // "plan" or "apply" - - // Context and cancel function from the CmdWrapper. Since bubbletea provides - // no context handling, we can't follow the usual pattern of keeping the - // context out of structs. - ctx context.Context - cancel context.CancelFunc - - // configuration - timeout time.Duration - app string - apiKey string - oi OvermindInstance // loaded from instanceLoadedMsg - requiredScopes []string - args []string - - // UI state - tasks map[string]tea.Model - fatalError string // this will get set if there's a fatalError coming through that doesn't have a task ID set - - frozen bool - frozenView string // this gets set if the view is frozen, and will be used to render the last view using the cliExecCommand - - hideStartupStatus bool - - // business logic. This model will implement the actual CLI functionality requested. - cmd tea.Model - - width int -} - -type freezeViewMsg struct{} -type execResultMsg struct { - fn tea.ExecCallback - err error -} -type unfreezeViewMsg struct{} - -type hideStartupStatusMsg struct{} - -type delayQuitMsg struct{} - -// fatalError is a wrapper for errors that should abort the running tea.Program. -type fatalError struct { - id int - err error -} - -// otherError is a wrapper for errors that should NOT abort the running tea.Program. -type otherError struct { - id int - err error -} - -func (m *cmdModel) Init() tea.Cmd { - // use the main cli context to not take this time from the main timeout - m.tasks["00_oi"] = NewInstanceLoaderModel(m.ctx, m.app, m.width) - m.tasks["01_token"] = NewEnsureTokenModel(m.ctx, m.app, m.apiKey, m.requiredScopes, m.width) - - if viper.GetString("ovm-test-fake") != "" { - // don't init sources on test-fake runs - // m.tasks["02_config"] = NewInitialiseSourcesModel() - return tea.Batch( - m.tasks["00_oi"].Init(), - m.tasks["01_token"].Init(), - // m.tasks["02_config"].Init(), - func() tea.Msg { - time.Sleep(3 * time.Second) - return sourcesInitialisedMsg{} - }, - m.cmd.Init(), - ) - } - - // these wait for taking a ctx until timeout and token are attached - m.tasks["02_config"] = NewInitialiseSourcesModel(m.width) - - return tea.Batch( - m.tasks["00_oi"].Init(), - m.tasks["01_token"].Init(), - m.tasks["02_config"].Init(), - m.cmd.Init(), - ) -} - -func (m *cmdModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - lastMsgType := fmt.Sprintf("%T", msg) - if lastMsgType != "spinner.TickMsg" { - log.Debugf("cmdModel: Update %v received %#v", lastMsgType, msg) - if cmdSpan != nil && - strings.HasPrefix(lastMsgType, "cmd.") && - !slices.Contains( - []string{"cmd.delayQuitMsg", "cmd.fatalError", "cmd.otherError"}, - lastMsgType, - ) { - cmdSpan.SetAttributes(attribute.String("ovm.cli.lastMsgType", lastMsgType)) - } - } - - cmds := []tea.Cmd{} - - // special case the messages that need to be handled at this level - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = min(MAX_TERMINAL_WIDTH, msg.Width) - - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - if cmdSpan != nil { - cmdSpan.SetAttributes(attribute.Bool("ovm.cli.aborted", true)) - } - return m, tea.Quit - } - case freezeViewMsg: - m.frozenView = m.View() - m.frozen = true - case execResultMsg: - // first update the state with the result of the command, only then - // unfreeze the view avoid showing the pre-command state - cmds = append(cmds, tea.Sequence( - func() tea.Msg { return msg.fn(msg.err) }, - func() tea.Msg { return unfreezeViewMsg{} }, - )) - case unfreezeViewMsg: - m.frozen = false - m.frozenView = "" - case hideStartupStatusMsg: - m.hideStartupStatus = true - - case fatalError: - log.WithError(msg.err).WithField("msg.id", msg.id).Debug("cmdModel: fatalError received") - span := trace.SpanFromContext(m.ctx) - span.RecordError(msg.err) - span.SetAttributes( - attribute.Bool("ovm.cli.fatalError", true), - attribute.Int("ovm.cli.fatalError.id", msg.id), - attribute.String("ovm.cli.fatalError.msg", msg.err.Error()), - ) - sentry.CaptureException(msg.err) - - // record the fatal error here, to repeat it at the end of the process - m.fatalError = msg.err.Error() - - cmds = append(cmds, func() tea.Msg { return delayQuitMsg{} }) - - case instanceLoadedMsg: - m.oi = msg.instance - // skip irrelevant status messages - // delete(m.tasks, "00_oi") - - case tokenAvailableMsg: - var cmd tea.Cmd - cmd = m.tokenChecks(msg.token) - cmds = append(cmds, cmd) - - case delayQuitMsg: - cmds = append(cmds, tea.Quit) - - } - - // update the main command - var cmd tea.Cmd - m.cmd, cmd = m.cmd.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - - // pass all messages to all tasks - for k, t := range m.tasks { - tm, cmd := t.Update(msg) - m.tasks[k] = tm - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return m, tea.Batch(cmds...) -} - -func (m *cmdModel) tokenChecks(token *oauth2.Token) tea.Cmd { - if viper.GetString("ovm-test-fake") != "" { - return func() tea.Msg { - return loadSourcesConfigMsg{ - ctx: m.ctx, - oi: m.oi, - action: m.action, - token: token, - tfArgs: m.args, - } - } - } - - // Check that we actually got the claims we asked for. If you don't have - // permission auth0 will just not assign those scopes rather than fail - ok, missing, err := HasScopesFlexible(token, m.requiredScopes) - if err != nil { - return func() tea.Msg { return fatalError{err: fmt.Errorf("error checking token scopes: %w", err)} } - } - if !ok { - return func() tea.Msg { - return fatalError{err: fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", missing)} - } - } - - // store the token for later use by sdp-go's auth client. Note that this - // loses access to the RefreshToken and could be done better by using an - // oauth2.TokenSource, but this would require more work on updating sdp-go - // that is currently not scheduled - m.ctx = context.WithValue(m.ctx, sdp.UserTokenContextKey{}, token.AccessToken) - - // apply the configured timeout to all future operations - m.ctx, m.cancel = context.WithTimeout(m.ctx, m.timeout) - - // daisy chain the next step. This is a bit of a hack, but it's the easiest - // for now, and we still need a good idea for a better way. Especially as - // some of the models require access to viper (for GetConfig/SetConfig) or - // contortions to store that data somewhere else. - return func() tea.Msg { - tok, err := josejwt.ParseSigned(token.AccessToken, []jose.SignatureAlgorithm{jose.RS256}) - if err != nil { - return fatalError{err: fmt.Errorf("received invalid token: %w", err)} - } - out := josejwt.Claims{} - customClaims := sdp.CustomClaims{} - err = tok.UnsafeClaimsWithoutVerification(&out, &customClaims) - if err != nil { - return fatalError{err: fmt.Errorf("received unparsable token: %w", err)} - } - - if cmdSpan != nil { - cmdSpan.SetAttributes( - attribute.Bool("ovm.cli.authenticated", true), - attribute.String("ovm.cli.accountName", customClaims.AccountName), - attribute.String("ovm.cli.userId", out.Subject), - ) - } - - return loadSourcesConfigMsg{ - ctx: m.ctx, - oi: m.oi, - action: m.action, - token: token, - tfArgs: m.args, - } - } -} - -func (m cmdModel) View() string { - if m.frozen { - return "" - } - bits := []string{} - - if !m.hideStartupStatus { - // show tasks in key order, skipping pending bits to keep the ui uncluttered - keys := make([]string, 0, len(m.tasks)) - for k := range m.tasks { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - t, ok := m.tasks[k].(WithTaskModel) - if ok { - if t.TaskModel().status == taskStatusPending { - continue - } - } - bits = append(bits, m.tasks[k].View()) - } - } - - bits = append(bits, m.cmd.View()) - if m.fatalError != "" { - md := markdownToString(m.width, fmt.Sprintf("> Fatal Error: %v", m.fatalError)) - bits = append(bits, strings.Trim(md, "\n")) - md = markdownToString(m.width, "> Get help in our [Discord](https://discord.com/invite/5UKsqAkPWG)") - bits = append(bits, strings.Trim(md, "\n")) - } - bits = slices.DeleteFunc(bits, func(s string) bool { - return s == "" || s == "\n" - }) - return strings.Join(bits, "\n") -} - -var applyOnlyArgs = []string{ - "auto-approve", -} - -// planArgsFromApplyArgs filters out all apply-specific arguments from arguments -// to `terraform apply`, so that we can run the corresponding `terraform plan` -// command -func planArgsFromApplyArgs(args []string) []string { - planArgs := []string{} -append: - for _, arg := range args { - for _, applyOnlyArg := range applyOnlyArgs { - if strings.HasPrefix(arg, "-"+applyOnlyArg) { - continue append - } - if strings.HasPrefix(arg, "--"+applyOnlyArg) { - continue append - } - } - planArgs = append(planArgs, arg) - } - return planArgs -} - -func Exec(c tea.ExecCommand, fn tea.ExecCallback) tea.Cmd { - return tea.Sequence( - func() tea.Msg { return freezeViewMsg{} }, - tea.Exec(c, func(err error) tea.Msg { return execResultMsg{fn, err} }), - ) -} diff --git a/cmd/tea_terraform_test.go b/cmd/tea_terraform_test.go deleted file mode 100644 index 3205d16e..00000000 --- a/cmd/tea_terraform_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "reflect" - "testing" -) - -func TestPlanArgsFromApplyArgs(t *testing.T) { - tests := []struct { - name string - args []string - want []string - }{ - { - name: "No apply-specific arguments", - args: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - want: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - }, - { - name: "Single apply-specific argument", - args: []string{"-var-file=vars.tfvars", "-out=tfplan", "--auto-approve"}, - want: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - }, - { - name: "Single apply-specific argument, one dash", - args: []string{"-var-file=vars.tfvars", "-out=tfplan", "-auto-approve"}, - want: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - }, - { - name: "Multiple apply-specific arguments", - args: []string{"-var-file=vars.tfvars", "-out=tfplan", "--auto-approve"}, - want: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - }, - { - name: "Arguments with boolean values", - args: []string{"-var-file=vars.tfvars", "-out=tfplan", "--auto-approve=FALSE"}, - want: []string{"-var-file=vars.tfvars", "-out=tfplan"}, - }, - { - name: "No arguments", - args: []string{}, - want: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := planArgsFromApplyArgs(tt.args) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("planArgsFromApplyArgs() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cmd/terraform.go b/cmd/terraform.go index c4a9dadd..137687e8 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -1,6 +1,8 @@ package cmd import ( + "strings" + "github.com/spf13/cobra" ) @@ -29,3 +31,27 @@ everything that happened, including any unexpected repercussions.`, func init() { rootCmd.AddCommand(terraformCmd) } + +var applyOnlyArgs = []string{ + "auto-approve", +} + +// planArgsFromApplyArgs filters out all apply-specific arguments from arguments +// to `terraform apply`, so that we can run the corresponding `terraform plan` +// command +func planArgsFromApplyArgs(args []string) []string { + planArgs := []string{} +append: + for _, arg := range args { + for _, applyOnlyArg := range applyOnlyArgs { + if strings.HasPrefix(arg, "-"+applyOnlyArg) { + continue append + } + if strings.HasPrefix(arg, "--"+applyOnlyArg) { + continue append + } + } + planArgs = append(planArgs, arg) + } + return planArgs +} diff --git a/cmd/theme.go b/cmd/theme.go index a7299fdc..a896a173 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -4,9 +4,7 @@ import ( _ "embed" "fmt" "strings" - "time" - "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" @@ -307,10 +305,10 @@ func MarkdownStyle() ansi.StyleConfig { } } -var DotsSpinner = spinner.Spinner{ - Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - FPS: 80 * time.Millisecond, -} +// var DotsSpinner = spinner.Spinner{ +// Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, +// FPS: 80 * time.Millisecond, +// } var titleStyle = lipgloss.NewStyle().Foreground(ColorPalette.BgMain).Bold(true) var textStyle = lipgloss.NewStyle().Foreground(ColorPalette.LabelBase) @@ -399,10 +397,3 @@ func RenderErr() string { } return lipgloss.NewStyle().Foreground(ColorPalette.BgDanger).Render(checkMark) } - -func PlatformSpinner() spinner.Spinner { - if IsConhost() { - return spinner.Line - } - return DotsSpinner -} diff --git a/go.mod b/go.mod index edb2cfa3..dfc8c1e6 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.27.33 github.com/aws/aws-sdk-go-v2/credentials v1.17.32 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 - github.com/charmbracelet/bubbles v0.19.0 - github.com/charmbracelet/bubbletea v0.27.1 github.com/charmbracelet/glamour v0.8.0 - github.com/charmbracelet/huh v0.5.3 github.com/charmbracelet/lipgloss v0.13.0 github.com/getsentry/sentry-go v0.28.1 github.com/go-jose/go-jose/v4 v4.0.4 @@ -61,7 +58,6 @@ require ( github.com/alecthomas/kingpin/v2 v2.3.2 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/auth0/go-jwt-middleware/v2 v2.2.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect @@ -100,17 +96,13 @@ require ( github.com/aws/smithy-go v1.20.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/catppuccin/go v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/charmbracelet/x/ansi v0.2.2 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect github.com/coder/websocket v1.8.12 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -128,16 +120,12 @@ require ( github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/micahhausler/aws-iam-policy v0.4.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/dns v1.1.62 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/nats-io/jwt/v2 v2.5.8 // indirect github.com/nats-io/nats.go v1.37.0 // indirect github.com/nats-io/nkeys v0.4.7 // indirect diff --git a/go.sum b/go.sum index cf5f41f9..3b4b426b 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= connectrpc.com/connect v1.16.2 h1:ybd6y+ls7GOlb7Bh5C8+ghA6SvCBajHwxssO2CGFjqE= connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -38,8 +36,6 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8V github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/auth0/go-jwt-middleware/v2 v2.2.1 h1:pqxEIwlCztD0T9ZygGfOrw4NK/F9iotnCnPJVADKbkE= github.com/auth0/go-jwt-middleware/v2 v2.2.1/go.mod h1:CSi0tuu0QrALbWdiQZwqFL8SbBhj4e2MJzkvNfjY0Us= github.com/aws/aws-sdk-go v1.54.13 h1:zpCuiG+/mFdDY/klKJvmSioAZWk45F4rLGq0JWVAAzk= @@ -128,28 +124,16 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= -github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= -github.com/charmbracelet/bubbletea v0.27.1 h1:/yhaJKX52pxG4jZVKCNWj/oq0QouPdXycriDRA6m6r8= -github.com/charmbracelet/bubbletea v0.27.1/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg= -github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/x/ansi v0.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0= github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= @@ -162,10 +146,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -243,8 +223,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -261,14 +239,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= @@ -438,7 +410,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=