fix: new Compose deployment with volumes and --recreate flag (fixes #176)

This commit is contained in:
Pasha Sviderski
2025-11-20 15:19:44 +10:00
parent ce4a8cce72
commit 57205e1149
7 changed files with 89 additions and 23 deletions
+21 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
)
type Client interface {
@@ -25,6 +26,8 @@ type Deployment struct {
Strategy Strategy
cli Client
plan *Plan
// state is an optional current and planned cluster state used for scheduling decisions.
state *scheduler.ClusterState
}
type Plan struct {
@@ -47,6 +50,16 @@ func NewDeployment(cli Client, spec api.ServiceSpec, strategy Strategy) *Deploym
}
}
// NewDeploymentWithClusterState creates a new deployment like NewDeployment but also with a provided current cluster
// state used for scheduling decisions.
func NewDeploymentWithClusterState(
cli Client, spec api.ServiceSpec, strategy Strategy, state *scheduler.ClusterState,
) *Deployment {
d := NewDeployment(cli, spec, strategy)
d.state = state
return d
}
// Plan returns a plan of operations to reconcile the service to the desired state.
// If a plan has already been created, the same plan will be returned.
func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
@@ -73,7 +86,14 @@ func (d *Deployment) Plan(ctx context.Context) (Plan, error) {
return Plan{}, fmt.Errorf("resolve service spec: %w", err)
}
plan, err := d.Strategy.Plan(ctx, d.cli, d.Service, resolvedSpec)
if d.state == nil {
d.state, err = scheduler.InspectClusterState(ctx, d.cli)
if err != nil {
return Plan{}, fmt.Errorf("inspect cluster state: %w", err)
}
}
plan, err := d.Strategy.Plan(d.state, d.Service, resolvedSpec)
if err != nil {
return Plan{}, fmt.Errorf("create plan using %s strategy: %w", d.Strategy.Type(), err)
}
+2 -1
View File
@@ -17,7 +17,7 @@ import (
// - If a volume already exists on a machine, it must be used instead of creating a new one.
// - A missing volume must only be created on one machine.
type VolumeScheduler struct {
// state is the current state of machines and their resources in the cluster.
// state is the current and planned state of machines and their resources in the cluster.
state *ClusterState
// serviceSpecs is a list of service specifications included in the deployment.
serviceSpecs []api.ServiceSpec
@@ -106,6 +106,7 @@ func NewVolumeScheduler(state *ClusterState, specs []api.ServiceSpec) (*VolumeSc
// Schedule determines what missing volumes should be created and where for services in the multi-service deployment.
// It returns a map of machine IDs to a list of api.VolumeSpec that should be created on that machine,
// or an error if services can't be scheduled due to scheduling constraints.
// It also updates the state of the machines in the cluster state to reflect the scheduled volumes.
func (s *VolumeScheduler) Schedule() (map[string][]api.VolumeSpec, error) {
if len(s.serviceSpecs) == 0 {
// No services with volume mounts, nothing to schedule.
+14 -15
View File
@@ -1,7 +1,6 @@
package deploy
import (
"context"
"fmt"
"math/rand/v2"
"slices"
@@ -18,31 +17,31 @@ type Strategy interface {
// Type returns the type of the deployment strategy, e.g. "rolling", "blue-green".
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 scheduler.Client, svc *api.Service, spec api.ServiceSpec) (Plan, error)
// If the service does not exist (new deployment), svc will be nil. state provides the current and planned state
// of the cluster for scheduling decisions.
Plan(state *scheduler.ClusterState, 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 {
State *scheduler.ClusterState
// ForceRecreate indicates whether all containers should be recreated during the deployment,
// regardless of whether their specifications have changed.
ForceRecreate bool
// state is the current and planned state of the cluster used for scheduling decisions.
state *scheduler.ClusterState
}
func (s *RollingStrategy) Type() string {
return "rolling"
}
func (s *RollingStrategy) Plan(
ctx context.Context, cli scheduler.Client, svc *api.Service, spec api.ServiceSpec,
) (Plan, error) {
if s.State == nil {
state, err := scheduler.InspectClusterState(ctx, cli)
if err != nil {
return Plan{}, fmt.Errorf("inspect cluster state: %w", err)
}
s.State = state
func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (Plan, error) {
if state == nil {
return Plan{}, fmt.Errorf("cluster state must be provided")
}
s.state = state
// We can assume that the spec is valid at this point because it has been validated by the deployment.
switch spec.Mode {
@@ -65,7 +64,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
return plan, err
}
sched := scheduler.NewServiceScheduler(s.State, spec)
sched := scheduler.NewServiceScheduler(s.state, spec)
// TODO: return a detailed report on required constraints and which ones are satisfied?
availableMachines, err := sched.EligibleMachines()
if err != nil {
@@ -221,7 +220,7 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
}
}
sched := scheduler.NewServiceScheduler(s.State, spec)
sched := scheduler.NewServiceScheduler(s.state, spec)
availableMachines, err := sched.EligibleMachines()
if err != nil {
return plan, err