feat(labels): support service and container labels, update labels and cpu/memory in-place

This commit is contained in:
Pasha Sviderski
2026-11-02 14:57:33 +10:00
parent b214d23f21
commit 81f30aac26
30 changed files with 1727 additions and 286 deletions
+58 -13
View File
@@ -1,7 +1,7 @@
package deploy
import (
"reflect"
"maps"
"sort"
"github.com/google/go-cmp/cmp"
@@ -12,7 +12,8 @@ import (
type ContainerSpecStatus string
const (
ContainerUpToDate ContainerSpecStatus = "up-to-date"
ContainerUpToDate ContainerSpecStatus = "up-to-date"
// ContainerNeedsUpdate means the container can be updated in place without recreating it.
ContainerNeedsUpdate ContainerSpecStatus = "needs-update"
ContainerNeedsRecreate ContainerSpecStatus = "needs-recreate"
)
@@ -83,15 +84,7 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
}
}
// Device reservations and mappings are immutable, so we'll need to recreate if any have changed
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
return ContainerNeedsRecreate
}
if !reflect.DeepEqual(current.Container.Resources.Devices, newResources.Devices) {
return ContainerNeedsRecreate
}
// Ulimits are immutable, so we'll need to recreate if any have changed.
if !reflect.DeepEqual(current.Container.Resources.Ulimits, newResources.Ulimits) {
if current.Container.Resources.ValidateUpdateInPlace(newResources) != nil {
return ContainerNeedsRecreate
}
@@ -100,8 +93,10 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
return ContainerNeedsRecreate
}
// Remaining resources are mutable.
if !reflect.DeepEqual(current.Container.Resources, newResources) {
// CPU and memory limits are updated on the running container. Service labels are only stored in the service spec
// and not applied to Docker containers.
if !current.Container.Resources.Equals(newResources) ||
!cmp.Equal(current.Labels, new.Labels, cmpopts.EquateEmpty()) {
return ContainerNeedsUpdate
}
@@ -119,3 +114,53 @@ func sortConfigs(configs []api.ConfigSpec) {
return configs[i].Name < configs[j].Name
})
}
// EvalContainerChange evaluates whether the running container matches the new spec, can be updated in place to
// match it, or needs to be recreated. Unlike EvalContainerSpecChange, it also compares the new spec with the CPU and
// memory limits that Docker applies to the container, so the container is updated when they drifted from its spec.
func EvalContainerChange(current api.ServiceContainer, new api.ServiceSpec) ContainerSpecStatus {
status := EvalContainerSpecChange(current.ServiceSpec, new)
if status == ContainerNeedsRecreate {
return status
}
applied := current.AppliedResources()
newResources := new.Container.Resources
if applied.ValidateUpdateInPlace(newResources) != nil {
return ContainerNeedsRecreate
}
// Only CPU and memory limits can differ after the in-place update check above.
if !applied.Equals(newResources) {
return ContainerNeedsUpdate
}
return status
}
// ContainerUpdateOptions returns the container properties that can be updated in place and differ between the new
// spec and either the container spec or the limits Docker applies to the container. It should only be used when
// EvalContainerChange returns ContainerNeedsUpdate.
func ContainerUpdateOptions(current api.ServiceContainer, new api.ServiceSpec) api.UpdateContainerOptions {
var opts api.UpdateContainerOptions
stored, applied := current.ServiceSpec.Container.Resources, current.AppliedResources()
res := new.Container.Resources
if res.CPU != stored.CPU || res.CPU != applied.CPU {
opts.CPU = &res.CPU
}
if res.Memory != stored.Memory || res.Memory != applied.Memory {
opts.Memory = &res.Memory
}
if res.MemoryReservation != stored.MemoryReservation || res.MemoryReservation != applied.MemoryReservation {
opts.MemoryReservation = &res.MemoryReservation
}
if !cmp.Equal(current.ServiceSpec.Labels, new.Labels, cmpopts.EquateEmpty()) {
labels := maps.Clone(new.Labels)
if labels == nil {
labels = map[string]string{}
}
opts.ServiceLabels = &labels
}
return opts
}
+235 -4
View File
@@ -86,6 +86,223 @@ func TestEvalContainerSpecChange_ContainerTty(t *testing.T) {
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
}
func TestEvalContainerSpecChange_ContainerLabels(t *testing.T) {
t.Parallel()
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Labels: map[string]string{"com.example.team": "web"},
},
}
emptySpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Labels: map[string]string{},
},
}
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpec))
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(newSpec, currentSpec))
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec))
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec))
}
func TestEvalContainerSpecChange_ServiceLabels(t *testing.T) {
t.Parallel()
currentSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
}
newSpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
Labels: map[string]string{"tier": "frontend"},
}
emptySpec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
},
Labels: map[string]string{},
}
assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(currentSpec, newSpec))
assert.Equal(t, ContainerNeedsUpdate, EvalContainerSpecChange(newSpec, currentSpec))
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(newSpec, newSpec))
assert.Equal(t, ContainerUpToDate, EvalContainerSpecChange(currentSpec, emptySpec))
// Changes that require recreating the container take precedence over the in-place update.
newSpecWithImage := newSpec.Clone()
newSpecWithImage.Container.Image = "nginx:alpine"
assert.Equal(t, ContainerNeedsRecreate, EvalContainerSpecChange(currentSpec, newSpecWithImage))
}
func TestEvalContainerChange(t *testing.T) {
t.Parallel()
spec := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024},
},
}
appliedSpec := container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024}
tests := []struct {
name string
hostConfig *container.HostConfig
change func(spec *api.ServiceSpec)
expected ContainerSpecStatus
}{
{
name: "applied limits match spec",
hostConfig: &container.HostConfig{Resources: appliedSpec},
change: func(*api.ServiceSpec) {},
expected: ContainerUpToDate,
},
{
name: "unknown host config falls back to spec limits",
change: func(*api.ServiceSpec) {},
expected: ContainerUpToDate,
},
{
name: "applied memory limit drifted from spec",
hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}},
change: func(*api.ServiceSpec) {},
expected: ContainerNeedsUpdate,
},
{
name: "applied limits already match new spec but stored spec doesn't",
hostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 512 * 1024 * 1024}},
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 },
expected: ContainerNeedsUpdate,
},
{
name: "applied limit not in spec can't be removed in place",
hostConfig: &container.HostConfig{Resources: container.Resources{
NanoCPUs: api.Core, Memory: 256 * 1024 * 1024, MemoryReservation: 128 * 1024 * 1024,
}},
change: func(*api.ServiceSpec) {},
expected: ContainerNeedsRecreate,
},
{
name: "spec change that requires recreating",
hostConfig: &container.HostConfig{Resources: appliedSpec},
change: func(spec *api.ServiceSpec) { spec.Container.Image = "nginx:alpine" },
expected: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
newSpec := spec.Clone()
tt.change(&newSpec)
assert.Equal(t, tt.expected, EvalContainerChange(serviceContainer(spec, tt.hostConfig), newSpec))
})
}
}
func TestContainerUpdateOptions(t *testing.T) {
t.Parallel()
current := api.ServiceSpec{
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: api.ContainerResources{CPU: api.Core, Memory: 256 * 1024 * 1024},
},
Labels: map[string]string{"tier": "frontend"},
}
applied := &container.HostConfig{Resources: container.Resources{NanoCPUs: api.Core, Memory: 256 * 1024 * 1024}}
tests := []struct {
name string
hostConfig *container.HostConfig
change func(spec *api.ServiceSpec)
expected api.UpdateContainerOptions
}{
{
name: "no changes",
hostConfig: applied,
change: func(*api.ServiceSpec) {},
},
{
name: "CPU only",
hostConfig: applied,
change: func(spec *api.ServiceSpec) { spec.Container.Resources.CPU = 2 * api.Core },
expected: api.UpdateContainerOptions{CPU: new(int64(2 * api.Core))},
},
{
name: "memory and reservation",
hostConfig: applied,
change: func(spec *api.ServiceSpec) {
spec.Container.Resources.Memory = 512 * 1024 * 1024
spec.Container.Resources.MemoryReservation = 128 * 1024 * 1024
},
expected: api.UpdateContainerOptions{
Memory: new(int64(512 * 1024 * 1024)),
MemoryReservation: new(int64(128 * 1024 * 1024)),
},
},
{
name: "applied memory limit drifted from spec",
hostConfig: &container.HostConfig{Resources: container.Resources{
NanoCPUs: api.Core, Memory: 512 * 1024 * 1024,
}},
change: func(*api.ServiceSpec) {},
expected: api.UpdateContainerOptions{Memory: new(int64(256 * 1024 * 1024))},
},
{
name: "applied memory limit already matches new spec",
hostConfig: &container.HostConfig{Resources: container.Resources{
NanoCPUs: api.Core, Memory: 512 * 1024 * 1024,
}},
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 512 * 1024 * 1024 },
expected: api.UpdateContainerOptions{Memory: new(int64(512 * 1024 * 1024))},
},
{
name: "change service labels",
hostConfig: applied,
change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} },
expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}},
},
{
name: "remove all service labels",
hostConfig: applied,
change: func(spec *api.ServiceSpec) { spec.Labels = nil },
expected: api.UpdateContainerOptions{ServiceLabels: &map[string]string{}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
newSpec := current.Clone()
tt.change(&newSpec)
assert.Equal(t, tt.expected, ContainerUpdateOptions(serviceContainer(current, tt.hostConfig), newSpec))
})
}
}
func serviceContainer(spec api.ServiceSpec, hostConfig *container.HostConfig) api.ServiceContainer {
return api.ServiceContainer{
Container: api.Container{InspectResponse: container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{HostConfig: hostConfig},
}},
ServiceSpec: spec,
}
}
func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
t.Parallel()
@@ -127,7 +344,7 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
CPU: 1000000000,
},
new: api.ContainerResources{},
want: ContainerNeedsUpdate,
want: ContainerNeedsRecreate,
},
// Memory
@@ -189,19 +406,33 @@ func TestEvalContainerSpecChange_ContainerResources(t *testing.T) {
name: "unset memory",
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
new: api.ContainerResources{MemoryReservation: 50 * 1024 * 1024},
want: ContainerNeedsUpdate,
want: ContainerNeedsRecreate,
},
{
name: "unset reservation",
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
new: api.ContainerResources{Memory: 100 * 1024 * 1024},
want: ContainerNeedsUpdate,
want: ContainerNeedsRecreate,
},
{
name: "unset memory and reservation",
current: api.ContainerResources{Memory: 100 * 1024 * 1024, MemoryReservation: 50 * 1024 * 1024},
new: api.ContainerResources{},
want: ContainerNeedsUpdate,
want: ContainerNeedsRecreate,
},
// Shared memory
{
name: "change shared memory",
current: api.ContainerResources{SharedMemory: 64 * 1024 * 1024},
new: api.ContainerResources{SharedMemory: 128 * 1024 * 1024},
want: ContainerNeedsRecreate,
},
{
name: "change memory and shared memory",
current: api.ContainerResources{Memory: 100 * 1024 * 1024},
new: api.ContainerResources{Memory: 200 * 1024 * 1024, SharedMemory: 64 * 1024 * 1024},
want: ContainerNeedsRecreate,
},
// Memory and CPU
+8 -1
View File
@@ -173,7 +173,7 @@ func (sp *ServicePlan) Format() string {
// FormatSummary counts operations in the service plan and renders a styled summary line.
func (sp *ServicePlan) FormatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int
machines := make(map[string]struct{})
for _, op := range sp.Operations {
@@ -188,6 +188,9 @@ func (sp *ServicePlan) FormatSummary() string {
} else {
startFirstCount++
}
case *operation.UpdateContainerOperation:
machines[o.MachineID] = struct{}{}
updateCount++
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
@@ -210,6 +213,10 @@ func (sp *ServicePlan) FormatSummary() string {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if updateCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
+51
View File
@@ -0,0 +1,51 @@
package deploy
import (
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert"
)
func TestServicePlan_FormatSummary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ops []operation.Operation
expected string
}{
{
name: "in-place updates only",
ops: []operation.Operation{
&operation.UpdateContainerOperation{MachineID: "m-1"},
&operation.UpdateContainerOperation{MachineID: "m-2"},
},
expected: "2 update (in place) · across 2 machines",
},
{
name: "all operation types",
ops: []operation.Operation{
&operation.RunContainerOperation{MachineID: "m-1"},
&operation.ReplaceContainerOperation{MachineID: "m-1", Order: api.UpdateOrderStartFirst},
&operation.ReplaceContainerOperation{MachineID: "m-2", Order: api.UpdateOrderStopFirst},
&operation.UpdateContainerOperation{MachineID: "m-2"},
&operation.RemoveContainerOperation{MachineID: "m-3"},
},
expected: "1 create · 1 replace (start-first) · 1 replace (stop-first) · 1 update (in place) · 1 remove · " +
"across 3 machines",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
plan := ServicePlan{}
plan.Operations = tt.ops
assert.Equal(t, tt.expected, ansi.Strip(plan.FormatSummary()))
})
}
}
+50 -24
View File
@@ -92,7 +92,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
// Organise existing containers by machine.
containersOnMachine := make(map[string][]api.ServiceContainer)
upToDateContainersOnMachine := make(map[string]int)
reusableContainersOnMachine := make(map[string]int)
containerSpecStatuses := make(map[string]ContainerSpecStatus)
if svc != nil {
for _, c := range svc.Containers {
@@ -105,40 +105,50 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
if s.ForceRecreate {
status = ContainerNeedsRecreate
} else {
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
status = EvalContainerChange(c.Container, spec)
}
containerSpecStatuses[c.Container.ID] = status
if status == ContainerUpToDate {
upToDateContainersOnMachine[c.MachineID] += 1
if status != ContainerNeedsRecreate {
reusableContainersOnMachine[c.MachineID] += 1
}
}
// Sort containers such that running containers with the desired spec are first.
// Sort containers such that running containers with the desired spec or that can be updated in place to
// the desired spec are first.
// TODO: consider ranking up-to-date containers above ones that need an in-place update (here, when sorting
// machines, and in reconcileGlobalContainer) to avoid updating a container while removing an up-to-date one.
isReusable := func(c api.MachineServiceContainer) bool {
// Not running containers have no status.
status, ok := containerSpecStatuses[c.Container.ID]
return ok && status != ContainerNeedsRecreate
}
slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
r1, r2 := isReusable(c1), isReusable(c2)
switch {
case r1 == r2:
return 0
case r1:
return -1
}
if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
default:
return 1
}
return 0
})
for _, c := range svc.Containers {
containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c.Container)
}
// Sort machines such that machines with the most up-to-date containers are first, followed by machines with
// existing containers, and finally machines without containers.
// Sort machines such that machines with the most containers that are up to date or can be updated in place
// are first, followed by machines with existing containers, and finally machines without containers.
slices.SortFunc(matchedMachines, func(m1, m2 *pb.MachineInfo) int {
if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 {
return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id]
if reusableContainersOnMachine[m1.Id] > 0 && reusableContainersOnMachine[m2.Id] > 0 {
return reusableContainersOnMachine[m2.Id] - reusableContainersOnMachine[m1.Id]
}
if upToDateContainersOnMachine[m1.Id] > 0 {
if reusableContainersOnMachine[m1.Id] > 0 {
return -1
}
if upToDateContainersOnMachine[m2.Id] > 0 {
if reusableContainersOnMachine[m2.Id] > 0 {
return 1
}
return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id])
@@ -170,7 +180,16 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
if status == ContainerUpToDate {
continue
}
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
if status == ContainerNeedsUpdate {
plan.Operations = append(plan.Operations, &operation.UpdateContainerOperation{
ServiceID: plan.ServiceID,
MachineID: m.Id,
MachineName: m.Name,
Container: ctr,
Options: ContainerUpdateOptions(ctr, spec),
})
continue
}
}
// Replace the old container with a new one.
@@ -301,7 +320,7 @@ func reconcileGlobalContainer(
}
// Check if there is a container with the same spec already running. If so, remove the rest.
upToDate := false
reused := false
for i, c := range containers {
if !c.Container.State.Running || c.Container.State.Paused {
// Skip containers that are not running.
@@ -312,12 +331,21 @@ func reconcileGlobalContainer(
if forceRecreate {
status = ContainerNeedsRecreate
} else {
status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
status = EvalContainerChange(c.Container, spec)
}
if status == ContainerUpToDate {
// The container is already running with the same spec.
upToDate = true
if status != ContainerNeedsRecreate {
// The container is already running with the desired spec or can be updated in place to it.
reused = true
if status == ContainerNeedsUpdate {
ops = append(ops, &operation.UpdateContainerOperation{
ServiceID: serviceID,
MachineID: machine.Id,
MachineName: machine.Name,
Container: c.Container,
Options: ContainerUpdateOptions(c.Container, spec),
})
}
for j, old := range containers {
if i == j {
continue
@@ -331,10 +359,8 @@ func reconcileGlobalContainer(
}
break
}
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
// Make sure to update preDeployOperation accordingly.
}
if upToDate {
if reused {
return ops, nil
}
+135 -1
View File
@@ -1,6 +1,8 @@
package deploy
import (
"slices"
"strings"
"testing"
"github.com/docker/docker/api/types/container"
@@ -11,6 +13,7 @@ import (
"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetermineUpdateOrder(t *testing.T) {
@@ -424,6 +427,121 @@ func TestReconcileGlobalContainer(t *testing.T) {
}
}
func TestRollingStrategy_Plan_UpdateInPlace(t *testing.T) {
t.Parallel()
state := &scheduler.ClusterState{
Machines: []*scheduler.Machine{
{Info: &pb.MachineInfo{Id: "m-1", Name: "machine-1"}},
{Info: &pb.MachineInfo{Id: "m-2", Name: "machine-2"}},
},
}
newContainer := func(id string, spec api.ServiceSpec) api.ServiceContainer {
ctr := newServiceContainer(id, container.State{Running: true, Status: "running"})
ctr.ServiceSpec = spec
return ctr
}
tests := []struct {
name string
change func(spec *api.ServiceSpec)
// options are the expected in-place update options. Nil means the containers must be recreated.
options *api.UpdateContainerOptions
}{
{
name: "service labels",
change: func(spec *api.ServiceSpec) { spec.Labels = map[string]string{"tier": "backend"} },
options: &api.UpdateContainerOptions{ServiceLabels: &map[string]string{"tier": "backend"}},
},
{
name: "CPU and memory limits",
change: func(spec *api.ServiceSpec) {
spec.Container.Resources.CPU = 2 * api.Core
spec.Container.Resources.Memory = 512 * 1024 * 1024
},
options: &api.UpdateContainerOptions{
CPU: new(int64(2 * api.Core)),
Memory: new(int64(512 * 1024 * 1024)),
},
},
{
name: "remove memory limit",
change: func(spec *api.ServiceSpec) { spec.Container.Resources.Memory = 0 },
},
}
for _, tt := range tests {
for _, mode := range []string{api.ServiceModeReplicated, api.ServiceModeGlobal} {
t.Run(tt.name+"/"+mode, func(t *testing.T) {
currentSpec := api.ServiceSpec{
Name: "app",
Mode: mode,
Container: api.ContainerSpec{
Image: "nginx:latest",
Resources: api.ContainerResources{
CPU: api.Core,
Memory: 256 * 1024 * 1024,
},
},
Labels: map[string]string{"tier": "frontend"},
PreDeploy: &api.PreDeployHook{Command: []string{"db", "migrate"}},
}
if mode == api.ServiceModeReplicated {
currentSpec.Replicas = 2
}
newSpec := currentSpec.Clone()
tt.change(&newSpec)
ctr1 := newContainer("container-1", currentSpec)
ctr2 := newContainer("container-2", currentSpec)
svc := &api.Service{
ID: "svc-1",
Name: "app",
Mode: mode,
Containers: []api.MachineServiceContainer{
{MachineID: "m-1", MachineName: "machine-1", Container: ctr1},
{MachineID: "m-2", MachineName: "machine-2", Container: ctr2},
},
}
plan, err := (&RollingStrategy{}).Plan(state, svc, newSpec)
require.NoError(t, err)
if tt.options == nil {
require.NotEmpty(t, plan.Operations)
for _, op := range plan.Operations {
_, isUpdate := op.(*operation.UpdateContainerOperation)
assert.False(t, isUpdate, "Expected containers to be recreated, got %s", op)
}
return
}
// The replicated strategy shuffles machines so sort the operations to compare them deterministically.
ops := slices.Clone(plan.Operations)
slices.SortFunc(ops, func(a, b operation.Operation) int {
return strings.Compare(a.String(), b.String())
})
// The containers are updated in place and the pre-deploy hook is not run.
assertOperationsEqual(t, []operation.Operation{
&operation.UpdateContainerOperation{
ServiceID: "svc-1",
MachineID: "m-1",
MachineName: "machine-1",
Container: ctr1,
Options: *tt.options,
},
&operation.UpdateContainerOperation{
ServiceID: "svc-1",
MachineID: "m-2",
MachineName: "machine-2",
Container: ctr2,
Options: *tt.options,
},
}, ops)
})
}
}
}
func TestPreDeployOperations(t *testing.T) {
hook := &api.PreDeployHook{
Command: []string{"db", "migrate"},
@@ -502,6 +620,20 @@ func TestPreDeployOperations(t *testing.T) {
},
},
},
{
name: "plan has only UpdateContainerOperation",
plan: ServicePlan{
ServiceID: "svc-1",
ServiceName: "app",
Spec: api.ServiceSpec{PreDeploy: hook},
SequenceOperation: operation.SequenceOperation{
Operations: []operation.Operation{
&operation.UpdateContainerOperation{MachineID: "m-1", MachineName: "machine-1"},
},
},
},
expected: nil,
},
{
name: "plan has only RemoveContainerOperation",
plan: ServicePlan{
@@ -614,7 +746,8 @@ func TestPreDeployOperations(t *testing.T) {
},
svc: &api.Service{
HookContainers: []api.MachineServiceContainer{
{MachineID: "m-1", Container: newServiceContainer("stopped-hook", container.State{Status: "exited"})},
{MachineID: "m-1", Container: newServiceContainer("stopped-hook",
container.State{Status: "exited"})},
{MachineID: "m-1", Container: runningHook1},
{MachineID: "m-3", Container: runningHook2},
},
@@ -660,6 +793,7 @@ func newServiceContainer(id string, state container.State) api.ServiceContainer
ID: id,
State: &state,
},
Config: &container.Config{},
},
}}
}