From 5c33e4be869936c8b1fe37e4f08f89889e9b26d0 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 23 Sep 2026 15:12:10 +1000 Subject: [PATCH] feat(runtime-templates): add support for runtime templates in bind mounts (#412) --- cmd/uc/service/run.go | 1 + internal/machine/docker/server.go | 11 +- .../docker/server_runtime_template_test.go | 53 +++++ pkg/api/runtime_template.go | 90 +++++++ pkg/api/runtime_template_test.go | 225 ++++++++++++++++++ pkg/api/service.go | 4 + pkg/api/volume.go | 16 +- pkg/client/compose/service_test.go | 50 ++++ .../compose/testdata/compose-full-spec.yaml | 1 + test/e2e/assert.go | 20 +- test/e2e/compose_deploy_test.go | 13 + test/e2e/fixtures/compose-volumes.yaml | 1 + .../6-services/3-runtime-templates.md | 91 +++++++ .../1-support-matrix.md | 2 +- .../3-image-tag-template.md | 1 + website/docs/9-cli-reference/uc_run.md | 1 + .../docs/9-cli-reference/uc_service_run.md | 1 + 17 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 internal/machine/docker/server_runtime_template_test.go create mode 100644 pkg/api/runtime_template.go create mode 100644 pkg/api/runtime_template_test.go create mode 100644 website/docs/3-concepts/6-services/3-runtime-templates.md diff --git a/cmd/uc/service/run.go b/cmd/uc/service/run.go index 5c4ab2e8..e7427f76 100644 --- a/cmd/uc/service/run.go +++ b/cmd/uc/service/run.go @@ -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"+ diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 2f1a363d..10bff2c1 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -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) diff --git a/internal/machine/docker/server_runtime_template_test.go b/internal/machine/docker/server_runtime_template_test.go new file mode 100644 index 00000000..010ad48e --- /dev/null +++ b/internal/machine/docker/server_runtime_template_test.go @@ -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") +} diff --git a/pkg/api/runtime_template.go b/pkg/api/runtime_template.go new file mode 100644 index 00000000..fd12e3e0 --- /dev/null +++ b/pkg/api/runtime_template.go @@ -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 +} diff --git a/pkg/api/runtime_template_test.go b/pkg/api/runtime_template_test.go new file mode 100644 index 00000000..939abf6e --- /dev/null +++ b/pkg/api/runtime_template_test.go @@ -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}, + }, + }, + } +} diff --git a/pkg/api/service.go b/pkg/api/service.go index 115f519d..77375c70 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 } diff --git a/pkg/api/volume.go b/pkg/api/volume.go index ade5bc1c..6dc988fa 100644 --- a/pkg/api/volume.go +++ b/pkg/api/volume.go @@ -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 diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index 079e9224..ff863283 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -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()) + } +} diff --git a/pkg/client/compose/testdata/compose-full-spec.yaml b/pkg/client/compose/testdata/compose-full-spec.yaml index 528b0c2d..f55ee424 100644 --- a/pkg/client/compose/testdata/compose-full-spec.yaml +++ b/pkg/client/compose/testdata/compose-full-spec.yaml @@ -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 diff --git a/test/e2e/assert.go b/test/e2e/assert.go index 1fc6bd5f..be5e8e6b 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -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) diff --git a/test/e2e/compose_deploy_test.go b/test/e2e/compose_deploy_test.go index 1e832729..bd767e39 100644 --- a/test/e2e/compose_deploy_test.go +++ b/test/e2e/compose_deploy_test.go @@ -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, } diff --git a/test/e2e/fixtures/compose-volumes.yaml b/test/e2e/fixtures/compose-volumes.yaml index 76045e19..d535657b 100644 --- a/test/e2e/fixtures/compose-volumes.yaml +++ b/test/e2e/fixtures/compose-volumes.yaml @@ -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: diff --git a/website/docs/3-concepts/6-services/3-runtime-templates.md b/website/docs/3-concepts/6-services/3-runtime-templates.md new file mode 100644 index 00000000..f2c1d28e --- /dev/null +++ b/website/docs/3-concepts/6-services/3-runtime-templates.md @@ -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) diff --git a/website/docs/8-compose-file-reference/1-support-matrix.md b/website/docs/8-compose-file-reference/1-support-matrix.md index d6f3fa52..e9c584aa 100644 --- a/website/docs/8-compose-file-reference/1-support-matrix.md +++ b/website/docs/8-compose-file-reference/1-support-matrix.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 | diff --git a/website/docs/8-compose-file-reference/3-image-tag-template.md b/website/docs/8-compose-file-reference/3-image-tag-template.md index f2540272..cf1ecde2 100644 --- a/website/docs/8-compose-file-reference/3-image-tag-template.md +++ b/website/docs/8-compose-file-reference/3-image-tag-template.md @@ -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) diff --git a/website/docs/9-cli-reference/uc_run.md b/website/docs/9-cli-reference/uc_run.md index 3ca4d5c7..c28a7fc3 100644 --- a/website/docs/9-cli-reference/uc_run.md +++ b/website/docs/9-cli-reference/uc_run.md @@ -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 diff --git a/website/docs/9-cli-reference/uc_service_run.md b/website/docs/9-cli-reference/uc_service_run.md index d3d73690..446959f7 100644 --- a/website/docs/9-cli-reference/uc_service_run.md +++ b/website/docs/9-cli-reference/uc_service_run.md @@ -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