From ad7f27adf8e0a18ae3b64a289caa2c543e3f7729 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 18:40:37 +0000 Subject: [PATCH] Have Docker create a missing volume host path (closes #235) Docker refused to bind-mount a host path that did not exist, so an app's first deploy failed until someone ran mkdir on the host. buildMounts now sets BindOptions.CreateMountpoint on every bind mount, so the Docker daemon creates a missing host path when the container starts and leaves an existing one alone. upaas itself cannot create it: it runs in a container and does not see the host's filesystem. The README gains a Volume mounts section saying so. Model: opus-5-5 --- README.md | 7 ++++++ TODO.md | 6 ++++++ internal/docker/client.go | 7 +++++- internal/docker/mounts_test.go | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 internal/docker/mounts_test.go diff --git a/README.md b/README.md index 3cc777e..a563186 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,13 @@ names Dockerfile frontend 1.3 or later, such as `docker/dockerfile:1`. Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. +### Volume mounts + +An app's volume mounts are bind mounts of host paths. When a host path does not +exist yet, upaas has Docker create it as an empty directory, owned by root, when +the app's container starts; there is no need to create it first. An existing +host path is left as it is. This needs Docker Engine 23.0 or later. + ## License WTFPL diff --git a/TODO.md b/TODO.md index 7684cdc..3b56966 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,12 @@ regress. # Completed Steps +- 2026-10-01: An app's first deploy no longer fails when a volume's host path + does not exist yet: upaas asks Docker to create a missing host path when the + app's container starts and to leave an existing one alone, so nobody has to + create it on the host first. The README has a Volume mounts section saying so + (#235). + - 2026-09-29: The app page is 50% wider on large screens (84rem instead of 56rem), its build log and container log boxes are twice as tall, the build log sits between the webhook URL and the environment variables, and the container diff --git a/internal/docker/client.go b/internal/docker/client.go index 2af6f52..ed48974 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -222,7 +222,9 @@ func buildEnvSlice(env map[string]string) []string { return envSlice } -// buildMounts converts volume mounts to Docker mount configuration. +// buildMounts converts volume mounts to Docker mount configuration. Docker, +// not upaas, creates a missing host path, because upaas runs in a container +// and cannot see the host's filesystem. An existing path is left alone. func buildMounts(volumes []VolumeMount) []mount.Mount { mounts := make([]mount.Mount, 0, len(volumes)) @@ -232,6 +234,9 @@ func buildMounts(volumes []VolumeMount) []mount.Mount { Source: vol.HostPath, Target: vol.ContainerPath, ReadOnly: vol.ReadOnly, + BindOptions: &mount.BindOptions{ + CreateMountpoint: true, + }, }) } diff --git a/internal/docker/mounts_test.go b/internal/docker/mounts_test.go new file mode 100644 index 0000000..395443d --- /dev/null +++ b/internal/docker/mounts_test.go @@ -0,0 +1,39 @@ +package docker //nolint:testpackage // tests unexported buildMounts + +import ( + "testing" + + "github.com/docker/docker/api/types/mount" + "github.com/stretchr/testify/assert" +) + +// TestBuildMountsCreatesMissingHostPath checks that every mount asks Docker +// to create its host path if it is missing, and keeps the rest of the volume +// as configured. +func TestBuildMountsCreatesMissingHostPath(t *testing.T) { + t.Parallel() + + volumes := []VolumeMount{ + {HostPath: "/srv/app/data", ContainerPath: "/data", ReadOnly: false}, + {HostPath: "/srv/app/config", ContainerPath: "/etc/app", ReadOnly: true}, + } + + want := []mount.Mount{ + { + Type: mount.TypeBind, + Source: "/srv/app/data", + Target: "/data", + ReadOnly: false, + BindOptions: &mount.BindOptions{CreateMountpoint: true}, + }, + { + Type: mount.TypeBind, + Source: "/srv/app/config", + Target: "/etc/app", + ReadOnly: true, + BindOptions: &mount.BindOptions{CreateMountpoint: true}, + }, + } + + assert.Equal(t, want, buildMounts(volumes)) +} -- 2.54.0