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
|
.PHONY: ucind-cluster
|
||||||
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
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -9,10 +11,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
dockercommand "github.com/docker/cli/cli/command"
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
@@ -596,6 +600,13 @@ func (s *Server) CreateServiceContainer(
|
|||||||
return nil, status.Error(codes.Internal, err.Error())
|
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)
|
respBytes, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
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
|
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 {
|
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
return 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"`
|
Replicas uint `json:",omitempty"`
|
||||||
// Volumes is list of data volumes that can be mounted into the container.
|
// Volumes is list of data volumes that can be mounted into the container.
|
||||||
Volumes []VolumeSpec
|
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.
|
// 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
|
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.
|
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
||||||
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
||||||
volumes := make(map[string]VolumeSpec)
|
volumes := make(map[string]VolumeSpec)
|
||||||
@@ -157,6 +168,7 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate volumes
|
||||||
volumeNames := make(map[string]struct{})
|
volumeNames := make(map[string]struct{})
|
||||||
for _, v := range s.Volumes {
|
for _, v := range s.Volumes {
|
||||||
if err := v.Validate(); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +247,9 @@ type ContainerSpec struct {
|
|||||||
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
||||||
// Each mount references a volume defined in ServiceSpec.Volumes.
|
// Each mount references a volume defined in ServiceSpec.Volumes.
|
||||||
VolumeMounts []VolumeMount
|
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.
|
// Volumes is list of data volumes to mount into the container.
|
||||||
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
||||||
Volumes []string
|
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.Volumes = volumeSpecs
|
||||||
spec.Container.VolumeMounts = volumeMounts
|
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
|
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.
|
// Check if any mutable properties changed.
|
||||||
if !current.Caddy.Equals(new.Caddy) {
|
if !current.Caddy.Equals(new.Caddy) {
|
||||||
return ContainerNeedsRecreate
|
return ContainerNeedsRecreate
|
||||||
@@ -88,3 +100,9 @@ func sortVolumes(volumes []api.VolumeSpec) {
|
|||||||
return volumes[i].Name < volumes[j].Name
|
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