feat: support x-machines placement constraints in compose files (#90)

This commit is contained in:
Evgenii Orlov
2025-07-10 16:46:29 +10:00
committed by GitHub
parent 492a0af2b2
commit 31cd4c77e9
22 changed files with 472 additions and 39 deletions
+83
View File
@@ -0,0 +1,83 @@
package compose
import (
"fmt"
"strings"
)
const MachinesExtensionKey = "x-machines"
// MachinesSource represents the parsed x-machines extension data as slice of strings
type MachinesSource []string
// DecodeMapstructure implements custom decoding for multiple input types
func (m *MachinesSource) DecodeMapstructure(value interface{}) error {
switch v := value.(type) {
case *MachinesSource:
// Handle case where compose-go passes a pointer to an already created instance
*m = *v
return nil
case MachinesSource:
// Handle case where compose-go passes a direct instance
*m = v
return nil
case string:
// Support single string value or comma-separated values
// x-machines: my-machine or x-machines: "machine-1,machine-2"
machines, err := parseMachineNames(v)
if err != nil {
return err
}
*m = MachinesSource(machines)
return nil
case []string:
// Support string array: x-machines: ["machine-1", "machine-2"]
machines, err := validateMachineNames(v)
if err != nil {
return err
}
*m = MachinesSource(machines)
return nil
case []interface{}:
// Support interface array that may come from YAML parsing
machineNames := make([]string, 0, len(v))
for i, machine := range v {
str, ok := machine.(string)
if !ok {
return fmt.Errorf("x-machines[%d] is not a string, got %T", i, machine)
}
machineNames = append(machineNames, str)
}
machines, err := validateMachineNames(machineNames)
if err != nil {
return err
}
*m = MachinesSource(machines)
return nil
default:
return fmt.Errorf("x-machines must be a string or list of strings, got %T", value)
}
}
// parseMachineNames parses a single string that may contain comma-separated machine names
func parseMachineNames(machinesStr string) ([]string, error) {
// Split by comma and process each machine name, works for both single and multiple values
parts := strings.Split(machinesStr, ",")
machines := make([]string, 0, len(parts))
for _, part := range parts {
machines = append(machines, strings.TrimSpace(part))
}
return validateMachineNames(machines)
}
// validateMachineNames validates machine names to ensure they are not empty and contain valid characters.
func validateMachineNames(machines []string) ([]string, error) {
for i, machine := range machines {
machine = strings.TrimSpace(machine)
if machine == "" {
return nil, fmt.Errorf("x-machines[%d] cannot be empty", i)
}
machines[i] = machine
}
return machines, nil
}
+6 -5
View File
@@ -5,7 +5,7 @@ package compose
import (
"context"
"fmt"
composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
)
@@ -25,8 +25,9 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
// If none was selected, get default Compose file names from current or parent folders.
composecli.WithDefaultConfigPath,
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
}
options, err := composecli.NewProjectOptions(
paths,
append(defaultOpts, opts...)...,
@@ -34,15 +35,15 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
if err != nil {
return nil, fmt.Errorf("create compose parser options: %w", err)
}
project, err := options.LoadProject(ctx)
if err != nil {
return nil, err
}
if project, err = transformServicesPortsExtension(project); err != nil {
return nil, err
}
return project, nil
}
+5 -1
View File
@@ -55,13 +55,16 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
},
Name: serviceName,
Mode: api.ServiceModeReplicated,
// TODO: implement and map x-machines to Placement.
}
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
spec.Ports = ports
}
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
spec.Placement.Machines = []string(machines)
}
// Map LogDriver if specified
if service.Logging != nil && service.Logging.Driver != "" {
spec.Container.LogDriver = &api.LogDriver{
@@ -85,6 +88,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
default:
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
}
}
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
+187 -14
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/mount"
"github.com/docker/go-units"
@@ -18,24 +18,39 @@ import (
"github.com/stretchr/testify/require"
)
// loadProjectFromFile loads a compose project from a YAML file
func loadProjectFromFile(t *testing.T, filename string) *types.Project {
// loadProjectFromContent loads a compose project from YAML content
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
t.Helper()
ctx := context.Background()
path := filepath.Join("testdata", filename)
options, err := cli.NewProjectOptions(
[]string{path},
cli.WithName(FakeProjectName),
cli.WithOsEnv,
cli.WithDotEnv,
)
require.NoError(t, err)
configDetails := types.ConfigDetails{
ConfigFiles: []types.ConfigFile{
{
Filename: "docker-compose.yml",
Content: []byte(content),
},
},
}
project, err := options.LoadProject(ctx)
require.NoError(t, err)
project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) {
o.SetProjectName("test", true)
// Register our custom extensions
if o.KnownExtensions == nil {
o.KnownExtensions = map[string]any{}
}
o.KnownExtensions[PortsExtensionKey] = PortsSource{}
o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
})
if err != nil {
return nil, err
}
return project
// Apply ports extension transformation since we're not using LoadProject
if project, err = transformServicesPortsExtension(project); err != nil {
return nil, err
}
return project, nil
}
func TestServiceSpecFromCompose(t *testing.T) {
@@ -236,3 +251,161 @@ func TestServiceSpecFromCompose(t *testing.T) {
})
}
}
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
tests := []struct {
name string
composeYAML string
expected api.Placement
expectError bool
}{
{
name: "valid x-machines with string array",
composeYAML: `
services:
test:
image: nginx
x-machines: ["machine-1", "machine-2"]
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
{
name: "valid x-machines with single string",
composeYAML: `
services:
test:
image: nginx
x-machines: my-machine
`,
expected: api.Placement{
Machines: []string{"my-machine"},
},
},
{
name: "valid x-machines with single quoted string",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1"
`,
expected: api.Placement{
Machines: []string{"machine-1"},
},
},
{
name: "valid x-machines with numeric string",
composeYAML: `
services:
test:
image: nginx
x-machines: "123"
`,
expected: api.Placement{
Machines: []string{"123"},
},
},
{
name: "valid x-machines with comma-separated string",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1,machine-2"
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
{
name: "valid x-machines with comma-separated string and spaces",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1, machine-2, machine-3"
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2", "machine-3"},
},
},
{
name: "empty x-machines array",
composeYAML: `
services:
test:
image: nginx
x-machines: []
`,
expected: api.Placement{
Machines: []string{},
},
},
{
name: "no x-machines",
composeYAML: `
services:
test:
image: nginx
`,
expected: api.Placement{},
},
{
name: "empty machine name in x-machines",
composeYAML: `
services:
test:
image: nginx
x-machines: ["machine-1", "", "machine-2"]
`,
expectError: true,
},
{
name: "empty machine name in comma-separated x-machines",
composeYAML: `
services:
test:
image: nginx
x-machines: "machine-1,,machine-2"
`,
expectError: true,
},
{
name: "x-machines with whitespace trimming",
composeYAML: `
services:
test:
image: nginx
x-machines: [" machine-1 ", "machine-2"]
`,
expected: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
// Convert to ServiceSpec
spec, err := ServiceSpecFromCompose(project, "test")
require.NoError(t, err)
if len(tt.expected.Machines) == 0 && len(spec.Placement.Machines) == 0 {
// Both are empty, consider them equal
return
}
assert.Equal(t, tt.expected, spec.Placement)
})
}
}