mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
feat: Initial support for Compose configs (#116)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
test config content
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user