package main
import (
"context"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type screen int
const (
screenDashboard screen = iota
screenUpdatesCheck
screenUpdatesAckBreaking
screenUpdatesConfirm
screenUpdatesRunning
screenUpdatesSuccess
)
// pipelineStepStatus tracks one named step of the update pipeline for the
// running screen's live checklist.
type pipelineStepStatus struct {
Label string
Done bool
Err error
}
type model struct {
screen screen
width, height int
quitting bool
fatalErr error
msgCh chan tea.Msg
ctx context.Context
cancel context.CancelFunc
sourceDir string
// dashboard / health
healthChecked bool
healthChecking bool
kubectlOK bool
k3sYAMLPresent bool
podLine string
podHealthy bool
podErr error
fixing bool
fixErr error
// updates: check
checking bool
checked bool
checkErr error
currentVersion string
targetVersion string
upToDate bool
// updates: changelog range + breaking-change acknowledgment
fetchingVersions bool
versionsErr error
versions []versionChangelog
breaking []bullet
ackChecklist []bool
ackCursor int
// updates: confirm
backupToggle bool
// updates: running
backupStep, pullStep, buildStep, importStep, applyStep, rolloutStep pipelineStepStatus
// stepLogs captures every step's output, keyed by stream name (see
// exec.go's logLineMsg) — not just the build step's, so a failing
// "pull"/"apply"/"rollout" step can show its real error output too
// instead of just the bare "exit status 1" a step's Go error carries.
stepLogs map[string][]string
resolvedVersion string
pipelineErr error
spin spinner.Model
}
func initialModel(sourceDir string) model {
ctx, cancel := context.WithCancel(context.Background())
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = styleAccent
return model{
screen: screenDashboard,
msgCh: make(chan tea.Msg, 16),
ctx: ctx,
cancel: cancel,
sourceDir: sourceDir,
backupToggle: true,
stepLogs: make(map[string][]string),
spin: sp,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(listen(m.msgCh), m.spin.Tick, m.cmdCheckHealth())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case healthCheckMsg:
m.healthChecked = true
m.healthChecking = false
m.kubectlOK = msg.kubectlOK
m.k3sYAMLPresent = msg.k3sYAMLPresent
m.podLine = msg.podLine
m.podHealthy = msg.podHealthy
m.podErr = msg.podErr
return m, listen(m.msgCh)
case fixKubeconfigDoneMsg:
m.fixing = false
m.fixErr = msg.err
if msg.err == nil {
return m, tea.Batch(m.cmdCheckHealth(), listen(m.msgCh))
}
return m, listen(m.msgCh)
case updateCheckMsg:
m.checking = false
m.checked = true
m.checkErr = msg.err
m.currentVersion = msg.current
m.targetVersion = msg.target
m.upToDate = msg.upToDate
if msg.err != nil || msg.upToDate {
return m, listen(m.msgCh)
}
m.fetchingVersions = true
return m, tea.Batch(m.cmdFetchVersionsRange(), listen(m.msgCh))
case versionsRangeMsg:
m.fetchingVersions = false
m.versionsErr = msg.err
m.versions = msg.versions
if msg.err != nil {
return m, listen(m.msgCh)
}
m.breaking = flattenBreaking(msg.versions)
if len(m.breaking) == 0 {
m.screen = screenUpdatesConfirm
return m, listen(m.msgCh)
}
m.ackChecklist = make([]bool, len(m.breaking))
m.ackCursor = 0
m.screen = screenUpdatesAckBreaking
return m, listen(m.msgCh)
case logLineMsg:
m.stepLogs[msg.stream] = append(m.stepLogs[msg.stream], msg.line)
return m, listen(m.msgCh)
case versionResolvedMsg:
m.resolvedVersion = msg.version
return m, listen(m.msgCh)
case stepResultMsg:
m.handlePipelineStep(msg)
return m, listen(m.msgCh)
case fatalErrMsg:
m.fatalErr = msg.err
return m, listen(m.msgCh)
}
return m, nil
}
func (m *model) handlePipelineStep(msg stepResultMsg) {
step := func(s *pipelineStepStatus, label string) {
s.Label = label
s.Err = msg.err
s.Done = msg.err == nil
}
switch msg.stream {
case "backup":
step(&m.backupStep, "Sauvegarde")
case "pull":
step(&m.pullStep, "Récupération du code")
case "version":
// no dedicated checklist row — this just re-writes deployment.yaml
// locally between "pull" and "build"; an error here surfaces on
// the pull row since nothing else is showing yet.
if msg.err != nil {
m.pullStep.Err = msg.err
}
case "build":
step(&m.buildStep, "Build de l'image")
case "import":
step(&m.importStep, "Import dans containerd")
case "apply":
step(&m.applyStep, "Application du déploiement")
case "rollout":
step(&m.rolloutStep, "Déploiement du pod")
if msg.err == nil {
m.screen = screenUpdatesSuccess
}
}
if msg.err != nil {
m.pipelineErr = msg.err
}
}
// --- messages produced by background goroutines ---
type healthCheckMsg struct {
kubectlOK bool
k3sYAMLPresent bool
podLine string
podHealthy bool
podErr error
}
type fixKubeconfigDoneMsg struct{ err error }
type updateCheckMsg struct {
current, target string
upToDate bool
err error
}
type versionsRangeMsg struct {
versions []versionChangelog
err error
}
type fatalErrMsg struct{ err error }
// --- commands: each launches a goroutine that eventually writes to m.msgCh ---
func (m model) cmdCheckHealth() tea.Cmd {
ch := m.msgCh
ctx := m.ctx
return func() tea.Msg {
go func() {
kubectlOK := checkKubectl(ctx)
k3sPresent := k3sYAMLExists()
var podLine string
var podHealthy bool
var podErr error
if kubectlOK {
podLine, podHealthy, podErr = podSummary(ctx)
}
ch <- healthCheckMsg{
kubectlOK: kubectlOK,
k3sYAMLPresent: k3sPresent,
podLine: podLine,
podHealthy: podHealthy,
podErr: podErr,
}
}()
return nil
}
}
func (m model) cmdFixKubeconfig() tea.Cmd {
ch := m.msgCh
return func() tea.Msg {
go func() { ch <- fixKubeconfigDoneMsg{err: fixKubeconfig()} }()
return nil
}
}
func (m model) cmdCheckUpdates() tea.Cmd {
ch := m.msgCh
ctx := m.ctx
sourceDir := m.sourceDir
return func() tea.Msg {
go func() {
current, target, upToDate, err := checkForUpdates(ctx, sourceDir)
ch <- updateCheckMsg{current: current, target: target, upToDate: upToDate, err: err}
}()
return nil
}
}
func (m model) cmdFetchVersionsRange() tea.Cmd {
ch := m.msgCh
ctx := m.ctx
sourceDir := m.sourceDir
current, target := m.currentVersion, m.targetVersion
return func() tea.Msg {
go func() {
versions, err := versionsInRange(ctx, sourceDir, current, target)
ch <- versionsRangeMsg{versions: versions, err: err}
}()
return nil
}
}
func (m model) cmdRunPipeline() tea.Cmd {
ch := m.msgCh
ctx := m.ctx
sourceDir := m.sourceDir
backup := m.backupToggle
return func() tea.Msg {
go runUpdatePipeline(ctx, ch, sourceDir, backup)
return nil
}
}