feat: Initial support for Compose configs (#116)

This commit is contained in:
Anton Ovchinnikov
2025-09-26 21:24:10 +10:00
committed by GitHub
parent 337c15de35
commit 63c4de512e
18 changed files with 1368 additions and 1 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ demo-reset:
.PHONY: ucind-cluster
ucind-cluster:
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
.PHONY: proto
proto:
+125
View File
@@ -1,6 +1,8 @@
package docker
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
@@ -9,10 +11,12 @@ import (
"log/slog"
"net/netip"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/distribution/reference"
dockercommand "github.com/docker/cli/cli/command"
@@ -596,6 +600,13 @@ func (s *Server) CreateServiceContainer(
return nil, status.Error(codes.Internal, err.Error())
}
// Inject configs into the created container
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
// Remove the container if config injection fails
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
@@ -672,6 +683,120 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
return dockerMounts, nil
}
// injectConfigs writes config content directly into the container.
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
if len(configs) == 0 || len(mounts) == 0 {
return nil
}
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
return fmt.Errorf("validate configs and mounts: %w", err)
}
// Create a map of config name to config spec for quick lookup
configMap := make(map[string]api.ConfigSpec)
for _, config := range configs {
configMap[config.Name] = config
}
// Process each config mount
for _, mount := range mounts {
config, exists := configMap[mount.ConfigName]
if !exists {
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", mount.ConfigName)
}
// Determine target path in container
targetPath := mount.ContainerPath
if targetPath == "" {
// This is the default from the Compose spec
targetPath = filepath.Join("/", mount.ConfigName)
}
// Determine file mode
fileMode := os.FileMode(0o444) // Default permissions
if mount.Mode != nil {
fileMode = *mount.Mode
}
uid, err := mount.GetNumericUid()
if err != nil {
return fmt.Errorf("invalid Uid: %w", err)
}
gid, err := mount.GetNumericGid()
if err != nil {
return fmt.Errorf("invalid Gid: %w", err)
}
// Copy the config content directly into the container
if err := s.copyContentToContainer(ctx, containerID, config.Content, targetPath, uid, gid, fileMode); err != nil {
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
}
slog.Debug("Injected config into container",
"config", config.Name,
"container", containerID[:12],
"target", targetPath)
}
return nil
}
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
// Create a tar archive containing the file
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
// Create tar header
header := &tar.Header{
Name: filepath.Base(targetPath),
Size: int64(len(content)),
Mode: int64(fileMode),
ModTime: time.Now(),
Typeflag: tar.TypeReg,
}
// Set ownership if specified
if uid != nil {
header.Uid = int(*uid)
}
if gid != nil {
header.Gid = int(*gid)
}
// Write header and content to tar archive
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}
if _, err := tw.Write(content); err != nil {
return fmt.Errorf("write content to tar: %w", err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("close tar writer: %w", err)
}
// Copy the tar archive to the container
targetDir := filepath.Dir(targetPath)
if targetDir == "." {
targetDir = "/"
}
if err := s.client.CopyToContainer(
ctx,
containerID,
targetDir,
&buf,
container.CopyToContainerOptions{CopyUIDGID: true},
); err != nil {
return fmt.Errorf("copy to container: %w", err)
}
return nil
}
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
if opts == nil {
return nil
+125
View File
@@ -0,0 +1,125 @@
// Implementation of Config feature from the Compose spec
package api
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
)
// ConfigSpec defines a configuration object that can be mounted into containers
type ConfigSpec struct {
Name string
// Content of the config when specified inline
Content []byte `json:",omitempty"`
// Note: NOT IMPLEMENTED
// External indicates this config already exists and should not be created
// External bool `json:",omitempty"`
// Note: NOT IMPLEMENTED
// Labels for the config
// Labels map[string]string `json:",omitempty"`
// TODO: add support for "environment"
}
func (c *ConfigSpec) Validate() error {
if c.Name == "" {
return fmt.Errorf("config name is required")
}
return nil
}
// Equals compares two ConfigSpec instances
func (c *ConfigSpec) Equals(other ConfigSpec) bool {
return c.Name == other.Name &&
bytes.Equal(c.Content, other.Content)
}
// ConfigMount defines how a config is mounted into a container
type ConfigMount struct {
// ConfigName references a config defined in ServiceSpec.Configs by its Name field
ConfigName string
// ContainerPath is the absolute path where the config is mounted in the container
ContainerPath string `json:",omitempty"`
// Uid for the mounted config file
Uid string `json:",omitempty"`
// Gid for the mounted config file
Gid string `json:",omitempty"`
// Mode (file permissions) for the mounted config file
Mode *os.FileMode `json:",omitempty"`
}
func (c *ConfigMount) GetNumericUid() (*uint64, error) {
if c.Uid == "" {
return nil, nil
}
uid, err := strconv.ParseUint(c.Uid, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Uid '%s': %w", c.Uid, err)
}
if int(uid) < 0 {
return nil, fmt.Errorf("invalid Uid '%s': value too high", c.Uid)
}
return &uid, nil
}
func (c *ConfigMount) GetNumericGid() (*uint64, error) {
if c.Gid == "" {
return nil, nil
}
gid, err := strconv.ParseUint(c.Gid, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Gid '%s': %w", c.Gid, err)
}
if int(gid) < 0 {
return nil, fmt.Errorf("invalid Gid '%s': value too high", c.Gid)
}
return &gid, nil
}
func (c *ConfigMount) Validate() error {
if c.ConfigName == "" {
return fmt.Errorf("config mount source is required")
}
if _, err := c.GetNumericUid(); err != nil {
return err
}
if _, err := c.GetNumericGid(); err != nil {
return err
}
if c.ContainerPath != "" && !filepath.IsAbs(c.ContainerPath) {
return fmt.Errorf("container path must be absolute")
}
return nil
}
// ValidateConfigsAndMounts takes config specs and config mounts and validates that all mounts refer to existing specs
func ValidateConfigsAndMounts(configs []ConfigSpec, mounts []ConfigMount) error {
configMap := make(map[string]struct{})
for _, cfg := range configs {
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if _, ok := configMap[cfg.Name]; ok {
return fmt.Errorf("duplicate config name: '%s'", cfg.Name)
}
configMap[cfg.Name] = struct{}{}
}
for _, mount := range mounts {
if err := mount.Validate(); err != nil {
return fmt.Errorf("invalid config mount: %w", err)
}
if _, exists := configMap[mount.ConfigName]; !exists {
return fmt.Errorf("config mount source '%s' does not refer to any defined config", mount.ConfigName)
}
}
return nil
}
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// uint64Ptr is a convenience function to create a pointer to a uint64 value
func uint64Ptr(v uint64) *uint64 {
return &v
}
func TestConfigMount_GetNumericUid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
uid string
expected *uint64
wantErr string
}{
{
name: "empty uid returns nil",
uid: "",
expected: nil,
},
{
name: "valid numeric uid",
uid: "1000",
expected: uint64Ptr(1000),
},
{
name: "zero uid",
uid: "0",
expected: uint64Ptr(0),
},
{
name: "invalid non-numeric uid",
uid: "root",
wantErr: "invalid Uid 'root'",
},
{
name: "negative uid",
uid: "-1",
wantErr: "invalid Uid",
},
{
name: "very large uid",
uid: "18446744073709551615", // max uint64
wantErr: "invalid Uid '18446744073709551615': value too high",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mount := &ConfigMount{Uid: tt.uid}
uid, err := mount.GetNumericUid()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.Nil(t, uid)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, uid)
} else {
require.NotNil(t, uid)
assert.Equal(t, *tt.expected, *uid)
}
})
}
}
func TestConfigMount_GetNumericGid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
gid string
expected *uint64
wantErr string
}{
{
name: "empty gid returns nil",
gid: "",
expected: nil,
},
{
name: "valid numeric gid",
gid: "1000",
expected: uint64Ptr(1000),
},
{
name: "zero gid",
gid: "0",
expected: uint64Ptr(0),
},
{
name: "invalid non-numeric gid",
gid: "wheel",
wantErr: "invalid Gid 'wheel'",
},
{
name: "negative gid",
gid: "-1",
wantErr: "invalid Gid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mount := &ConfigMount{Gid: tt.gid}
gid, err := mount.GetNumericGid()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.Nil(t, gid)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, gid)
} else {
require.NotNil(t, gid)
assert.Equal(t, *tt.expected, *gid)
}
})
}
}
func TestValidateConfigsAndMounts(t *testing.T) {
t.Parallel()
mode := os.FileMode(0o644)
tests := []struct {
name string
configs []ConfigSpec
mounts []ConfigMount
wantErr string
}{
{
name: "empty configs and mounts",
configs: []ConfigSpec{},
mounts: []ConfigMount{},
},
{
name: "valid configs without mounts",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config2", Content: []byte("content2")},
},
mounts: []ConfigMount{},
},
{
name: "valid configs with valid mounts",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config2", Content: []byte("content2")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config1"},
{ConfigName: "config2", ContainerPath: "/etc/config2", Uid: "1000", Gid: "1000"},
},
},
{
name: "config with empty name",
configs: []ConfigSpec{
{Name: "", Content: []byte("content")},
},
mounts: []ConfigMount{},
wantErr: "config name is required",
},
{
name: "duplicate config names",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
{Name: "config1", Content: []byte("content2")},
},
mounts: []ConfigMount{},
wantErr: "duplicate config name: 'config1'",
},
{
name: "mount with empty config name",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "", ContainerPath: "/etc/config"},
},
wantErr: "config mount source is required",
},
{
name: "mount referencing non-existent config",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "nonexistent", ContainerPath: "/etc/config"},
},
wantErr: "config mount source 'nonexistent' does not refer to any defined config",
},
{
name: "mount with invalid uid",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config", Uid: "invalid"},
},
wantErr: "invalid Uid 'invalid'",
},
{
name: "mount with invalid gid",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/etc/config", Gid: "invalid"},
},
wantErr: "invalid Gid 'invalid'",
},
{
name: "mount with relative container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "relative/path"},
},
wantErr: "container path must be absolute",
},
{
name: "mount with empty container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: ""},
},
// Empty path is allowed
},
{
name: "mount with absolute container path",
configs: []ConfigSpec{
{Name: "config1", Content: []byte("content1")},
},
mounts: []ConfigMount{
{ConfigName: "config1", ContainerPath: "/absolute/path"},
},
},
{
name: "complex valid scenario",
configs: []ConfigSpec{
{Name: "nginx-conf", Content: []byte("server { listen 80; }")},
{Name: "app-config", Content: []byte("debug=true")},
{Name: "cert", Content: []byte("-----BEGIN CERTIFICATE-----")},
},
mounts: []ConfigMount{
{ConfigName: "nginx-conf", ContainerPath: "/etc/nginx/nginx.conf", Uid: "0", Gid: "0", Mode: &mode},
{ConfigName: "app-config", ContainerPath: "/app/config.env"},
{ConfigName: "cert", ContainerPath: "/etc/ssl/cert.pem", Uid: "1000", Gid: "1000"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateConfigsAndMounts(tt.configs, tt.mounts)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
+20
View File
@@ -60,6 +60,8 @@ type ServiceSpec struct {
Replicas uint `json:",omitempty"`
// Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec
// Configs is list of configuration objects that can be mounted into the container.
Configs []ConfigSpec
}
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
@@ -79,6 +81,15 @@ func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
return VolumeSpec{}, false
}
func (s *ServiceSpec) Config(name string) (ConfigSpec, bool) {
for _, c := range s.Configs {
if c.Name == name {
return c, true
}
}
return ConfigSpec{}, false
}
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
volumes := make(map[string]VolumeSpec)
@@ -157,6 +168,7 @@ func (s *ServiceSpec) Validate() error {
}
}
// Validate volumes
volumeNames := make(map[string]struct{})
for _, v := range s.Volumes {
if err := v.Validate(); err != nil {
@@ -177,6 +189,11 @@ func (s *ServiceSpec) Validate() error {
}
}
// Validate configs
if err := ValidateConfigsAndMounts(s.Configs, s.Container.ConfigMounts); err != nil {
return fmt.Errorf("validate service configs and mounts: %w", err)
}
return nil
}
@@ -230,6 +247,9 @@ type ContainerSpec struct {
// VolumeMounts specifies how volumes are mounted into the container filesystem.
// Each mount references a volume defined in ServiceSpec.Volumes.
VolumeMounts []VolumeMount
// ConfigMounts specifies how configs are mounted into the container filesystem.
// Each mount references a config defined in ServiceSpec.Configs.
ConfigMounts []ConfigMount
// Volumes is list of data volumes to mount into the container.
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
Volumes []string
+75
View File
@@ -0,0 +1,75 @@
package compose
import (
"fmt"
"os"
"path/filepath"
"github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api"
)
// TODO: add support for short syntax configs
func configSpecsFromCompose(
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
) ([]api.ConfigSpec, []api.ConfigMount, error) {
var configSpecs []api.ConfigSpec
var configMounts []api.ConfigMount
for _, serviceConfig := range serviceConfigs {
var spec api.ConfigSpec
if projectConfig, exists := configs[serviceConfig.Source]; exists {
if projectConfig.External {
return nil, nil, fmt.Errorf("external configs are not supported: %s",
serviceConfig.Source)
}
spec = api.ConfigSpec{
Name: serviceConfig.Source,
Content: []byte(projectConfig.Content),
}
// If File is specified, read the file contents
if projectConfig.File != "" {
configPath := projectConfig.File
// TODO: handle this in a separate function?
if !filepath.IsAbs(configPath) {
configPath = filepath.Join(workingDir, configPath)
}
fileContent, err := os.ReadFile(configPath)
if err != nil {
return nil, nil, fmt.Errorf("read config from file '%s': %w", projectConfig.File, err)
}
spec.Content = fileContent
}
} else {
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
}
configSpecs = append(configSpecs, spec)
// Create config mount
target := serviceConfig.Target
if target == "" {
target = "/" + serviceConfig.Source // Default mount path
}
mount := api.ConfigMount{
ConfigName: spec.Name,
ContainerPath: target,
Uid: serviceConfig.UID,
Gid: serviceConfig.GID,
}
if serviceConfig.Mode != nil {
mode := os.FileMode(*serviceConfig.Mode)
mount.Mode = &mode
}
configMounts = append(configMounts, mount)
}
return configSpecs, configMounts, nil
}
+114
View File
@@ -0,0 +1,114 @@
package compose
import (
"os"
"testing"
"github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigSpecsFromCompose(t *testing.T) {
tests := []struct {
name string
configs types.Configs
serviceConfigs []types.ServiceConfigObjConfig
expectedSpecs []api.ConfigSpec
expectedMounts []api.ConfigMount
expectError bool
}{
{
name: "project-level config with file",
configs: types.Configs{
"app-config": types.ConfigObjConfig{
File: "testdata/config1.txt",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "app-config",
Target: "/app/config.json",
UID: "1000",
GID: "1000",
},
},
expectedSpecs: []api.ConfigSpec{
{
Name: "app-config",
Content: []byte("test config content\n"),
},
},
expectedMounts: []api.ConfigMount{
{
ConfigName: "app-config",
ContainerPath: "/app/config.json",
Uid: "1000",
Gid: "1000",
},
},
},
{
name: "config with mode",
configs: types.Configs{
"nginx-config": types.ConfigObjConfig{
File: "./testdata/nginx.conf",
},
},
serviceConfigs: []types.ServiceConfigObjConfig{
{
Source: "nginx-config",
Target: "/etc/nginx/nginx.conf",
Mode: func() *uint32 { m := uint32(0o644); return &m }(),
},
},
expectedSpecs: []api.ConfigSpec{
{
Name: "nginx-config",
Content: []byte("user nginx;\nworker_processes auto;\n"),
},
},
expectedMounts: []api.ConfigMount{
{
ConfigName: "nginx-config",
ContainerPath: "/etc/nginx/nginx.conf",
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configSpecs, configMounts, err := configSpecsFromCompose(tt.configs, tt.serviceConfigs, ".")
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.ElementsMatch(t, tt.expectedSpecs, configSpecs)
assert.Equal(t, tt.expectedMounts, configMounts)
})
}
}
func TestConfigSpecEquals(t *testing.T) {
config1 := api.ConfigSpec{
Name: "test-config",
}
config2 := api.ConfigSpec{
Name: "test-config",
}
config3 := api.ConfigSpec{
Name: "test-config",
Content: []byte("some content"),
}
assert.True(t, config1.Equals(config2))
assert.False(t, config1.Equals(config3))
}
+9
View File
@@ -105,6 +105,15 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec.Volumes = volumeSpecs
spec.Container.VolumeMounts = volumeMounts
// Parse configs
configSpecs, configMounts, err := configSpecsFromCompose(project.Configs, service.Configs, project.WorkingDir)
if err != nil {
return spec, err
}
spec.Configs = configSpecs
spec.Container.ConfigMounts = configMounts
return spec, nil
}
+1
View File
@@ -0,0 +1 @@
test config content
+2
View File
@@ -0,0 +1,2 @@
user nginx;
worker_processes auto;
+18
View File
@@ -71,6 +71,18 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
}
}
// Compare configs.
if len(current.Configs) != len(new.Configs) {
return ContainerNeedsRecreate
}
sortConfigs(current.Configs)
sortConfigs(new.Configs)
for i := range current.Configs {
if !current.Configs[i].Equals(new.Configs[i]) {
return ContainerNeedsRecreate
}
}
// Check if any mutable properties changed.
if !current.Caddy.Equals(new.Caddy) {
return ContainerNeedsRecreate
@@ -88,3 +100,9 @@ func sortVolumes(volumes []api.VolumeSpec) {
return volumes[i].Name < volumes[j].Name
})
}
func sortConfigs(configs []api.ConfigSpec) {
sort.Slice(configs, func(i, j int) bool {
return configs[i].Name < configs[j].Name
})
}
+110
View File
@@ -0,0 +1,110 @@
package e2e
import (
"context"
"os"
"testing"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComposeConfigs(t *testing.T) {
t.Parallel()
clusterName := "ucind-test.compose-configs"
ctx := context.Background()
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true)
machine := c.Machines[0]
cli, err := machine.Connect(ctx)
require.NoError(t, err)
t.Run("basic configs", func(t *testing.T) {
t.Parallel()
name := "web"
t.Cleanup(func() {
removeServices(t, cli, name)
})
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-configs.yaml"})
require.NoError(t, err)
deploy, err := compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err := deploy.Plan(ctx)
require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service deployment")
err = deploy.Run(ctx)
require.NoError(t, err)
svc, err := cli.InspectService(ctx, name)
require.NoError(t, err)
expectedSpec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Command: []string{"true"},
Image: "busybox:1.37.0-uclibc",
ConfigMounts: []api.ConfigMount{
{
ConfigName: "from-file",
ContainerPath: "/etc/config-from-file.conf",
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
},
{
ConfigName: "from-inline",
ContainerPath: "/etc/config-inline.conf",
Uid: "1000",
Gid: "1000",
Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(),
},
},
},
Configs: []api.ConfigSpec{
{
Name: "from-file",
Content: []byte("this is file config\n"),
},
{
Name: "from-inline",
Content: []byte("this is inline config\n"),
},
},
Replicas: 1,
}
assertServiceMatchesSpec(t, svc, expectedSpec)
// Verify deployment is up-to-date after initial deployment
deploy, err = compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err = deploy.Plan(ctx)
require.NoError(t, err)
assert.Len(t, plan.Operations, 0, "Expected no new operations after configs deployment")
// Verify the config files are actually created in the container and contain expected content
containerName := svc.Containers[0].Container.Name
configContentFirst, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-from-file.conf")
require.NoError(t, err)
assert.Equal(t, fileInfo{
permissions: 0o644,
content: "this is file config\n",
}, configContentFirst)
configContentSecond, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-inline.conf")
require.NoError(t, err)
assert.Equal(t, fileInfo{
permissions: 0o600,
content: "this is inline config\n",
}, configContentSecond)
})
}
+21
View File
@@ -0,0 +1,21 @@
services:
web:
image: busybox:1.37.0-uclibc
command: ["true"]
configs:
- source: from-file
target: /etc/config-from-file.conf
mode: 0644
- source: from-inline
target: /etc/config-inline.conf
uid: "1000"
gid: "1000"
mode: 0600
deploy:
replicas: 1
configs:
from-file:
file: ./configs/test-config.conf
from-inline:
content: |
this is inline config
@@ -0,0 +1 @@
this is file config
+105
View File
@@ -0,0 +1,105 @@
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/stretchr/testify/assert"
)
type fileInfo struct {
permissions os.FileMode
content string
}
// a helper function that takes a machine, container name inside it and file path, and returns the file contents along with metadata
//
// Uncloud API does not currently expose exec functionality to run commands inside containers.
// Instead this helper function uses "docker cp" inside the ucind container to copy the file from the target container
// to a temporary location (also inside the ucind container), and then inspect its content and permissions.
func readFileInfoInContainer(t *testing.T, machine *ucind.Machine, containerName, filePath string) (fileInfo, error) {
t.Helper()
ctx := context.Background()
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv)
if err != nil {
return fileInfo{}, err
}
defer dockerCli.Close()
machineContainerName := fmt.Sprintf("%s-%s", machine.ClusterName, machine.Name)
// 1. Create an exec instance
fileLocation := fmt.Sprintf("%s:%s", containerName, filePath)
tmpFileLocation := fmt.Sprintf("/tmp/%s-%s", containerName, filepath.Base(filePath))
cmdDocker := fmt.Sprintf("docker cp %s %s", fileLocation, tmpFileLocation)
cmdPermissions := fmt.Sprintf("stat -c %%a %s", tmpFileLocation)
cmdCat := fmt.Sprintf("cat %s", tmpFileLocation)
cmdCombined := fmt.Sprintf("%s; %s; %s", cmdDocker, cmdPermissions, cmdCat)
execConfig := container.ExecOptions{
Cmd: []string{"sh", "-ec", cmdCombined},
AttachStdout: true,
AttachStderr: true,
}
resp, err := dockerCli.ContainerExecCreate(ctx, machineContainerName, execConfig)
if err != nil {
return fileInfo{}, err
}
// 2. Attach to the exec session
hijackResp, err := dockerCli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return fileInfo{}, err
}
defer hijackResp.Close()
// 3. Inspect result
inspectResp, err := dockerCli.ContainerExecInspect(ctx, resp.ID)
if err != nil {
return fileInfo{}, err
}
assert.Equal(t, 0, inspectResp.ExitCode, "Expected exit code 0 from exec command")
// 4. Read output to string using stdcopy to demultiplex the Docker stream
var stdout, stderr strings.Builder
_, err = stdcopy.StdCopy(&stdout, &stderr, hijackResp.Reader)
if err != nil {
return fileInfo{}, err
}
if stderr.Len() > 0 {
return fileInfo{}, fmt.Errorf("stderr output: %s", stderr.String())
}
// 5. Parse output: permissions, content
outputLines := strings.SplitN(stdout.String(), "\n", 2)
if len(outputLines) < 2 {
return fileInfo{}, fmt.Errorf("unexpected output format, expected at least 2 lines, got %d", len(outputLines))
}
permissions := outputLines[0]
// convert to octal number
permissionsOctal, err := strconv.ParseInt(permissions, 8, 32)
if err != nil {
return fileInfo{}, fmt.Errorf("parse file permissions: %w", err)
}
mode := os.FileMode(permissionsOctal)
fileContent := outputLines[1]
return fileInfo{
permissions: mode,
content: fileContent,
}, nil
}
@@ -0,0 +1,107 @@
# Compose support matrix
Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations.
The following table shows the support status for main Compose features:
| Feature | Support Status | Notes |
| ------------------ | ------------------- | --------------------------------------------------- |
| **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile |
| `command` | ✅ Supported | Override container command |
| `configs` | ✅ Supported | File-based and inline configs |
| `cpus` | ✅ Supported | CPU limit |
| `depends_on` | ❌ Not supported | Services start independently |
| `dns` | ❌ Not supported | Built-in service discovery |
| `dns_search` | ❌ Not supported | Built-in service discovery |
| `entrypoint` | ✅ Supported | Override container entrypoint |
| `env_file` | ✅ Supported | Environment file |
| `environment` | ✅ Supported | Environment variables |
| `image` | ✅ Supported | Container image specification |
| `init` | ✅ Supported | Run init process in container |
| `labels` | ❌ Not supported | Not currently needed |
| `links` | ❌ Not supported | Use service names for communication |
| `logging` | ✅ Supported | Uses Docker daemon logging |
| `mem_limit` | ✅ Supported | Memory limit |
| `mem_reservation` | ✅ Supported | Memory reservation |
| `mem_swappiness` | ❌ Not supported | |
| `memswap_limit` | ❌ Not supported | |
| `networks` | ❌ Not supported | All containers share cluster network |
| `ports` | ⚠️ Limited | Basic port publishing. Use `x-ports` for HTTP/HTTPS |
| `privileged` | ✅ Supported | Run containers in privileged mode |
| `pull_policy` | ✅ Supported | always, missing, never |
| `secrets` | ❌ Not supported | Use configs or environment variables |
| `security_opt` | ❌ Not supported | |
| `storage_opt` | ❌ Not supported | |
| `user` | ✅ Supported | Set container user |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| **Deploy** | | |
| `labels` | ❌ Not supported | Not needed |
| `mode` | ⚠️ Limited | Only `replicated` supported |
| `placement` | ❌ Not supported | Use `x-machines` extension |
| `replicas` | ✅ Supported | Number of container replicas |
| `resources` | ⚠️ Limited | CPU and memory limits only |
| `restart_policy` | ❌ Not supported | Services auto-restart |
| **Volumes** | | |
| Named volumes | ✅ Supported | Docker volumes |
| Bind mounts | ✅ Supported | Host path binding |
| Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels |
| External volumes | ⚠️ Limited | Must exist before deployment |
| Volume drivers | ⚠️ Limited | Local driver only |
| **Configs** | | |
| File-based configs | ✅ Supported | Read from file |
| Inline configs | ✅ Supported | Defined in compose file |
| External configs | ❌ Not supported | Not supported |
| Short syntax | ❌ Not supported | Use long syntax only |
| **Extensions** | | |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `x-ports` | ✅ Uncloud-specific | HTTP/HTTPS port publishing |
### Legend
-**Supported**: Feature works as documented
- ⚠️ **Limited**: Partial support or with restrictions
-**Not supported**: Feature is not (yet) available
## Uncloud Extensions
Uncloud provides several custom extensions to enhance the Compose experience:
### `x-ports`
Define HTTP/HTTPS endpoints for services:
```yaml
services:
web:
image: nginx
x-ports:
- 80/https
- example.com:80/https
```
### `x-caddy`
Custom Caddy reverse proxy configuration:
```yaml
services:
web:
image: nginx
x-caddy: |
example.com {
reverse_proxy {{ upstreams 80 }}
}
```
### `x-machines`
Specify on what machines the service should run:
```yaml
services:
web:
image: nginx
x-machines: ["web-1", "web-2"]
```
@@ -0,0 +1,236 @@
# Configs
Uncloud supports [Compose configs](https://github.com/compose-spec/compose-spec/blob/main/08-configs.md) for managing configuration files in your services. Configs allow you to store non-sensitive configuration data separately from your container images and mount them into containers at runtime.
See also [Docker Compose documentation](https://docs.docker.com/reference/compose-file/configs/) for the same feature.
## Overview
Configs provide a way to:
- Store configuration files outside of container images
- Share configuration between multiple services
- Update configuration without rebuilding images
- Version control your configuration separately
## Defining Configs
Configs are defined in two places in your `compose.yaml`:
1. **Top-level `configs` section**: Define the config content
2. **Service-level `configs` section**: Mount configs into containers
## Top-level Configs
Define configs using either file-based or inline content:
### File-based Configs
Read configuration from a file on the (local/control) host where `uc deploy` is run:
```yaml
configs:
nginx_config:
file: ./nginx.conf
app_config:
file: ./config/app.properties
```
The file path is relative to the compose file location.
### Inline Configs
Define configuration content directly in the compose file:
```yaml
configs:
app_config:
content: |
database_url=postgres://localhost:5432/myapp
redis_url=redis://localhost:6379
# Variable interpolation is supported
log_level=${LOG_LEVEL:-info}
```
When using inline configs, [environment variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) is supported so that you can customize configuration based on your deployment environment. Variables are resolved from the environment where `uc deploy` is executed.
## Service-level Config Mounts
Mount configs into containers using the long syntax:
```yaml
services:
web:
image: nginx:alpine
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
mode: 0644
- source: app_config
target: /app/config.properties
uid: "1000"
gid: "1000"
mode: 0600
```
### Config Mount Options
| Option | Description | Default |
| -------- | ------------------------------------------------- | ---------- |
| `source` | Name of the config (from top-level configs) | Required |
| `target` | Path where the config is mounted in the container | Required |
| `mode` | File permissions (octal format) | `0644` |
| `uid` | User ID that owns the file | Root user |
| `gid` | Group ID that owns the file | Root group |
## Complete Examples
### Example 1: Web Server with Custom Configuration
```yaml
services:
web:
image: nginx:alpine
configs:
- source: nginx_conf
target: /etc/nginx/nginx.conf
x-ports:
- 80/https
configs:
nginx_conf:
file: ./nginx.conf
```
Create `nginx.conf` in the same directory as your compose file:
```nginx
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
return 200 'Hello from Uncloud!\n';
add_header Content-Type text/plain;
}
}
}
```
### Example 2: Application with Multiple Config Files
```yaml
services:
app:
image: node:18-alpine
command: ["node", "server.js"]
configs:
- source: app_config
target: /app/config.json
mode: 0644
- source: database_config
target: /app/database.json
uid: "1000"
gid: "1000"
mode: 0600
environment:
NODE_ENV: production
configs:
app_config:
content: |
{
"port": 3000,
"logLevel": "info",
"features": {
"analytics": true,
"cache": true
}
}
database_config:
file: ./configs/database.json
```
## Implementation details
Here are the key characteristics of the configs feature implementation:
- **Client-side processing**: When you run `uc deploy`, the Uncloud CLI reads config files from your local machine and includes their content in the service specification.
- **Content transfer**: Config content (both file-based and inline) is sent to the Uncloud daemon via gRPC as part of the deployment request.
- **Container deployment**: During container creation, configs are copied inside the container.
- **File lifecycle**: Config files exist only for the lifetime of the container. When a container is removed, its config files are cleaned up automatically.
- **Per-container isolation**: Each container gets its own copy of config files.
- **Atomic updates**: Config changes require redeployment, ensuring consistency across all replicas.
## Best Practices
### Security Considerations
- **Sensitive Data**: Don't put secrets in configs. Use environment variables or external secret management
- **File Permissions**: Set appropriate `mode`, `uid`, and `gid` for sensitive config files
- **Version Control**: Be careful about committing sensitive configuration files to git
### Config Sharing
Configs can be shared across multiple services:
```yaml
services:
web:
image: nginx
configs:
- source: shared_config
target: /etc/app/config.yaml
api:
image: myapi
configs:
- source: shared_config
target: /app/config.yaml
configs:
shared_config:
content: |
environment: production
debug: false
```
## Limitations
- **External configs**: Not supported. All configs must be defined in the compose file
- **Short syntax**: Not yet supported. Use the long syntax with `source` and `target`
- **Config updates**: Changing config content requires redeployment to take effect
## Troubleshooting
### Config File Not Found
If you get an error about config file not found:
1. Check the file path is correct relative to the compose file
2. Ensure the file exists and is readable
3. Verify file permissions
### Permission Denied
If containers can't read config files:
1. Check the `mode` setting allows read access
2. Verify `uid` and `gid` match the container's user
3. Ensure the container user has permission to access the target directory
### Config Not Updating
If config changes don't take effect:
1. Run `uc deploy` to redeploy with new config content
2. Check that you're modifying the correct config file
3. Verify the config is properly mounted in the container with `docker exec <service> cat <config-path>` on the remote machine
@@ -0,0 +1,4 @@
label: Compose features
collapsed: true # keep the category closed by default
link:
type: generated-index