forked from toolshed/abra
421
pkg/ui/deploy.go
Normal file
421
pkg/ui/deploy.go
Normal file
@ -0,0 +1,421 @@
|
||||
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
|
||||
}
|
Reference in New Issue
Block a user