Gitfed
bastien-mrq/gitfed / cmd / gitfed-install / model.go
package main

import (
	"context"
	"os"
	"os/exec"
	"time"

	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
)

type step int

const (
	stepWelcome step = iota
	stepScan
	stepDomain
	stepDNS
	stepK3s
	stepBuild
	stepDeploy
	stepPodStatus
	stepPodStuck
	stepVerify
	stepAccount
	stepSuccess
)

// deployFileStatus tracks one manifest's apply progress in stepDeploy.
type deployFileStatus struct {
	Label string
	Done  bool
	Err   error
}

type verifyResult struct {
	Label  string
	OK     bool
	Detail string
}

type model struct {
	step          step
	width, height int
	quitting      bool
	fatalErr      error

	msgCh  chan tea.Msg
	ctx    context.Context
	cancel context.CancelFunc

	sourceURL string
	workDir   string
	sourceDir string

	scan     scanResult
	scanDone bool

	domainInput, contactInput textinput.Model
	focusIdx                  int
	publicIP                  string
	publicIPDone              bool

	dns         dnsOutcome
	dnsChecked  bool
	dnsChecking bool
	dnsRetryIn  int

	k3sLines        []string
	k3sDone         bool
	certManagerDone bool
	issuerDone      bool
	installErr      error

	buildLines []string
	buildDone  bool
	importDone bool
	buildErr   error

	deployStatus []deployFileStatus
	deployIdx    int
	deployErr    error

	pod        podStatus
	podPolling bool
	podErr     error

	stuckDiag     tagDiagnosis
	stuckDiagDone bool

	verify     []verifyResult
	verifyDone bool

	ctlInstallDone bool
	ctlInstallErr  error

	spin spinner.Model
}

func initialModel(sourceURL string) model {
	ctx, cancel := context.WithCancel(context.Background())

	di := textinput.New()
	di.Placeholder = "git.mondomaine.fr"
	di.Focus()
	di.CharLimit = 253
	di.Width = 40

	ci := textinput.New()
	ci.Placeholder = "moi@example.com"
	ci.CharLimit = 253
	ci.Width = 40

	sp := spinner.New()
	sp.Spinner = spinner.Dot
	sp.Style = styleAccent

	return model{
		step:         stepWelcome,
		msgCh:        make(chan tea.Msg, 16),
		ctx:          ctx,
		cancel:       cancel,
		sourceURL:    sourceURL,
		domainInput:  di,
		contactInput: ci,
		spin:         sp,
	}
}

func (m model) Init() tea.Cmd {
	return tea.Batch(listen(m.msgCh), m.spin.Tick)
}

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 scanResultMsg:
		m.scan = msg.result
		m.scanDone = true
		return m, listen(m.msgCh)

	case publicIPMsg:
		// The domain field is never prefilled from this — the IP is shown
		// alongside it purely as a reference for the DNS record to add.
		m.publicIP = msg.ip
		m.publicIPDone = true
		return m, listen(m.msgCh)

	case dnsResultMsg:
		m.dns = msg.outcome
		m.dnsChecked = true
		m.dnsChecking = false
		if !m.dns.Matches {
			m.dnsRetryIn = 15
			return m, tea.Batch(listen(m.msgCh), dnsTick())
		}
		return m, listen(m.msgCh)

	case dnsTickMsg:
		if m.step != stepDNS || m.dns.Matches {
			return m, nil
		}
		m.dnsRetryIn--
		if m.dnsRetryIn <= 0 {
			m.dnsChecking = true
			return m, tea.Batch(m.cmdCheckDNS(), listen(m.msgCh))
		}
		return m, dnsTick()

	case logLineMsg:
		m.appendLog(msg.stream, msg.line)
		return m, listen(m.msgCh)

	case stepResultMsg:
		m.handleStepResult(msg)
		return m, listen(m.msgCh)

	case podPollTickMsg:
		if m.step != stepPodStatus {
			return m, nil
		}
		m.podPolling = true
		return m, tea.Batch(m.cmdPollPod(), listen(m.msgCh))

	case podPollMsg:
		m.pod = msg.status
		m.podErr = msg.err
		m.podPolling = false
		if msg.err == nil && msg.status.Phase == podReady {
			m.step = stepVerify
			return m, tea.Batch(m.cmdVerify(), m.cmdInstallCtl(), listen(m.msgCh))
		}
		if msg.err == nil && msg.status.Phase == podErrImageNeverPull {
			m.step = stepPodStuck
			m.stuckDiagDone = false
			return m, tea.Batch(m.cmdDiagnoseStuck(), listen(m.msgCh))
		}
		// still starting — poll again shortly
		return m, tea.Batch(pollAgain(), listen(m.msgCh))

	case stuckDiagMsg:
		m.stuckDiag = msg.diag
		m.stuckDiagDone = true
		return m, listen(m.msgCh)

	case verifyResultMsg:
		m.verify = msg.results
		m.verifyDone = true
		return m, listen(m.msgCh)

	case ctlInstallMsg:
		m.ctlInstallDone = true
		m.ctlInstallErr = msg.err
		return m, listen(m.msgCh)

	case accountHandoffDoneMsg:
		m.step = stepSuccess
		return m, listen(m.msgCh)

	case fatalErrMsg:
		m.fatalErr = msg.err
		return m, listen(m.msgCh)
	}
	return m, nil
}

func (m *model) appendLog(stream, line string) {
	switch stream {
	case "k3s", "certmanager", "issuer":
		m.k3sLines = append(m.k3sLines, line)
	case "clone", "build", "import":
		m.buildLines = append(m.buildLines, line)
	}
}

func (m *model) handleStepResult(msg stepResultMsg) {
	switch msg.stream {
	case "k3s":
		m.installErr = msg.err
		if msg.err == nil {
			m.k3sDone = true
		}
	case "certmanager":
		if msg.err == nil {
			m.certManagerDone = true
		} else {
			m.installErr = msg.err
		}
	case "issuer":
		if msg.err == nil {
			m.issuerDone = true
		} else {
			m.installErr = msg.err
		}
	case "clone", "build":
		if msg.err != nil {
			m.buildErr = msg.err
		} else if msg.stream == "build" {
			m.buildDone = true
		}
	case "import":
		if msg.err != nil {
			m.buildErr = msg.err
		} else {
			m.importDone = true
		}
	default:
		if len(msg.stream) > 7 && msg.stream[:7] == "deploy:" {
			m.handleDeployResult(msg)
		}
	}
}

func (m *model) handleDeployResult(msg stepResultMsg) {
	for i := range m.deployStatus {
		if "deploy:"+m.deployStatus[i].Label == msg.stream {
			m.deployStatus[i].Done = msg.err == nil
			m.deployStatus[i].Err = msg.err
		}
	}
	if msg.err != nil {
		m.deployErr = msg.err
		return
	}
	m.deployIdx++
}

// --- messages produced by background goroutines ---

type scanResultMsg struct{ result scanResult }
type publicIPMsg struct{ ip string }
type dnsResultMsg struct{ outcome dnsOutcome }
type dnsTickMsg struct{}
type podPollMsg struct {
	status podStatus
	err    error
}
type verifyResultMsg struct{ results []verifyResult }
type ctlInstallMsg struct{ err error }
type stuckDiagMsg struct{ diag tagDiagnosis }
type accountHandoffDoneMsg struct{}
type fatalErrMsg struct{ err error }

func dnsTick() tea.Cmd {
	return tea.Tick(time.Second, func(time.Time) tea.Msg { return dnsTickMsg{} })
}

type podPollTickMsg struct{}

func pollAgain() tea.Cmd {
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return podPollTickMsg{} })
}

// --- commands: each launches a goroutine that eventually writes to m.msgCh ---

func (m model) cmdScan() tea.Cmd {
	ch := m.msgCh
	return func() tea.Msg {
		go func() { ch <- scanResultMsg{result: runScan()} }()
		return nil
	}
}

func (m model) cmdDetectIP() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	return func() tea.Msg {
		go func() { ch <- publicIPMsg{ip: detectPublicIP(ctx)} }()
		return nil
	}
}

func (m model) cmdCheckDNS() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	domain, ip := m.domainInput.Value(), m.publicIP
	return func() tea.Msg {
		go func() { ch <- dnsResultMsg{outcome: checkDNS(ctx, domain, ip)} }()
		return nil
	}
}

func (m model) cmdInstallDeps() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	scan := m.scan
	cfg := instanceConfig{Domain: m.domainInput.Value(), Contact: m.contactInput.Value()}
	return func() tea.Msg {
		go runInstallDeps(ctx, ch, scan, cfg)
		return nil
	}
}

func (m model) cmdBuildImage() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	return func() tea.Msg {
		go runBuildImage(ctx, ch, m.sourceURL, m.workDir)
		return nil
	}
}

func (m model) cmdDeploy() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	cfg := instanceConfig{Domain: m.domainInput.Value(), Contact: m.contactInput.Value()}
	sourceDir := m.sourceDir
	return func() tea.Msg {
		go runDeploy(ctx, ch, sourceDir, cfg)
		return nil
	}
}

func (m model) cmdPollPod() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	return func() tea.Msg {
		go func() {
			status, err := pollPodOnce(ctx)
			ch <- podPollMsg{status: status, err: err}
		}()
		return nil
	}
}

func (m model) cmdDiagnoseStuck() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	sourceDir := m.sourceDir
	return func() tea.Msg {
		go func() { ch <- stuckDiagMsg{diag: diagnoseStuckPod(ctx, sourceDir)} }()
		return nil
	}
}

func (m model) cmdVerify() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	domain := m.domainInput.Value()
	return func() tea.Msg {
		go func() { ch <- verifyResultMsg{results: runVerify(ctx, domain)} }()
		return nil
	}
}

// cmdInstallCtl installs gitfed-ctl (the update utility, see cmd/gitfed-ctl)
// straight into /usr/local/bin so it's ready for future updates without a
// separate manual step — best-effort, never blocks the wizard from
// finishing (see viewVerify/viewSuccess for how a failure here is shown).
func (m model) cmdInstallCtl() tea.Cmd {
	ch := m.msgCh
	ctx := m.ctx
	return func() tea.Msg {
		go func() { ch <- ctlInstallMsg{err: installGitfedCtl(ctx)} }()
		return nil
	}
}

// launchAccountTUI suspends gitfed-install and hands the terminal to a
// real, unmodified, interactive `kubectl exec -it ... gitfed-tui` session
// — see screens_finish.go for why this is a handoff rather than gitfed-
// install driving account creation itself.
func launchAccountTUI() tea.Cmd {
	c := exec.Command("kubectl", "-n", "gitfed", "exec", "-it", "deployment/gitfed", "-c", "server",
		"--", "gitfed-tui", "-config", "/etc/gitfed/gitfed.json")
	c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
	return tea.ExecProcess(c, func(error) tea.Msg { return accountHandoffDoneMsg{} })
}