Skip to content
Open
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
32 changes: 32 additions & 0 deletions internal/command/settings_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type settingsFlags struct {
host string
disableTLS bool
env []string
mounts []string
smtpServer string
smtpPort string
smtpUsername string
Expand All @@ -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")
Expand All @@ -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
}
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion internal/docker/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand 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
}

Expand Down
42 changes: 42 additions & 0 deletions internal/docker/application_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package docker

import (
"encoding/json"
"path/filepath"
"strconv"
)

Expand Down Expand Up @@ -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"`
Expand All @@ -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) {
Expand All @@ -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
Comment on lines +92 to +96
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

ValidateMounts checks reserved/duplicate targets using the raw m.Target string. That can be bypassed with equivalent paths like /storage/, /storage/., or /storage/../storage, which would slip past the reserved/seen maps but still resolve to the same mount point. Consider normalizing (e.g., filepath.Clean (or path.Clean for container paths) + possibly trimming trailing slashes) before the abs/reserved/duplicate checks, and perform comparisons on the normalized value.

Copilot uses AI. Check for mistakes.
}
if seen[m.Target] {
return ErrMountDuplicateTarget
}
seen[m.Target] = true
}
return nil
}

Expand Down Expand Up @@ -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
}

Expand Down
94 changes: 94 additions & 0 deletions internal/docker/application_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
SettingsSectionResources
SettingsSectionUpdates
SettingsSectionBackups
SettingsSectionMounts
)

type App struct {
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading