Files
pulse/internal/dockeragent/agent_internal_test.go
T
rcourtman 3355f7a671 Read host Docker credentials for private registry update checks
Container update detection only ever negotiated anonymous pull tokens, so
containers from registries that reject anonymous digest HEADs pinned a
permanent "authentication required" badge (#1706). The agent already runs
on the Docker host, so the checker now resolves the same credential store
docker pull uses - config.json auths entries, credsStore/credHelpers
credential helpers (docker-credential-<name> get), and Podman's auth.json -
and presents the stored login: Basic auth on Bearer token negotiation and
on the hardcoded Docker Hub / ghcr.io token endpoints, direct answers to
Basic challenges, and the refresh-token grant for identity-token logins
such as Azure ACR.

Credentials never leave the host: they are only presented to the registry
or its token endpoint, helper output stays out of reported check errors,
and lookups are cached in memory for five minutes. Helper names are
validated before exec, and a stale login falls back to the anonymous path
so checks that used to work keep working. Set
PULSE_DISABLE_REGISTRY_CREDENTIALS=true (--disable-registry-credentials)
to keep detection anonymous-only. The agent-lifecycle and security-privacy
subsystem contracts pin the host-local credential boundary.
2026-08-13 10:06:26 +01:00

1728 lines
50 KiB
Go

package dockeragent
import (
"context"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io"
"math"
"math/big"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
containertypes "github.com/moby/moby/api/types/container"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/client"
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
"github.com/rs/zerolog"
)
func TestNormalizeTargets(t *testing.T) {
targets, err := normalizeTargets([]TargetConfig{
{URL: " https://pulse.example.com/ ", Token: "tokenA", InsecureSkipVerify: false, CACertPath: " /etc/pulse/ca.pem ", ServerFingerprint: " AB:CD "},
{URL: "https://pulse.example.com", Token: "tokenA", InsecureSkipVerify: false, CACertPath: "/etc/pulse/ca.pem", ServerFingerprint: "AB:CD"}, // duplicate
{URL: "https://pulse-dr.example.com", Token: "tokenB", InsecureSkipVerify: true},
})
if err != nil {
t.Fatalf("normalizeTargets returned error: %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected 2 targets, got %d", len(targets))
}
if targets[0].URL != "https://pulse.example.com" || targets[0].Token != "tokenA" || targets[0].InsecureSkipVerify || targets[0].CACertPath != "/etc/pulse/ca.pem" || targets[0].ServerFingerprint != "AB:CD" {
t.Fatalf("unexpected first target: %+v", targets[0])
}
if targets[1].URL != "https://pulse-dr.example.com" || targets[1].Token != "tokenB" || !targets[1].InsecureSkipVerify {
t.Fatalf("unexpected second target: %+v", targets[1])
}
}
func TestNormalizeTargetsSkipsEmpty(t *testing.T) {
targets, err := normalizeTargets([]TargetConfig{
{URL: "", Token: ""},
{URL: "https://pulse.example.com", Token: "token"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 1 {
t.Fatalf("expected 1 target, got %d", len(targets))
}
}
func TestNormalizeTargetsInvalid(t *testing.T) {
if _, err := normalizeTargets([]TargetConfig{{URL: "", Token: "token"}}); err == nil {
t.Fatalf("expected error for missing URL")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "https://pulse.example.com", Token: ""}}); err == nil {
t.Fatalf("expected error for missing token")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "pulse.example.com", Token: "token"}}); err == nil {
t.Fatalf("expected error for missing scheme")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "ftp://pulse.example.com", Token: "token"}}); err == nil {
t.Fatalf("expected error for unsupported scheme")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "https://pulse.example.com:70000", Token: "token"}}); err == nil {
t.Fatalf("expected error for out-of-range port")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "https://pulse.example.com/path?env=prod", Token: "token"}}); err == nil {
t.Fatalf("expected error for URL query parameters")
}
if _, err := normalizeTargets([]TargetConfig{{URL: "https://user:pass@pulse.example.com", Token: "token"}}); err == nil {
t.Fatalf("expected error for URL userinfo")
}
}
func TestNormalizeTargetURL(t *testing.T) {
normalized, err := normalizeTargetURL(" HTTPS://Pulse.EXAMPLE.com:443/api/ ")
if err != nil {
t.Fatalf("normalizeTargetURL returned error: %v", err)
}
if normalized != "https://pulse.example.com:443/api" {
t.Fatalf("unexpected normalized URL %q", normalized)
}
}
func TestNormalizeTargetURLAllowsLocalNetworkHTTP(t *testing.T) {
normalized, err := normalizeTargetURL(" http://10.0.0.5:7655/api/ ")
if err != nil {
t.Fatalf("normalizeTargetURL returned error: %v", err)
}
if normalized != "http://10.0.0.5:7655/api" {
t.Fatalf("unexpected normalized URL %q", normalized)
}
}
func TestNormalizeTargetURLRejectsPublicHTTP(t *testing.T) {
if _, err := normalizeTargetURL("http://pulse.example.com:7655/api"); err == nil {
t.Fatal("expected public HTTP Pulse target to be rejected")
}
}
func TestNormalizeTargetsAllowsLocalNetworkHTTP(t *testing.T) {
targets, err := normalizeTargets([]TargetConfig{{URL: "http://10.0.0.5:7655", Token: "token"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 1 {
t.Fatalf("expected 1 target, got %d", len(targets))
}
if targets[0].URL != "http://10.0.0.5:7655" {
t.Fatalf("unexpected target URL %q", targets[0].URL)
}
}
func TestNormalizeTargetsRejectsInsecureOrInvalidURLs(t *testing.T) {
tests := []struct {
name string
url string
}{
{name: "public http", url: "http://pulse.example.com"},
{name: "unsupported scheme", url: "ftp://pulse.example.com"},
{name: "missing scheme", url: "pulse.example.com"},
{name: "query string", url: "https://pulse.example.com?x=1"},
{name: "fragment", url: "https://pulse.example.com#frag"},
{name: "userinfo", url: "https://user:pass@pulse.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := normalizeTargets([]TargetConfig{{URL: tt.url, Token: "token"}}); err == nil {
t.Fatalf("expected error for URL %q", tt.url)
}
})
}
}
func TestNormalizeTargetsAllowsLoopbackHTTP(t *testing.T) {
targets, err := normalizeTargets([]TargetConfig{
{URL: "http://localhost:7655/", Token: "token"},
{URL: "http://127.0.0.1:7655", Token: "token2"},
{URL: "http://[::1]:7655", Token: "token3"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 3 {
t.Fatalf("expected 3 targets, got %d", len(targets))
}
if targets[0].URL != "http://localhost:7655" {
t.Fatalf("unexpected localhost URL: %q", targets[0].URL)
}
if targets[1].URL != "http://127.0.0.1:7655" {
t.Fatalf("unexpected IPv4 loopback URL: %q", targets[1].URL)
}
if targets[2].URL != "http://[::1]:7655" {
t.Fatalf("unexpected IPv6 loopback URL: %q", targets[2].URL)
}
}
func TestNormalizeContainerStates(t *testing.T) {
states, err := normalizeContainerStates([]string{"running", "Exited", "running", "stopped"})
if err != nil {
t.Fatalf("normalizeContainerStates returned error: %v", err)
}
expected := []string{"running", "exited"}
if !reflect.DeepEqual(states, expected) {
t.Fatalf("expected %v, got %v", expected, states)
}
}
func TestNormalizeContainerStatesEmpty(t *testing.T) {
states, err := normalizeContainerStates(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if states != nil {
t.Fatalf("expected nil, got %v", states)
}
states, err = normalizeContainerStates([]string{"", " "})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 0 {
t.Fatalf("expected empty slice, got %v", states)
}
}
func TestNormalizeContainerStatesInvalid(t *testing.T) {
if _, err := normalizeContainerStates([]string{"unknown"}); err == nil {
t.Fatalf("expected error for invalid container state")
}
}
func TestNormalizeSwarmScope(t *testing.T) {
tests := map[string]string{
"": "node",
"node": "node",
"NODE": "node",
"cluster": "cluster",
"AUTO": "auto",
}
for input, expected := range tests {
scope, err := normalizeSwarmScope(input)
if err != nil {
t.Fatalf("normalizeSwarmScope(%q) returned error: %v", input, err)
}
if scope != expected {
t.Fatalf("normalizeSwarmScope(%q)=%q, expected %q", input, scope, expected)
}
}
}
func TestNormalizeSwarmScopeInvalid(t *testing.T) {
if _, err := normalizeSwarmScope("invalid"); err == nil {
t.Fatalf("expected error for invalid swarm scope")
}
}
func TestNormalizeRuntime(t *testing.T) {
tests := map[string]RuntimeKind{
"": RuntimeAuto,
"auto": RuntimeAuto,
"docker": RuntimeDocker,
"podman": RuntimePodman,
" Auto ": RuntimeAuto,
"DOCKER": RuntimeDocker,
"PODMAN": RuntimePodman,
}
for input, expected := range tests {
runtime, err := normalizeRuntime(input)
if err != nil {
t.Fatalf("normalizeRuntime(%q) returned error: %v", input, err)
}
if runtime != expected {
t.Fatalf("normalizeRuntime(%q) = %q, expected %q", input, runtime, expected)
}
}
}
func TestNormalizeRuntimeInvalid(t *testing.T) {
if _, err := normalizeRuntime("containerd"); err == nil {
t.Fatalf("expected error for unsupported runtime")
}
}
func TestCalculateCPUPercent(t *testing.T) {
tests := []struct {
name string
stats containertypes.StatsResponse
hostCPUs int
want float64
}{
{
name: "normal calculation",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 200000000, // 200ms
},
SystemUsage: 2000000000, // 2s
OnlineCPUs: 4,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000, // 100ms
},
SystemUsage: 1000000000, // 1s
},
},
hostCPUs: 4,
want: 40.0, // (100ms / 1s) * 4 CPUs * 100
},
{
name: "zero system delta returns zero",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 200000000,
},
SystemUsage: 1000000000,
OnlineCPUs: 4,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000,
},
SystemUsage: 1000000000, // same as current = zero delta
},
},
hostCPUs: 4,
want: 0,
},
{
name: "zero total delta returns zero",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000,
},
SystemUsage: 2000000000,
OnlineCPUs: 4,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000, // same as current
},
SystemUsage: 1000000000,
},
},
hostCPUs: 4,
want: 0,
},
{
name: "uses PercpuUsage length when OnlineCPUs is zero",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 200000000,
PercpuUsage: []uint64{1, 2, 3, 4}, // 4 CPUs
},
SystemUsage: 2000000000,
OnlineCPUs: 0,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000,
},
SystemUsage: 1000000000,
},
},
hostCPUs: 8,
want: 40.0, // uses PercpuUsage length (4), not hostCPUs
},
{
name: "uses hostCPUs when OnlineCPUs and PercpuUsage both zero",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 200000000,
},
SystemUsage: 2000000000,
OnlineCPUs: 0,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000,
},
SystemUsage: 1000000000,
},
},
hostCPUs: 2,
want: 20.0, // uses hostCPUs (2)
},
{
name: "returns zero when no CPU count available",
stats: containertypes.StatsResponse{
CPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 200000000,
},
SystemUsage: 2000000000,
OnlineCPUs: 0,
},
PreCPUStats: containertypes.CPUStats{
CPUUsage: containertypes.CPUUsage{
TotalUsage: 100000000,
},
SystemUsage: 1000000000,
},
},
hostCPUs: 0,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calculateCPUPercent(tt.stats, tt.hostCPUs)
if math.Abs(got-tt.want) > 0.001 {
t.Errorf("calculateCPUPercent() = %v, want %v", got, tt.want)
}
})
}
}
func TestCalculateMemoryUsage(t *testing.T) {
tests := []struct {
name string
stats containertypes.StatsResponse
wantUsage int64
wantLimit int64
wantPercent float64
}{
{
name: "normal calculation with cache",
stats: containertypes.StatsResponse{
MemoryStats: containertypes.MemoryStats{
Usage: 1000000,
Limit: 4000000,
Stats: map[string]uint64{"cache": 200000},
},
},
wantUsage: 800000,
wantLimit: 4000000,
wantPercent: 20.0,
},
{
name: "no cache in stats",
stats: containertypes.StatsResponse{
MemoryStats: containertypes.MemoryStats{
Usage: 1000000,
Limit: 4000000,
Stats: map[string]uint64{},
},
},
wantUsage: 1000000,
wantLimit: 4000000,
wantPercent: 25.0,
},
{
name: "cache larger than usage keeps raw usage",
stats: containertypes.StatsResponse{
MemoryStats: containertypes.MemoryStats{
Usage: 1000000,
Limit: 4000000,
Stats: map[string]uint64{"cache": 2000000}, // more than usage
},
},
wantUsage: 1000000, // keeps raw usage when cache > usage
wantLimit: 4000000,
wantPercent: 25.0,
},
{
name: "cgroup v2 uses inactive_file when no cache",
stats: containertypes.StatsResponse{
MemoryStats: containertypes.MemoryStats{
Usage: 1000000,
Limit: 4000000,
Stats: map[string]uint64{"inactive_file": 300000}, // cgroup v2 style
},
},
wantUsage: 700000, // 1000000 - 300000
wantLimit: 4000000,
wantPercent: 17.5,
},
{
name: "zero limit returns zero percent",
stats: containertypes.StatsResponse{
MemoryStats: containertypes.MemoryStats{
Usage: 1000000,
Limit: 0,
Stats: map[string]uint64{},
},
},
wantUsage: 1000000,
wantLimit: 0,
wantPercent: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usage, limit, percent := calculateMemoryUsage(tt.stats)
if usage != tt.wantUsage {
t.Errorf("usage = %v, want %v", usage, tt.wantUsage)
}
if limit != tt.wantLimit {
t.Errorf("limit = %v, want %v", limit, tt.wantLimit)
}
if math.Abs(percent-tt.wantPercent) > 0.001 {
t.Errorf("percent = %v, want %v", percent, tt.wantPercent)
}
})
}
}
func TestSafeFloat(t *testing.T) {
tests := []struct {
name string
input float64
want float64
}{
{"normal positive", 42.5, 42.5},
{"normal negative", -42.5, -42.5},
{"zero", 0, 0},
{"NaN returns zero", math.NaN(), 0},
{"positive infinity returns zero", math.Inf(1), 0},
{"negative infinity returns zero", math.Inf(-1), 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := safeFloat(tt.input)
if got != tt.want {
t.Errorf("safeFloat(%v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseTime(t *testing.T) {
tests := []struct {
name string
input string
want time.Time
}{
{"empty string", "", time.Time{}},
{"zero time string", "0001-01-01T00:00:00Z", time.Time{}},
{"RFC3339Nano", "2024-01-15T10:30:00.123456789Z", time.Date(2024, 1, 15, 10, 30, 0, 123456789, time.UTC)},
{"RFC3339", "2024-01-15T10:30:00Z", time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)},
{"RFC3339 with offset", "2024-01-15T10:30:00+05:00", time.Date(2024, 1, 15, 10, 30, 0, 0, time.FixedZone("", 5*3600))},
{"invalid format", "not-a-time", time.Time{}},
{"partial date", "2024-01-15", time.Time{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTime(tt.input)
if !got.Equal(tt.want) {
t.Errorf("parseTime(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestTrimLeadingSlash(t *testing.T) {
tests := []struct {
name string
names []string
want string
}{
{"empty slice", []string{}, ""},
{"single name with slash", []string{"/mycontainer"}, "mycontainer"},
{"single name without slash", []string{"mycontainer"}, "mycontainer"},
{"multiple names uses first", []string{"/first", "/second"}, "first"},
{"name with multiple slashes only trims first", []string{"//double"}, "/double"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := trimLeadingSlash(tt.names)
if got != tt.want {
t.Errorf("trimLeadingSlash(%v) = %q, want %q", tt.names, got, tt.want)
}
})
}
}
func TestSummarizeBlockIO(t *testing.T) {
tests := []struct {
name string
stats containertypes.StatsResponse
want *containertypes.BlkioStatEntry
}{
{
name: "read and write ops",
stats: containertypes.StatsResponse{
BlkioStats: containertypes.BlkioStats{
IoServiceBytesRecursive: []containertypes.BlkioStatEntry{
{Op: "Read", Value: 1000},
{Op: "Write", Value: 2000},
{Op: "Read", Value: 500}, // multiple read entries
{Op: "Write", Value: 300}, // multiple write entries
},
},
},
want: &containertypes.BlkioStatEntry{}, // will check values separately
},
{
name: "case insensitive ops",
stats: containertypes.StatsResponse{
BlkioStats: containertypes.BlkioStats{
IoServiceBytesRecursive: []containertypes.BlkioStatEntry{
{Op: "READ", Value: 100},
{Op: "write", Value: 200},
},
},
},
want: &containertypes.BlkioStatEntry{},
},
{
name: "zero values returns nil",
stats: containertypes.StatsResponse{
BlkioStats: containertypes.BlkioStats{
IoServiceBytesRecursive: []containertypes.BlkioStatEntry{},
},
},
want: nil,
},
{
name: "only non-read-write ops returns nil",
stats: containertypes.StatsResponse{
BlkioStats: containertypes.BlkioStats{
IoServiceBytesRecursive: []containertypes.BlkioStatEntry{
{Op: "Sync", Value: 1000},
{Op: "Async", Value: 2000},
},
},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := summarizeBlockIO(tt.stats)
if tt.want == nil {
if got != nil {
t.Errorf("summarizeBlockIO() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("summarizeBlockIO() = nil, want non-nil")
return
}
})
}
// Additional test for exact values
t.Run("exact read/write values", func(t *testing.T) {
stats := containertypes.StatsResponse{
BlkioStats: containertypes.BlkioStats{
IoServiceBytesRecursive: []containertypes.BlkioStatEntry{
{Op: "Read", Value: 1000},
{Op: "Write", Value: 2000},
{Op: "Read", Value: 500},
},
},
}
got := summarizeBlockIO(stats)
if got == nil {
t.Fatal("expected non-nil result")
}
if got.ReadBytes != 1500 {
t.Errorf("ReadBytes = %d, want 1500", got.ReadBytes)
}
if got.WriteBytes != 2000 {
t.Errorf("WriteBytes = %d, want 2000", got.WriteBytes)
}
})
}
func TestSummarizeNetworkIO(t *testing.T) {
t.Run("aggregates all interfaces", func(t *testing.T) {
stats := containertypes.StatsResponse{
Networks: map[string]containertypes.NetworkStats{
"eth0": {RxBytes: 1200, TxBytes: 3400},
"eth1": {RxBytes: 800, TxBytes: 600},
},
}
rx, tx := summarizeNetworkIO(stats)
if rx != 2000 {
t.Fatalf("rx bytes = %d, want 2000", rx)
}
if tx != 4000 {
t.Fatalf("tx bytes = %d, want 4000", tx)
}
})
t.Run("empty stats returns zero", func(t *testing.T) {
rx, tx := summarizeNetworkIO(containertypes.StatsResponse{})
if rx != 0 || tx != 0 {
t.Fatalf("expected zero rx/tx, got %d/%d", rx, tx)
}
})
}
func TestExtractPodmanMetadata(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want func(*testing.T, *agentsdocker.PodmanContainer)
}{
{
name: "nil labels returns nil",
labels: nil,
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
},
},
{
name: "empty labels returns nil",
labels: map[string]string{},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
},
},
{
name: "unrelated labels returns nil",
labels: map[string]string{
"com.docker.compose.project": "myproject",
"maintainer": "test@example.com",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
},
},
{
name: "pod metadata",
labels: map[string]string{
"io.podman.annotations.pod.name": "mypod",
"io.podman.annotations.pod.id": "abc123",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.PodName != "mypod" {
t.Errorf("PodName = %q, want %q", got.PodName, "mypod")
}
if got.PodID != "abc123" {
t.Errorf("PodID = %q, want %q", got.PodID, "abc123")
}
},
},
{
name: "infra container true",
labels: map[string]string{
"io.podman.annotations.pod.infra": "true",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if !got.Infra {
t.Error("Infra should be true")
}
},
},
{
name: "infra container yes",
labels: map[string]string{
"io.podman.annotations.pod.infra": "yes",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if !got.Infra {
t.Error("Infra should be true for 'yes' value")
}
},
},
{
name: "infra container false",
labels: map[string]string{
"io.podman.annotations.pod.name": "mypod",
"io.podman.annotations.pod.infra": "false",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.Infra {
t.Error("Infra should be false")
}
},
},
{
name: "compose metadata",
labels: map[string]string{
"io.podman.compose.project": "myproject",
"io.podman.compose.service": "web",
"io.podman.compose.working_dir": "/app",
"io.podman.compose.config-hash": "sha256:abc123",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.ComposeProject != "myproject" {
t.Errorf("ComposeProject = %q, want %q", got.ComposeProject, "myproject")
}
if got.ComposeService != "web" {
t.Errorf("ComposeService = %q, want %q", got.ComposeService, "web")
}
if got.ComposeWorkdir != "/app" {
t.Errorf("ComposeWorkdir = %q, want %q", got.ComposeWorkdir, "/app")
}
if got.ComposeConfig != "sha256:abc123" {
t.Errorf("ComposeConfig = %q, want %q", got.ComposeConfig, "sha256:abc123")
}
},
},
{
name: "auto-update metadata",
labels: map[string]string{
"io.containers.autoupdate": "registry",
"io.containers.autoupdate.restart": "true",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.AutoUpdatePolicy != "registry" {
t.Errorf("AutoUpdatePolicy = %q, want %q", got.AutoUpdatePolicy, "registry")
}
if got.AutoUpdateRestart != "true" {
t.Errorf("AutoUpdateRestart = %q, want %q", got.AutoUpdateRestart, "true")
}
},
},
{
name: "user namespace from podman annotations",
labels: map[string]string{
"io.podman.annotations.userns": "keep-id",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.UserNS != "keep-id" {
t.Errorf("UserNS = %q, want %q", got.UserNS, "keep-id")
}
},
},
{
name: "user namespace from io.containers",
labels: map[string]string{
"io.containers.userns": "auto",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.UserNS != "auto" {
t.Errorf("UserNS = %q, want %q", got.UserNS, "auto")
}
},
},
{
name: "podman annotation takes precedence over io.containers for userns",
labels: map[string]string{
"io.podman.annotations.userns": "keep-id",
"io.containers.userns": "auto",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.UserNS != "keep-id" {
t.Errorf("UserNS = %q, want %q (podman annotation should take precedence)", got.UserNS, "keep-id")
}
},
},
{
name: "whitespace is trimmed",
labels: map[string]string{
"io.podman.annotations.pod.name": " mypod ",
"io.podman.compose.project": "\tproject\t",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.PodName != "mypod" {
t.Errorf("PodName = %q, want %q", got.PodName, "mypod")
}
if got.ComposeProject != "project" {
t.Errorf("ComposeProject = %q, want %q", got.ComposeProject, "project")
}
},
},
{
name: "full realistic example",
labels: map[string]string{
"io.podman.annotations.pod.name": "webapp-pod",
"io.podman.annotations.pod.id": "deadbeef1234",
"io.podman.annotations.pod.infra": "false",
"io.podman.compose.project": "myapp",
"io.podman.compose.service": "api",
"io.podman.compose.working_dir": "/home/user/myapp",
"io.podman.compose.config-hash": "sha256:abc",
"io.containers.autoupdate": "local",
"io.containers.autoupdate.restart": "always",
"io.podman.annotations.userns": "keep-id:uid=1000,gid=1000",
},
want: func(t *testing.T, got *agentsdocker.PodmanContainer) {
if got == nil {
t.Fatal("expected non-nil")
}
if got.PodName != "webapp-pod" {
t.Errorf("PodName = %q, want %q", got.PodName, "webapp-pod")
}
if got.PodID != "deadbeef1234" {
t.Errorf("PodID = %q, want %q", got.PodID, "deadbeef1234")
}
if got.Infra {
t.Error("Infra should be false")
}
if got.ComposeProject != "myapp" {
t.Errorf("ComposeProject = %q, want %q", got.ComposeProject, "myapp")
}
if got.ComposeService != "api" {
t.Errorf("ComposeService = %q, want %q", got.ComposeService, "api")
}
if got.AutoUpdatePolicy != "local" {
t.Errorf("AutoUpdatePolicy = %q, want %q", got.AutoUpdatePolicy, "local")
}
if got.UserNS != "keep-id:uid=1000,gid=1000" {
t.Errorf("UserNS = %q, want %q", got.UserNS, "keep-id:uid=1000,gid=1000")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPodmanMetadata(tt.labels)
tt.want(t, got)
})
}
}
func TestDetectRuntime(t *testing.T) {
tests := []struct {
name string
info systemtypes.Info
endpoint string
preference RuntimeKind
want RuntimeKind
}{
{
name: "preference podman with unlabeled endpoint returns podman",
info: systemtypes.Info{},
endpoint: "unix:///custom/container.sock",
preference: RuntimePodman,
want: RuntimePodman,
},
{
// Issue #1647: a podman preference must not mislabel a connection
// that actually landed on the Docker socket.
name: "preference podman with docker socket endpoint returns docker",
info: systemtypes.Info{},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimePodman,
want: RuntimeDocker,
},
{
name: "podman in endpoint lowercase",
info: systemtypes.Info{},
endpoint: "unix:///run/podman/podman.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "libpod in endpoint",
info: systemtypes.Info{},
endpoint: "unix:///run/user/1000/libpod/libpod.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "podman in endpoint uppercase",
info: systemtypes.Info{},
endpoint: "unix:///run/PODMAN/podman.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "podman in InitBinary",
info: systemtypes.Info{
InitBinary: "/usr/bin/podman",
},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "podman in ServerVersion",
info: systemtypes.Info{
ServerVersion: "4.5.0-podman",
},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "podman in DriverStatus",
info: systemtypes.Info{
DriverStatus: [][2]string{
{"Driver", "overlay"},
{"Backing Filesystem", "extfs"},
{"Supports d_type", "true"},
{"Using metacopy", "podman-default"},
},
},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "podman in SecurityOptions",
info: systemtypes.Info{
SecurityOptions: []string{
"name=seccomp,profile=default",
"name=rootless,podman",
},
},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimePodman,
},
{
name: "preference docker returns docker",
info: systemtypes.Info{},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeDocker,
want: RuntimeDocker,
},
{
name: "auto with docker socket returns docker",
info: systemtypes.Info{},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimeDocker,
},
{
name: "docker info with no podman indicators returns docker",
info: systemtypes.Info{
InitBinary: "docker-init",
ServerVersion: "24.0.5",
DriverStatus: [][2]string{{"Driver", "overlay2"}},
SecurityOptions: []string{"name=seccomp,profile=default"},
},
endpoint: "unix:///var/run/docker.sock",
preference: RuntimeAuto,
want: RuntimeDocker,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectRuntime(tt.info, tt.endpoint, tt.preference)
if got != tt.want {
t.Errorf("detectRuntime() = %v, want %v", got, tt.want)
}
})
}
}
func TestBuildRuntimeCandidates(t *testing.T) {
tests := []struct {
name string
preference RuntimeKind
wantMin int // minimum expected candidates
}{
{
name: "auto includes both podman and docker sockets",
preference: RuntimeAuto,
wantMin: 3, // at least env defaults + podman rootless + docker default
},
{
name: "docker preference includes docker socket",
preference: RuntimeDocker,
wantMin: 2, // at least env defaults + docker default
},
{
name: "podman preference includes podman sockets",
preference: RuntimePodman,
wantMin: 4, // at least podman rootless + podman system + podman (var/run) + env defaults
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
candidates := buildRuntimeCandidates(tt.preference)
if len(candidates) < tt.wantMin {
t.Errorf("buildRuntimeCandidates(%v) returned %d candidates, want at least %d", tt.preference, len(candidates), tt.wantMin)
}
// When podman is explicitly requested, podman sockets should be tried first.
// For auto/docker modes, environment defaults should be first.
if len(candidates) > 0 {
if tt.preference == RuntimePodman {
if candidates[0].label != "podman rootless socket" {
t.Errorf("podman preference: first candidate should be 'podman rootless socket', got %q", candidates[0].label)
}
} else if candidates[0].label != "environment defaults" {
t.Errorf("first candidate should be 'environment defaults', got %q", candidates[0].label)
}
}
// Verify no duplicates
seen := make(map[string]bool)
for _, c := range candidates {
key := c.host
if key == "" {
key = "__default__"
}
if seen[key] {
t.Errorf("duplicate candidate with host %q", c.host)
}
seen[key] = true
}
})
}
}
func TestBuildRuntimeCandidatesContent(t *testing.T) {
// Test that docker preference doesn't include podman sockets
t.Run("docker excludes podman sockets", func(t *testing.T) {
candidates := buildRuntimeCandidates(RuntimeDocker)
for _, c := range candidates {
if c.label == "podman rootless socket" || c.label == "podman system socket" {
t.Errorf("docker preference should not include %q", c.label)
}
}
})
// Test that podman preference doesn't include default docker socket
t.Run("podman excludes default docker socket", func(t *testing.T) {
candidates := buildRuntimeCandidates(RuntimePodman)
for _, c := range candidates {
if c.label == "default docker socket" {
t.Errorf("podman preference should not include %q", c.label)
}
}
})
// Test that auto includes both
t.Run("auto includes both docker and podman", func(t *testing.T) {
candidates := buildRuntimeCandidates(RuntimeAuto)
hasDocker := false
hasPodman := false
for _, c := range candidates {
if c.label == "default docker socket" {
hasDocker = true
}
if c.label == "podman rootless socket" || c.label == "podman system socket" {
hasPodman = true
}
}
if !hasDocker {
t.Error("auto preference should include docker socket")
}
if !hasPodman {
t.Error("auto preference should include podman sockets")
}
})
}
func TestBuildRuntimeCandidatesEnv(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/docker.sock")
t.Setenv("CONTAINER_HOST", "unix:///tmp/container.sock")
t.Setenv("PODMAN_HOST", "unix:///tmp/podman.sock")
candidates := buildRuntimeCandidates(RuntimeAuto)
labels := make(map[string]struct{}, len(candidates))
for _, c := range candidates {
labels[c.label] = struct{}{}
}
for _, label := range []string{"DOCKER_HOST", "CONTAINER_HOST", "PODMAN_HOST"} {
if _, ok := labels[label]; !ok {
t.Fatalf("expected candidate %q", label)
}
}
}
func TestBuildRuntimeCandidatesDeduplicates(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/shared.sock")
t.Setenv("CONTAINER_HOST", "unix:///tmp/shared.sock")
candidates := buildRuntimeCandidates(RuntimeAuto)
count := 0
for _, c := range candidates {
if c.host == "unix:///tmp/shared.sock" {
count++
}
}
if count != 1 {
t.Fatalf("expected duplicate host to be deduplicated, got %d", count)
}
}
func TestRandomDuration(t *testing.T) {
tests := []struct {
name string
max time.Duration
}{
{"zero max returns zero", 0},
{"negative max returns zero", -time.Second},
{"positive max returns value in range", 5 * time.Second},
{"large max works", time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.max <= 0 {
got := randomDuration(tt.max)
if got != 0 {
t.Errorf("randomDuration(%v) = %v, want 0", tt.max, got)
}
return
}
// For positive max, verify the result is in range
// Run multiple times to check randomness
for i := 0; i < 10; i++ {
got := randomDuration(tt.max)
if got < 0 || got >= tt.max {
t.Errorf("randomDuration(%v) = %v, want [0, %v)", tt.max, got, tt.max)
}
}
})
}
// Test that results are actually random (not all the same)
t.Run("results vary", func(t *testing.T) {
max := 10 * time.Second
results := make(map[time.Duration]bool)
for i := 0; i < 100; i++ {
results[randomDuration(max)] = true
}
// With 100 iterations, we should get more than 1 unique result
if len(results) < 2 {
t.Error("randomDuration appears to not be random")
}
})
}
func TestRandomDurationRandError(t *testing.T) {
swap(t, &randIntFn, func(io.Reader, *big.Int) (*big.Int, error) {
return nil, errors.New("boom")
})
if got := randomDuration(10 * time.Second); got != 0 {
t.Fatalf("expected 0 on rand error, got %v", got)
}
}
func TestDetermineSelfUpdateArch(t *testing.T) {
// This test validates the function returns a valid result
// The actual result depends on the runtime environment
got := determineSelfUpdateArch()
// Should be one of the known values or empty (if architecture unknown)
validArches := map[string]bool{
"": true,
"linux-amd64": true,
"linux-arm64": true,
"linux-armv7": true,
}
if !validArches[got] {
t.Errorf("determineSelfUpdateArch() = %q, want one of %v", got, validArches)
}
// On most test environments, we should get a non-empty result
// But we can't assert this as it depends on the machine
}
func TestDetectHostRemovedError(t *testing.T) {
tests := []struct {
name string
body []byte
want string
}{
{
name: "empty body returns empty",
body: []byte{},
want: "",
},
{
name: "nil body returns empty",
body: nil,
want: "",
},
{
name: "invalid JSON returns empty",
body: []byte("not json"),
want: "",
},
{
name: "wrong error code returns empty",
body: []byte(`{"error": "host was removed", "code": "invalid_token"}`),
want: "",
},
{
name: "correct code but wrong error message returns empty",
body: []byte(`{"error": "invalid request", "code": "invalid_report"}`),
want: "",
},
{
name: "host removed error returns message",
body: []byte(`{"error": "Docker host xyz was removed from this Pulse instance", "code": "invalid_report"}`),
want: "Docker host xyz was removed from this Pulse instance",
},
{
name: "case insensitive code matching",
body: []byte(`{"error": "host was removed", "code": "INVALID_REPORT"}`),
want: "host was removed",
},
{
name: "case insensitive error matching",
body: []byte(`{"error": "Host WAS REMOVED from server", "code": "invalid_report"}`),
want: "Host WAS REMOVED from server",
},
{
name: "extra fields in JSON are ignored",
body: []byte(`{"error": "host was removed", "code": "invalid_report", "timestamp": "2024-01-01T00:00:00Z"}`),
want: "host was removed",
},
{
name: "missing error field returns empty",
body: []byte(`{"code": "invalid_report"}`),
want: "",
},
{
name: "missing code field returns empty",
body: []byte(`{"error": "host was removed"}`),
want: "",
},
{
name: "monitoring stopped error is detected",
body: []byte(`{"error": "docker host \"host-1\" had monitoring stopped at 2025-11-02T13:45:15Z and cannot report again", "code": "invalid_report"}`),
want: `docker host "host-1" had monitoring stopped at 2025-11-02T13:45:15Z and cannot report again`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectHostRemovedError(tt.body)
if got != tt.want {
t.Errorf("detectHostRemovedError() = %q, want %q", got, tt.want)
}
})
}
}
// TestAgentHookSeamsArePerAgentFields pins the shape of the two indirection
// seams the Docker agent exposes for tests: the timer constructor read by
// waitForAsyncDelay and the JSON marshaller read by acknowledgement delivery
// and update-payload decoding. Both used to be package-level vars in deps.go,
// which raced with async goroutines leaked from earlier tests. They are now
// fields on Agent, injected at construction and never reassigned afterwards.
func TestAgentHookSeamsArePerAgentFields(t *testing.T) {
agentType := reflect.TypeOf(Agent{})
for _, tc := range []struct {
field string
want reflect.Type
}{
{field: "newTimerFn", want: reflect.TypeOf((func(time.Duration) *time.Timer)(nil))},
{field: "jsonMarshalFn", want: reflect.TypeOf((func(any) ([]byte, error))(nil))},
} {
field, ok := agentType.FieldByName(tc.field)
if !ok {
t.Fatalf("Agent is missing the %s test seam field; the hook must stay per-Agent, not a package global", tc.field)
}
if field.Type != tc.want {
t.Fatalf("Agent.%s has type %s, want %s", tc.field, field.Type, tc.want)
}
if field.PkgPath == "" {
t.Fatalf("Agent.%s must stay unexported; it is an internal test seam, not a configuration surface", tc.field)
}
}
}
// TestAgentHookSeamsHaveNoPackageGlobals fails if either seam is reintroduced
// as a package-level var in the non-test sources of internal/dockeragent. A
// package global is reachable from every leaked goroutine in the package, which
// is exactly the race the per-Agent fields removed.
func TestAgentHookSeamsHaveNoPackageGlobals(t *testing.T) {
forbidden := map[string]struct{}{
"newTimerFn": {},
"jsonMarshalFn": {},
}
sources, err := filepath.Glob("*.go")
if err != nil {
t.Fatalf("glob package sources: %v", err)
}
if len(sources) == 0 {
t.Fatal("no package sources found; the global scan would vacuously pass")
}
scanned := 0
for _, source := range sources {
if strings.HasSuffix(source, "_test.go") {
continue
}
scanned++
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, source, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", source, err)
}
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.VAR {
continue
}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for _, name := range valueSpec.Names {
if _, bad := forbidden[name.Name]; bad {
t.Errorf(
"%s declares package-level var %s; the timer and JSON-marshal seams must stay Agent fields so async goroutines never read shared state",
source, name.Name,
)
}
}
}
}
}
if scanned == 0 {
t.Fatal("scanned no non-test sources; the global scan would vacuously pass")
}
}
// TestAgentHookSeamsDefaultToStdlib proves a zero-value Agent — the production
// shape, where nothing injects a hook — runs the standard library behind both
// seams.
func TestAgentHookSeamsDefaultToStdlib(t *testing.T) {
agent := &Agent{}
body, err := agent.jsonMarshal(map[string]string{"k": "v"})
if err != nil {
t.Fatalf("nil jsonMarshalFn should fall back to json.Marshal: %v", err)
}
if string(body) != `{"k":"v"}` {
t.Fatalf("nil jsonMarshalFn produced %q, want json.Marshal output", body)
}
timer := agent.newTimer(time.Millisecond)
if timer == nil {
t.Fatal("nil newTimerFn should fall back to time.NewTimer")
}
defer stopTimer(timer)
select {
case <-timer.C:
case <-time.After(5 * time.Second):
t.Fatal("fallback timer never fired")
}
}
// TestAgentHookSeamsAreIsolatedPerAgent is the regression proof for the race
// the per-Agent seams fixed. Two Agents constructed with different hooks are
// exercised concurrently; with a package global, one Agent's injection would be
// visible to the other and the swap would race the reads. Run under -race.
func TestAgentHookSeamsAreIsolatedPerAgent(t *testing.T) {
marshalErr := errors.New("injected marshal failure")
failing := &Agent{
hostID: "failing",
jsonMarshalFn: func(any) ([]byte, error) {
return nil, marshalErr
},
newTimerFn: func(time.Duration) *time.Timer {
return time.NewTimer(0)
},
}
plain := &Agent{hostID: "plain"}
var wg sync.WaitGroup
errs := make(chan error, 64)
for range 16 {
wg.Add(2)
go func() {
defer wg.Done()
if _, err := failing.jsonMarshal(struct{}{}); !errors.Is(err, marshalErr) {
errs <- fmt.Errorf("injected agent should use its own marshaller, got %v", err)
}
}()
go func() {
defer wg.Done()
body, err := plain.jsonMarshal(map[string]int{"n": 1})
if err != nil {
errs <- fmt.Errorf("sibling agent must not observe the injected marshaller: %v", err)
return
}
if string(body) != `{"n":1}` {
errs <- fmt.Errorf("sibling agent produced %q, want json.Marshal output", body)
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
// The injected timer fires immediately; the sibling's nil field must still
// resolve to a real time.NewTimer rather than the injected constructor.
injected := failing.newTimer(time.Hour)
defer stopTimer(injected)
select {
case <-injected.C:
case <-time.After(5 * time.Second):
t.Fatal("injected newTimerFn was not used by its own Agent")
}
sibling := plain.newTimer(time.Hour)
defer stopTimer(sibling)
select {
case <-sibling.C:
t.Fatal("sibling agent observed the injected immediate timer; the seam is not per-Agent")
case <-time.After(50 * time.Millisecond):
}
}
// TestSendCommandAckUsesReceiverMarshaller proves the acknowledgement transport
// path in agent.go marshals through the receiver's own seam, so an injected
// failure stays scoped to the Agent under test.
func TestSendCommandAckUsesReceiverMarshaller(t *testing.T) {
marshalErr := errors.New("injected ack marshal failure")
agent := &Agent{
hostID: "host1",
jsonMarshalFn: func(any) ([]byte, error) {
return nil, marshalErr
},
}
err := agent.sendCommandAck(context.Background(), TargetConfig{URL: "http://example"}, "cmd", "status", "msg")
if err == nil {
t.Fatal("expected the injected marshaller to fail the acknowledgement")
}
if !errors.Is(err, marshalErr) {
t.Fatalf("acknowledgement error %v does not wrap the injected failure; the ack path is not using a.jsonMarshal", err)
}
if !strings.Contains(err.Error(), "marshal command acknowledgement") {
t.Fatalf("acknowledgement error %v lost its marshal context", err)
}
}
// TestIssue1647ConnectRuntimePodmanPreferenceFallsBackToDockerSocket pins the
// runtime-labeling half of issue #1647: when the pinned rootless podman socket
// is gone (it is socket-activated and dies with the login session) and the
// connection falls through to the system Docker socket, the agent must report
// docker — not the preference — so Swarm collection stays available.
func TestIssue1647ConnectRuntimePodmanPreferenceFallsBackToDockerSocket(t *testing.T) {
docker := &fakeDockerClient{daemonHost: "unix:///var/run/docker.sock"}
swap(t, &buildRuntimeCandidatesFn, func(preference RuntimeKind) []runtimeCandidate {
if preference != RuntimePodman {
t.Fatalf("expected podman preference, got %v", preference)
}
return []runtimeCandidate{
{label: "podman rootless socket", host: "unix:///run/user/0/podman/podman.sock"},
{label: "environment defaults", applyDockerEnv: true},
}
})
calls := 0
swap(t, &tryRuntimeCandidateFn, func(_ []client.Opt) (dockerClient, systemtypes.Info, error) {
calls++
if calls == 1 {
return nil, systemtypes.Info{}, errors.New("dial unix /run/user/0/podman/podman.sock: connect: no such file or directory")
}
return docker, systemtypes.Info{ServerVersion: "24.0.5", InitBinary: "docker-init"}, nil
})
cli, info, runtimeKind, err := connectRuntime(RuntimePodman, nil)
if err != nil {
t.Fatalf("expected fallback connection to succeed, got %v", err)
}
if cli != docker {
t.Fatalf("expected the docker fallback client to be returned")
}
if runtimeKind != RuntimeDocker {
t.Fatalf("runtime = %v, want %v (podman preference must not mislabel a Docker connection)", runtimeKind, RuntimeDocker)
}
if info.ServerVersion != "24.0.5" {
t.Fatalf("expected docker info to be returned, got %+v", info)
}
}
// TestIssue1647ReconnectAfterPersistentDaemonGone pins the reconnect half of
// issue #1647: when the bound socket disappears mid-run, the agent re-runs
// runtime discovery after persistent daemon-unavailable collects instead of
// erroring forever until a process restart.
func TestIssue1647ReconnectAfterPersistentDaemonGone(t *testing.T) {
failing := &fakeDockerClient{
daemonHost: "unix:///run/user/0/podman/podman.sock",
infoFunc: func(context.Context) (systemtypes.Info, error) {
return systemtypes.Info{}, errors.New("Cannot connect to the Docker daemon at unix:///run/user/0/podman/podman.sock. Is the docker daemon running?")
},
}
healthy := &fakeDockerClient{
daemonHost: "unix:///var/run/docker.sock",
infoFunc: func(context.Context) (systemtypes.Info, error) {
return systemtypes.Info{ServerVersion: "24.0.5", InitBinary: "docker-init"}, nil
},
containerListFunc: func(context.Context, dockerContainerListOptions) ([]containertypes.Summary, error) {
return nil, nil
},
closeFn: func() error { return nil },
}
reconnects := 0
swap(t, &connectRuntimeFn, func(preference RuntimeKind, _ *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) {
reconnects++
if preference != RuntimePodman {
t.Errorf("reconnect preference = %v, want %v", preference, RuntimePodman)
}
return healthy, systemtypes.Info{ServerVersion: "24.0.5", InitBinary: "docker-init"}, RuntimeDocker, nil
})
swap(t, &hostmetricsCollect, func(context.Context, []string) (hostmetrics.Snapshot, error) {
return hostmetrics.Snapshot{}, nil
})
agent := &Agent{
cfg: Config{IncludeContainers: true},
logger: zerolog.Nop(),
docker: newSwappableDockerClient(failing),
runtime: RuntimePodman,
runtimePref: RuntimePodman,
}
ctx := context.Background()
for i := 0; i < runtimeReconnectFailureThreshold-1; i++ {
if err := agent.collectOnce(ctx); err == nil {
t.Fatalf("collect %d: expected daemon-unavailable error", i+1)
}
if reconnects != 0 {
t.Fatalf("collect %d: reconnect attempted before failure threshold", i+1)
}
}
if err := agent.collectOnce(ctx); err != nil {
t.Fatalf("collect at threshold should reconnect and succeed, got %v", err)
}
if reconnects != 1 {
t.Fatalf("reconnects = %d, want 1", reconnects)
}
if agent.runtime != RuntimeDocker {
t.Fatalf("runtime after reconnect = %v, want %v", agent.runtime, RuntimeDocker)
}
if !agent.supportsSwarm {
t.Fatalf("expected Swarm support after reconnecting to Docker")
}
if agent.daemonHost != "unix:///var/run/docker.sock" {
t.Fatalf("daemonHost after reconnect = %q, want the docker socket", agent.daemonHost)
}
if agent.runtimeGoneStreak != 0 {
t.Fatalf("failure streak not reset after successful reconnect: %d", agent.runtimeGoneStreak)
}
if err := agent.collectOnce(ctx); err != nil {
t.Fatalf("follow-up collect on reconnected client failed: %v", err)
}
if reconnects != 1 {
t.Fatalf("healthy collects must not trigger further reconnects, got %d", reconnects)
}
}
func TestBuildReportForwardsExplicitDiskIncludesAndExcludes(t *testing.T) {
var gotExclude, gotInclude []string
swap(t, &hostmetricsCollectWithDiskFilters, func(_ context.Context, exclude, include []string) (hostmetrics.Snapshot, error) {
gotExclude = append([]string(nil), exclude...)
gotInclude = append([]string(nil), include...)
return hostmetrics.Snapshot{}, nil
})
agent := &Agent{
cfg: Config{
AgentID: "docker-agent-1",
Interval: 30 * time.Second,
DiskExclude: []string{"/mnt/private"},
DiskInclude: []string{"/mnt/containers"},
},
docker: &fakeDockerClient{
infoFunc: func(context.Context) (systemtypes.Info, error) {
return systemtypes.Info{ID: "daemon", ServerVersion: "24.0.0"}, nil
},
},
logger: zerolog.Nop(),
}
if _, err := agent.buildReport(context.Background()); err != nil {
t.Fatalf("buildReport() failed: %v", err)
}
if got := strings.Join(gotExclude, ","); got != "/mnt/private" {
t.Fatalf("disk excludes = %q, want /mnt/private", got)
}
if got := strings.Join(gotInclude, ","); got != "/mnt/containers" {
t.Fatalf("disk includes = %q, want /mnt/containers", got)
}
}
func TestRegistryCredentialSourceForConfig(t *testing.T) {
logger := zerolog.Nop()
source := registryCredentialSourceForConfig(Config{}, logger)
if _, ok := source.(*dockerConfigCredentials); !ok {
t.Fatalf("expected host Docker credential source by default, got %T", source)
}
if disabled := registryCredentialSourceForConfig(Config{DisableRegistryCredentials: true}, logger); disabled != nil {
t.Fatalf("expected nil credential source when disabled, got %T", disabled)
}
}