mirror of
https://github.com/psviderski/uncloud.git
synced 2026-10-06 13:18:58 +00:00
feat(runtime-templates): add support for runtime templates in bind mounts (#412)
This commit is contained in:
1 parent
88dda435e7
commit
5c33e4be86
17 files changed
+571
-10
No files matched your search
@@ -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"+
|
||||
"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"+
|
||||
"Host and container paths support runtime templates such as {{.Container.Name}}.\n"+
|
||||
"Examples:\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"+
|
||||
|
||||
@@ -538,6 +538,15 @@ func (s *Server) CreateServiceContainer(
|
||||
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)
|
||||
if envVars == nil {
|
||||
envVars = make(api.EnvVars)
|
||||
@@ -745,7 +754,7 @@ func (s *Server) CreateServiceContainer(
|
||||
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
||||
}
|
||||
|
||||
specBytes, err := json.Marshal(spec)
|
||||
specBytes, err := json.Marshal(desiredSpec)
|
||||
if err != nil {
|
||||
removeContainer()
|
||||
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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -160,6 +160,10 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
ContainerPath: "/host/etc/passwd",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
VolumeName: "bind-cd9bef32904c6831a0cd0d49cf503078a46a0989acea375fdd3d3b43b2bfec4f",
|
||||
ContainerPath: "/unique/host/bind",
|
||||
},
|
||||
{
|
||||
VolumeName: "data1",
|
||||
ContainerPath: "/data1",
|
||||
@@ -240,6 +244,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
|
||||
CreateHostPath: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bind-cd9bef32904c6831a0cd0d49cf503078a46a0989acea375fdd3d3b43b2bfec4f",
|
||||
Type: "bind",
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: "/runtime/template/{{.Container.Name}}",
|
||||
CreateHostPath: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "data-external",
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
user: nginx:nginx
|
||||
volumes:
|
||||
- /etc/passwd:/host/etc/passwd:ro
|
||||
- /runtime/template/{{.Container.Name}}:/unique/host/bind
|
||||
- data1:/data1
|
||||
- type: bind
|
||||
source: /path/on/host
|
||||
|
||||
+18
-2
@@ -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) {
|
||||
spec = spec.SetDefaults()
|
||||
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, spec)
|
||||
desiredSpec := spec.SetDefaults()
|
||||
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, desiredSpec)
|
||||
assert.Equal(t, deploy.ContainerUpToDate, status)
|
||||
spec = renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
|
||||
|
||||
// Verify labels.
|
||||
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()
|
||||
require.NotEmpty(t, svc.HookContainers, "Expected at least one hook container")
|
||||
|
||||
desiredSpec := spec
|
||||
for _, mc := range svc.HookContainers {
|
||||
ctr := mc.Container
|
||||
spec := renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
|
||||
|
||||
// Verify labels.
|
||||
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) {
|
||||
expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -282,6 +282,11 @@ func TestComposeDeployment(t *testing.T) {
|
||||
ContainerPath: "/host/etc/passwd",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
VolumeName: "bind-b78f4a1e9255dca2997ee1006f32b0fc168bac785cf2440f00aa84c61e58f6fb",
|
||||
ContainerPath: "/host/{{.Container.Name}}",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []api.VolumeSpec{
|
||||
@@ -297,6 +302,14 @@ func TestComposeDeployment(t *testing.T) {
|
||||
CreateHostPath: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bind-b78f4a1e9255dca2997ee1006f32b0fc168bac785cf2440f00aa84c61e58f6fb",
|
||||
Type: api.VolumeTypeBind,
|
||||
BindOptions: &api.BindOptions{
|
||||
HostPath: "/tmp/uncloud-e2e/{{.Container.Name}}",
|
||||
CreateHostPath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Replicas: 3,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
volumes:
|
||||
- test-compose-volumes-data1:/data1
|
||||
- /etc/passwd:/host/etc/passwd:ro
|
||||
- "/tmp/uncloud-e2e/{{.Container.Name}}:/host/{{.Container.Name}}:ro"
|
||||
deploy:
|
||||
replicas: 3
|
||||
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) |
|
||||
| **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 |
|
||||
| Volume labels | ✅ Supported | Custom labels |
|
||||
| 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
|
||||
|
||||
- [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
|
||||
- [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)
|
||||
|
||||
@@ -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
|
||||
the volume is located. Can be specified multiple times.
|
||||
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:
|
||||
-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
|
||||
|
||||
@@ -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
|
||||
the volume is located. Can be specified multiple times.
|
||||
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:
|
||||
-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
|
||||
|
||||
Reference in new issue
Block a user