Fix transient rootless Podman socket outranking rootful Docker (#1647)

The installer's discover_rootless_container_runtime only globbed
/run/user/* sockets and never consulted the system Docker daemon, so a
socket-activated rootless Podman API socket (alive only for root's login
session on Debian/OMV) won discovery over a healthy rootful Docker and
pinned PULSE_DOCKER_RUNTIME=podman plus CONTAINER_HOST/PODMAN_HOST/
XDG_RUNTIME_DIR into the agent unit. The env-application block also ran
for explicit --enable-docker installs. Rootless discovery now defers to
system_docker_runtime_is_active (docker info with DOCKER_HOST stripped,
or a live /var/run/docker.sock probe) before touching rootless sockets.

On the agent, detectRuntime short-circuited to podman whenever the
preference said podman, mislabeling connections that actually fell
through to the Docker socket and disabling Swarm collection. The
preference is now an ordering hint: a podman-preferred connection landing
on a docker endpoint reports docker, while unlabeled endpoints with no
runtime signals still honor the pin. When the bound socket disappears
mid-run the agent re-runs runtime discovery after three consecutive
daemon-unavailable collects, swapping the connection behind a
swappableDockerClient so concurrent goroutines keep a stable handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com 2026-07-28 22:47:50 +01:00
parent 07a0d84464
commit e734bd0097
7 changed files with 541 additions and 13 deletions

View file

@ -2233,6 +2233,36 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Rootful Docker outranks rootless socket discovery and runtime labels are truthful
Installer container-runtime discovery
(`discover_rootless_container_runtime` in `scripts/install.sh`) consults a
working system Docker first: when `system_docker_runtime_is_active` confirms a
rootful daemon (a `docker info` success with `DOCKER_HOST`/`CONTAINER_HOST`
stripped, or a live `/var/run/docker.sock` probe), rootless socket discovery
returns nothing and no `PULSE_DOCKER_RUNTIME=podman` /
`CONTAINER_HOST`/`PODMAN_HOST`/`XDG_RUNTIME_DIR` pin is written into the agent
service environment. This holds for auto-detection and for explicit
`--enable-docker` alike (#1647) — previously a transient rootless Podman API
socket (socket-activated for root's login session on Debian/OMV) outranked a
healthy rootful Docker and pinned the agent to a socket that vanished with the
session.
On the agent side (`internal/dockeragent/agent.go`), the podman runtime
preference is an ordering hint, not an identity override: `detectRuntime` no
longer short-circuits to podman on preference alone, so a podman-preferred
connection that falls through to a docker-named endpoint reports `docker` and
keeps Swarm collection enabled, while an unlabeled endpoint with no runtime
signals still honors the preference. `connectRuntime` accepts that fallback
(only an explicit docker preference still hard-rejects a podman endpoint). When
the bound socket disappears mid-run, the agent counts consecutive
daemon-unavailable collect cycles and after `runtimeReconnectFailureThreshold`
re-runs runtime discovery against the original preference, swapping the
connection through `swappableDockerClient`
(`internal/dockeragent/runtime_reconnect.go`) so concurrent cleanup and
container-update goroutines keep a stable handle, and refreshing the reported
runtime, daemon host, and Swarm capability from the new connection.
### Shared system-settings boundary dropped dead auto-update schedule fields
The shared `internal/api` system-settings surface this subsystem consumes

View file

@ -1107,6 +1107,20 @@ TLS floor in the dynamic config.
## Current State
The shell installer's container-runtime discovery prefers a working rootful
Docker daemon over any rootless socket (#1647).
`discover_rootless_container_runtime` in `scripts/install.sh` first calls
`system_docker_runtime_is_active` — a `docker info` check with
`DOCKER_HOST`/`CONTAINER_HOST` stripped, falling back to probing
`/var/run/docker.sock` directly — and returns without discovering anything
when the system daemon answers. Only when no rootful Docker is live does it
glob `/run/user/*` for rootless docker/podman sockets and pin
`PULSE_DOCKER_RUNTIME`/`CONTAINER_HOST`/`PODMAN_HOST`/`XDG_RUNTIME_DIR` into
the agent service environment. This applies to auto-detection and explicit
`--enable-docker` runs alike, so a transient socket-activated rootless Podman
API socket can no longer capture the agent unit on hosts whose real runtime is
rootful Docker.
Stable and stable-dry-run callers now select SignPath as the canonical Windows
Authenticode backend. The reusable builder fails fast on missing configuration,
submits the GitHub-hosted unsigned artifact through the pinned official action,

View file

@ -111,6 +111,8 @@ type Agent struct {
daemonHost string
daemonID string // Cached at init; Podman can return unstable IDs across calls
runtime RuntimeKind
runtimePref RuntimeKind // user-requested runtime preference, kept for reconnects
runtimeGoneStreak int // consecutive daemon-unavailable collects; guarded by collectMu
runtimeVer string
agentVersion string
supportsSwarm bool
@ -339,10 +341,11 @@ func New(cfg Config) (*Agent, error) {
agent := &Agent{
cfg: cfg,
docker: dockerClient,
docker: newSwappableDockerClient(dockerClient),
daemonHost: dockerClient.DaemonHost(),
daemonID: info.ID, // Cache at init for stable agent ID
runtime: runtimeKind,
runtimePref: runtimePref,
runtimeVer: info.ServerVersion,
agentVersion: agentVersion,
supportsSwarm: runtimeKind == RuntimeDocker,
@ -533,7 +536,7 @@ func connectRuntime(preference RuntimeKind, logger *zerolog.Logger) (dockerClien
endpoint := cli.DaemonHost()
runtime := detectRuntime(info, endpoint, preference)
if preference != RuntimeAuto && runtime != preference {
if preference == RuntimeDocker && runtime != preference {
attempts = append(attempts, fmt.Sprintf("%s: detected %s runtime", candidate.label, runtime))
if closeErr := cli.Close(); closeErr != nil {
attempts = append(attempts, fmt.Sprintf("%s: close client after runtime mismatch: %v", candidate.label, closeErr))
@ -541,6 +544,16 @@ func connectRuntime(preference RuntimeKind, logger *zerolog.Logger) (dockerClien
continue
}
// A podman preference is an ordering hint, not a hard requirement: the
// pin usually comes from install-time socket discovery, and a rootless
// podman API socket can be gone by the time the agent starts (#1647).
// If every podman candidate failed and the connection landed on a real
// Docker daemon, keep it and report the runtime truthfully so Swarm
// collection stays available.
if preference == RuntimePodman && runtime != preference && logger != nil {
logger.Warn().Str("host", endpoint).Msg("Podman runtime preferred but connected endpoint is Docker; reporting docker runtime")
}
if logger != nil {
logger.Debug().Str("host", endpoint).Str("runtime", string(runtime)).Msg("Connected to container runtime")
}
@ -682,10 +695,6 @@ func buildRuntimeCandidates(preference RuntimeKind) []runtimeCandidate {
}
func detectRuntime(info systemtypes.Info, endpoint string, preference RuntimeKind) RuntimeKind {
if preference == RuntimePodman {
return RuntimePodman
}
lowerEndpoint := strings.ToLower(endpoint)
if strings.Contains(lowerEndpoint, "podman") || strings.Contains(lowerEndpoint, "libpod") {
return RuntimePodman
@ -711,10 +720,19 @@ func detectRuntime(info systemtypes.Info, endpoint string, preference RuntimeKin
}
}
if preference == RuntimeDocker {
// No podman signal anywhere. A docker-named endpoint is authoritative even
// under a podman preference: the connection fell through to a real Docker
// daemon, and labeling it podman would disable Swarm collection (#1647).
if strings.Contains(lowerEndpoint, "docker") {
return RuntimeDocker
}
// Unlabeled endpoint with no signals either way: trust the preference so an
// explicit podman pin on a custom socket path keeps its podman semantics.
if preference == RuntimePodman {
return RuntimePodman
}
return RuntimeDocker
}
@ -854,9 +872,13 @@ func (a *Agent) collectOnceWithReport(ctx context.Context) (agentsdocker.Report,
defer a.collectMu.Unlock()
report, err := a.buildReport(ctx)
if err != nil && a.maybeReconnectRuntime(err) {
report, err = a.buildReport(ctx)
}
if err != nil {
return agentsdocker.Report{}, fmt.Errorf("build docker report: %w", err)
}
a.runtimeGoneStreak = 0
if err := a.deliverReport(ctx, report); err != nil {
return report, err

View file

@ -19,7 +19,10 @@ import (
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) {
@ -941,11 +944,20 @@ func TestDetectRuntime(t *testing.T) {
want RuntimeKind
}{
{
name: "preference podman returns podman",
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: RuntimePodman,
want: RuntimeDocker,
},
{
name: "podman in endpoint lowercase",
@ -1545,3 +1557,124 @@ func TestSendCommandAckUsesReceiverMarshaller(t *testing.T) {
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)
}
}

View file

@ -0,0 +1,213 @@
package dockeragent
import (
"context"
"io"
"sync"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
// runtimeReconnectFailureThreshold is how many consecutive daemon-unavailable
// collect cycles are tolerated before the agent tries to re-run runtime
// discovery. A bound socket can disappear for good mid-run (issue #1647: a
// socket-activated rootless podman API socket vanishes when the login session
// ends) and without a reconnect the agent errors forever until restarted.
const runtimeReconnectFailureThreshold = 3
// swappableDockerClient lets the agent replace its runtime connection while
// concurrent goroutines (orphan cleanup, container update commands) keep using
// the same dockerClient handle. Only the inner pointer swaps, under the lock.
type swappableDockerClient struct {
mu sync.RWMutex
inner dockerClient
}
func newSwappableDockerClient(inner dockerClient) *swappableDockerClient {
return &swappableDockerClient{inner: inner}
}
func (s *swappableDockerClient) get() dockerClient {
s.mu.RLock()
defer s.mu.RUnlock()
return s.inner
}
func (s *swappableDockerClient) swap(next dockerClient) dockerClient {
s.mu.Lock()
defer s.mu.Unlock()
previous := s.inner
s.inner = next
return previous
}
func (s *swappableDockerClient) Info(ctx context.Context) (systemtypes.Info, error) {
return s.get().Info(ctx)
}
func (s *swappableDockerClient) DaemonHost() string {
return s.get().DaemonHost()
}
func (s *swappableDockerClient) ContainerList(ctx context.Context, options dockerContainerListOptions) ([]containertypes.Summary, error) {
return s.get().ContainerList(ctx, options)
}
func (s *swappableDockerClient) ContainerInspectWithRaw(ctx context.Context, containerID string, size bool) (containertypes.InspectResponse, []byte, error) {
return s.get().ContainerInspectWithRaw(ctx, containerID, size)
}
func (s *swappableDockerClient) ContainerStatsOneShot(ctx context.Context, containerID string) (dockerStatsResponseReader, error) {
return s.get().ContainerStatsOneShot(ctx, containerID)
}
func (s *swappableDockerClient) ContainerInspect(ctx context.Context, containerID string) (containertypes.InspectResponse, error) {
return s.get().ContainerInspect(ctx, containerID)
}
func (s *swappableDockerClient) ImagePull(ctx context.Context, ref string, options dockerImagePullOptions) (io.ReadCloser, error) {
return s.get().ImagePull(ctx, ref, options)
}
func (s *swappableDockerClient) ContainerStop(ctx context.Context, containerID string, options dockerContainerStopOptions) error {
return s.get().ContainerStop(ctx, containerID, options)
}
func (s *swappableDockerClient) ContainerRename(ctx context.Context, containerID, newName string) error {
return s.get().ContainerRename(ctx, containerID, newName)
}
func (s *swappableDockerClient) ContainerCreate(ctx context.Context, config *containertypes.Config, hostConfig *containertypes.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (containertypes.CreateResponse, error) {
return s.get().ContainerCreate(ctx, config, hostConfig, networkingConfig, platform, containerName)
}
func (s *swappableDockerClient) NetworkConnect(ctx context.Context, networkID, containerID string, config *network.EndpointSettings) error {
return s.get().NetworkConnect(ctx, networkID, containerID, config)
}
func (s *swappableDockerClient) ContainerStart(ctx context.Context, containerID string, options dockerContainerStartOptions) error {
return s.get().ContainerStart(ctx, containerID, options)
}
func (s *swappableDockerClient) ContainerRemove(ctx context.Context, containerID string, options dockerContainerRemoveOptions) error {
return s.get().ContainerRemove(ctx, containerID, options)
}
func (s *swappableDockerClient) ImageList(ctx context.Context, options dockerImageListOptions) ([]image.Summary, error) {
return s.get().ImageList(ctx, options)
}
func (s *swappableDockerClient) VolumeList(ctx context.Context, options dockerVolumeListOptions) ([]volume.Volume, error) {
return s.get().VolumeList(ctx, options)
}
func (s *swappableDockerClient) NetworkList(ctx context.Context, options dockerNetworkListOptions) ([]network.Summary, error) {
return s.get().NetworkList(ctx, options)
}
func (s *swappableDockerClient) DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error) {
return s.get().DiskUsage(ctx, options)
}
func (s *swappableDockerClient) ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error) {
return s.get().ServiceList(ctx, options)
}
func (s *swappableDockerClient) TaskList(ctx context.Context, options dockerTaskListOptions) ([]swarmtypes.Task, error) {
return s.get().TaskList(ctx, options)
}
func (s *swappableDockerClient) NodeList(ctx context.Context, options dockerNodeListOptions) ([]swarmtypes.Node, error) {
return s.get().NodeList(ctx, options)
}
func (s *swappableDockerClient) SecretList(ctx context.Context, options dockerSecretListOptions) ([]swarmtypes.Secret, error) {
return s.get().SecretList(ctx, options)
}
func (s *swappableDockerClient) ConfigList(ctx context.Context, options dockerConfigListOptions) ([]swarmtypes.Config, error) {
return s.get().ConfigList(ctx, options)
}
func (s *swappableDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
return s.get().ImageInspectWithRaw(ctx, imageID)
}
func (s *swappableDockerClient) Close() error {
return s.get().Close()
}
// maybeReconnectRuntime is called under collectMu after a failed buildReport.
// It returns true when a fresh runtime connection was established and the
// collect should be retried immediately.
func (a *Agent) maybeReconnectRuntime(err error) bool {
if !isDockerDaemonUnavailable(err) {
a.runtimeGoneStreak = 0
return false
}
a.runtimeGoneStreak++
if a.runtimeGoneStreak < runtimeReconnectFailureThreshold {
return false
}
cli, info, runtimeKind, connErr := connectRuntimeFn(a.runtimePref, &a.logger)
if connErr != nil {
a.logger.Warn().
Err(connErr).
Int("consecutive_failures", a.runtimeGoneStreak).
Msg("Container runtime endpoint unavailable; reconnect attempt failed")
return false
}
previous := a.adoptRuntimeConnection(cli, info, runtimeKind)
if previous != nil {
if closeErr := previous.Close(); closeErr != nil {
a.logger.Debug().Err(closeErr).Msg("Failed to close stale runtime client after reconnect")
}
}
a.runtimeGoneStreak = 0
a.logger.Info().
Str("runtime", string(runtimeKind)).
Str("daemon_host", cli.DaemonHost()).
Msg("Reconnected to container runtime")
return true
}
// adoptRuntimeConnection swaps in a freshly discovered runtime connection and
// refreshes the runtime identity fields. Called under collectMu; returns the
// replaced client so the caller can close it.
func (a *Agent) adoptRuntimeConnection(cli dockerClient, info systemtypes.Info, runtimeKind RuntimeKind) dockerClient {
var previous dockerClient
if sw, ok := a.docker.(*swappableDockerClient); ok {
previous = sw.swap(cli)
} else {
previous = a.docker
a.docker = cli
}
a.daemonHost = cli.DaemonHost()
a.runtimeVer = info.ServerVersion
if runtimeKind != a.runtime {
a.logger.Info().
Str("runtime_previous", string(a.runtime)).
Str("runtime_current", string(runtimeKind)).
Msg("Detected container runtime change during reconnect")
}
a.runtime = runtimeKind
a.supportsSwarm = runtimeKind == RuntimeDocker
if runtimeKind == RuntimePodman {
a.cfg.IncludeServices = false
a.cfg.IncludeTasks = false
}
a.cfg.Runtime = string(runtimeKind)
return previous
}

View file

@ -1581,9 +1581,39 @@ discover_single_socket_match() {
esac
}
system_docker_runtime_is_active() {
# True when a rootful/system Docker daemon is actually answering. Checked
# before any rootless socket discovery (issue #1647): a transient rootless
# Podman API socket (socket-activated for a login session) must never
# outrank a working system Docker, or the agent service gets pinned to a
# socket that disappears when the session ends.
local socket_path="${PULSE_SYSTEM_DOCKER_SOCKET:-/var/run/docker.sock}"
if command -v docker &>/dev/null; then
# Strip DOCKER_HOST/CONTAINER_HOST so only the default system daemon counts.
if (unset DOCKER_HOST CONTAINER_HOST; docker info &>/dev/null); then
return 0
fi
fi
if [[ -S "$socket_path" ]]; then
if command -v curl &>/dev/null; then
if curl -sf --max-time 3 --unix-socket "$socket_path" http://localhost/_ping &>/dev/null; then
return 0
fi
return 1
fi
# Socket exists but no way to probe it; assume the daemon owns it.
return 0
fi
return 1
}
discover_rootless_container_runtime() {
local docker_match=""
local podman_match=""
local rootless_root="${PULSE_ROOTLESS_RUNTIME_ROOT:-/run/user}"
ROOTLESS_RUNTIME_KIND=""
ROOTLESS_RUNTIME_SOCKET_PATH=""
@ -1594,8 +1624,13 @@ discover_rootless_container_runtime() {
return 1
fi
docker_match=$(discover_single_socket_match "/run/user/*/docker.sock" || true)
podman_match=$(discover_single_socket_match "/run/user/*/podman/podman.sock" || true)
if system_docker_runtime_is_active; then
# A live rootful Docker outranks any rootless socket (issue #1647).
return 1
fi
docker_match=$(discover_single_socket_match "${rootless_root}/*/docker.sock" || true)
podman_match=$(discover_single_socket_match "${rootless_root}/*/podman/podman.sock" || true)
if [[ "$docker_match" == "__AMBIGUOUS__" ]]; then
log_warn "Multiple rootless Docker sockets found under /run/user; not auto-selecting one."
@ -3003,6 +3038,9 @@ else
log_info " Command execution is off; enable only when Patrol actions or Proxmox LXC Docker inventory are needed."
fi
# discover_rootless_container_runtime returns non-zero when a rootful Docker
# daemon is live, so an explicit --enable-docker with working system Docker
# never gets the rootless podman socket pinned into the service environment.
if [[ "$ENABLE_DOCKER" == "true" ]] && discover_rootless_container_runtime; then
if [[ "$ROOTLESS_RUNTIME_KIND" == "docker" ]]; then
if ! service_env_has_key "DOCKER_HOST"; then

View file

@ -1693,9 +1693,13 @@ func TestInstallSHPersistsRootlessContainerRuntimeServiceEnvironment(t *testing.
script := string(content)
required := []string{
`ROOTLESS_RUNTIME_SOCKET_URI=""`,
`system_docker_runtime_is_active() {`,
`discover_rootless_container_runtime() {`,
`discover_single_socket_match "/run/user/*/docker.sock"`,
`discover_single_socket_match "/run/user/*/podman/podman.sock"`,
// Issue #1647: a live rootful Docker must win before any rootless
// socket discovery pins podman variables into the service environment.
`if system_docker_runtime_is_active; then`,
`discover_single_socket_match "${rootless_root}/*/docker.sock"`,
`discover_single_socket_match "${rootless_root}/*/podman/podman.sock"`,
`append_service_env "DOCKER_HOST" "$ROOTLESS_RUNTIME_SOCKET_URI"`,
`append_service_env "PULSE_DOCKER_RUNTIME" "podman"`,
`append_service_env "CONTAINER_HOST" "$ROOTLESS_RUNTIME_SOCKET_URI"`,
@ -1731,6 +1735,9 @@ func TestInstallSHServiceEnvAccumulatorRendersRootlessSocketVariables(t *testing
append_service_env "DOCKER_HOST" "unix:///run/user/1000/docker.sock"
append_service_env "XDG_RUNTIME_DIR" "/run/user/1000"
append_service_env "DOCKER_HOST" "unix:///run/user/2000/docker.sock"
append_service_env "PULSE_DOCKER_RUNTIME" "podman"
append_service_env "CONTAINER_HOST" "unix:///run/user/1000/podman/podman.sock"
append_service_env "PODMAN_HOST" "unix:///run/user/1000/podman/podman.sock"
finalize_plist_env_block
printf '%s\n---shell---\n%s\n---sed---\n%s\n---plist---\n%s\n' "$SYSTEMD_ENV_LINES" "$SHELL_EXPORT_LINES" "$SED_EXPORT_LINES" "$PLIST_ENV_BLOCK"
`
@ -1748,6 +1755,9 @@ func TestInstallSHServiceEnvAccumulatorRendersRootlessSocketVariables(t *testing
`export DOCKER_HOST="unix:///run/user/1000/docker.sock"; export XDG_RUNTIME_DIR="/run/user/1000"`,
`<key>DOCKER_HOST</key>`,
`<string>unix:///run/user/1000/docker.sock</string>`,
`Environment="PULSE_DOCKER_RUNTIME=podman"`,
`Environment="CONTAINER_HOST=unix:///run/user/1000/podman/podman.sock"`,
`Environment="PODMAN_HOST=unix:///run/user/1000/podman/podman.sock"`,
}
for _, needle := range required {
if !strings.Contains(got, needle) {
@ -1759,6 +1769,74 @@ func TestInstallSHServiceEnvAccumulatorRendersRootlessSocketVariables(t *testing
}
}
// TestInstallSHIssue1647RootfulDockerOutranksRootlessPodmanSocket pins the fix
// for issue #1647: a transient rootless Podman API socket (socket-activated for
// a login session) must not win runtime discovery while a rootful Docker daemon
// is alive, or the agent service gets podman variables pinned to a socket that
// disappears when the session ends.
func TestInstallSHIssue1647RootfulDockerOutranksRootlessPodmanSocket(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "pulse-1647-*")
if err != nil {
t.Fatalf("mktemp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
podmanDir := filepath.Join(tmpDir, "run", "user", "1000", "podman")
if err := os.MkdirAll(podmanDir, 0o755); err != nil {
t.Fatalf("mkdir podman socket dir: %v", err)
}
socketPath := filepath.Join(podmanDir, "podman.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("listen unix socket: %v", err)
}
defer listener.Close()
common := `
set -uo pipefail
log_warn() { :; }
log_info() { :; }
uname() { echo Linux; }
PULSE_ROOTLESS_RUNTIME_ROOT="` + filepath.Join(tmpDir, "run", "user") + `"
PULSE_SYSTEM_DOCKER_SOCKET="` + filepath.Join(tmpDir, "absent", "docker.sock") + `"
` + extractInstallShellFunction(t, "system_docker_runtime_is_active") + `
` + extractInstallShellFunction(t, "discover_single_socket_match") + `
` + extractInstallShellFunction(t, "discover_rootless_container_runtime") + `
`
liveDocker := common + `
docker() { return 0; }
if discover_rootless_container_runtime; then
echo "rootless podman socket must not outrank a live rootful docker" >&2
exit 1
fi
`
out, err := exec.Command("bash", "-c", liveDocker).CombinedOutput()
if err != nil {
t.Fatalf("live rootful docker case: %v\n%s", err, out)
}
deadDocker := common + `
docker() { return 1; }
if ! discover_rootless_container_runtime; then
echo "expected rootless podman discovery when no rootful docker answers" >&2
exit 1
fi
if [[ "$ROOTLESS_RUNTIME_KIND" != "podman" ]]; then
echo "kind=$ROOTLESS_RUNTIME_KIND, want podman" >&2
exit 1
fi
if [[ "$ROOTLESS_RUNTIME_SOCKET_URI" != "unix://` + socketPath + `" ]]; then
echo "uri=$ROOTLESS_RUNTIME_SOCKET_URI" >&2
exit 1
fi
`
out, err = exec.Command("bash", "-c", deadDocker).CombinedOutput()
if err != nil {
t.Fatalf("no rootful docker case: %v\n%s", err, out)
}
}
func TestInstallSHDiscoverSingleSocketMatch(t *testing.T) {
tmpDir, err := os.MkdirTemp("/tmp", "pulse-sock-*")
if err != nil {