feat(runtime-templates): add support for runtime templates in bind mounts (#412)

This commit is contained in:
Pasha Sviderski committed 2026-09-23 15:12:10 +10:00
1 parent 88dda435e7
commit 5c33e4be86
17 files changed
+571 -10

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
package api
import (
"bytes"
"fmt"
"text/template"
)
// RuntimeTemplateContext contains the data available to runtime templates in a service spec.
type RuntimeTemplateContext struct {
Container RuntimeTemplateContainerContext
}
// RuntimeTemplateContainerContext contains container data available to runtime templates in a service spec.
type RuntimeTemplateContainerContext struct {
Name string
}
// RenderRuntimeTemplates returns a copy of the service spec with supported runtime template expressions rendered
// using metadata from ctx. Runtime templates use Go template expressions such as {{.Container.Name}} and are supported
// in bind volume host paths and volume mount container paths. The original service spec is not modified.
func (s *ServiceSpec) RenderRuntimeTemplates(ctx RuntimeTemplateContext) (ServiceSpec, error) {
if ctx.Container.Name == "" {
return ServiceSpec{}, fmt.Errorf("container name must not be empty")
}
spec := s.Clone()
for i := range spec.Volumes {
volume := &spec.Volumes[i]
if volume.Type != VolumeTypeBind {
continue
}
if err := volume.Validate(); err != nil {
return ServiceSpec{}, fmt.Errorf("invalid bind volume '%s': %w", volume.Name, err)
}
hostPath, err := renderRuntimeTemplate(volume.BindOptions.HostPath, ctx)
if err != nil {
return ServiceSpec{}, fmt.Errorf("render runtime template in bind volume '%s' host path: %w",
volume.Name, err)
}
volume.BindOptions.HostPath = hostPath
if err = volume.Validate(); err != nil {
return ServiceSpec{}, fmt.Errorf("invalid rendered bind volume '%s': %w", volume.Name, err)
}
}
for i := range spec.Container.VolumeMounts {
volumeMount := &spec.Container.VolumeMounts[i]
if err := volumeMount.Validate(); err != nil {
return ServiceSpec{}, fmt.Errorf("invalid volume mount '%s': %w", volumeMount.VolumeName, err)
}
containerPath, err := renderRuntimeTemplate(volumeMount.ContainerPath, ctx)
if err != nil {
return ServiceSpec{}, fmt.Errorf(
"render runtime template in volume '%s' container path: %w", volumeMount.VolumeName, err)
}
volumeMount.ContainerPath = containerPath
if err = volumeMount.Validate(); err != nil {
return ServiceSpec{}, fmt.Errorf("invalid rendered volume mount '%s': %w", volumeMount.VolumeName, err)
}
}
return spec, nil
}
func renderRuntimeTemplate(value string, ctx RuntimeTemplateContext) (string, error) {
tmpl, err := template.New("runtime template").Option("missingkey=error").Parse(value)
if err != nil {
return "", fmt.Errorf("parse runtime template: %w", err)
}
var rendered bytes.Buffer
if err = tmpl.Execute(&rendered, ctx); err != nil {
return "", fmt.Errorf("execute runtime template: %w", err)
}
return rendered.String(), nil
}
func validateRuntimeTemplates(spec *ServiceSpec) error {
_, err := spec.RenderRuntimeTemplates(RuntimeTemplateContext{
Container: RuntimeTemplateContainerContext{Name: "validation-container-name"},
})
return err
}
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"testing"
"github.com/docker/docker/api/types/mount"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceSpec_RenderRuntimeTemplates(t *testing.T) {
t.Parallel()
spec := ServiceSpec{
Container: ContainerSpec{
Image: "busybox:latest",
Env: EnvVars{
"UNCHANGED": "{{.Container.Name}}",
},
VolumeMounts: []VolumeMount{
{VolumeName: "config", ContainerPath: "/etc/app/{{.Container.Name}}", ReadOnly: true},
{VolumeName: "data", ContainerPath: "/{{.Container.Name}}"},
{VolumeName: "scratch", ContainerPath: "/tmp/{{.Container.Name}}"},
},
Volumes: []string{"/legacy/{{.Container.Name}}:/legacy"},
},
Volumes: []VolumeSpec{
{
Name: "config",
Type: VolumeTypeBind,
BindOptions: &BindOptions{
HostPath: "/var/lib/uncloud/{{.Container.Name}}",
CreateHostPath: true,
Propagation: mount.PropagationRShared,
},
},
{
Name: "data",
Type: VolumeTypeVolume,
VolumeOptions: &VolumeOptions{
Name: "data-{{.Container.Name}}",
},
},
{Name: "scratch", Type: VolumeTypeTmpfs},
},
}
original := spec.Clone()
rendered, err := spec.RenderRuntimeTemplates(RuntimeTemplateContext{
Container: RuntimeTemplateContainerContext{Name: "web-a1b2"},
})
require.NoError(t, err)
assert.Equal(t, "/var/lib/uncloud/web-a1b2", rendered.Volumes[0].BindOptions.HostPath)
assert.True(t, rendered.Volumes[0].BindOptions.CreateHostPath)
assert.Equal(t, mount.PropagationRShared, rendered.Volumes[0].BindOptions.Propagation)
assert.Equal(t, "data-{{.Container.Name}}", rendered.Volumes[1].VolumeOptions.Name)
assert.Equal(t, "/etc/app/web-a1b2", rendered.Container.VolumeMounts[0].ContainerPath)
assert.Equal(t, "/web-a1b2", rendered.Container.VolumeMounts[1].ContainerPath)
assert.Equal(t, "/tmp/web-a1b2", rendered.Container.VolumeMounts[2].ContainerPath)
assert.Equal(t, "{{.Container.Name}}", rendered.Container.Env["UNCHANGED"])
assert.Equal(t, "/legacy/{{.Container.Name}}:/legacy", rendered.Container.Volumes[0])
assert.Equal(t, original, spec)
}
func TestServiceSpec_RenderRuntimeTemplates_GoTemplateLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
runtimeTemplate string
containerName string
want string
}{
{
name: "condition variables and pipeline",
runtimeTemplate: `/data/{{if .Container.Name}}{{$name := .Container.Name}}{{$name | printf "%s"}}{{end}}`,
containerName: "web-a1b2",
want: "/data/web-a1b2",
},
{
name: "literal delimiters",
runtimeTemplate: `/data/{{"{{"}}.Container.Name{{"}}"}}`,
containerName: "web-a1b2",
want: "/data/{{.Container.Name}}",
},
{
name: "one pass",
runtimeTemplate: "/data/{{.Container.Name}}",
containerName: "{{.Container.Name}}",
want: "/data/{{.Container.Name}}",
},
{
name: "static path",
runtimeTemplate: "/data/static",
containerName: "web-a1b2",
want: "/data/static",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := serviceSpecWithBindRuntimeTemplate(tt.runtimeTemplate)
rendered, err := spec.RenderRuntimeTemplates(RuntimeTemplateContext{
Container: RuntimeTemplateContainerContext{Name: tt.containerName},
})
require.NoError(t, err)
assert.Equal(t, tt.want, rendered.Volumes[0].BindOptions.HostPath)
})
}
}
func TestServiceSpec_RenderRuntimeTemplates_Errors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
runtimeTemplate string
containerName string
wantErr string
}{
{
name: "empty container name",
runtimeTemplate: "/data/{{.Container.Name}}",
containerName: "",
wantErr: "container name must not be empty",
},
{
name: "malformed runtime template",
runtimeTemplate: "/data/{{.Container.Name",
containerName: "web-a1b2",
wantErr: "parse runtime template",
},
{
name: "unknown field",
runtimeTemplate: "/data/{{.Container.ID}}",
containerName: "web-a1b2",
wantErr: "can't evaluate field ID",
},
{
name: "unregistered function",
runtimeTemplate: `/data/{{env "HOME"}}`,
containerName: "web-a1b2",
wantErr: `function "env" not defined`,
},
{
name: "relative rendered path",
runtimeTemplate: "{{.Container.Name}}",
containerName: "web-a1b2",
wantErr: "must be an absolute path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := serviceSpecWithBindRuntimeTemplate(tt.runtimeTemplate)
_, err := spec.RenderRuntimeTemplates(RuntimeTemplateContext{
Container: RuntimeTemplateContainerContext{Name: tt.containerName},
})
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestServiceSpec_Validate_RuntimeTemplates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
runtimeTemplate string
wantErr string
}{
{
name: "valid",
runtimeTemplate: "/data/{{.Container.Name}}",
},
{
name: "malformed",
runtimeTemplate: "/data/{{.Container.Name",
wantErr: "validate runtime templates",
},
{
name: "unknown field",
runtimeTemplate: "/data/{{.Container.ID}}",
wantErr: "can't evaluate field ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := serviceSpecWithBindRuntimeTemplate(tt.runtimeTemplate)
err := spec.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
})
}
}
func serviceSpecWithBindRuntimeTemplate(hostPath string) ServiceSpec {
return ServiceSpec{
Name: "web",
Container: ContainerSpec{
Image: "busybox:latest",
VolumeMounts: []VolumeMount{
{VolumeName: "data", ContainerPath: "/data"},
},
},
Volumes: []VolumeSpec{
{
Name: "data",
Type: VolumeTypeBind,
BindOptions: &BindOptions{HostPath: hostPath},
},
},
}
}
+4
View File
@@ -214,6 +214,10 @@ func (s *ServiceSpec) Validate() error {
}
}
if err := validateRuntimeTemplates(s); err != nil {
return fmt.Errorf("validate runtime templates: %w", err)
}
return nil
}
+10 -6
View File
@@ -3,10 +3,10 @@ package api
import (
"fmt"
"maps"
"path"
"reflect"
"slices"
"sort"
"strings"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
@@ -39,7 +39,8 @@ type VolumeSpec struct {
// BindOptions represents options for a bind volume.
type BindOptions struct {
// HostPath is the absolute path on the host filesystem.
// HostPath is the absolute path on the host filesystem. It may contain runtime templates such as
// {{.Container.Name}}, which the destination daemon renders before creating the container.
HostPath string
// CreateHostPath indicates whether the host path should be created if it doesn't exist.
// If false, deployment will fail if the path doesn't exist.
@@ -103,6 +104,9 @@ func (v *VolumeSpec) Validate() error {
if v.BindOptions == nil {
return fmt.Errorf("bind volume must have bind options")
}
if !path.IsAbs(v.BindOptions.HostPath) {
return fmt.Errorf("invalid host path: %q must be an absolute path", v.BindOptions.HostPath)
}
case VolumeTypeVolume, VolumeTypeTmpfs:
default:
return fmt.Errorf("invalid volume type: '%s', must be one of '%s', '%s', '%s')",
@@ -196,7 +200,8 @@ func (v *VolumeSpec) Clone() VolumeSpec {
type VolumeMount struct {
// VolumeName references a volume defined in ServiceSpec.Volumes by its Name field.
VolumeName string
// ContainerPath is the absolute path where the volume is mounted in the container.
// ContainerPath is the absolute path where the volume is mounted in the container. It may contain runtime templates
// such as {{.Container.Name}}, which the destination daemon renders before creating the container.
ContainerPath string
// ReadOnly indicates whether the volume should be mounted read-only.
// If false (default), the volume is mounted read-write.
@@ -207,9 +212,8 @@ func (m *VolumeMount) Validate() error {
if m.VolumeName == "" {
return fmt.Errorf("volume name must not be empty")
}
if !strings.HasPrefix(m.ContainerPath, "/") {
return fmt.Errorf("invalid container path: '%s', must be an absolute path in the container", m.ContainerPath)
if !path.IsAbs(m.ContainerPath) {
return fmt.Errorf("invalid container path: %q must be an absolute path", m.ContainerPath)
}
return nil