diff --git a/Makefile b/Makefile index 56b31744..84cdd85d 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index aecc347b..1b218cd8 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -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 diff --git a/pkg/api/config.go b/pkg/api/config.go new file mode 100644 index 00000000..411313bd --- /dev/null +++ b/pkg/api/config.go @@ -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 +} diff --git a/pkg/api/config_test.go b/pkg/api/config_test.go new file mode 100644 index 00000000..9f45208f --- /dev/null +++ b/pkg/api/config_test.go @@ -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) + }) + } +} diff --git a/pkg/api/service.go b/pkg/api/service.go index 976c36a7..224b2afc 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 diff --git a/pkg/client/compose/config.go b/pkg/client/compose/config.go new file mode 100644 index 00000000..23bd31c8 --- /dev/null +++ b/pkg/client/compose/config.go @@ -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 +} diff --git a/pkg/client/compose/config_test.go b/pkg/client/compose/config_test.go new file mode 100644 index 00000000..5f9bd23c --- /dev/null +++ b/pkg/client/compose/config_test.go @@ -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)) +} diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index bdaf0ecb..544ce7bb 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -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 } diff --git a/pkg/client/compose/testdata/config1.txt b/pkg/client/compose/testdata/config1.txt new file mode 100644 index 00000000..d196f8cb --- /dev/null +++ b/pkg/client/compose/testdata/config1.txt @@ -0,0 +1 @@ +test config content diff --git a/pkg/client/compose/testdata/nginx.conf b/pkg/client/compose/testdata/nginx.conf new file mode 100644 index 00000000..59be35d5 --- /dev/null +++ b/pkg/client/compose/testdata/nginx.conf @@ -0,0 +1,2 @@ +user nginx; +worker_processes auto; diff --git a/pkg/client/deploy/container.go b/pkg/client/deploy/container.go index e00075e3..739e305a 100644 --- a/pkg/client/deploy/container.go +++ b/pkg/client/deploy/container.go @@ -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 + }) +} diff --git a/test/e2e/compose_configs_test.go b/test/e2e/compose_configs_test.go new file mode 100644 index 00000000..dffc167a --- /dev/null +++ b/test/e2e/compose_configs_test.go @@ -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) + }) +} diff --git a/test/e2e/fixtures/compose-configs.yaml b/test/e2e/fixtures/compose-configs.yaml new file mode 100644 index 00000000..d2e59857 --- /dev/null +++ b/test/e2e/fixtures/compose-configs.yaml @@ -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 diff --git a/test/e2e/fixtures/configs/test-config.conf b/test/e2e/fixtures/configs/test-config.conf new file mode 100644 index 00000000..ed9ebab1 --- /dev/null +++ b/test/e2e/fixtures/configs/test-config.conf @@ -0,0 +1 @@ +this is file config diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go new file mode 100644 index 00000000..c0f25f53 --- /dev/null +++ b/test/e2e/helpers.go @@ -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 +} diff --git a/website/docs/3-concepts/5-compose/1-support-matrix.md b/website/docs/3-concepts/5-compose/1-support-matrix.md new file mode 100644 index 00000000..034b0829 --- /dev/null +++ b/website/docs/3-concepts/5-compose/1-support-matrix.md @@ -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"] +``` diff --git a/website/docs/3-concepts/5-compose/5-configs.md b/website/docs/3-concepts/5-compose/5-configs.md new file mode 100644 index 00000000..b4a54922 --- /dev/null +++ b/website/docs/3-concepts/5-compose/5-configs.md @@ -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 cat ` on the remote machine diff --git a/website/docs/3-concepts/5-compose/_category_.yaml b/website/docs/3-concepts/5-compose/_category_.yaml new file mode 100644 index 00000000..210244e3 --- /dev/null +++ b/website/docs/3-concepts/5-compose/_category_.yaml @@ -0,0 +1,4 @@ +label: Compose features +collapsed: true # keep the category closed by default +link: + type: generated-index