Pulse/internal/dockeragent/container_update_contract_test.go
2026-07-26 20:38:42 +01:00

314 lines
12 KiB
Go

package dockeragent
import (
"context"
"errors"
"io"
"net/netip"
"reflect"
"runtime"
"strings"
"testing"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/rs/zerolog"
)
func TestBuildContainerRecreatePlanNetworkModesAndDesiredConfiguration(t *testing.T) {
const containerID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
base := func() containertypes.InspectResponse {
inspect := baseInspect()
inspect.ID = containerID
inspect.Config.Hostname = containerID[:12]
inspect.Config.Domainname = "internal.example"
inspect.Config.Labels = map[string]string{
"com.docker.compose.project": "issue1564",
"com.docker.compose.service": "app",
"com.docker.compose.depends_on": `db:service_started:false`,
"pulse.test": "network-lifecycle",
}
inspect.Config.Volumes = map[string]struct{}{"/data": {}}
inspect.HostConfig.Binds = []string{"issue1564-data:/data", "/tmp/issue1564:/host"}
inspect.HostConfig.Mounts = []mount.Mount{{
Type: mount.TypeVolume,
Source: "issue1564-cache",
Target: "/cache",
}}
inspect.HostConfig.RestartPolicy = containertypes.RestartPolicy{Name: "unless-stopped"}
return inspect
}
t.Run("standalone bridge clears only a generated hostname", func(t *testing.T) {
inspect := base()
inspect.HostConfig.NetworkMode = "bridge"
inspect.NetworkSettings.Networks = map[string]*network.EndpointSettings{
"bridge": {Aliases: []string{"app"}},
}
plan, err := buildContainerRecreatePlan(inspect)
if err != nil {
t.Fatal(err)
}
if plan.config.Hostname != "" {
t.Fatalf("generated hostname was retained as explicit: %q", plan.config.Hostname)
}
assertRecreatePlanPreservesNonNetworkConfiguration(t, inspect, plan)
assertPrimaryNetwork(t, plan, "bridge")
if inspect.Config.Hostname != containerID[:12] {
t.Fatal("building the recreate plan mutated inspect output")
}
})
t.Run("standalone custom networks preserve explicit configuration deterministically", func(t *testing.T) {
inspect := base()
inspect.Config.Hostname = "operator-hostname"
inspect.HostConfig.NetworkMode = "app-primary"
primaryEndpoint := &network.EndpointSettings{
IPAMConfig: &network.EndpointIPAMConfig{
IPv4Address: netip.MustParseAddr("172.30.0.20"),
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.20.1")},
},
Links: []string{"db:database"},
Aliases: []string{"app", "api"},
DriverOpts: map[string]string{"com.example.mode": "isolated"},
GwPriority: 100,
NetworkID: "observed-network-id",
EndpointID: "observed-endpoint-id",
Gateway: netip.MustParseAddr("172.30.0.1"),
IPAddress: netip.MustParseAddr("172.30.0.20"),
MacAddress: network.HardwareAddr{0x02, 0x42, 0xac, 0x1e, 0x00, 0x14},
IPPrefixLen: 24,
DNSNames: []string{"observed-runtime-name"},
}
inspect.NetworkSettings.Networks = map[string]*network.EndpointSettings{
"z-observer": {Aliases: []string{"observer"}, GwPriority: 10},
"app-primary": primaryEndpoint,
"a-metrics": {Aliases: []string{"metrics"}, GwPriority: 20},
}
plan, err := buildContainerRecreatePlan(inspect)
if err != nil {
t.Fatal(err)
}
if plan.config.Hostname != "operator-hostname" {
t.Fatalf("explicit hostname changed: %q", plan.config.Hostname)
}
assertRecreatePlanPreservesNonNetworkConfiguration(t, inspect, plan)
assertPrimaryNetwork(t, plan, "app-primary")
if got := []string{plan.additionalNetworks[0].name, plan.additionalNetworks[1].name}; !reflect.DeepEqual(got, []string{"a-metrics", "z-observer"}) {
t.Fatalf("additional network order = %v", got)
}
desired := plan.networkingConfig.EndpointsConfig["app-primary"]
if !reflect.DeepEqual(desired.IPAMConfig, primaryEndpoint.IPAMConfig) ||
!reflect.DeepEqual(desired.Links, primaryEndpoint.Links) ||
!reflect.DeepEqual(desired.Aliases, primaryEndpoint.Aliases) ||
!reflect.DeepEqual(desired.DriverOpts, primaryEndpoint.DriverOpts) ||
desired.GwPriority != primaryEndpoint.GwPriority ||
!reflect.DeepEqual(desired.MacAddress, primaryEndpoint.MacAddress) {
t.Fatalf("desired endpoint configuration was not preserved: %#v", desired)
}
if desired.NetworkID != "" || desired.EndpointID != "" || desired.Gateway.IsValid() ||
desired.IPAddress.IsValid() || desired.IPPrefixLen != 0 || len(desired.DNSNames) != 0 {
t.Fatalf("observed endpoint state leaked into create input: %#v", desired)
}
desired.Aliases[0] = "changed"
desired.DriverOpts["com.example.mode"] = "changed"
if primaryEndpoint.Aliases[0] != "app" || primaryEndpoint.DriverOpts["com.example.mode"] != "isolated" {
t.Fatal("desired endpoint aliases or driver options alias inspect output")
}
})
for _, tc := range []struct {
name string
networkMode containertypes.NetworkMode
}{
{name: "compose service shared namespace", networkMode: "container:compose-owner-id"},
{name: "standalone container shared namespace", networkMode: "container:standalone-owner-id"},
{name: "host namespace", networkMode: "host"},
} {
t.Run(tc.name, func(t *testing.T) {
if runtime.GOOS == "windows" && tc.networkMode == "host" {
t.Skip("Docker host network mode is unsupported on Windows")
}
inspect := base()
inspect.Config.ExposedPorts = network.PortSet{network.MustParsePort("8080/tcp"): {}}
inspect.HostConfig.PortBindings = network.PortMap{
network.MustParsePort("8080/tcp"): {{HostPort: "8080"}},
}
inspect.HostConfig.NetworkMode = tc.networkMode
// Malformed/legacy inspect output must not cause an explicit
// endpoint attachment for namespace-sharing modes.
inspect.NetworkSettings.Networks = map[string]*network.EndpointSettings{
"bridge": {Aliases: []string{"stale"}},
}
plan, err := buildContainerRecreatePlan(inspect)
if err != nil {
t.Fatal(err)
}
if plan.config.Hostname != "" || plan.config.Domainname != "" {
t.Fatalf("namespace-owned hostname/domain retained: %q/%q", plan.config.Hostname, plan.config.Domainname)
}
if plan.networkingConfig != nil || len(plan.additionalNetworks) != 0 {
t.Fatalf("shared namespace received endpoint configuration: %#v", plan)
}
assertRecreatePlanPreservesNonNetworkConfiguration(t, inspect, plan)
if tc.networkMode.IsContainer() {
if len(plan.config.ExposedPorts) != 0 || len(plan.hostConfig.PortBindings) != 0 {
t.Fatal("container namespace retained conflicting port configuration")
}
} else if len(plan.config.ExposedPorts) != 1 || len(plan.hostConfig.PortBindings) != 1 {
t.Fatal("host namespace lost otherwise-valid port configuration")
}
})
}
}
func TestBuildContainerRecreatePlanRejectsIncompleteInspectBeforeMutation(t *testing.T) {
for _, tc := range []struct {
name string
inspect containertypes.InspectResponse
}{
{name: "missing config", inspect: containertypes.InspectResponse{HostConfig: &containertypes.HostConfig{}}},
{name: "missing host config", inspect: containertypes.InspectResponse{Config: &containertypes.Config{Image: "busybox:1.36"}}},
} {
t.Run(tc.name, func(t *testing.T) {
pullCalled := false
agent := &Agent{
docker: &fakeDockerClient{
containerInspectFn: func(context.Context, string) (containertypes.InspectResponse, error) {
inspect := tc.inspect
inspect.Name = "/app"
return inspect, nil
},
imagePullFn: func(context.Context, string, dockerImagePullOptions) (io.ReadCloser, error) {
pullCalled = true
return nil, errors.New("must not pull")
},
},
logger: zerolog.Nop(),
}
result := agent.updateContainerWithProgress(context.Background(), "container-id", nil)
if result.Success || !strings.Contains(result.Error, "Failed to prepare container recreation") {
t.Fatalf("result = %+v", result)
}
if pullCalled || result.BackupCreated || result.RollbackAttempted {
t.Fatalf("invalid inspect crossed mutation boundary: %+v", result)
}
})
}
}
func TestUpdateContainerRollsBackWhenAdditionalNetworkCannotBeRestored(t *testing.T) {
inspect := baseInspect()
inspect.ID = strings.Repeat("a", 64)
inspect.HostConfig.NetworkMode = "primary"
inspect.NetworkSettings.Networks = map[string]*network.EndpointSettings{
"secondary": {Aliases: []string{"secondary-alias"}, GwPriority: 10},
"primary": {Aliases: []string{"primary-alias"}, GwPriority: 100},
}
var (
createdPrimary string
removedID string
renameCalls [][2]string
startedIDs []string
progress []string
)
agent := &Agent{
docker: &fakeDockerClient{
containerInspectFn: func(context.Context, string) (containertypes.InspectResponse, error) {
return inspect, nil
},
imagePullFn: func(context.Context, string, dockerImagePullOptions) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("{}")), nil
},
containerStopFn: func(context.Context, string, dockerContainerStopOptions) error {
return nil
},
containerRenameFn: func(_ context.Context, id, name string) error {
renameCalls = append(renameCalls, [2]string{id, name})
return nil
},
containerCreateFn: func(_ context.Context, _ *containertypes.Config, _ *containertypes.HostConfig, networkingConfig *network.NetworkingConfig, _ *v1.Platform, _ string) (containertypes.CreateResponse, error) {
for name := range networkingConfig.EndpointsConfig {
createdPrimary = name
}
return containertypes.CreateResponse{ID: "replacement-id"}, nil
},
networkConnectFn: func(_ context.Context, name, id string, endpoint *network.EndpointSettings) error {
if name != "secondary" || id != "replacement-id" || !reflect.DeepEqual(endpoint.Aliases, []string{"secondary-alias"}) {
t.Fatalf("unexpected NetworkConnect(%q, %q, %#v)", name, id, endpoint)
}
return errors.New("network unavailable")
},
containerRemoveFn: func(_ context.Context, id string, _ dockerContainerRemoveOptions) error {
removedID = id
return nil
},
containerStartFn: func(_ context.Context, id string, _ dockerContainerStartOptions) error {
startedIDs = append(startedIDs, id)
return nil
},
},
logger: zerolog.Nop(),
}
result := agent.updateContainerWithProgress(context.Background(), "original-id", func(step string) {
progress = append(progress, step)
})
if result.Success || !result.BackupCreated || !result.RollbackAttempted || !result.RolledBack {
t.Fatalf("network restoration failure did not report successful compensation: %+v", result)
}
if !strings.Contains(result.Error, "network secondary") {
t.Fatalf("network error was not surfaced: %q", result.Error)
}
if createdPrimary != "primary" || removedID != "replacement-id" {
t.Fatalf("primary=%q removed=%q", createdPrimary, removedID)
}
if len(renameCalls) != 2 || renameCalls[1][0] != result.BackupContainer || renameCalls[1][1] != "app" {
t.Fatalf("rollback rename calls = %#v", renameCalls)
}
if !reflect.DeepEqual(startedIDs, []string{"original-id"}) {
t.Fatalf("rollback starts = %v", startedIDs)
}
if !containsStringFragment(progress, "network secondary") {
t.Fatalf("network progress was not reported: %v", progress)
}
}
func assertRecreatePlanPreservesNonNetworkConfiguration(t *testing.T, inspect containertypes.InspectResponse, plan containerRecreatePlan) {
t.Helper()
if !reflect.DeepEqual(plan.config.Labels, inspect.Config.Labels) ||
!reflect.DeepEqual(plan.config.Volumes, inspect.Config.Volumes) ||
!reflect.DeepEqual(plan.hostConfig.Binds, inspect.HostConfig.Binds) ||
!reflect.DeepEqual(plan.hostConfig.Mounts, inspect.HostConfig.Mounts) ||
!reflect.DeepEqual(plan.hostConfig.RestartPolicy, inspect.HostConfig.RestartPolicy) ||
plan.hostConfig.NetworkMode != inspect.HostConfig.NetworkMode {
t.Fatalf("non-network desired configuration changed: plan=%#v inspect=%#v", plan, inspect)
}
}
func assertPrimaryNetwork(t *testing.T, plan containerRecreatePlan, want string) {
t.Helper()
if plan.networkingConfig == nil || len(plan.networkingConfig.EndpointsConfig) != 1 {
t.Fatalf("networking config = %#v", plan.networkingConfig)
}
if _, ok := plan.networkingConfig.EndpointsConfig[want]; !ok {
t.Fatalf("primary network = %#v, want %q", plan.networkingConfig.EndpointsConfig, want)
}
}
func containsStringFragment(values []string, fragment string) bool {
for _, value := range values {
if strings.Contains(value, fragment) {
return true
}
}
return false
}