mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat: support x-machines placement constraints in compose files (#90)
This commit is contained in:
@@ -2,8 +2,8 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCreateCommand() *cobra.Command {
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRemoveCommand() *cobra.Command {
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package machine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/psviderski/uncloud/internal/daemon"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenOptions struct {
|
type tokenOptions struct {
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
||||||
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
systemd "github.com/coreos/go-systemd/daemon"
|
systemd "github.com/coreos/go-systemd/daemon"
|
||||||
"log/slog"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
// One2ManyResponder converts upstream responses into messages from upstreams, so that multiple
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"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 {
|
type Cluster struct {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package tunnel
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"golang.zx2c4.com/wireguard/conn"
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
"golang.zx2c4.com/wireguard/device"
|
"golang.zx2c4.com/wireguard/device"
|
||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/vishvananda/netlink"
|
"github.com/vishvananda/netlink"
|
||||||
"go4.org/netipx"
|
"go4.org/netipx"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -17,7 +18,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WireGuardNetwork struct {
|
type WireGuardNetwork struct {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package machine
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
|
||||||
"log/slog"
|
|
||||||
"github.com/psviderski/uncloud/internal/corrosion"
|
"github.com/psviderski/uncloud/internal/corrosion"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
// If none was selected, get default Compose file names from current or parent folders.
|
// If none was selected, get default Compose file names from current or parent folders.
|
||||||
composecli.WithDefaultConfigPath,
|
composecli.WithDefaultConfigPath,
|
||||||
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
|
||||||
|
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
options, err := composecli.NewProjectOptions(
|
options, err := composecli.NewProjectOptions(
|
||||||
|
|||||||
@@ -55,13 +55,16 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
},
|
},
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
Mode: api.ServiceModeReplicated,
|
Mode: api.ServiceModeReplicated,
|
||||||
// TODO: implement and map x-machines to Placement.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
||||||
spec.Ports = ports
|
spec.Ports = ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
||||||
|
spec.Placement.Machines = []string(machines)
|
||||||
|
}
|
||||||
|
|
||||||
// Map LogDriver if specified
|
// Map LogDriver if specified
|
||||||
if service.Logging != nil && service.Logging.Driver != "" {
|
if service.Logging != nil && service.Logging.Driver != "" {
|
||||||
spec.Container.LogDriver = &api.LogDriver{
|
spec.Container.LogDriver = &api.LogDriver{
|
||||||
@@ -85,6 +88,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
default:
|
default:
|
||||||
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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/compose-spec/compose-go/v2/types"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
@@ -18,24 +18,39 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadProjectFromFile loads a compose project from a YAML file
|
// loadProjectFromContent loads a compose project from YAML content
|
||||||
func loadProjectFromFile(t *testing.T, filename string) *types.Project {
|
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
path := filepath.Join("testdata", filename)
|
|
||||||
|
|
||||||
options, err := cli.NewProjectOptions(
|
configDetails := types.ConfigDetails{
|
||||||
[]string{path},
|
ConfigFiles: []types.ConfigFile{
|
||||||
cli.WithName(FakeProjectName),
|
{
|
||||||
cli.WithOsEnv,
|
Filename: "docker-compose.yml",
|
||||||
cli.WithDotEnv,
|
Content: []byte(content),
|
||||||
)
|
},
|
||||||
require.NoError(t, err)
|
},
|
||||||
|
}
|
||||||
|
|
||||||
project, err := options.LoadProject(ctx)
|
project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) {
|
||||||
require.NoError(t, err)
|
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) {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -254,4 +254,151 @@ func TestComposeDeployment(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, plan.Operations, 0, "Expected no new operations after deployment")
|
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")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user