Adds a StepRunUpdater step that starts app-dono-updater after the app container comes up: a poll loop (docker pull, compare image IDs, recreate on change) baked into the official docker:cli image via `sh -c`, reusing the same docker run argv as the initial container start so the two can't drift. Not built on Watchtower: containrrr/watchtower was archived upstream in Dec 2025 with no maintained successor recommended for production use, so this avoids taking on that dependency.
452 lines
10 KiB
Go
452 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
|
|
"charm.land/bubbles/v2/spinner"
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
const (
|
|
imageName = "hub.davinti.com.br:443/app-dono/app-cliente:latest"
|
|
wireguardImageName = "hub.davinti.com.br:443/davinti-vproxy:latest"
|
|
updaterImageName = "docker:cli"
|
|
configPath = "config.toml"
|
|
wireguardConfigPath = "envs"
|
|
)
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
switch key.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
// Global "back" on input steps. Action/wait steps handle esc themselves.
|
|
if m.canGoBack() {
|
|
m.goBack()
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
// Fit inputs to the terminal: subtract the left padding, the "> " prompt and
|
|
// the cursor cell, with a sensible floor for very narrow terminals.
|
|
inputWidth := msg.Width - (padding * 2) - 4
|
|
if inputWidth < 20 {
|
|
inputWidth = 20
|
|
}
|
|
for _, f := range []*FormStep{
|
|
&m.loginForm, &m.wireguardForm, &m.appForm,
|
|
&m.serverForm, &m.dbForm, &m.certForm,
|
|
} {
|
|
f.SetWidth(inputWidth)
|
|
}
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
switch m.currentStep {
|
|
case StepCheckDocker:
|
|
return m.updateCheckDocker(msg)
|
|
case StepDockerInstall:
|
|
return m.updateDockerInstall(msg)
|
|
case StepDockerLogin:
|
|
done, cmd := m.loginForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Login = m.loginForm.Values()
|
|
m.advance(StepDownloadImage)
|
|
|
|
return m, DownloadImageCmd(imageName, m.configValues.Login["user"], m.configValues.Login["password"])
|
|
}
|
|
|
|
return m, cmd
|
|
case StepDownloadImage:
|
|
return m.updateDownloadImage(msg)
|
|
|
|
// Ip & Wireguard
|
|
case StepIPQuestion:
|
|
return m.updateIPQuestion(msg)
|
|
case StepWireguardConfig:
|
|
done, cmd := m.wireguardForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Wireguard = m.wireguardForm.Values()
|
|
|
|
m.downloadDone = false
|
|
m.downloadMessage = ""
|
|
m.downloadError = nil
|
|
m.advance(StepGenerateWireguardFile)
|
|
|
|
return m, GenerateWireguardConfigFile(m.configValues, wireguardConfigPath)
|
|
}
|
|
|
|
return m, cmd
|
|
case StepGenerateWireguardFile:
|
|
return m.updateGenerateWireguardFile(msg)
|
|
case StepDownloadWireguard:
|
|
return m.updateDownloadWireguard(msg)
|
|
case StepRunWireguard:
|
|
return m.updateRunWireguardDocker(msg)
|
|
|
|
case StepAppConfig:
|
|
done, cmd := m.appForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Application = m.appForm.Values()
|
|
m.advance(StepServerConfig)
|
|
}
|
|
|
|
return m, cmd
|
|
case StepServerConfig:
|
|
done, cmd := m.serverForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Server = m.serverForm.Values()
|
|
m.advance(StepDatabaseConfig)
|
|
}
|
|
|
|
return m, cmd
|
|
case StepDatabaseConfig:
|
|
done, cmd := m.dbForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Database = m.dbForm.Values()
|
|
m.advance(StepCertConfig)
|
|
}
|
|
|
|
return m, cmd
|
|
case StepCertConfig:
|
|
done, cmd := m.certForm.Update(msg)
|
|
|
|
if done {
|
|
m.configValues.Cert = m.certForm.Values()
|
|
m.advance(StepReview)
|
|
}
|
|
|
|
return m, cmd
|
|
case StepReview:
|
|
return m.updateReview(msg)
|
|
case StepGenerateFile:
|
|
return m.updateGenerateFile(msg)
|
|
case StepRunDocker:
|
|
return m.updateRunDocker(msg)
|
|
case StepRunUpdater:
|
|
return m.updateRunUpdater(msg)
|
|
case StepDone:
|
|
return m, tea.Quit
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateCheckDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DockerCheckedMsg:
|
|
m.dockerInstalled = msg.Installed
|
|
m.checkDockerDone = true
|
|
case TickMsg:
|
|
if m.checkProgress < 1.0 {
|
|
m.checkProgress += 0.2 + (rand.Float64()*0.2 - 0.2)
|
|
if m.checkProgress > 1.0 {
|
|
m.checkProgress = 1.0
|
|
}
|
|
return m, TickCmd()
|
|
}
|
|
case tea.KeyPressMsg:
|
|
if m.checkDockerDone && m.checkProgress == 1 {
|
|
if m.dockerInstalled {
|
|
m.loading = true
|
|
m.advance(StepDockerLogin)
|
|
} else {
|
|
m.advance(StepDockerInstall)
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateDockerInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg.(type) {
|
|
case tea.KeyPressMsg:
|
|
return m, tea.Quit
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateDownloadImage(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ImageDownloadFinishedMsg:
|
|
m.downloadDone = true
|
|
m.downloadMessage = msg.Message
|
|
m.downloadError = msg.Err
|
|
return m, nil
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.downloadDone && m.downloadError == nil {
|
|
m.advance(StepIPQuestion)
|
|
} else if m.downloadDone {
|
|
switch msg.String() {
|
|
case "r":
|
|
m.downloadDone = false
|
|
m.downloadMessage = ""
|
|
m.downloadError = nil
|
|
return m, DownloadImageCmd(imageName, m.configValues.Login["user"], m.configValues.Login["password"])
|
|
default:
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateIPQuestion(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
numOptions := 2
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyPressMsg:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "down", "j":
|
|
if m.cursor < numOptions-1 {
|
|
m.cursor++
|
|
}
|
|
case "enter":
|
|
// Yes
|
|
if m.cursor == 0 {
|
|
m.advance(StepAppConfig)
|
|
return m, nil
|
|
}
|
|
|
|
m.advance(StepWireguardConfig)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateDownloadWireguard(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ImageDownloadFinishedMsg:
|
|
m.downloadDone = true
|
|
m.downloadMessage = msg.Message
|
|
m.downloadError = msg.Err
|
|
|
|
if m.downloadError == nil {
|
|
m.currentStep = StepRunWireguard
|
|
|
|
return m, RunWireguardContainer(wireguardConfigPath, m.configValues)
|
|
}
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.downloadDone && m.downloadError == nil {
|
|
m.currentStep = StepRunWireguard
|
|
} else if m.downloadDone {
|
|
switch msg.String() {
|
|
case "r":
|
|
m.downloadDone = false
|
|
m.downloadMessage = ""
|
|
m.downloadError = nil
|
|
return m, DownloadImageCmd(wireguardImageName, m.configValues.Login["user"], m.configValues.Login["password"])
|
|
default:
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateGenerateWireguardFile(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ConfigFileMsg:
|
|
m.finishedFile = true
|
|
m.configFileError = msg.Err
|
|
|
|
if msg.Err == nil {
|
|
m.currentStep = StepDownloadWireguard
|
|
|
|
return m, DownloadImageCmd(wireguardImageName, m.configValues.Login["user"], m.configValues.Login["password"])
|
|
}
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.finishedFile && m.configFileError != nil {
|
|
// Validation error (e.g. MTU): go back to the form to correct it. The form
|
|
// is the top of the history stack, so goBack lands on it without leaving a
|
|
// duplicate entry; fall back to a direct set if history is unexpectedly empty.
|
|
if !m.goBack() {
|
|
m.currentStep = StepWireguardConfig
|
|
}
|
|
} else if m.finishedFile && m.configFileError == nil {
|
|
m.currentStep = StepDownloadWireguard
|
|
|
|
return m, DownloadImageCmd(wireguardImageName, m.configValues.Login["user"], m.configValues.Login["password"])
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateRunWireguardDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DockerRunMsg:
|
|
m.finishedDockerRun = true
|
|
m.dockerRunError = msg.Err
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
switch msg.String() {
|
|
case "r":
|
|
m.finishedDockerRun = false
|
|
m.dockerRunError = nil
|
|
return m, RunWireguardContainer(wireguardConfigPath, m.configValues)
|
|
default:
|
|
return m, tea.Quit
|
|
}
|
|
} else if m.finishedDockerRun && m.dockerRunError == nil {
|
|
m.advance(StepAppConfig)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// updateReview shows the collected configuration for confirmation before any files are
|
|
// written. Enter confirms and generates config.toml; Esc (handled globally) goes back
|
|
// to the previous form to edit.
|
|
func (m Model) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyPressMsg:
|
|
switch msg.String() {
|
|
case "enter":
|
|
m.advance(StepGenerateFile)
|
|
|
|
m.finishedFile = false
|
|
m.configFileError = nil
|
|
|
|
return m, GenerateConfigFile(m.configValues, configPath)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateGenerateFile(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ConfigFileMsg:
|
|
m.finishedFile = true
|
|
m.configFileError = msg.Err
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.finishedFile && m.configFileError != nil {
|
|
// Validation error: return to the review (top of the stack) so the user can
|
|
// navigate back to the forms; direct set as a fallback.
|
|
if !m.goBack() {
|
|
m.currentStep = StepReview
|
|
}
|
|
} else if m.finishedFile && m.configFileError == nil {
|
|
m.currentStep = StepRunDocker
|
|
|
|
m.finishedDockerRun = false
|
|
m.dockerRunError = nil
|
|
|
|
return m, RunAppContainer(
|
|
imageName,
|
|
"app-dono-cliente",
|
|
configPath,
|
|
fmt.Sprintf("/app/%s", configPath),
|
|
m.configValues,
|
|
)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateRunDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DockerRunMsg:
|
|
m.finishedDockerRun = true
|
|
m.dockerRunError = msg.Err
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
switch msg.String() {
|
|
case "r":
|
|
m.finishedDockerRun = false
|
|
m.dockerRunError = nil
|
|
return m, RunAppContainer(
|
|
imageName,
|
|
"app-dono-cliente",
|
|
configPath,
|
|
fmt.Sprintf("/app/%s", configPath),
|
|
m.configValues,
|
|
)
|
|
default:
|
|
return m, tea.Quit
|
|
}
|
|
} else if m.finishedDockerRun && m.dockerRunError == nil {
|
|
m.currentStep = StepRunUpdater
|
|
|
|
m.finishedDockerRun = false
|
|
m.dockerRunError = nil
|
|
|
|
return m, RunUpdaterContainer(
|
|
imageName,
|
|
"app-dono-cliente",
|
|
configPath,
|
|
fmt.Sprintf("/app/%s", configPath),
|
|
m.configValues,
|
|
)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// updateRunUpdater starts the auto-update agent that watches the app-dono-cliente
|
|
// container and pulls/recreates it whenever a new image is pushed to :latest.
|
|
func (m Model) updateRunUpdater(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DockerRunMsg:
|
|
m.finishedDockerRun = true
|
|
m.dockerRunError = msg.Err
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
switch msg.String() {
|
|
case "r":
|
|
m.finishedDockerRun = false
|
|
m.dockerRunError = nil
|
|
return m, RunUpdaterContainer(
|
|
imageName,
|
|
"app-dono-cliente",
|
|
configPath,
|
|
fmt.Sprintf("/app/%s", configPath),
|
|
m.configValues,
|
|
)
|
|
default:
|
|
return m, tea.Quit
|
|
}
|
|
} else if m.finishedDockerRun && m.dockerRunError == nil {
|
|
m.currentStep = StepDone
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|