422 lines
8.7 KiB
Go
422 lines
8.7 KiB
Go
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
|
|
}
|