diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index 2ec77a4..4e12a30 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -14,6 +14,7 @@ type settingsFlags struct { host string disableTLS bool env []string + mounts []string smtpServer string smtpPort string smtpUsername string @@ -30,6 +31,7 @@ func (f *settingsFlags) register(cmd *cobra.Command) { cmd.Flags().StringVar(&f.host, "host", "", "hostname for the application") cmd.Flags().BoolVar(&f.disableTLS, "disable-tls", false, "disable TLS for this application") cmd.Flags().StringArrayVar(&f.env, "env", nil, "environment variable in KEY=VALUE format (can be repeated)") + cmd.Flags().StringArrayVar(&f.mounts, "mount", nil, "bind mount in SOURCE:TARGET format (can be repeated)") cmd.Flags().StringVar(&f.smtpServer, "smtp-server", "", "SMTP server address") cmd.Flags().StringVar(&f.smtpPort, "smtp-port", "", "SMTP server port") cmd.Flags().StringVar(&f.smtpUsername, "smtp-username", "", "SMTP username") @@ -48,6 +50,11 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet return docker.ApplicationSettings{}, err } + mounts, err := f.parseMounts() + if err != nil { + return docker.ApplicationSettings{}, err + } + if f.backupPath != "" && !filepath.IsAbs(f.backupPath) { return docker.ApplicationSettings{}, docker.ErrBackupPathRelative } @@ -57,6 +64,7 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet Host: host, DisableTLS: f.disableTLS, EnvVars: envVars, + Mounts: mounts, SMTP: docker.SMTPSettings{ Server: f.smtpServer, Port: f.smtpPort, @@ -133,6 +141,13 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica if cmd.Flags().Changed("auto-backup") { s.Backup.AutoBackup = f.autoBackup } + if cmd.Flags().Changed("mount") { + mounts, err := f.parseMounts() + if err != nil { + return s, err + } + s.Mounts = mounts + } if err := s.Validate(); err != nil { return s, err @@ -141,6 +156,23 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica return s, nil } +func (f *settingsFlags) parseMounts() ([]docker.MountSetting, error) { + if f.mounts == nil { + return nil, nil + } + + var mounts []docker.MountSetting + for _, m := range f.mounts { + source, target, ok := strings.Cut(m, ":") + if !ok { + return nil, fmt.Errorf("invalid mount %q: must be in SOURCE:TARGET format", m) + } + mounts = append(mounts, docker.MountSetting{Source: source, Target: target}) + } + + return mounts, nil +} + func (f *settingsFlags) parseEnvVars() (map[string]string, error) { if f.env == nil { return nil, nil diff --git a/internal/docker/application.go b/internal/docker/application.go index 00836c7..b6443a8 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -30,7 +30,11 @@ var ( msg: "pull failed", description: "Failed to download the application image. Check that the image name is correct and try again.", } - ErrDeployFailed = errors.New("deploy failed") + ErrMountSourceRelative = errors.New("mount source path must be absolute") + ErrMountTargetRelative = errors.New("mount target path must be absolute") + ErrMountDuplicateTarget = errors.New("duplicate mount target path") + ErrMountTargetReserved = errors.New("mount target conflicts with built-in volume mount") + ErrDeployFailed = errors.New("deploy failed") ErrVerificationFailed = &describedError{ msg: "verification failed", description: "The application couldn't be verified. Please check that you have a valid DNS record set up.", @@ -426,6 +430,13 @@ func (a *Application) volumeMounts(vol *ApplicationVolume) []mount.Mount { Target: target, }) } + for _, m := range a.Settings.Mounts { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeBind, + Source: m.Source, + Target: m.Target, + }) + } return mounts } diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 25d2f8e..02c8b83 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -2,6 +2,7 @@ package docker import ( "encoding/json" + "path/filepath" "strconv" ) @@ -36,6 +37,11 @@ type BackupSettings struct { AutoBackup bool `json:"autoBackup,omitempty"` } +type MountSetting struct { + Source string `json:"source"` + Target string `json:"target"` +} + type ApplicationSettings struct { Name string `json:"name"` Image string `json:"image"` @@ -46,6 +52,7 @@ type ApplicationSettings struct { Resources ContainerResources `json:"resources"` AutoUpdate bool `json:"autoUpdate"` Backup BackupSettings `json:"backup"` + Mounts []MountSetting `json:"mounts,omitempty"` } func UnmarshalApplicationSettings(s string) (ApplicationSettings, error) { @@ -66,6 +73,33 @@ func (s ApplicationSettings) Validate() error { if s.Backup.AutoBackup && s.Backup.Path == "" { return ErrAutoBackupWithoutPath } + if err := ValidateMounts(s.Mounts); err != nil { + return err + } + return nil +} + +func ValidateMounts(mounts []MountSetting) error { + reserved := make(map[string]bool, len(AppVolumeMountTargets)) + for _, t := range AppVolumeMountTargets { + reserved[t] = true + } + seen := make(map[string]bool) + for _, m := range mounts { + if !filepath.IsAbs(m.Source) { + return ErrMountSourceRelative + } + if !filepath.IsAbs(m.Target) { + return ErrMountTargetRelative + } + if reserved[m.Target] { + return ErrMountTargetReserved + } + if seen[m.Target] { + return ErrMountDuplicateTarget + } + seen[m.Target] = true + } return nil } @@ -97,6 +131,14 @@ func (s ApplicationSettings) Equal(other ApplicationSettings) bool { return false } } + if len(s.Mounts) != len(other.Mounts) { + return false + } + for i, m := range s.Mounts { + if m != other.Mounts[i] { + return false + } + } return true } diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 72f677d..291cf72 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -155,6 +155,100 @@ func TestEnvVarsEqualDiffers(t *testing.T) { assert.False(t, base.Equal(none)) } +func TestMountsMarshalRoundTrip(t *testing.T) { + original := ApplicationSettings{ + Name: "app", + Image: "img:latest", + Mounts: []MountSetting{ + {Source: "/host/data", Target: "/container/data"}, + {Source: "/host/config", Target: "/container/config"}, + }, + } + restored, err := UnmarshalApplicationSettings(original.Marshal()) + require.NoError(t, err) + require.Len(t, restored.Mounts, 2) + assert.Equal(t, "/host/data", restored.Mounts[0].Source) + assert.Equal(t, "/container/data", restored.Mounts[0].Target) + assert.Equal(t, "/host/config", restored.Mounts[1].Source) + assert.Equal(t, "/container/config", restored.Mounts[1].Target) + assert.True(t, original.Equal(restored)) +} + +func TestMountsOmittedWhenEmpty(t *testing.T) { + original := ApplicationSettings{Name: "app", Image: "img:latest"} + marshalled := original.Marshal() + assert.NotContains(t, marshalled, "mounts") +} + +func TestMountsEqualDiffers(t *testing.T) { + base := ApplicationSettings{ + Name: "app", + Mounts: []MountSetting{{Source: "/a", Target: "/b"}}, + } + + different := ApplicationSettings{ + Name: "app", + Mounts: []MountSetting{{Source: "/a", Target: "/c"}}, + } + assert.False(t, base.Equal(different)) + + extra := ApplicationSettings{ + Name: "app", + Mounts: []MountSetting{ + {Source: "/a", Target: "/b"}, + {Source: "/x", Target: "/y"}, + }, + } + assert.False(t, base.Equal(extra)) + + none := ApplicationSettings{Name: "app"} + assert.False(t, base.Equal(none)) +} + +func TestMountsValidation(t *testing.T) { + relativeSource := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{{Source: "relative/path", Target: "/container"}}, + } + assert.ErrorIs(t, relativeSource.Validate(), ErrMountSourceRelative) + + relativeTarget := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{{Source: "/host", Target: "relative/path"}}, + } + assert.ErrorIs(t, relativeTarget.Validate(), ErrMountTargetRelative) + + duplicateTarget := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{ + {Source: "/a", Target: "/same"}, + {Source: "/b", Target: "/same"}, + }, + } + assert.ErrorIs(t, duplicateTarget.Validate(), ErrMountDuplicateTarget) + + reservedTarget := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{{Source: "/host/data", Target: "/storage"}}, + } + assert.ErrorIs(t, reservedTarget.Validate(), ErrMountTargetReserved) + + reservedTarget2 := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{{Source: "/host/data", Target: "/rails/storage"}}, + } + assert.ErrorIs(t, reservedTarget2.Validate(), ErrMountTargetReserved) + + valid := ApplicationSettings{ + Image: "img:latest", + Mounts: []MountSetting{ + {Source: "/host/a", Target: "/container/a"}, + {Source: "/host/b", Target: "/container/b"}, + }, + } + assert.NoError(t, valid.Validate()) +} + func TestAutoUpdateAndBackupMarshalRoundTrip(t *testing.T) { original := ApplicationSettings{ Name: "app", diff --git a/internal/ui/app.go b/internal/ui/app.go index 468bae5..718d701 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -55,6 +55,7 @@ const ( SettingsSectionResources SettingsSectionUpdates SettingsSectionBackups + SettingsSectionMounts ) type App struct { diff --git a/internal/ui/settings.go b/internal/ui/settings.go index c9906f7..37c1767 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -92,6 +92,8 @@ func NewSettings(ns *docker.Namespace, app *docker.Application, sectionType Sett section = NewSettingsFormUpdates(app, appState.LastUpdateResult()) case SettingsSectionBackups: section = NewSettingsFormBackups(app, appState.LastBackupResult()) + case SettingsSectionMounts: + section = NewSettingsFormMounts(app.Settings) } h := NewHelp() @@ -240,6 +242,10 @@ func (m Settings) navigateToDashboard() tea.Cmd { } func (m Settings) handleFormSubmit(msg SettingsSectionSubmitMsg) (Component, tea.Cmd) { + if err := msg.Settings.Validate(); err != nil { + m.err = err + return m, nil + } if msg.Settings.Equal(m.app.Settings) { return m, m.navigateToDashboard() } diff --git a/internal/ui/settings_form_mounts.go b/internal/ui/settings_form_mounts.go new file mode 100644 index 0000000..fd6975d --- /dev/null +++ b/internal/ui/settings_form_mounts.go @@ -0,0 +1,214 @@ +package ui + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/once/internal/docker" + "github.com/basecamp/once/internal/mouse" +) + +type SettingsFormMounts struct { + settingsFormBase + width int + height int + scroll int + settings docker.ApplicationSettings +} + +func NewSettingsFormMounts(settings docker.ApplicationSettings) SettingsFormMounts { + var items []FormItem + + for _, m := range settings.Mounts { + items = append(items, newMountSourceItem(m.Source), newMountTargetItem(m.Target)) + } + items = append(items, newMountSourceItem(""), newMountTargetItem("")) + + f := SettingsFormMounts{ + settingsFormBase: settingsFormBase{ + title: "Mounts", + form: NewForm("Done", items...), + }, + settings: settings, + } + + f.form.OnRebuild(func(form *Form) { + lastSourceIdx := form.ItemCount() - 2 + if lastSourceIdx >= 0 && form.TextField(lastSourceIdx).Value() != "" { + form.AppendItems(newMountSourceItem(""), newMountTargetItem("")) + } + }) + + f.form.OnSubmit(func(form *Form) tea.Cmd { + s := settings + s.Mounts = nil + for i := 0; i < form.ItemCount(); i += 2 { + source := form.TextField(i).Value() + if source == "" { + continue + } + s.Mounts = append(s.Mounts, docker.MountSetting{ + Source: source, + Target: form.TextField(i + 1).Value(), + }) + } + return func() tea.Msg { return SettingsSectionSubmitMsg{Settings: s} } + }) + + f.form.OnCancel(func(form *Form) tea.Cmd { + return func() tea.Msg { return SettingsSectionCancelMsg{} } + }) + + return f +} + +func (m SettingsFormMounts) Update(msg tea.Msg) (SettingsSection, tea.Cmd) { + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + m.width = wsm.Width + m.height = wsm.Height + } + + var cmd tea.Cmd + m.settingsFormBase, cmd = m.update(msg) + m.setFieldWidths() + m.adjustScroll() + return m, cmd +} + +func (m SettingsFormMounts) View() string { + return m.renderContent() +} + +// Private + +func (m SettingsFormMounts) rowCount() int { + return m.form.ItemCount() / 2 +} + +func (m SettingsFormMounts) columnWidths() (int, int) { + totalWidth := max(min(m.width, 64), 6) + sourceWidth := totalWidth / 2 + targetWidth := totalWidth - sourceWidth - 1 + return sourceWidth, targetWidth +} + +func (m SettingsFormMounts) setFieldWidths() { + sourceWidth, targetWidth := m.columnWidths() + for i := range m.form.ItemCount() { + if i%2 == 0 { + m.form.TextField(i).SetWidth(max(sourceWidth-4, 1)) + } else { + m.form.TextField(i).SetWidth(max(targetWidth-4, 1)) + } + } +} + +func (m *SettingsFormMounts) adjustScroll() { + maxVisible := m.maxVisibleRows() + if maxVisible <= 0 { + return + } + + focusedRow := m.focusedRow() + if focusedRow < 0 { + focusedRow = m.rowCount() - 1 + } + + if focusedRow < m.scroll { + m.scroll = focusedRow + } + if focusedRow >= m.scroll+maxVisible { + m.scroll = focusedRow - maxVisible + 1 + } +} + +func (m SettingsFormMounts) focusedRow() int { + focused := m.form.Focused() + if focused < m.form.ItemCount() { + return focused / 2 + } + return -1 +} + +func (m SettingsFormMounts) maxVisibleRows() int { + if m.height <= 0 { + return m.rowCount() + } + available := m.height - 11 + rowHeight := 4 + visible := available / rowHeight + return max(visible, 1) +} + +func (m SettingsFormMounts) renderContent() string { + sourceWidth, targetWidth := m.columnWidths() + + headerStyle := lipgloss.NewStyle().Bold(true) + sourceHeader := headerStyle.Width(sourceWidth).Render("Source") + targetHeader := headerStyle.Width(targetWidth).Render("Target") + header := lipgloss.JoinHorizontal(lipgloss.Top, sourceHeader, " ", targetHeader) + + var parts []string + parts = append(parts, header, "") + + maxVisible := m.maxVisibleRows() + rows := m.rowCount() + end := min(m.scroll+maxVisible, rows) + + if m.scroll > 0 { + indicator := lipgloss.NewStyle().Foreground(Colors.Border). + Render(fmt.Sprintf("\u2191 %d more above", m.scroll)) + parts = append(parts, indicator) + } + + focused := m.form.Focused() + for i := m.scroll; i < end; i++ { + srcIdx := i * 2 + tgtIdx := i*2 + 1 + + srcStyle := Styles.Focus(Styles.Input, focused == srcIdx).Width(sourceWidth) + tgtStyle := Styles.Focus(Styles.Input, focused == tgtIdx).Width(targetWidth) + + srcView := mouse.Mark(fieldTarget(srcIdx), srcStyle.Render(m.form.TextField(srcIdx).View())) + tgtView := mouse.Mark(fieldTarget(tgtIdx), tgtStyle.Render(m.form.TextField(tgtIdx).View())) + + rowView := lipgloss.JoinHorizontal(lipgloss.Top, srcView, " ", tgtView) + parts = append(parts, rowView, "") + } + + if end < rows { + remaining := rows - end + indicator := lipgloss.NewStyle().Foreground(Colors.Border). + Render(fmt.Sprintf("\u2193 %d more below", remaining)) + parts = append(parts, indicator) + } + + submitIdx := m.form.ItemCount() + cancelIdx := m.form.ItemCount() + 1 + submitButton := mouse.Mark("submit", Styles.Focus(Styles.ButtonPrimary, focused == submitIdx). + Render("Done")) + cancelButton := mouse.Mark("cancel", Styles.Focus(Styles.Button, focused == cancelIdx). + Render("Cancel")) + buttons := lipgloss.JoinHorizontal(lipgloss.Center, submitButton, cancelButton) + parts = append(parts, buttons) + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +// Helpers + +func newMountSourceItem(value string) FormItem { + f := NewTextField("/host/path") + f.SetValue(value) + f.SetCharLimit(1024) + return FormItem{Field: f} +} + +func newMountTargetItem(value string) FormItem { + f := NewTextField("/container/path") + f.SetValue(value) + f.SetCharLimit(1024) + return FormItem{Field: f} +} diff --git a/internal/ui/settings_menu.go b/internal/ui/settings_menu.go index a50aa14..e2b4254 100644 --- a/internal/ui/settings_menu.go +++ b/internal/ui/settings_menu.go @@ -35,6 +35,7 @@ func NewSettingsMenu(app *docker.Application) SettingsMenu { MenuItem{Label: "Resources", Key: int(SettingsSectionResources), Shortcut: WithHelp(NewKeyBinding("r"), "r", "")}, MenuItem{Label: "Updates", Key: int(SettingsSectionUpdates), Shortcut: WithHelp(NewKeyBinding("u"), "u", "")}, MenuItem{Label: "Backups", Key: int(SettingsSectionBackups), Shortcut: WithHelp(NewKeyBinding("b"), "b", "")}, + MenuItem{Label: "Mounts", Key: int(SettingsSectionMounts), Shortcut: WithHelp(NewKeyBinding("m"), "m", "")}, ), help: h, }