mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
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:
parent
07a0d84464
commit
e734bd0097
7 changed files with 541 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
213
internal/dockeragent/runtime_reconnect.go
Normal file
213
internal/dockeragent/runtime_reconnect.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue