chore: replace machine filter with placement constraint in service spec

This commit is contained in:
Pavel Sviderski
2025-04-16 22:04:37 +10:00
parent 4532b985d4
commit f3cb6657ae
15 changed files with 238 additions and 294 deletions
+1
View File
@@ -15,6 +15,7 @@ type Client interface {
api.ImageClient
api.MachineClient
api.ServiceClient
api.VolumeClient
}
// Deployment manages the process of creating or updating a service to match a desired state.
+13 -1
View File
@@ -16,10 +16,22 @@ type Constraint interface {
}
func constraintsFromSpec(spec api.ServiceSpec) []Constraint {
return []Constraint{}
var constraints []Constraint
if len(spec.Placement.Machines) > 0 {
constraints = append(constraints, &PlacementConstraint{
Machines: spec.Placement.Machines,
})
}
// TODO: inspect and add VolumeConstraint.
return constraints
}
type PlacementConstraint struct {
// Machines is a list of machine names or IDs where service containers are allowed to be deployed.
// If empty, containers can be deployed to any available machine in the cluster.
Machines []string
}
+18 -12
View File
@@ -1,32 +1,38 @@
package scheduler
import (
"context"
"errors"
"fmt"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
)
type ServiceScheduler struct {
machines []*Machine
spec api.ServiceSpec
constraints []Constraint
Machines []*Machine
Spec api.ServiceSpec
Constraints []Constraint
}
func NewServiceScheduler(machines []*Machine, spec api.ServiceSpec, constraints []Constraint) *ServiceScheduler {
specConstraints := constraintsFromSpec(spec)
specConstraints = append(specConstraints, constraints...)
func NewServiceScheduler(ctx context.Context, cli Client, spec api.ServiceSpec) (*ServiceScheduler, error) {
machines, err := InspectMachines(ctx, cli)
if err != nil {
return nil, fmt.Errorf("inspect machines: %w", err)
}
constraints := constraintsFromSpec(spec)
return &ServiceScheduler{
machines: machines,
spec: spec,
constraints: specConstraints,
}
Machines: machines,
Spec: spec,
Constraints: constraints,
}, nil
}
func (s *ServiceScheduler) AvailableMachines() ([]*Machine, error) {
var available []*Machine
for _, machine := range s.machines {
for _, machine := range s.Machines {
if s.evaluateConstraints(machine) {
available = append(available, machine)
}
@@ -38,7 +44,7 @@ func (s *ServiceScheduler) AvailableMachines() ([]*Machine, error) {
}
func (s *ServiceScheduler) evaluateConstraints(machine *Machine) bool {
for _, c := range s.constraints {
for _, c := range s.Constraints {
if !c.Evaluate(machine) {
return false
}
+37 -50
View File
@@ -9,6 +9,7 @@ import (
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
)
// Strategy defines how a service should be deployed or updated. Different implementations can provide various
@@ -18,22 +19,19 @@ type Strategy interface {
Type() string
// Plan returns the operation to reconcile the service to the desired state.
// If the service does not exist (new deployment), svc will be nil.
Plan(ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec) (Plan, error)
Plan(ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
}
// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
// to minimize service disruption.
type RollingStrategy struct {
// MachineFilter optionally restricts which machines can be used for deployment.
MachineFilter MachineFilter
}
type RollingStrategy struct{}
func (s *RollingStrategy) Type() string {
return "rolling"
}
func (s *RollingStrategy) Plan(
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
// We can assume that the spec is valid at this point because it has been validated by the deployment.
switch spec.Mode {
@@ -51,37 +49,29 @@ func (s *RollingStrategy) Plan(
// in the cluster.
// TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'.
func (s *RollingStrategy) planReplicated(
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
plan, err := newEmptyPlan(svc, spec)
if err != nil {
return plan, err
}
availableMachines, err := cli.ListMachines(ctx, &api.MachineFilter{Available: true})
sched, err := scheduler.NewServiceScheduler(ctx, cli, spec)
if err != nil {
return plan, fmt.Errorf("list machines: %w", err)
return plan, err
}
// TODO: return a detailed report on required constraints and which ones are satisfied?
availableMachines, err := sched.AvailableMachines()
if err != nil {
return plan, err
}
// Filter machines that match the machine filter if provided.
var matchedMachines []*pb.MachineInfo
var unmatchedMachines []*pb.MachineInfo
for _, m := range availableMachines {
if s.MachineFilter == nil || s.MachineFilter(m.Machine) {
matchedMachines = append(matchedMachines, m.Machine)
} else {
unmatchedMachines = append(unmatchedMachines, m.Machine)
}
matchedMachines = append(matchedMachines, m.Info)
}
if len(matchedMachines) == 0 {
if s.MachineFilter != nil {
return plan, ErrNoMatchingMachines
}
return plan, fmt.Errorf("no available machines to deploy service")
}
// TODO: filter machines that contain the service volumes if the service uses any.s
// TODO: filter machines that contain the service volumes if the service uses any.
// Randomise the order of machines to avoid always deploying to the same machines first.
rand.Shuffle(len(matchedMachines), func(i, j int) {
@@ -209,7 +199,7 @@ func (s *RollingStrategy) planReplicated(
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
// that are down.
func (s *RollingStrategy) planGlobal(
ctx context.Context, cli api.MachineClient, svc *api.Service, spec api.ServiceSpec,
ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
plan, err := newEmptyPlan(svc, spec)
if err != nil {
@@ -226,39 +216,36 @@ func (s *RollingStrategy) planGlobal(
}
}
machines, err := cli.ListMachines(ctx, nil)
sched, err := scheduler.NewServiceScheduler(ctx, cli, spec)
if err != nil {
return plan, fmt.Errorf("list machines: %w", err)
}
// Filter machines if a machine filter is provided.
// TODO: not sure this is the right behaviour to ignore other machines that might run service containers.
// Maybe there should be another filter to specify which machines to deploy to but keep the rest running.
// Could be useful to test a new version on a subset of machines before rolling out to all.
if s.MachineFilter != nil {
machines = slices.DeleteFunc(machines, func(m *pb.MachineMember) bool {
return !s.MachineFilter(m.Machine)
})
if len(machines) == 0 {
return plan, ErrNoMatchingMachines
}
return plan, err
}
// TODO: figure out how to return a warning if there are machines down. Embed the machinesDown in the plan?
var machinesDown []*pb.MachineInfo
for _, m := range machines {
// Skip machines that are down but collect them to report a warning later.
if m.State == pb.MachineMember_DOWN {
machinesDown = append(machinesDown, m.Machine)
fmt.Printf("WARNING: failed to run a service container on machine '%s' which is Down.\n", m.Machine.Id)
continue
}
availableMachines, err := sched.AvailableMachines()
if err != nil {
return plan, err
}
containers := containersOnMachine[m.Machine.Id]
ops, err := reconcileGlobalContainer(containers, spec, plan.ServiceID, m.Machine.Id)
for _, m := range availableMachines {
containers := containersOnMachine[m.Info.Id]
ops, err := reconcileGlobalContainer(containers, spec, plan.ServiceID, m.Info.Id)
if err != nil {
return plan, err
}
plan.Operations = append(plan.Operations, ops...)
delete(containersOnMachine, m.Info.Id)
}
// Remove any remaining containers on machines that don't match the new placement constraints.
for _, containers := range containersOnMachine {
for _, c := range containers {
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
ServiceID: plan.ServiceID,
ContainerID: c.Container.ID,
MachineID: c.MachineID,
})
}
}
return plan, nil