Files
pulse/internal/dockeragent/container_update_integration_test.go
2026-07-23 22:06:57 +01:00

312 lines
9.8 KiB
Go

package dockeragent
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/rs/zerolog"
)
func TestIntegrationContainerUpdateComposeNetworkLifecycle(t *testing.T) {
if os.Getenv("PULSE_RUN_DOCKER_INTEGRATION") != "1" {
t.Skip("set PULSE_RUN_DOCKER_INTEGRATION=1 to run live Docker update lifecycle proof")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI is unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
suffix := updateIntegrationSuffix(t)
project := "pulseit1564" + suffix
testLabel := "pulse.integration.issue1564=" + suffix
if os.Getenv("DOCKER_HOST") == "" {
contextHost, contextErr := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output()
if contextErr == nil {
if host := strings.TrimSpace(string(contextHost)); strings.HasPrefix(host, "unix://") {
t.Setenv("DOCKER_HOST", host)
}
}
}
rawClient, err := client.New(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rawClient.Close() })
if _, err := rawClient.Info(ctx, client.InfoOptions{}); err != nil {
t.Skipf("Docker daemon is unavailable: %v", err)
}
pull, err := rawClient.ImagePull(ctx, "busybox:1.36", client.ImagePullOptions{})
if err != nil {
t.Fatalf("prepare integration image: %v", err)
}
_, _ = io.Copy(io.Discard, pull)
_ = pull.Close()
externalOwnerName := project + "-external-owner"
externalOwner, err := rawClient.ContainerCreate(ctx, client.ContainerCreateOptions{
Name: externalOwnerName,
Config: &containertypes.Config{
Image: "busybox:1.36",
Cmd: []string{"sleep", "300"},
Labels: map[string]string{"pulse.integration.issue1564": suffix},
},
HostConfig: &containertypes.HostConfig{},
})
if err != nil {
t.Fatalf("create external namespace owner: %v", err)
}
if _, err := rawClient.ContainerStart(ctx, externalOwner.ID, client.ContainerStartOptions{}); err != nil {
t.Fatalf("start external namespace owner: %v", err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute)
defer cleanupCancel()
result, listErr := rawClient.ContainerList(cleanupCtx, client.ContainerListOptions{
All: true,
Filters: client.Filters{
"label": {testLabel: true},
},
})
if listErr == nil {
for _, item := range result.Items {
_, _ = rawClient.ContainerRemove(cleanupCtx, item.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
}
}
_, _ = rawClient.ContainerRemove(cleanupCtx, externalOwnerName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
})
composeFile := filepath.Join(t.TempDir(), "compose.yaml")
composeYAML := fmt.Sprintf(`services:
owner:
image: busybox:1.36
command: ["sleep", "300"]
labels:
pulse.integration.issue1564: %q
networks:
- primary
bridge:
image: busybox:1.36
command: ["sleep", "300"]
network_mode: bridge
labels:
pulse.integration.issue1564: %q
volumes:
- data:/data
service_shared:
image: busybox:1.36
command: ["sleep", "300"]
network_mode: service:owner
depends_on:
owner:
condition: service_started
labels:
pulse.integration.issue1564: %q
volumes:
- data:/data
container_shared:
image: busybox:1.36
command: ["sleep", "300"]
network_mode: container:%s
labels:
pulse.integration.issue1564: %q
volumes:
- data:/data
host:
image: busybox:1.36
command: ["sleep", "300"]
network_mode: host
labels:
pulse.integration.issue1564: %q
volumes:
- data:/data
custom:
image: busybox:1.36
command: ["sleep", "300"]
hostname: explicit-custom-host
depends_on:
owner:
condition: service_started
labels:
pulse.integration.issue1564: %q
volumes:
- data:/data
networks:
primary:
aliases:
- application
gw_priority: 100
secondary:
aliases:
- metrics
gw_priority: 10
volumes:
data:
networks:
primary:
secondary:
`, suffix, suffix, suffix, externalOwner.ID, suffix, suffix, suffix)
if err := os.WriteFile(composeFile, []byte(composeYAML), 0o600); err != nil {
t.Fatal(err)
}
runCompose := func(args ...string) ([]byte, error) {
commandArgs := append([]string{"compose", "-f", composeFile, "-p", project}, args...)
command := exec.CommandContext(ctx, "docker", commandArgs...)
return command.CombinedOutput()
}
if output, err := runCompose("up", "-d"); err != nil {
t.Fatalf("docker compose up: %v\n%s", err, output)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute)
defer cleanupCancel()
command := exec.CommandContext(cleanupCtx, "docker", "compose", "-f", composeFile, "-p", project, "down", "--volumes", "--remove-orphans")
_ = command.Run()
})
moduleClient, err := newMobyDockerClient(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
t.Fatal(err)
}
agent := &Agent{docker: moduleClient, logger: zerolog.Nop()}
t.Cleanup(func() { _ = agent.Close() })
swap(t, &sleepFn, func(time.Duration) {})
serviceID := func(service string) string {
t.Helper()
output, psErr := runCompose("ps", "-q", service)
if psErr != nil {
t.Fatalf("docker compose ps %s: %v\n%s", service, psErr, output)
}
id := strings.TrimSpace(string(output))
if id == "" {
t.Fatalf("docker compose service %s has no container", service)
}
return id
}
ownerID := serviceID("owner")
for _, tc := range []struct {
service string
wantMode string
wantHostname string
wantNetworks []string
wantDependency bool
}{
{service: "bridge", wantMode: "bridge", wantNetworks: []string{"bridge"}},
{service: "service_shared", wantMode: "container:" + ownerID, wantHostname: ownerID[:12], wantDependency: true},
{service: "container_shared", wantMode: "container:" + externalOwner.ID, wantHostname: externalOwner.ID[:12]},
{service: "host", wantMode: "host"},
{
service: "custom", wantMode: project + "_primary", wantHostname: "explicit-custom-host",
wantNetworks: []string{project + "_primary", project + "_secondary"}, wantDependency: true,
},
} {
t.Run(tc.service, func(t *testing.T) {
oldID := serviceID(tc.service)
beforeResult, inspectErr := rawClient.ContainerInspect(ctx, oldID, client.ContainerInspectOptions{})
if inspectErr != nil {
t.Fatal(inspectErr)
}
before := beforeResult.Container
var progress []string
result := agent.updateContainerWithProgress(ctx, oldID, func(step string) {
progress = append(progress, step)
})
if !result.Success {
t.Fatalf("update failed: %+v (progress=%v)", result, progress)
}
if result.NewContainerID == "" || result.NewContainerID == oldID || !result.BackupCreated {
t.Fatalf("update did not recreate with a backup: %+v", result)
}
afterResult, inspectErr := rawClient.ContainerInspect(ctx, result.NewContainerID, client.ContainerInspectOptions{})
if inspectErr != nil {
t.Fatal(inspectErr)
}
after := afterResult.Container
if after.State == nil || !after.State.Running {
t.Fatalf("replacement is not running: %#v", after.State)
}
if after.HostConfig == nil || string(after.HostConfig.NetworkMode) != tc.wantMode {
t.Fatalf("network mode = %#v, want %q", after.HostConfig, tc.wantMode)
}
if after.Config == nil || after.Config.Labels["pulse.integration.issue1564"] != suffix ||
after.Config.Labels["com.docker.compose.project"] != project ||
after.Config.Labels["com.docker.compose.service"] != tc.service {
t.Fatalf("Compose and integration labels were not preserved: %#v", after.Config)
}
if tc.wantDependency && after.Config.Labels["com.docker.compose.depends_on"] == "" {
t.Fatalf("Compose dependency metadata was not preserved: %#v", after.Config.Labels)
}
if !hasMountTarget(after.Mounts, "/data") {
t.Fatalf("named volume mount was not preserved: %#v", after.Mounts)
}
if tc.wantHostname != "" && after.Config.Hostname != tc.wantHostname {
t.Fatalf("hostname = %q, want %q", after.Config.Hostname, tc.wantHostname)
}
if tc.service == "bridge" {
if before.Config == nil || before.Config.Hostname != oldID[:12] {
t.Fatalf("bridge fixture did not start with a generated hostname: %#v", before.Config)
}
if after.Config.Hostname != result.NewContainerID[:12] || after.Config.Hostname == before.Config.Hostname {
t.Fatalf("generated hostname was not regenerated: before=%q after=%q", before.Config.Hostname, after.Config.Hostname)
}
}
for _, networkName := range tc.wantNetworks {
if after.NetworkSettings == nil || after.NetworkSettings.Networks[networkName] == nil {
t.Fatalf("network %q was not restored: %#v", networkName, after.NetworkSettings)
}
}
if tc.service == "custom" {
if !containsString(after.NetworkSettings.Networks[project+"_primary"].Aliases, "application") ||
!containsString(after.NetworkSettings.Networks[project+"_secondary"].Aliases, "metrics") {
t.Fatalf("custom network aliases were not preserved: %#v", after.NetworkSettings.Networks)
}
}
})
}
}
func updateIntegrationSuffix(t *testing.T) string {
t.Helper()
var value [4]byte
if _, err := rand.Read(value[:]); err != nil {
t.Fatal(err)
}
return hex.EncodeToString(value[:])
}
func hasMountTarget(mounts []containertypes.MountPoint, target string) bool {
for _, item := range mounts {
if item.Destination == target {
return true
}
}
return false
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}