mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-10 17:46:15 +00:00
314 lines
12 KiB
Go
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
|
|
}
|