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
+1
View File
@@ -30,6 +30,7 @@ type ContainerClient interface {
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
UpdateContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts UpdateContainerOptions) error
WaitContainerHealthy(
ctx context.Context, serviceNameOrID, containerNameOrID string, opts WaitContainerHealthyOptions,
) error
+33
View File
@@ -19,6 +19,11 @@ const (
// in internal/machine/docker/manager.go.
DockerNetworkName = "uncloud"
// LabelReservedPrefix is the prefix of labels reserved for Uncloud that users can't set on services and containers.
LabelReservedPrefix = "uncloud."
// LabelDaemonReservedPrefix is the prefix of labels reserved for the Uncloud daemon system containers.
LabelDaemonReservedPrefix = "uncloudd."
// LabelDaemonManaged marks system containers as managed by the Uncloud daemon.
LabelDaemonManaged = "uncloudd.managed"
@@ -175,6 +180,19 @@ type CreateContainerResponse struct {
Name string
}
// UpdateContainerOptions defines the properties of a service container to update in place without recreating it.
// Nil fields are left unchanged.
type UpdateContainerOptions struct {
// CPU is the new CPU limit in nanocores. The limit can be changed but not removed.
CPU *int64
// Memory is the new memory limit in bytes. The limit can be changed but not removed.
Memory *int64
// MemoryReservation is the new memory reservation in bytes. The reservation can be changed but not removed.
MemoryReservation *int64
// ServiceLabels replaces all service labels. A pointer to an empty map removes all service labels.
ServiceLabels *map[string]string
}
type ServiceContainer struct {
Container
ServiceSpec ServiceSpec
@@ -195,6 +213,21 @@ func (c *ServiceContainer) ServiceName() string {
return c.Config.Labels[LabelServiceName]
}
// AppliedResources returns the resources from the service spec with the CPU and memory limits that Docker currently
// applies to the container. The applied limits may differ from the spec if an in-place update was interrupted or
// the container was updated outside of Uncloud.
func (c *ServiceContainer) AppliedResources() ContainerResources {
resources := c.ServiceSpec.Container.Resources
if c.ContainerJSONBase == nil || c.HostConfig == nil {
return resources
}
resources.CPU = c.HostConfig.NanoCPUs
resources.Memory = c.HostConfig.Memory
resources.MemoryReservation = c.HostConfig.MemoryReservation
return resources
}
// ServiceMode returns the replication mode of the service this container belongs to.
func (c *ServiceContainer) ServiceMode() string {
return c.ServiceSpec.Mode
+33
View File
@@ -1,7 +1,11 @@
package api
import (
"errors"
"github.com/docker/docker/api/types/container"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
const (
@@ -27,6 +31,35 @@ type ContainerResources struct {
Ulimits map[string]Ulimit
}
// Equals returns true if the resources are equal, treating nil and empty slices and maps as equal.
func (r ContainerResources) Equals(other ContainerResources) bool {
return cmp.Equal(r, other, cmpopts.EquateEmpty())
}
// ValidateUpdateInPlace returns an error explaining why a container created with these resources can't be updated
// to the new resources without recreating it. It returns nil if the container can be updated in place.
// Docker can only change the CPU and memory limits of an existing container and can't remove a limit once it's set.
func (r ContainerResources) ValidateUpdateInPlace(new ContainerResources) error {
// Docker treats zero values as unchanged when updating a container.
if r.CPU != 0 && new.CPU == 0 {
return errors.New("CPU limit can't be removed")
}
if r.Memory != 0 && new.Memory == 0 {
return errors.New("memory limit can't be removed")
}
if r.MemoryReservation != 0 && new.MemoryReservation == 0 {
return errors.New("memory reservation can't be removed")
}
r.CPU = new.CPU
r.Memory = new.Memory
r.MemoryReservation = new.MemoryReservation
if !r.Equals(new) {
return errors.New("only CPU and memory limits can be changed")
}
return nil
}
// DeviceMapping represents a device mapping between host and container.
type DeviceMapping struct {
// HostPath is the path to the device on the host.
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestContainerResources_ValidateUpdateInPlace(t *testing.T) {
t.Parallel()
current := ContainerResources{
CPU: Core,
Memory: 512 * 1024 * 1024,
MemoryReservation: 256 * 1024 * 1024,
}
tests := []struct {
name string
new func(r *ContainerResources)
wantErr string
}{
{
name: "same resources",
new: func(*ContainerResources) {},
},
{
name: "change CPU and memory limits",
new: func(r *ContainerResources) {
r.CPU = 2 * Core
r.Memory = 1024 * 1024 * 1024
r.MemoryReservation = 128 * 1024 * 1024
},
},
{
name: "remove CPU limit",
new: func(r *ContainerResources) { r.CPU = 0 },
wantErr: "CPU limit can't be removed",
},
{
name: "remove memory limit",
new: func(r *ContainerResources) { r.Memory = 0 },
wantErr: "memory limit can't be removed",
},
{
name: "remove memory reservation",
new: func(r *ContainerResources) { r.MemoryReservation = 0 },
wantErr: "memory reservation can't be removed",
},
{
name: "change shared memory",
new: func(r *ContainerResources) { r.SharedMemory = 64 * 1024 * 1024 },
wantErr: "only CPU and memory limits can be changed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
newResources := current
tt.new(&newResources)
err := current.ValidateUpdateInPlace(newResources)
if tt.wantErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErr)
}
})
}
}
+29
View File
@@ -77,6 +77,9 @@ type ServiceSpec struct {
Configs []ConfigSpec
// Container defines the desired state of each container in the service.
Container ContainerSpec
// Labels are user-defined key-value metadata attached to the service. Unlike container labels, they're not
// applied to Docker containers. Keys must not use the reserved "uncloud." prefix.
Labels map[string]string
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
Mode string
Name string
@@ -157,6 +160,9 @@ func (s *ServiceSpec) Validate() error {
if err := s.Container.Validate(); err != nil {
return err
}
if err := ValidateLabels(s.Labels); err != nil {
return fmt.Errorf("invalid service labels: %w", err)
}
switch s.Mode {
case "", ServiceModeGlobal, ServiceModeReplicated:
@@ -243,6 +249,7 @@ func (s *ServiceSpec) Clone() ServiceSpec {
spec.Caddy = &caddyCopy
}
spec.Container = s.Container.Clone()
spec.Labels = maps.Clone(s.Labels)
spec.PreDeploy = s.PreDeploy.Clone()
if s.Ports != nil {
@@ -279,6 +286,10 @@ type ContainerSpec struct {
Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool
// Labels are user-defined key-value metadata applied as Docker labels to each container in the service.
// Docker labels are immutable so changing them requires recreating the containers.
// Keys must not use the reserved "uncloud." prefix.
Labels map[string]string
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
LogDriver *LogDriver
// PidMode sets the PID namespace mode for the container. Currently only "" or "host" is supported.
@@ -333,6 +344,9 @@ func (s *ContainerSpec) Validate() error {
if _, err := reference.ParseDockerRef(s.Image); err != nil {
return fmt.Errorf("invalid image '%s': %w", s.Image, err)
}
if err := ValidateLabels(s.Labels); err != nil {
return fmt.Errorf("invalid container labels: %w", err)
}
for _, m := range s.VolumeMounts {
if err := m.Validate(); err != nil {
@@ -390,6 +404,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
hc.Test = slices.Clone(s.Healthcheck.Test)
spec.Healthcheck = &hc
}
spec.Labels = maps.Clone(s.Labels)
if s.LogDriver != nil {
logDriver := *s.LogDriver
if s.LogDriver.Options != nil {
@@ -428,6 +443,20 @@ func (s *ContainerSpec) Clone() ContainerSpec {
return spec
}
// ValidateLabels checks that label keys are non-empty and don't use the reserved Uncloud prefixes.
func ValidateLabels(labels map[string]string) error {
for k := range labels {
if strings.TrimSpace(k) == "" {
return fmt.Errorf("label key must not be empty")
}
if strings.HasPrefix(k, LabelReservedPrefix) || strings.HasPrefix(k, LabelDaemonReservedPrefix) {
return fmt.Errorf("label '%s' cannot use the reserved prefix '%s' or '%s'",
k, LabelReservedPrefix, LabelDaemonReservedPrefix)
}
}
return nil
}
type EnvVars map[string]string
// ToSlice converts the environment variables to a slice of strings in the format "key=value".
+46 -2
View File
@@ -63,6 +63,47 @@ func TestServiceSpec_Validate_Name(t *testing.T) {
}
}
func TestServiceSpec_Validate_Labels(t *testing.T) {
t.Parallel()
tests := []struct {
name string
labels map[string]string
wantErr string
}{
{name: "nil labels"},
{name: "valid labels", labels: map[string]string{"com.example.team": "web", "tier": ""}},
{name: "prefix without dot", labels: map[string]string{"uncloudfoo": "bar"}},
{name: "empty key", labels: map[string]string{"": "bar"}, wantErr: "must not be empty"},
{name: "reserved prefix", labels: map[string]string{LabelManaged: ""}, wantErr: "reserved prefix"},
{name: "reserved daemon prefix", labels: map[string]string{"uncloudd.foo": "bar"}, wantErr: "reserved prefix"},
}
for _, tt := range tests {
t.Run(tt.name+"/service", func(t *testing.T) {
t.Parallel()
spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest"}, Labels: tt.labels}
err := spec.Validate()
if tt.wantErr != "" {
require.ErrorContains(t, err, "invalid service labels")
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
t.Run(tt.name+"/container", func(t *testing.T) {
t.Parallel()
spec := ServiceSpec{Container: ContainerSpec{Image: "nginx:latest", Labels: tt.labels}}
err := spec.Validate()
if tt.wantErr != "" {
require.ErrorContains(t, err, "invalid container labels")
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
tests := []struct {
name string
@@ -267,8 +308,9 @@ func TestContainerSpec_Clone(t *testing.T) {
"FOO": "bar",
"BAZ": "qux",
},
Image: "nginx:latest",
Init: new(true),
Image: "nginx:latest",
Init: new(true),
Labels: map[string]string{"com.example.team": "web"},
LogDriver: &LogDriver{
Name: "json-file",
Options: map[string]string{
@@ -313,6 +355,7 @@ func TestContainerSpec_Clone(t *testing.T) {
original.Command[0] = stringModified
original.Entrypoint[0] = stringModified
original.Env["FOO"] = stringModified
original.Labels["com.example.team"] = stringModified
original.LogDriver.Options["max-size"] = stringModified
original.Volumes[0] = stringModified
original.VolumeMounts[0].ContainerPath = stringModified
@@ -328,6 +371,7 @@ func TestContainerSpec_Clone(t *testing.T) {
assert.Equal(t, "NET_ADMIN", cloned.CapAdd[0])
assert.Equal(t, "ALL", cloned.CapDrop[0])
assert.Equal(t, "sh", cloned.Command[0])
assert.Equal(t, "web", cloned.Labels["com.example.team"])
assert.Equal(t, "/bin/bash", cloned.Entrypoint[0])
assert.Equal(t, "bar", cloned.Env["FOO"])
assert.Equal(t, "qux", cloned.Env["BAZ"])