Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions integration/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,177 @@ func TestRemoveApplicationWithData(t *testing.T) {
assert.ErrorIs(t, err, docker.ErrVolumeNotFound)
}

func TestDeployWithSettings(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-settings-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

settings := docker.ApplicationSettings{
Name: "settingsapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "settingsapp.localhost",
DisableTLS: true,
EnvVars: map[string]string{"CUSTOM_VAR": "custom_value", "ANOTHER": "thing"},
SMTP: docker.SMTPSettings{
Server: "smtp.example.com",
Port: "587",
Username: "user",
Password: "pass",
From: "noreply@example.com",
},
Resources: docker.ContainerResources{CPUs: 1, MemoryMB: 512},
AutoUpdate: false,
Backup: docker.BackupSettings{Path: "/backups", AutoBackup: true},
}

app := deployApp(t, ctx, ns, settings)

// Verify settings persisted via label restore
ns2, err := docker.RestoreNamespace(ctx, "once-settings-test")
require.NoError(t, err)

restored := ns2.Application("settingsapp")
require.NotNil(t, restored)
assert.True(t, restored.Settings.DisableTLS)
assert.Equal(t, "custom_value", restored.Settings.EnvVars["CUSTOM_VAR"])
assert.Equal(t, "thing", restored.Settings.EnvVars["ANOTHER"])
assert.Equal(t, "smtp.example.com", restored.Settings.SMTP.Server)
assert.Equal(t, "587", restored.Settings.SMTP.Port)
assert.False(t, restored.Settings.AutoUpdate)
assert.Equal(t, "/backups", restored.Settings.Backup.Path)
assert.True(t, restored.Settings.Backup.AutoBackup)

// Verify container env vars
containerName, err := app.ContainerName(ctx)
require.NoError(t, err)
envVars := inspectContainerEnv(t, ctx, containerName)
assert.Contains(t, envVars, "CUSTOM_VAR=custom_value")
assert.Contains(t, envVars, "ANOTHER=thing")
assert.Contains(t, envVars, "SMTP_ADDRESS=smtp.example.com")

// Verify container resources
assertContainerResources(t, ctx, containerName, 1e9, 512*1024*1024)
}

func TestUpdatePreservesSettings(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-update-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

// Deploy with full settings
app := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "updateapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "update.localhost",
EnvVars: map[string]string{"MY_VAR": "my_value"},
SMTP: docker.SMTPSettings{
Server: "smtp.example.com",
Port: "587",
},
Resources: docker.ContainerResources{CPUs: 2, MemoryMB: 1024},
})

vol, err := app.Volume(ctx)
require.NoError(t, err)
originalSecretKeyBase := vol.SecretKeyBase()

// Update only the env vars, leaving everything else as-is
newSettings := app.Settings
newSettings.EnvVars = map[string]string{"NEW_VAR": "new_value"}
app.Settings = newSettings
require.NoError(t, app.Deploy(ctx, nil))
require.NoError(t, ns.Refresh(ctx))

updatedApp := ns.ApplicationByHost("update.localhost")
require.NotNil(t, updatedApp)

// Name preserved
assert.Equal(t, "updateapp", updatedApp.Settings.Name)

// SMTP and resources preserved
assert.Equal(t, "smtp.example.com", updatedApp.Settings.SMTP.Server)
assert.Equal(t, "587", updatedApp.Settings.SMTP.Port)
assert.Equal(t, 2, updatedApp.Settings.Resources.CPUs)
assert.Equal(t, 1024, updatedApp.Settings.Resources.MemoryMB)

// Env vars replaced
containerName, err := updatedApp.ContainerName(ctx)
require.NoError(t, err)
envVars := inspectContainerEnv(t, ctx, containerName)
assert.Contains(t, envVars, "NEW_VAR=new_value")
assertEnvAbsent(t, envVars, "MY_VAR")

// Volume preserved
vol2, err := updatedApp.Volume(ctx)
require.NoError(t, err)
assert.Equal(t, originalSecretKeyBase, vol2.SecretKeyBase())
}

func TestUpdateChangeHost(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-update-host-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

app := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "hostchangeapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "old.localhost",
})

// Change the host
app.Settings.Host = "new.localhost"
require.NoError(t, app.Deploy(ctx, nil))
require.NoError(t, ns.Refresh(ctx))

assert.Nil(t, ns.ApplicationByHost("old.localhost"))
assert.NotNil(t, ns.ApplicationByHost("new.localhost"))
}

func TestUpdateHostCollision(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-update-collision-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "app1",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "host1.localhost",
})

app2 := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "app2",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "host2.localhost",
})

// Attempting to change app2's host to app1's host should be detected
assert.True(t, ns.HostInUseByAnother("host1.localhost", app2.Settings.Name))
}

// Helpers

func TestContainerResources(t *testing.T) {
Expand Down Expand Up @@ -1035,6 +1206,27 @@ func buildTestBackup(t *testing.T, imageName string) []byte {
return buf.Bytes()
}

func inspectContainerEnv(t *testing.T, ctx context.Context, name string) []string {
t.Helper()
c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
require.NoError(t, err)
defer c.Close()

info, err := c.ContainerInspect(ctx, name)
require.NoError(t, err)
return info.Config.Env
}

func assertEnvAbsent(t *testing.T, envVars []string, key string) {
t.Helper()
prefix := key + "="
for _, e := range envVars {
if strings.HasPrefix(e, prefix) {
t.Errorf("expected env var %s to be absent, but found %s", key, e)
}
}
}

func extractTarGz(t *testing.T, r io.Reader) map[string][]byte {
t.Helper()

Expand Down
13 changes: 8 additions & 5 deletions internal/command/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type backupCommand struct {
func newBackupCommand() *backupCommand {
b := &backupCommand{}
b.cmd = &cobra.Command{
Use: "backup <app> <filename>",
Use: "backup <host> <filename>",
Short: "Backup an application to a file",
Args: cobra.ExactArgs(2),
RunE: WithNamespace(b.run),
Expand All @@ -28,19 +28,22 @@ func newBackupCommand() *backupCommand {
// Private

func (b *backupCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobra.Command, args []string) error {
appName := args[0]
filename := args[1]
host := args[0]
filename, err := filepath.Abs(args[1])
if err != nil {
return fmt.Errorf("resolving path: %w", err)
}

dir := filepath.Dir(filename)
name := filepath.Base(filename)

err := withApplication(ns, appName, "backing up", func(app *docker.Application) error {
err = withApplication(ns, host, "backing up", func(app *docker.Application) error {
return app.BackupToFile(ctx, dir, name)
})
if err != nil {
return err
}

fmt.Printf("Backed up %s to %s\n", appName, filename)
fmt.Printf("Backed up %s to %s\n", host, filename)
return nil
}
122 changes: 122 additions & 0 deletions internal/command/cli_progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package command

import (
"fmt"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

"github.com/basecamp/once/internal/docker"
"github.com/basecamp/once/internal/ui"
)

type cliProgress struct {
label string
stage string
progress ui.Progress
progressChan chan docker.DeployProgress
err error
width int

task func(docker.DeployProgressCallback) error
}

type cliProgressDoneMsg struct{ err error }
type cliProgressUpdateMsg struct{ p docker.DeployProgress }

func newCLIProgress(label string, task func(docker.DeployProgressCallback) error) *cliProgress {
return &cliProgress{
label: label,
stage: "preparing",
progress: ui.NewProgress(0, lipgloss.BrightBlue),
progressChan: make(chan docker.DeployProgress, 16),
task: task,
}
}

func (m *cliProgress) Run() error {
_, err := tea.NewProgram(m).Run()
if err != nil {
return err
}

if m.err != nil {
fmt.Printf("%s: %s\n", m.label, lipgloss.NewStyle().Foreground(lipgloss.Red).Render("failed"))
} else {
fmt.Printf("%s: %s\n", m.label, lipgloss.NewStyle().Foreground(lipgloss.Green).Render("done"))
}

return m.err
}

func (m *cliProgress) Init() tea.Cmd {
return tea.Batch(
m.runTask(),
m.waitForProgress(),
m.progress.Init(),
)
}

func (m *cliProgress) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.progress = m.progress.SetWidth(m.barWidth())
return m, nil

case cliProgressUpdateMsg:
switch msg.p.Stage {
case docker.DeployStageDownloading:
m.stage = "downloading"
m.progress = m.progress.SetPercent(msg.p.Percentage)
case docker.DeployStageStarting:
m.stage = "starting"
m.progress = m.progress.SetPercent(-1)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cliProgress.Update doesn't handle docker.DeployStageFinished, so the UI stage/progress can remain stuck on "starting" (with indeterminate progress) even after deploy has completed, until the program quits. Consider adding a DeployStageFinished case to set a sensible final stage (or transition to a new stage like "verifying") and/or set percent to 100.

Suggested change
m.progress = m.progress.SetPercent(-1)
m.progress = m.progress.SetPercent(-1)
case docker.DeployStageFinished:
m.stage = "finished"
m.progress = m.progress.SetPercent(1)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the TUI exits at that stage, so it shouldn't be trying to displaying anything.

}
return m, m.waitForProgress()

case cliProgressDoneMsg:
m.err = msg.err
return m, tea.Quit

case ui.ProgressTickMsg:
var cmd tea.Cmd
m.progress, cmd = m.progress.Update(msg)
return m, cmd
}

return m, nil
}

func (m *cliProgress) View() tea.View {
prefix := m.label + " " + m.stage + ": "
return tea.NewView(prefix + m.progress.View())
}

// Private

func (m *cliProgress) barWidth() int {
prefixWidth := len(m.label) + 1 + len(m.stage) + 2 // " " + stage + ": "
w := m.width - prefixWidth
if w < 10 {
w = 10
}
return w
}

func (m *cliProgress) runTask() tea.Cmd {
return func() tea.Msg {
callback := func(p docker.DeployProgress) {
m.progressChan <- p
}
err := m.task(callback)
return cliProgressDoneMsg{err: err}
}
}

func (m *cliProgress) waitForProgress() tea.Cmd {
return func() tea.Msg {
p := <-m.progressChan
return cliProgressUpdateMsg{p: p}
}
}
Loading
Loading