Gitfed
bastien-mrq/gitfed / cmd / gitfed-ctl / model.go
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
	}
}