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

+1
View File
@@ -122,6 +122,7 @@ func NewRunCommand(groupID string) *cobra.Command {
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+ "Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
"the volume is located. Can be specified multiple times.\n"+ "the volume is located. Can be specified multiple times.\n"+
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+ "Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
"Host and container paths support runtime templates such as {{.Container.Name}}.\n"+
"Examples:\n"+ "Examples:\n"+
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+ " -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+ " -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
+10 -1
View File
@@ -538,6 +538,15 @@ func (s *Server) CreateServiceContainer(
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix) containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
} }
desiredSpec := spec
renderedSpec, renderErr := desiredSpec.RenderRuntimeTemplates(api.RuntimeTemplateContext{
Container: api.RuntimeTemplateContainerContext{Name: containerName},
})
if renderErr != nil {
return nil, status.Errorf(codes.InvalidArgument, "render runtime templates: %v", renderErr)
}
spec = renderedSpec
envVars := maps.Clone(spec.Container.Env) envVars := maps.Clone(spec.Container.Env)
if envVars == nil { if envVars == nil {
envVars = make(api.EnvVars) envVars = make(api.EnvVars)
@@ -745,7 +754,7 @@ func (s *Server) CreateServiceContainer(
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true}) _ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
} }
specBytes, err := json.Marshal(spec) specBytes, err := json.Marshal(desiredSpec)
if err != nil { if err != nil {
removeContainer() removeContainer()
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err) return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
@@ -0,0 +1,53 @@
package docker
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/psviderski/uncloud/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
_ "modernc.org/sqlite"
)
func TestCreateServiceContainer_RejectsRuntimeTemplateError(t *testing.T) {
t.Parallel()
desiredSpec := api.ServiceSpec{
Name: "web",
Container: api.ContainerSpec{
Image: "busybox:latest",
VolumeMounts: []api.VolumeMount{
{VolumeName: "data", ContainerPath: "/data"},
},
},
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
// Early validation takes the valid branch. Rendering with the real container name takes the invalid one.
HostPath: `/host/{{if eq .Container.Name "web-a1b2"}}{{.Container.ID}}{{else}}valid{{end}}`,
},
},
},
}
specJSON, err := json.Marshal(desiredSpec)
require.NoError(t, err)
server := &Server{}
_, err = server.CreateServiceContainer(context.Background(), &pb.CreateServiceContainerRequest{
ServiceId: strings.Repeat("a", 32),
ServiceSpec: specJSON,
ContainerName: "web-a1b2",
})
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
assert.ErrorContains(t, err, "render runtime template in bind volume 'data' host path")
}
+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 return nil
} }
+10 -6
View File
@@ -3,10 +3,10 @@ package api
import ( import (
"fmt" "fmt"
"maps" "maps"
"path"
"reflect" "reflect"
"slices" "slices"
"sort" "sort"
"strings"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
@@ -39,7 +39,8 @@ type VolumeSpec struct {
// BindOptions represents options for a bind volume. // BindOptions represents options for a bind volume.
type BindOptions struct { 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 HostPath string
// CreateHostPath indicates whether the host path should be created if it doesn't exist. // 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. // If false, deployment will fail if the path doesn't exist.
@@ -103,6 +104,9 @@ func (v *VolumeSpec) Validate() error {
if v.BindOptions == nil { if v.BindOptions == nil {
return fmt.Errorf("bind volume must have bind options") 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: case VolumeTypeVolume, VolumeTypeTmpfs:
default: default:
return fmt.Errorf("invalid volume type: '%s', must be one of '%s', '%s', '%s')", 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 { type VolumeMount struct {
// VolumeName references a volume defined in ServiceSpec.Volumes by its Name field. // VolumeName references a volume defined in ServiceSpec.Volumes by its Name field.
VolumeName string 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 ContainerPath string
// ReadOnly indicates whether the volume should be mounted read-only. // ReadOnly indicates whether the volume should be mounted read-only.
// If false (default), the volume is mounted read-write. // If false (default), the volume is mounted read-write.
@@ -207,9 +212,8 @@ func (m *VolumeMount) Validate() error {
if m.VolumeName == "" { if m.VolumeName == "" {
return fmt.Errorf("volume name must not be empty") return fmt.Errorf("volume name must not be empty")
} }
if !path.IsAbs(m.ContainerPath) {
if !strings.HasPrefix(m.ContainerPath, "/") { return fmt.Errorf("invalid container path: %q must be an absolute path", m.ContainerPath)
return fmt.Errorf("invalid container path: '%s', must be an absolute path in the container", m.ContainerPath)
} }
return nil return nil
+50
View File
@@ -160,6 +160,10 @@ func TestServiceSpecFromCompose(t *testing.T) {
ContainerPath: "/host/etc/passwd", ContainerPath: "/host/etc/passwd",
ReadOnly: true, ReadOnly: true,
}, },
{
VolumeName: "bind-cd9bef32904c6831a0cd0d49cf503078a46a0989acea375fdd3d3b43b2bfec4f",
ContainerPath: "/unique/host/bind",
},
{ {
VolumeName: "data1", VolumeName: "data1",
ContainerPath: "/data1", ContainerPath: "/data1",
@@ -240,6 +244,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
CreateHostPath: true, CreateHostPath: true,
}, },
}, },
{
Name: "bind-cd9bef32904c6831a0cd0d49cf503078a46a0989acea375fdd3d3b43b2bfec4f",
Type: "bind",
BindOptions: &api.BindOptions{
HostPath: "/runtime/template/{{.Container.Name}}",
CreateHostPath: true,
},
},
{ {
Name: "data-external", Name: "data-external",
Type: api.VolumeTypeVolume, Type: api.VolumeTypeVolume,
@@ -1293,3 +1305,41 @@ services:
}) })
} }
} }
func TestServiceSpecFromCompose_RuntimeTemplates(t *testing.T) {
t.Setenv("UNCLOUD_TEST_TEMPLATE_ROOT", "uncloud-test")
project, err := LoadProjectFromContent(context.Background(), `
services:
short:
image: busybox:latest
volumes:
- "/var/lib/${UNCLOUD_TEST_TEMPLATE_ROOT}/{{.Container.Name}}:/data/{{.Container.Name}}:ro"
long:
image: busybox:latest
volumes:
- type: bind
source: "/var/lib/${UNCLOUD_TEST_TEMPLATE_ROOT}/{{.Container.Name}}"
target: "/data/{{.Container.Name}}"
read_only: true
bind:
create_host_path: true
`)
require.NoError(t, err)
shortSpec, err := ServiceSpecFromCompose(project, "short")
require.NoError(t, err)
longSpec, err := ServiceSpecFromCompose(project, "long")
require.NoError(t, err)
for _, spec := range []api.ServiceSpec{shortSpec, longSpec} {
require.Len(t, spec.Volumes, 1)
require.NotNil(t, spec.Volumes[0].BindOptions)
assert.Equal(t, "/var/lib/uncloud-test/{{.Container.Name}}", spec.Volumes[0].BindOptions.HostPath)
assert.True(t, spec.Volumes[0].BindOptions.CreateHostPath)
require.Len(t, spec.Container.VolumeMounts, 1)
assert.Equal(t, "/data/{{.Container.Name}}", spec.Container.VolumeMounts[0].ContainerPath)
assert.True(t, spec.Container.VolumeMounts[0].ReadOnly)
require.NoError(t, spec.Validate())
}
}
+1
View File
@@ -52,6 +52,7 @@ services:
user: nginx:nginx user: nginx:nginx
volumes: volumes:
- /etc/passwd:/host/etc/passwd:ro - /etc/passwd:/host/etc/passwd:ro
- /runtime/template/{{.Container.Name}}:/unique/host/bind
- data1:/data1 - data1:/data1
- type: bind - type: bind
source: /path/on/host source: /path/on/host
+18 -2
View File
@@ -38,9 +38,10 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
} }
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) { func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
spec = spec.SetDefaults() desiredSpec := spec.SetDefaults()
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, spec) status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, desiredSpec)
assert.Equal(t, deploy.ContainerUpToDate, status) assert.Equal(t, deploy.ContainerUpToDate, status)
spec = renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
// Verify labels. // Verify labels.
assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID])) assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID]))
@@ -148,8 +149,10 @@ func assertHookContainersMatchSpec(t *testing.T, svc api.Service, spec api.Servi
t.Helper() t.Helper()
require.NotEmpty(t, svc.HookContainers, "Expected at least one hook container") require.NotEmpty(t, svc.HookContainers, "Expected at least one hook container")
desiredSpec := spec
for _, mc := range svc.HookContainers { for _, mc := range svc.HookContainers {
ctr := mc.Container ctr := mc.Container
spec := renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
// Verify labels. // Verify labels.
assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID])) assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID]))
@@ -215,6 +218,19 @@ func assertHookContainersMatchSpec(t *testing.T, svc api.Service, spec api.Servi
} }
} }
func renderRuntimeTemplatesForContainer(
t *testing.T, spec api.ServiceSpec, containerName string,
) api.ServiceSpec {
t.Helper()
renderedSpec, err := spec.RenderRuntimeTemplates(api.RuntimeTemplateContext{
Container: api.RuntimeTemplateContainerContext{Name: containerName},
})
require.NoError(t, err)
return renderedSpec
}
func assertContainerMountsMatchSpec(t *testing.T, mounts []mount.Mount, spec api.ServiceSpec) { func assertContainerMountsMatchSpec(t *testing.T, mounts []mount.Mount, spec api.ServiceSpec) {
expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts) expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
require.NoError(t, err) require.NoError(t, err)
+13
View File
@@ -282,6 +282,11 @@ func TestComposeDeployment(t *testing.T) {
ContainerPath: "/host/etc/passwd", ContainerPath: "/host/etc/passwd",
ReadOnly: true, ReadOnly: true,
}, },
{
VolumeName: "bind-b78f4a1e9255dca2997ee1006f32b0fc168bac785cf2440f00aa84c61e58f6fb",
ContainerPath: "/host/{{.Container.Name}}",
ReadOnly: true,
},
}, },
}, },
Volumes: []api.VolumeSpec{ Volumes: []api.VolumeSpec{
@@ -297,6 +302,14 @@ func TestComposeDeployment(t *testing.T) {
CreateHostPath: true, CreateHostPath: true,
}, },
}, },
{
Name: "bind-b78f4a1e9255dca2997ee1006f32b0fc168bac785cf2440f00aa84c61e58f6fb",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/tmp/uncloud-e2e/{{.Container.Name}}",
CreateHostPath: true,
},
},
}, },
Replicas: 3, Replicas: 3,
} }
+1
View File
@@ -5,6 +5,7 @@ services:
volumes: volumes:
- test-compose-volumes-data1:/data1 - test-compose-volumes-data1:/data1
- /etc/passwd:/host/etc/passwd:ro - /etc/passwd:/host/etc/passwd:ro
- "/tmp/uncloud-e2e/{{.Container.Name}}:/host/{{.Container.Name}}:ro"
deploy: deploy:
replicas: 3 replicas: 3
test-compose-volumes-service2: test-compose-volumes-service2:
@@ -0,0 +1,91 @@
# Runtime templates
Runtime templates let service configuration use metadata that is only known when Uncloud creates a container. They use
[Go template](https://pkg.go.dev/text/template) syntax.
Uncloud renders runtime templates on the destination machine before it creates each container. This means that every
replica can receive a value based on its own container metadata.
## Available metadata
Runtime templates currently expose the following fields:
| Field | Description | Example |
|-------------------|--------------------------------|------------|
| `.Container.Name` | Name assigned to the container | `app-c1zd` |
## Supported template locations
You can currently use runtime templates in these locations of a service definition:
| Location | Compose attribute |
|---------------------------------|-----------------------------------------------------------------------------|
| Host path of a bind mount | `volumes[].source` or `volumes` short-syntax `- /host/path:/container/path` |
| Mount path inside the container | `volumes[].target` or `volumes` short-syntax `- /host/path:/container/path` |
:::tip Want other locations or metadata fields?
If you want to use runtime templates in other locations, such as environment variable, or use other metadata fields, add
a 👍 reaction or comment with your use case on the issue [#431](https://github.com/psviderski/uncloud/issues/431).
:::
## Use runtime templates in a Compose file
The following service gets a separate host path for every replica:
```yaml title="compose.yaml"
services:
app:
image: app:latest
volumes:
- "/var/lib/app/{{.Container.Name}}:/data"
scale: 2
```
If Uncloud names the containers `app-c1zd` and `app-f7kx`, their host paths are `/var/lib/app/app-c1zd` and
`/var/lib/app/app-f7kx`.
You can combine runtime templates with
[Compose environment interpolation](https://github.com/compose-spec/compose-spec/blob/main/12-interpolation.md):
```yaml
volumes:
- "${DATA_ROOT:-/var/lib/app}/{{.Container.Name}}:/data"
```
Uncloud expands the `DATA_ROOT` environment variable on the local machine first and renders `.Container.Name` later on
the destination machine.
## Use runtime templates with `uc run`
Quote the volume argument so your shell passes the template through unchanged:
```shell
uc run --replicas 2 \
--volume "/var/lib/app/{{.Container.Name}}:/data" \
app:latest
```
:::warning Clean up templated host directories
Uncloud does not remove host directories created for bind mounts when it replaces a container or removes a service. If a
runtime template gives each container a unique host directory, you are responsible for cleaning up directories that are
no longer needed.
If you only need per-container ephemeral storage, consider a `tmpfs` mount. An anonymous volume declared by the Docker
image can also provide Docker-managed disk storage for each container. Use a named volume or a stable bind mount path
for data that must remain available across container replacements.
:::
## Runtime templates and image tag templates
Runtime templates are separate from [image tag templates](../../8-compose-file-reference/3-image-tag-template.md). Image
tag templates run locally when Uncloud builds an image. Runtime templates run on a cluster machine when Uncloud deploys
a container. Each template type exposes different metadata.
## See also
- [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md)
- [`uc run` reference](../../9-cli-reference/uc_run.md)
@@ -64,7 +64,7 @@ If you rely on a specific Compose feature that is not supported by Uncloud, plea
| `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) | | `update_config` | ⚠️ Limited | `order` and `monitor` supported. See [rolling deployments](../4-guides/1-deployments/4-rolling-deployments.md) |
| **Volumes** | | | | **Volumes** | | |
| Named volumes | ✅ Supported | Docker volumes | | Named volumes | ✅ Supported | Docker volumes |
| Bind mounts | ✅ Supported | Host path binding | | Bind mounts | ✅ Supported | Host path binding with optional [runtime templates](../3-concepts/6-services/3-runtime-templates.md) |
| Tmpfs mounts | ✅ Supported | In-memory filesystems | | Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels | | Volume labels | ✅ Supported | Custom labels |
| External volumes | ✅ Supported | Must exist before deployment | | External volumes | ✅ Supported | Must exist before deployment |
@@ -143,6 +143,7 @@ image: myapp:{{gitsha 7}}.${GITHUB_RUN_ID:-local} # GITHUB_RUN_ID not set →
## See also ## See also
- [Runtime templates](../3-concepts/6-services/3-runtime-templates.md): Use per-container metadata in service configuration
- [Deploy an app](../4-guides/1-deployments/1-deploy-app.md): Deploy from source code or pre-built images - [Deploy an app](../4-guides/1-deployments/1-deploy-app.md): Deploy from source code or pre-built images
- [Compose Build Specification](https://github.com/compose-spec/compose-spec/blob/main/build.md) - [Compose Build Specification](https://github.com/compose-spec/compose-spec/blob/main/build.md)
- [Compose Specification: image](https://github.com/compose-spec/compose-spec/blob/main/spec.md#image) - [Compose Specification: image](https://github.com/compose-spec/compose-spec/blob/main/spec.md#image)
+1
View File
@@ -47,6 +47,7 @@ uc run IMAGE [COMMAND...] [flags]
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
the volume is located. Can be specified multiple times. the volume is located. Can be specified multiple times.
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro] Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
Host and container paths support runtime templates such as {{.Container.Name}}.
Examples: Examples:
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container
@@ -47,6 +47,7 @@ uc service run IMAGE [COMMAND...] [flags]
-v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where -v, --volume strings Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where
the volume is located. Can be specified multiple times. the volume is located. Can be specified multiple times.
Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro] Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]
Host and container paths support runtime templates such as {{.Container.Name}}.
Examples: Examples:
-v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container
-v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container