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{} })
}