Skip to content

Commit 5c33e4b

Browse files
committed
feat(runtime-templates): add support for runtime templates in bind mounts (#412)
1 parent 88dda43 commit 5c33e4b

17 files changed

Lines changed: 571 additions & 10 deletions

File tree

‎cmd/uc/service/run.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func NewRunCommand(groupID string) *cobra.Command {
122122
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
123123
"the volume is located. Can be specified multiple times.\n"+
124124
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
125+
"Host and container paths support runtime templates such as {{.Container.Name}}.\n"+
125126
"Examples:\n"+
126127
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
127128
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+

‎internal/machine/docker/server.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,15 @@ func (s *Server) CreateServiceContainer(
538538
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
539539
}
540540

541+
desiredSpec := spec
542+
renderedSpec, renderErr := desiredSpec.RenderRuntimeTemplates(api.RuntimeTemplateContext{
543+
Container: api.RuntimeTemplateContainerContext{Name: containerName},
544+
})
545+
if renderErr != nil {
546+
return nil, status.Errorf(codes.InvalidArgument, "render runtime templates: %v", renderErr)
547+
}
548+
spec = renderedSpec
549+
541550
envVars := maps.Clone(spec.Container.Env)
542551
if envVars == nil {
543552
envVars = make(api.EnvVars)
@@ -745,7 +754,7 @@ func (s *Server) CreateServiceContainer(
745754
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
746755
}
747756

748-
specBytes, err := json.Marshal(spec)
757+
specBytes, err := json.Marshal(desiredSpec)
749758
if err != nil {
750759
removeContainer()
751760
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/psviderski/uncloud/api/pb"
10+
"github.com/psviderski/uncloud/pkg/api"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"google.golang.org/grpc/codes"
14+
"google.golang.org/grpc/status"
15+
_ "modernc.org/sqlite"
16+
)
17+
18+
func TestCreateServiceContainer_RejectsRuntimeTemplateError(t *testing.T) {
19+
t.Parallel()
20+
21+
desiredSpec := api.ServiceSpec{
22+
Name: "web",
23+
Container: api.ContainerSpec{
24+
Image: "busybox:latest",
25+
VolumeMounts: []api.VolumeMount{
26+
{VolumeName: "data", ContainerPath: "/data"},
27+
},
28+
},
29+
Volumes: []api.VolumeSpec{
30+
{
31+
Name: "data",
32+
Type: api.VolumeTypeBind,
33+
BindOptions: &api.BindOptions{
34+
// Early validation takes the valid branch. Rendering with the real container name takes the invalid one.
35+
HostPath: `/host/{{if eq .Container.Name "web-a1b2"}}{{.Container.ID}}{{else}}valid{{end}}`,
36+
},
37+
},
38+
},
39+
}
40+
specJSON, err := json.Marshal(desiredSpec)
41+
require.NoError(t, err)
42+
43+
server := &Server{}
44+
_, err = server.CreateServiceContainer(context.Background(), &pb.CreateServiceContainerRequest{
45+
ServiceId: strings.Repeat("a", 32),
46+
ServiceSpec: specJSON,
47+
ContainerName: "web-a1b2",
48+
})
49+
50+
require.Error(t, err)
51+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
52+
assert.ErrorContains(t, err, "render runtime template in bind volume 'data' host path")
53+
}

‎pkg/api/runtime_template.go‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"text/template"
7+
)
8+
9+
// RuntimeTemplateContext contains the data available to runtime templates in a service spec.
10+
type RuntimeTemplateContext struct {
11+
Container RuntimeTemplateContainerContext
12+
}
13+
14+
// RuntimeTemplateContainerContext contains container data available to runtime templates in a service spec.
15+
type RuntimeTemplateContainerContext struct {
16+
Name string
17+
}
18+
19+
// RenderRuntimeTemplates returns a copy of the service spec with supported runtime template expressions rendered
20+
// using metadata from ctx. Runtime templates use Go template expressions such as {{.Container.Name}} and are supported
21+
// in bind volume host paths and volume mount container paths. The original service spec is not modified.
22+
func (s *ServiceSpec) RenderRuntimeTemplates(ctx RuntimeTemplateContext) (ServiceSpec, error) {
23+
if ctx.Container.Name == "" {
24+
return ServiceSpec{}, fmt.Errorf("container name must not be empty")
25+
}
26+
27+
spec := s.Clone()
28+
29+
for i := range spec.Volumes {
30+
volume := &spec.Volumes[i]
31+
if volume.Type != VolumeTypeBind {
32+
continue
33+
}
34+
if err := volume.Validate(); err != nil {
35+
return ServiceSpec{}, fmt.Errorf("invalid bind volume '%s': %w", volume.Name, err)
36+
}
37+
38+
hostPath, err := renderRuntimeTemplate(volume.BindOptions.HostPath, ctx)
39+
if err != nil {
40+
return ServiceSpec{}, fmt.Errorf("render runtime template in bind volume '%s' host path: %w",
41+
volume.Name, err)
42+
}
43+
volume.BindOptions.HostPath = hostPath
44+
45+
if err = volume.Validate(); err != nil {
46+
return ServiceSpec{}, fmt.Errorf("invalid rendered bind volume '%s': %w", volume.Name, err)
47+
}
48+
}
49+
50+
for i := range spec.Container.VolumeMounts {
51+
volumeMount := &spec.Container.VolumeMounts[i]
52+
if err := volumeMount.Validate(); err != nil {
53+
return ServiceSpec{}, fmt.Errorf("invalid volume mount '%s': %w", volumeMount.VolumeName, err)
54+
}
55+
56+
containerPath, err := renderRuntimeTemplate(volumeMount.ContainerPath, ctx)
57+
if err != nil {
58+
return ServiceSpec{}, fmt.Errorf(
59+
"render runtime template in volume '%s' container path: %w", volumeMount.VolumeName, err)
60+
}
61+
volumeMount.ContainerPath = containerPath
62+
63+
if err = volumeMount.Validate(); err != nil {
64+
return ServiceSpec{}, fmt.Errorf("invalid rendered volume mount '%s': %w", volumeMount.VolumeName, err)
65+
}
66+
}
67+
68+
return spec, nil
69+
}
70+
71+
func renderRuntimeTemplate(value string, ctx RuntimeTemplateContext) (string, error) {
72+
tmpl, err := template.New("runtime template").Option("missingkey=error").Parse(value)
73+
if err != nil {
74+
return "", fmt.Errorf("parse runtime template: %w", err)
75+
}
76+
77+
var rendered bytes.Buffer
78+
if err = tmpl.Execute(&rendered, ctx); err != nil {
79+
return "", fmt.Errorf("execute runtime template: %w", err)
80+
}
81+
82+
return rendered.String(), nil
83+
}
84+
85+
func validateRuntimeTemplates(spec *ServiceSpec) error {
86+
_, err := spec.RenderRuntimeTemplates(RuntimeTemplateContext{
87+
Container: RuntimeTemplateContainerContext{Name: "validation-container-name"},
88+
})
89+
return err
90+
}

0 commit comments

Comments
 (0)