package ui import ( "bytes" "context" "encoding/json" "fmt" "io" "strings" "time" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/logs" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/docker/cli/cli/command/service/progress" containerTypes "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" dockerClient "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" "github.com/muesli/reflow/wordwrap" ) type statusMsg struct { stream stream jsonMsg jsonmessage.JSONMessage } type healthState struct { log string } type healthcheckMsg struct { stream stream state healthState } type progressCompleteMsg struct { stream stream // NOTE(d1): failure scenarios are as follows // an error from ServiceProgress // a rollback failed bool } type ServiceMeta struct { Name string ID string } type Model struct { appName string cl *dockerClient.Client count int ctx context.Context err error info string timeout time.Duration width int LogsBuffer *bytes.Buffer Streams *[]stream Failed bool Timeout bool } func (m Model) complete() bool { if m.count == len(*m.Streams) { return true } return false } type stream struct { decoder *json.Decoder id string reader *io.PipeReader writer *io.PipeWriter overallStatus string rollbackStatus string verifyStatus string healthcheckStatus string Err error ErrStatus string Name string } func (s stream) String() string { out := fmt.Sprintf("{decoder: %v, ", s.decoder) out += fmt.Sprintf("err: %v, ", s.Err) out += fmt.Sprintf("id: %s, ", s.id) out += fmt.Sprintf("name: %s, ", s.Name) out += fmt.Sprintf("reader: %v, ", s.reader) out += fmt.Sprintf("writer: %v, ", s.writer) out += fmt.Sprintf("overallStatus: %s, ", s.overallStatus) out += fmt.Sprintf("rollbackStatus: %s, ", s.rollbackStatus) out += fmt.Sprintf("verifyStatus: %s, ", s.verifyStatus) out += fmt.Sprintf("errStatus: %s, ", s.ErrStatus) out += fmt.Sprintf("healthcheckStatus: %s}", s.healthcheckStatus) return out } func (s stream) progress(m Model) tea.Msg { if err := progress.ServiceProgress(m.ctx, m.cl, s.id, s.writer); err != nil { s.Err = err return progressCompleteMsg{ stream: s, failed: true, } } return progressCompleteMsg{stream: s} } func (s stream) process() tea.Msg { var jsonMsg jsonmessage.JSONMessage if err := s.decoder.Decode(&jsonMsg); err != nil { if err == io.EOF { // NOTE(d1): end processing messages return nil } s.Err = err } return statusMsg{ stream: s, jsonMsg: jsonMsg, } } func (s stream) healthcheck(m Model) tea.Msg { filters := filters.NewArgs() filters.Add("name", fmt.Sprintf("^%s", s.Name)) containers, err := m.cl.ContainerList(m.ctx, containerTypes.ListOptions{Filters: filters}) if err != nil { s.Err = err return healthcheckMsg{stream: s} } if len(containers) == 0 { return healthcheckMsg{stream: s} } container := containers[0] containerState, err := m.cl.ContainerInspect(m.ctx, container.ID) if err != nil { s.Err = err return healthcheckMsg{stream: s} } var log string if containerState.State.Health != nil { if len(containerState.State.Health.Log) > 0 { entry := containerState.State.Health.Log[0] if entry.ExitCode < 0 { log = entry.Output } } return healthcheckMsg{ stream: s, state: healthState{log: log}, } } return healthcheckMsg{stream: s} } func DeployInitialModel( ctx context.Context, cl *dockerClient.Client, services []ServiceMeta, appName string, timeout time.Duration, ) Model { var streams []stream for _, service := range services { r, w := io.Pipe() d := json.NewDecoder(r) streams = append(streams, stream{ Name: service.Name, id: service.ID, reader: r, writer: w, decoder: d, }) } infoRenderer := lipgloss.NewStyle(). Bold(true). MaxWidth(4). Foreground(lipgloss.Color("86")) infoMarker := infoRenderer.Render("INFO") var logsBuffer bytes.Buffer return Model{ ctx: ctx, cl: cl, appName: appName, timeout: timeout, Streams: &streams, info: infoMarker, LogsBuffer: &logsBuffer, } } func (m Model) Init() tea.Cmd { var cmds []tea.Cmd for _, stream := range *m.Streams { cmds = append( cmds, []tea.Cmd{ func() tea.Msg { return stream.progress(m) }, func() tea.Msg { return stream.process() }, func() tea.Msg { return stream.healthcheck(m) }, }..., ) } cmds = append(cmds, func() tea.Msg { return deployTimeout(m) }) cmds = append(cmds, func() tea.Msg { return m.gatherLogs() }) return tea.Batch(cmds...) } func (m Model) gatherLogs() tea.Msg { var services []string for _, s := range *m.Streams { services = append(services, s.Name) } opts := logs.TailOpts{ AppName: m.appName, Services: services, StdErr: true, Buffer: m.LogsBuffer, ToBuffer: true, } if err := logs.TailLogs(m.cl, opts); err != nil { // TODO // log.Debugf("gatherLogs: %s", err) } return nil } type timeoutMsg struct{} func deployTimeout(m Model) tea.Msg { <-time.After(m.timeout) return timeoutMsg{} } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width case progressCompleteMsg: if msg.failed { m.Failed = true } m.count += 1 if m.complete() { return m, tea.Quit } case healthcheckMsg: if msg.state != (healthState{}) && msg.state.log != "" { msg.stream.healthcheckStatus = strings.ToLower(msg.state.log) if msg.stream.Err != nil { msg.stream.ErrStatus = msg.stream.Err.Error() } for idx, s := range *m.Streams { if s.id == msg.stream.id { (*m.Streams)[idx].healthcheckStatus = msg.stream.healthcheckStatus } } } cmds = append( cmds, func() tea.Msg { return msg.stream.healthcheck(m) }, ) case timeoutMsg: m.Timeout = true return m, tea.Quit case statusMsg: switch msg.jsonMsg.ID { case "rollback": m.Failed = true msg.stream.rollbackStatus = strings.ToLower(msg.jsonMsg.Status) case "overall progress": msg.stream.overallStatus = strings.ToLower(msg.jsonMsg.Status) case "verify": msg.stream.verifyStatus = strings.ToLower(msg.jsonMsg.Status) } if msg.stream.Err != nil { msg.stream.ErrStatus = msg.stream.Err.Error() } for idx, s := range *m.Streams { if s.id == msg.stream.id { switch msg.jsonMsg.ID { case "rollback": (*m.Streams)[idx].rollbackStatus = msg.stream.rollbackStatus case "overall progress": (*m.Streams)[idx].overallStatus = msg.stream.overallStatus case "verify": (*m.Streams)[idx].verifyStatus = msg.stream.verifyStatus } } } cmds = append( cmds, func() tea.Msg { return msg.stream.process() }, ) } return m, tea.Batch(cmds...) } func (m Model) View() string { body := strings.Builder{} for _, stream := range *m.Streams { split := strings.Split(stream.Name, "_") short := split[len(split)-1] newLineSpacing := " " for range len(short) { newLineSpacing += " " } output := fmt.Sprintf("%s %s: %s", m.info, formatter.BoldStyle.Render(short), stream.overallStatus, ) if stream.verifyStatus != "" { output += fmt.Sprintf(" (%s)", stream.verifyStatus) } if stream.rollbackStatus != "" { output += fmt.Sprintf("\n%s%s: %s", newLineSpacing, formatter.BoldUnderlineStyle.Render("ROLLBACK"), stream.rollbackStatus, ) } if stream.healthcheckStatus != "" { output += fmt.Sprintf("\n%s%s: %s", newLineSpacing, formatter.BoldUnderlineStyle.Render("HEALTHCHECK"), wrapHealthstatus(m, stream, newLineSpacing), ) } if stream.ErrStatus != "" { output += fmt.Sprintf("\n%s%s: %s", newLineSpacing, formatter.BoldUnderlineStyle.Render("ERROR"), stream.ErrStatus, ) } body.WriteString(output) body.WriteString("\n") } return body.String() } // wrapHealthstatus wraps the health check output which is mostly quite long. func wrapHealthstatus(m Model, s stream, newLineSpacing string) string { // NOTE(d1): the spacing here represents "HEALTCHECK: ". 20 is an arbitrary // padding chosen in the hope of not overrunning horizontal space. newLineSpacingWithIndent := newLineSpacing + " " firstWrap := wordwrap.String( s.healthcheckStatus, ((m.width - len(newLineSpacingWithIndent)) - 20), ) var finalWrap string for idx, line := range strings.Split(firstWrap, "\n") { if idx == 0 { finalWrap = line continue } finalWrap += fmt.Sprintf("\n%s%s", newLineSpacingWithIndent, line) } return finalWrap }