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
+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
})
}