abra/pkg/ui/deploy.go
decentral1se 8a19536ace
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
wip: fix: deploy status
#478
2025-02-11 11:03:02 +01:00

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
}