From 31cd4c77e966e3924d867f4ae2f6ee7294f99b05 Mon Sep 17 00:00:00 2001 From: Evgenii Orlov <4623930+OrlovEvgeny@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:46:29 +0200 Subject: [PATCH] feat: support x-machines placement constraints in compose files (#90) --- cmd/ucind/cluster/create.go | 2 +- cmd/ucind/cluster/remove.go | 2 +- cmd/uncloud/machine/token.go | 2 +- experiment/talos_discovery.go | 2 +- internal/daemon/daemon.go | 2 +- internal/machine/api/proxy/backend.go | 2 +- internal/machine/cluster/cluster.go | 10 +- internal/machine/corroservice/config.go | 2 +- internal/machine/network/tunnel/tunnel.go | 2 +- internal/machine/network/wireguard.go | 2 +- internal/machine/network/wireguard_linux.go | 2 +- internal/machine/state.go | 2 +- internal/machine/store/store.go | 4 +- internal/machine/token.go | 2 +- pkg/client/compose/machines.go | 83 ++++++++ pkg/client/compose/project.go | 11 +- pkg/client/compose/service.go | 6 +- pkg/client/compose/service_test.go | 201 ++++++++++++++++-- test/e2e/compose_deploy_test.go | 147 +++++++++++++ .../e2e/fixtures/compose-placement-comma.yaml | 7 + .../compose-placement-nonexistent.yaml | 7 + test/e2e/fixtures/compose-placement.yaml | 11 + 22 files changed, 472 insertions(+), 39 deletions(-) create mode 100644 pkg/client/compose/machines.go create mode 100644 test/e2e/fixtures/compose-placement-comma.yaml create mode 100644 test/e2e/fixtures/compose-placement-nonexistent.yaml create mode 100644 test/e2e/fixtures/compose-placement.yaml diff --git a/cmd/ucind/cluster/create.go b/cmd/ucind/cluster/create.go index 2e23c648..8ead4845 100644 --- a/cmd/ucind/cluster/create.go +++ b/cmd/ucind/cluster/create.go @@ -2,8 +2,8 @@ package cluster import ( "fmt" - "github.com/spf13/cobra" "github.com/psviderski/uncloud/internal/ucind" + "github.com/spf13/cobra" ) func NewCreateCommand() *cobra.Command { diff --git a/cmd/ucind/cluster/remove.go b/cmd/ucind/cluster/remove.go index ce82721f..c4d01bca 100644 --- a/cmd/ucind/cluster/remove.go +++ b/cmd/ucind/cluster/remove.go @@ -2,8 +2,8 @@ package cluster import ( "fmt" - "github.com/spf13/cobra" "github.com/psviderski/uncloud/internal/ucind" + "github.com/spf13/cobra" ) func NewRemoveCommand() *cobra.Command { diff --git a/cmd/uncloud/machine/token.go b/cmd/uncloud/machine/token.go index 5538d334..ccce8831 100644 --- a/cmd/uncloud/machine/token.go +++ b/cmd/uncloud/machine/token.go @@ -2,9 +2,9 @@ package machine import ( "fmt" - "github.com/spf13/cobra" "github.com/psviderski/uncloud/internal/daemon" "github.com/psviderski/uncloud/internal/machine" + "github.com/spf13/cobra" ) type tokenOptions struct { diff --git a/experiment/talos_discovery.go b/experiment/talos_discovery.go index 96420cc9..6dc7dba5 100644 --- a/experiment/talos_discovery.go +++ b/experiment/talos_discovery.go @@ -6,12 +6,12 @@ import ( "crypto/cipher" "encoding/hex" "fmt" + "github.com/psviderski/uncloud/internal/machine/network" "github.com/siderolabs/discovery-api/api/v1alpha1/client/pb" discovery "github.com/siderolabs/discovery-client/pkg/client" "go.uber.org/zap" "net/netip" "time" - "github.com/psviderski/uncloud/internal/machine/network" ) const ( diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d1f4e25e..ebf83044 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -4,8 +4,8 @@ import ( "context" "fmt" systemd "github.com/coreos/go-systemd/daemon" - "log/slog" "github.com/psviderski/uncloud/internal/machine" + "log/slog" ) type Daemon struct { diff --git a/internal/machine/api/proxy/backend.go b/internal/machine/api/proxy/backend.go index 977b01d1..3d13e324 100644 --- a/internal/machine/api/proxy/backend.go +++ b/internal/machine/api/proxy/backend.go @@ -2,10 +2,10 @@ package proxy import ( "fmt" + "github.com/psviderski/uncloud/internal/machine/api/pb" "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" - "github.com/psviderski/uncloud/internal/machine/api/pb" ) // One2ManyResponder converts upstream responses into messages from upstreams, so that multiple diff --git a/internal/machine/cluster/cluster.go b/internal/machine/cluster/cluster.go index 8fe6a72a..0e293a28 100644 --- a/internal/machine/cluster/cluster.go +++ b/internal/machine/cluster/cluster.go @@ -5,17 +5,17 @@ import ( "context" "errors" "fmt" + "github.com/psviderski/uncloud/internal/corrosion" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/psviderski/uncloud/internal/machine/network" + "github.com/psviderski/uncloud/internal/machine/store" + "github.com/psviderski/uncloud/internal/secret" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "log/slog" "net/netip" "time" - "github.com/psviderski/uncloud/internal/corrosion" - "github.com/psviderski/uncloud/internal/machine/api/pb" - "github.com/psviderski/uncloud/internal/machine/network" - "github.com/psviderski/uncloud/internal/machine/store" - "github.com/psviderski/uncloud/internal/secret" ) type Cluster struct { diff --git a/internal/machine/corroservice/config.go b/internal/machine/corroservice/config.go index af7cd07d..d0202ca4 100644 --- a/internal/machine/corroservice/config.go +++ b/internal/machine/corroservice/config.go @@ -4,10 +4,10 @@ import ( "bytes" "fmt" "github.com/BurntSushi/toml" + "github.com/psviderski/uncloud/internal/fs" "net/netip" "os" "path/filepath" - "github.com/psviderski/uncloud/internal/fs" ) const ( diff --git a/internal/machine/network/tunnel/tunnel.go b/internal/machine/network/tunnel/tunnel.go index b3c64dd0..ebbcba88 100644 --- a/internal/machine/network/tunnel/tunnel.go +++ b/internal/machine/network/tunnel/tunnel.go @@ -3,13 +3,13 @@ package tunnel import ( "context" "fmt" + "github.com/psviderski/uncloud/internal/secret" "golang.zx2c4.com/wireguard/conn" "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "net" "net/netip" "time" - "github.com/psviderski/uncloud/internal/secret" ) const ( diff --git a/internal/machine/network/wireguard.go b/internal/machine/network/wireguard.go index c4ca5cde..96863dea 100644 --- a/internal/machine/network/wireguard.go +++ b/internal/machine/network/wireguard.go @@ -2,10 +2,10 @@ package network import ( "fmt" + "github.com/psviderski/uncloud/internal/secret" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "net/netip" "time" - "github.com/psviderski/uncloud/internal/secret" ) const ( diff --git a/internal/machine/network/wireguard_linux.go b/internal/machine/network/wireguard_linux.go index 873f87dc..e2f3331a 100644 --- a/internal/machine/network/wireguard_linux.go +++ b/internal/machine/network/wireguard_linux.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "github.com/psviderski/uncloud/internal/secret" "github.com/vishvananda/netlink" "go4.org/netipx" "golang.org/x/sys/unix" @@ -17,7 +18,6 @@ import ( "slices" "sync" "time" - "github.com/psviderski/uncloud/internal/secret" ) type WireGuardNetwork struct { diff --git a/internal/machine/state.go b/internal/machine/state.go index e90f7d40..6ddca418 100644 --- a/internal/machine/state.go +++ b/internal/machine/state.go @@ -3,10 +3,10 @@ package machine import ( "encoding/json" "fmt" + "github.com/psviderski/uncloud/internal/machine/network" "os" "path/filepath" "sync" - "github.com/psviderski/uncloud/internal/machine/network" ) const ( diff --git a/internal/machine/store/store.go b/internal/machine/store/store.go index b6954b13..f11e7b99 100644 --- a/internal/machine/store/store.go +++ b/internal/machine/store/store.go @@ -5,10 +5,10 @@ import ( _ "embed" "errors" "fmt" - "google.golang.org/protobuf/encoding/protojson" - "log/slog" "github.com/psviderski/uncloud/internal/corrosion" "github.com/psviderski/uncloud/internal/machine/api/pb" + "google.golang.org/protobuf/encoding/protojson" + "log/slog" ) var ( diff --git a/internal/machine/token.go b/internal/machine/token.go index 78abec28..12a554da 100644 --- a/internal/machine/token.go +++ b/internal/machine/token.go @@ -4,9 +4,9 @@ import ( "encoding/base64" "encoding/json" "fmt" + "github.com/psviderski/uncloud/internal/secret" "net/netip" "strings" - "github.com/psviderski/uncloud/internal/secret" ) const ( diff --git a/pkg/client/compose/machines.go b/pkg/client/compose/machines.go new file mode 100644 index 00000000..d828e4c4 --- /dev/null +++ b/pkg/client/compose/machines.go @@ -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 +} diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index efc3bbe2..f76f5c8e 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -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 } diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 6dc9d0ff..5c55dede 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -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? diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index 071dc824..60fb7e53 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -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) + }) + } +} diff --git a/test/e2e/compose_deploy_test.go b/test/e2e/compose_deploy_test.go index 07812dd2..e9bb95a5 100644 --- a/test/e2e/compose_deploy_test.go +++ b/test/e2e/compose_deploy_test.go @@ -254,4 +254,151 @@ func TestComposeDeployment(t *testing.T) { require.NoError(t, err) assert.Len(t, plan.Operations, 0, "Expected no new operations after deployment") }) + + t.Run("x-machines placement constraint", func(t *testing.T) { + t.Parallel() + + name := "test-compose-placement" + t.Cleanup(func() { + removeServices(t, cli, name) + }) + + project, err := compose.LoadProject(ctx, []string{"fixtures/compose-placement.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 to deploy") + + 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{ + Env: map[string]string{ + "VAR": "value", + "BOOL": "true", + "EMPTY": "", + }, + Image: "portainer/pause:3.9", + }, + Placement: api.Placement{ + Machines: []string{"machine-2", "machine-3"}, + }, + Replicas: 2, + } + assertServiceMatchesSpec(t, svc, expectedSpec) + + // Verify that containers are only deployed on specified machines + // Since we only specified 2 machines in x-machines and have 2 replicas, + // and the cluster has 3 machines, the third machine should have no containers + serviceMachines := serviceMachines(svc) + assert.Len(t, serviceMachines.ToSlice(), 2, "Service should only be on 2 machines") + + // Verify machines match the expected machine IDs (machine-2 = c.Machines[1], machine-3 = c.Machines[2]) + assert.ElementsMatch(t, serviceMachines.ToSlice(), []string{c.Machines[1].ID, c.Machines[2].ID}, + "Service containers should only be on machines 2 and 3") + }) + + t.Run("x-machines placement constraint with non-existing machine", func(t *testing.T) { + t.Parallel() + + name := "test-compose-placement-nonexistent" + t.Cleanup(func() { + removeServices(t, cli, name) + }) + + project, err := compose.LoadProject(ctx, []string{"fixtures/compose-placement-nonexistent.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 to deploy") + + 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{ + Image: "portainer/pause:3.9", + }, + Placement: api.Placement{ + Machines: []string{"machine-2", "nonexistent-machine"}, + }, + Replicas: 2, + } + assertServiceMatchesSpec(t, svc, expectedSpec) + + // Verify that containers are deployed only on existing machines + // Non-existent machine names should be ignored by the scheduler + serviceMachines := serviceMachines(svc) + + // Should only deploy on machine-2 since nonexistent-machine doesn't exist + // The scheduler should intersect placement constraints with available machines + assert.Len(t, serviceMachines.ToSlice(), 1, "Service should only be on 1 existing machine") + assert.ElementsMatch(t, serviceMachines.ToSlice(), []string{c.Machines[1].ID}, + "Service containers should only be on machine-2 (existing machine)") + }) + + t.Run("x-machines placement constraint with comma-separated string", func(t *testing.T) { + t.Parallel() + + name := "test-compose-placement-comma" + t.Cleanup(func() { + removeServices(t, cli, name) + }) + + project, err := compose.LoadProject(ctx, []string{"fixtures/compose-placement-comma.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 to deploy") + + 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{ + Image: "portainer/pause:3.9", + }, + Placement: api.Placement{ + Machines: []string{"machine-1", "machine-3"}, + }, + Replicas: 2, + } + assertServiceMatchesSpec(t, svc, expectedSpec) + + // Verify that containers are deployed on specified machines from comma-separated list + serviceMachines := serviceMachines(svc) + assert.Len(t, serviceMachines.ToSlice(), 2, "Service should be on 2 machines") + + // Verify machines match the expected machine IDs (machine-1 = c.Machines[0], machine-3 = c.Machines[2]) + assert.ElementsMatch(t, serviceMachines.ToSlice(), []string{c.Machines[0].ID, c.Machines[2].ID}, + "Service containers should be on machines 1 and 3 from comma-separated list") + }) } diff --git a/test/e2e/fixtures/compose-placement-comma.yaml b/test/e2e/fixtures/compose-placement-comma.yaml new file mode 100644 index 00000000..f2186e40 --- /dev/null +++ b/test/e2e/fixtures/compose-placement-comma.yaml @@ -0,0 +1,7 @@ +services: + test-compose-placement-comma: + image: portainer/pause:3.9 + x-machines: "machine-1,machine-3" + deploy: + mode: replicated + replicas: 2 \ No newline at end of file diff --git a/test/e2e/fixtures/compose-placement-nonexistent.yaml b/test/e2e/fixtures/compose-placement-nonexistent.yaml new file mode 100644 index 00000000..a63acfef --- /dev/null +++ b/test/e2e/fixtures/compose-placement-nonexistent.yaml @@ -0,0 +1,7 @@ +services: + test-compose-placement-nonexistent: + image: portainer/pause:3.9 + x-machines: ["machine-2", "nonexistent-machine"] + deploy: + mode: replicated + replicas: 2 \ No newline at end of file diff --git a/test/e2e/fixtures/compose-placement.yaml b/test/e2e/fixtures/compose-placement.yaml new file mode 100644 index 00000000..5a65f0f0 --- /dev/null +++ b/test/e2e/fixtures/compose-placement.yaml @@ -0,0 +1,11 @@ +services: + test-compose-placement: + image: portainer/pause:3.9 + environment: + VAR: value + BOOL: "true" + EMPTY: "" + x-machines: ["machine-2", "machine-3"] + deploy: + mode: replicated + replicas: 2 \ No newline at end of file