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
+4 -1
View File
@@ -39,7 +39,7 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
desiredSpec := spec.SetDefaults()
status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, desiredSpec)
status := deploy.EvalContainerChange(ctr, desiredSpec)
assert.Equal(t, deploy.ContainerUpToDate, status)
spec = renderRuntimeTemplatesForContainer(t, desiredSpec, ctr.Name)
@@ -48,6 +48,9 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode])
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
for k, v := range spec.Container.Labels {
assert.Equal(t, v, ctr.Config.Labels[k], "Expected container label '%s'", k)
}
// Command and Entrypoint can only be compared if they are set in the spec.
// Otherwise, the container takes them from the image.
+82
View File
@@ -2,7 +2,9 @@ package e2e
import (
"context"
"fmt"
"testing"
"time"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
@@ -10,6 +12,7 @@ import (
"github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/compose"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -548,6 +551,85 @@ func TestComposeDeployment(t *testing.T) {
"Service containers should be on machines 1 and 3 from comma-separated list")
})
t.Run("labels and in-place update of service labels and resources", func(t *testing.T) {
t.Parallel()
name := "test-compose-labels"
t.Cleanup(func() {
removeServices(t, cli, name)
})
composeYAML := func(tier, memLimit string) string {
return fmt.Sprintf(`
services:
%s:
image: portainer/pause:3.9
mem_limit: %s
labels:
com.example.team: web
deploy:
replicas: 2
labels:
tier: %s
`, name, memLimit, tier)
}
project, err := compose.LoadProjectFromContent(ctx, composeYAML("frontend", "64m"))
require.NoError(t, err)
deployment, err := compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
require.NoError(t, deployment.Run(ctx))
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",
Labels: map[string]string{"com.example.team": "web"},
Resources: api.ContainerResources{Memory: 64 * 1024 * 1024},
},
Labels: map[string]string{"tier": "frontend"},
Replicas: 2,
}
assertServiceMatchesSpec(t, svc, expectedSpec)
for _, mc := range svc.Containers {
assert.NotContains(t, mc.Container.Config.Labels, "tier",
"Service labels must not be applied to containers")
}
containers := serviceContainerIDs(svc)
// Changing the service labels and memory limit should update the containers in place.
project, err = compose.LoadProjectFromContent(ctx, composeYAML("backend", "128m"))
require.NoError(t, err)
deployment, err = compose.NewDeployment(ctx, cli, project)
require.NoError(t, err)
plan, err := deployment.Plan(ctx)
require.NoError(t, err)
require.Len(t, plan.Services, 1)
require.Len(t, plan.Services[0].Operations, 2)
for _, op := range plan.Services[0].Operations {
assert.IsType(t, &operation.UpdateContainerOperation{}, op)
}
require.NoError(t, deployment.Run(ctx))
// The updated spec propagates to the cluster store asynchronously.
expectedSpec.Labels = map[string]string{"tier": "backend"}
expectedSpec.Container.Resources.Memory = 128 * 1024 * 1024
assert.EventuallyWithT(t, func(c *assert.CollectT) {
svc, err = cli.InspectService(ctx, name)
require.NoError(c, err)
for _, mc := range svc.Containers {
assert.Equal(c, expectedSpec.Labels, mc.Container.ServiceSpec.Labels)
assert.Equal(c, expectedSpec.Container.Resources.Memory, mc.Container.HostConfig.Memory)
}
}, 10*time.Second, 200*time.Millisecond)
assertServiceMatchesSpec(t, svc, expectedSpec)
assert.Equal(t, containers, serviceContainerIDs(svc), "Expected containers to be updated in place")
})
// Catches regression: https://github.com/psviderski/uncloud/issues/176
t.Run("plan new deployment with volumes and recreate strategy", func(t *testing.T) {
t.Parallel()
+84
View File
@@ -1761,6 +1761,90 @@ func TestServiceLifecycle(t *testing.T) {
require.ErrorIs(t, err, api.ErrNotFound)
})
t.Run("update container in place", func(t *testing.T) {
t.Parallel()
serviceID := newServiceID()
spec := api.ServiceSpec{
Name: "container-update",
Container: api.ContainerSpec{
Image: "portainer/pause:latest",
Resources: api.ContainerResources{
CPU: 100 * api.MilliCore,
Memory: 20 * units.MiB,
},
},
Labels: map[string]string{"tier": "frontend"},
}
ctr, err := cli.CreateContainer(ctx, serviceID, spec, c.Machines[0].Name)
require.NoError(t, err)
t.Cleanup(func() {
err := cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true})
if !errors.Is(err, api.ErrNotFound) {
require.NoError(t, err)
}
})
require.NoError(t, cli.StartContainer(ctx, serviceID, ctr.ID))
// Update the CPU and memory limits and replace the service labels.
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
CPU: new(int64(200 * api.MilliCore)),
Memory: new(int64(40 * units.MiB)),
MemoryReservation: new(int64(10 * units.MiB)),
ServiceLabels: &map[string]string{"tier": "backend"},
})
require.NoError(t, err)
spec.Container.Resources.CPU = 200 * api.MilliCore
spec.Container.Resources.Memory = 40 * units.MiB
spec.Container.Resources.MemoryReservation = 10 * units.MiB
spec.Labels = map[string]string{"tier": "backend"}
mc, err := cli.InspectContainer(ctx, serviceID, ctr.ID)
require.NoError(t, err)
assert.Equal(t, ctr.ID, mc.Container.ID, "Expected container to be updated in place")
assert.True(t, mc.Container.State.Running, "Expected container to keep running")
assertContainerMatchesSpec(t, mc.Container, spec)
assert.Equal(t, spec.Labels, mc.Container.ServiceSpec.Labels)
assert.EqualValues(t, 80*units.MiB, mc.Container.HostConfig.MemorySwap,
"Expected swap limit to be twice the memory limit")
// Remove all service labels and leave the limits unchanged.
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
ServiceLabels: &map[string]string{},
})
require.NoError(t, err)
spec.Labels = nil
mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID)
require.NoError(t, err)
assertContainerMatchesSpec(t, mc.Container, spec)
assert.Nil(t, mc.Container.ServiceSpec.Labels)
// A limit can't be removed without recreating the container.
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{Memory: new(int64(0))})
require.ErrorContains(t, err, "memory limit can't be removed")
mc, err = cli.InspectContainer(ctx, serviceID, ctr.ID)
require.NoError(t, err)
assertContainerMatchesSpec(t, mc.Container, spec)
// Reserved service labels are rejected.
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
ServiceLabels: &map[string]string{api.LabelManaged: ""},
})
require.ErrorContains(t, err, "reserved prefix")
require.NoError(t, cli.RemoveContainer(ctx, serviceID, ctr.ID, container.RemoveOptions{Force: true}))
err = cli.UpdateContainer(ctx, serviceID, ctr.ID, api.UpdateContainerOptions{
ServiceLabels: &map[string]string{"tier": "frontend"},
})
require.ErrorIs(t, err, api.ErrNotFound)
})
t.Run("1 replica", func(t *testing.T) {
t.Parallel()