feat: Initial support for Compose configs (#116)

This commit is contained in:
Anton Ovchinnikov
2025-09-26 21:24:10 +10:00
committed by GitHub
parent 337c15de35
commit 63c4de512e
18 changed files with 1368 additions and 1 deletions
+110
View File
@@ -0,0 +1,110 @@
package e2e
import (
"context"
"os"
"testing"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComposeConfigs(t *testing.T) {
t.Parallel()
clusterName := "ucind-test.compose-configs"
ctx := context.Background()
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true)
machine := c.Machines[0]
cli, err := machine.Connect(ctx)
require.NoError(t, err)
t.Run("basic configs", func(t *testing.T) {
t.Parallel()
name := "web"
t.Cleanup(func() {
removeServices(t, cli, name)
})
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-configs.yaml"})
require.NoError(t, err)
deploy, err := compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err := deploy.Plan(ctx)
require.NoError(t, err)
assert.Len(t, plan.Operations, 1, "Expected 1 service deployment")
err = deploy.Run(ctx)
require.NoError(t, err)
svc, err := cli.InspectService(ctx, name)
require.NoError(t, err)
expectedSpec := api.ServiceSpec{
Name: name,
Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{
Command: []string{"true"},
Image: "busybox:1.37.0-uclibc",
ConfigMounts: []api.ConfigMount{
{
ConfigName: "from-file",
ContainerPath: "/etc/config-from-file.conf",
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
},
{
ConfigName: "from-inline",
ContainerPath: "/etc/config-inline.conf",
Uid: "1000",
Gid: "1000",
Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(),
},
},
},
Configs: []api.ConfigSpec{
{
Name: "from-file",
Content: []byte("this is file config\n"),
},
{
Name: "from-inline",
Content: []byte("this is inline config\n"),
},
},
Replicas: 1,
}
assertServiceMatchesSpec(t, svc, expectedSpec)
// Verify deployment is up-to-date after initial deployment
deploy, err = compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err = deploy.Plan(ctx)
require.NoError(t, err)
assert.Len(t, plan.Operations, 0, "Expected no new operations after configs deployment")
// Verify the config files are actually created in the container and contain expected content
containerName := svc.Containers[0].Container.Name
configContentFirst, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-from-file.conf")
require.NoError(t, err)
assert.Equal(t, fileInfo{
permissions: 0o644,
content: "this is file config\n",
}, configContentFirst)
configContentSecond, err := readFileInfoInContainer(t, &machine, containerName, "/etc/config-inline.conf")
require.NoError(t, err)
assert.Equal(t, fileInfo{
permissions: 0o600,
content: "this is inline config\n",
}, configContentSecond)
})
}
+21
View File
@@ -0,0 +1,21 @@
services:
web:
image: busybox:1.37.0-uclibc
command: ["true"]
configs:
- source: from-file
target: /etc/config-from-file.conf
mode: 0644
- source: from-inline
target: /etc/config-inline.conf
uid: "1000"
gid: "1000"
mode: 0600
deploy:
replicas: 1
configs:
from-file:
file: ./configs/test-config.conf
from-inline:
content: |
this is inline config
@@ -0,0 +1 @@
this is file config
+105
View File
@@ -0,0 +1,105 @@
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/psviderski/uncloud/internal/ucind"
"github.com/stretchr/testify/assert"
)
type fileInfo struct {
permissions os.FileMode
content string
}
// a helper function that takes a machine, container name inside it and file path, and returns the file contents along with metadata
//
// Uncloud API does not currently expose exec functionality to run commands inside containers.
// Instead this helper function uses "docker cp" inside the ucind container to copy the file from the target container
// to a temporary location (also inside the ucind container), and then inspect its content and permissions.
func readFileInfoInContainer(t *testing.T, machine *ucind.Machine, containerName, filePath string) (fileInfo, error) {
t.Helper()
ctx := context.Background()
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv)
if err != nil {
return fileInfo{}, err
}
defer dockerCli.Close()
machineContainerName := fmt.Sprintf("%s-%s", machine.ClusterName, machine.Name)
// 1. Create an exec instance
fileLocation := fmt.Sprintf("%s:%s", containerName, filePath)
tmpFileLocation := fmt.Sprintf("/tmp/%s-%s", containerName, filepath.Base(filePath))
cmdDocker := fmt.Sprintf("docker cp %s %s", fileLocation, tmpFileLocation)
cmdPermissions := fmt.Sprintf("stat -c %%a %s", tmpFileLocation)
cmdCat := fmt.Sprintf("cat %s", tmpFileLocation)
cmdCombined := fmt.Sprintf("%s; %s; %s", cmdDocker, cmdPermissions, cmdCat)
execConfig := container.ExecOptions{
Cmd: []string{"sh", "-ec", cmdCombined},
AttachStdout: true,
AttachStderr: true,
}
resp, err := dockerCli.ContainerExecCreate(ctx, machineContainerName, execConfig)
if err != nil {
return fileInfo{}, err
}
// 2. Attach to the exec session
hijackResp, err := dockerCli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return fileInfo{}, err
}
defer hijackResp.Close()
// 3. Inspect result
inspectResp, err := dockerCli.ContainerExecInspect(ctx, resp.ID)
if err != nil {
return fileInfo{}, err
}
assert.Equal(t, 0, inspectResp.ExitCode, "Expected exit code 0 from exec command")
// 4. Read output to string using stdcopy to demultiplex the Docker stream
var stdout, stderr strings.Builder
_, err = stdcopy.StdCopy(&stdout, &stderr, hijackResp.Reader)
if err != nil {
return fileInfo{}, err
}
if stderr.Len() > 0 {
return fileInfo{}, fmt.Errorf("stderr output: %s", stderr.String())
}
// 5. Parse output: permissions, content
outputLines := strings.SplitN(stdout.String(), "\n", 2)
if len(outputLines) < 2 {
return fileInfo{}, fmt.Errorf("unexpected output format, expected at least 2 lines, got %d", len(outputLines))
}
permissions := outputLines[0]
// convert to octal number
permissionsOctal, err := strconv.ParseInt(permissions, 8, 32)
if err != nil {
return fileInfo{}, fmt.Errorf("parse file permissions: %w", err)
}
mode := os.FileMode(permissionsOctal)
fileContent := outputLines[1]
return fileInfo{
permissions: mode,
content: fileContent,
}, nil
}