fix: enhance DockerUnavailableError to include detailed error messages and improve handling in ListContainers

This commit is contained in:
GitHub Actions
2026-02-24 22:24:38 +00:00
parent bf53712b7c
commit a9dcc007e5
6 changed files with 405 additions and 479 deletions

View File

@@ -71,10 +71,14 @@ func (h *DockerHandler) ListContainers(c *gin.Context) {
if err != nil {
var unavailableErr *services.DockerUnavailableError
if errors.As(err, &unavailableErr) {
details := unavailableErr.Details()
if details == "" {
details = "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted)."
}
log.WithFields(map[string]any{"server_id": util.SanitizeForLog(serverID), "host": util.SanitizeForLog(host), "error": util.SanitizeForLog(err.Error())}).Warn("docker unavailable")
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Docker daemon unavailable",
"details": "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted).",
"details": details,
})
return
}

View File

@@ -63,7 +63,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
gin.SetMode(gin.TestMode)
router := gin.New()
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))}
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"), "Local Docker socket is mounted but not accessible by current process")}
remoteSvc := &fakeRemoteServerService{}
h := NewDockerHandler(dockerSvc, remoteSvc)
@@ -78,7 +78,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
// Verify the new details field is included in the response
assert.Contains(t, w.Body.String(), "details")
assert.Contains(t, w.Body.String(), "Docker is running")
assert.Contains(t, w.Body.String(), "not accessible by current process")
}
func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) {

View File

@@ -7,6 +7,8 @@ import (
"net"
"net/url"
"os"
"slices"
"strconv"
"strings"
"syscall"
@@ -16,11 +18,17 @@ import (
)
type DockerUnavailableError struct {
err error
err error
details string
}
func NewDockerUnavailableError(err error) *DockerUnavailableError {
return &DockerUnavailableError{err: err}
func NewDockerUnavailableError(err error, details ...string) *DockerUnavailableError {
detailMsg := ""
if len(details) > 0 {
detailMsg = details[0]
}
return &DockerUnavailableError{err: err, details: detailMsg}
}
func (e *DockerUnavailableError) Error() string {
@@ -37,6 +45,13 @@ func (e *DockerUnavailableError) Unwrap() error {
return e.err
}
func (e *DockerUnavailableError) Details() string {
if e == nil {
return ""
}
return e.details
}
type DockerPort struct {
PrivatePort uint16 `json:"private_port"`
PublicPort uint16 `json:"public_port"`
@@ -55,8 +70,9 @@ type DockerContainer struct {
}
type DockerService struct {
client *client.Client
initErr error // Stores initialization error if Docker is unavailable
client *client.Client
initErr error // Stores initialization error if Docker is unavailable
localHost string
}
// NewDockerService creates a new Docker service instance.
@@ -64,21 +80,33 @@ type DockerService struct {
// DockerUnavailableError for all operations. This allows routes to be registered
// and provide helpful error messages to users.
func NewDockerService() *DockerService {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
envHost := strings.TrimSpace(os.Getenv("DOCKER_HOST"))
localHost := resolveLocalDockerHost()
if envHost != "" && !strings.HasPrefix(envHost, "unix://") {
logger.Log().WithFields(map[string]any{"docker_host_env": envHost, "local_host": localHost}).Info("ignoring non-unix DOCKER_HOST for local docker mode")
}
cli, err := client.NewClientWithOpts(client.WithHost(localHost), client.WithAPIVersionNegotiation())
if err != nil {
logger.Log().WithError(err).Warn("Failed to initialize Docker client - Docker features will be unavailable")
unavailableErr := NewDockerUnavailableError(err, buildLocalDockerUnavailableDetails(err, localHost))
return &DockerService{
client: nil,
initErr: err,
client: nil,
initErr: unavailableErr,
localHost: localHost,
}
}
return &DockerService{client: cli, initErr: nil}
return &DockerService{client: cli, initErr: nil, localHost: localHost}
}
func (s *DockerService) ListContainers(ctx context.Context, host string) ([]DockerContainer, error) {
// Check if Docker was available during initialization
if s.initErr != nil {
return nil, &DockerUnavailableError{err: s.initErr}
var unavailableErr *DockerUnavailableError
if errors.As(s.initErr, &unavailableErr) {
return nil, unavailableErr
}
return nil, NewDockerUnavailableError(s.initErr, buildLocalDockerUnavailableDetails(s.initErr, s.localHost))
}
var cli *client.Client
@@ -101,7 +129,10 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock
containers, err := cli.ContainerList(ctx, container.ListOptions{All: false})
if err != nil {
if isDockerConnectivityError(err) {
return nil, &DockerUnavailableError{err: err}
if host == "" || host == "local" {
return nil, NewDockerUnavailableError(err, buildLocalDockerUnavailableDetails(err, s.localHost))
}
return nil, NewDockerUnavailableError(err)
}
return nil, fmt.Errorf("failed to list containers: %w", err)
}
@@ -206,3 +237,118 @@ func isDockerConnectivityError(err error) bool {
return false
}
func resolveLocalDockerHost() string {
envHost := strings.TrimSpace(os.Getenv("DOCKER_HOST"))
if strings.HasPrefix(envHost, "unix://") {
socketPath := socketPathFromDockerHost(envHost)
if socketPath != "" {
if _, err := os.Stat(socketPath); err == nil {
return envHost
}
}
}
defaultSocketPath := "/var/run/docker.sock"
if _, err := os.Stat(defaultSocketPath); err == nil {
return "unix:///var/run/docker.sock"
}
rootlessSocketPath := fmt.Sprintf("/run/user/%d/docker.sock", os.Getuid())
if _, err := os.Stat(rootlessSocketPath); err == nil {
return "unix://" + rootlessSocketPath
}
return "unix:///var/run/docker.sock"
}
func socketPathFromDockerHost(host string) string {
trimmedHost := strings.TrimSpace(host)
if !strings.HasPrefix(trimmedHost, "unix://") {
return ""
}
return strings.TrimPrefix(trimmedHost, "unix://")
}
func buildLocalDockerUnavailableDetails(err error, localHost string) string {
socketPath := socketPathFromDockerHost(localHost)
if socketPath == "" {
socketPath = "/var/run/docker.sock"
}
uid := os.Getuid()
gid := os.Getgid()
groups, _ := os.Getgroups()
groupsStr := ""
if len(groups) > 0 {
groupValues := make([]string, 0, len(groups))
for _, groupID := range groups {
groupValues = append(groupValues, strconv.Itoa(groupID))
}
groupsStr = strings.Join(groupValues, ",")
}
if errno, ok := extractErrno(err); ok {
switch errno {
case syscall.ENOENT:
return fmt.Sprintf("Local Docker socket not found at %s (local host selector uses %s). Mount %s as read-only or read-write.", socketPath, localHost, socketPath)
case syscall.ECONNREFUSED:
return fmt.Sprintf("Docker daemon is not accepting connections at %s.", socketPath)
case syscall.EACCES, syscall.EPERM:
infoMsg, socketGID := localSocketStatSummary(socketPath)
permissionHint := ""
if socketGID >= 0 && !slices.Contains(groups, socketGID) {
permissionHint = fmt.Sprintf(" Process groups (%s) do not include socket gid %d; run container with matching supplemental group (e.g., --group-add %d).", groupsStr, socketGID, socketGID)
}
return fmt.Sprintf("Local Docker socket is mounted but not accessible by current process (uid=%d gid=%d). %s%s", uid, gid, infoMsg, permissionHint)
}
}
if errors.Is(err, os.ErrNotExist) {
return fmt.Sprintf("Local Docker socket not found at %s (local host selector uses %s).", socketPath, localHost)
}
return fmt.Sprintf("Cannot connect to local Docker via %s. Ensure Docker is running and the mounted socket permissions allow uid=%d gid=%d access.", localHost, uid, gid)
}
func extractErrno(err error) (syscall.Errno, bool) {
if err == nil {
return 0, false
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Unwrap()
}
var syscallErr *os.SyscallError
if errors.As(err, &syscallErr) {
err = syscallErr.Unwrap()
}
var opErr *net.OpError
if errors.As(err, &opErr) {
err = opErr.Unwrap()
}
var errno syscall.Errno
if errors.As(err, &errno) {
return errno, true
}
return 0, false
}
func localSocketStatSummary(socketPath string) (string, int) {
info, statErr := os.Stat(socketPath)
if statErr != nil {
return fmt.Sprintf("Socket path %s could not be stat'ed: %v.", socketPath, statErr), -1
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat == nil {
return fmt.Sprintf("Socket path %s has mode %s.", socketPath, info.Mode().String()), -1
}
return fmt.Sprintf("Socket path %s has mode %s owner uid=%d gid=%d.", socketPath, info.Mode().String(), stat.Uid, stat.Gid), int(stat.Gid)
}

View File

@@ -6,10 +6,13 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDockerService_New(t *testing.T) {
@@ -58,6 +61,10 @@ func TestDockerUnavailableError_ErrorMethods(t *testing.T) {
unwrapped := err.Unwrap()
assert.Equal(t, baseErr, unwrapped)
// Test Details()
errWithDetails := NewDockerUnavailableError(baseErr, "socket permission mismatch")
assert.Equal(t, "socket permission mismatch", errWithDetails.Details())
// Test nil receiver cases
var nilErr *DockerUnavailableError
assert.Equal(t, "docker unavailable", nilErr.Error())
@@ -67,6 +74,7 @@ func TestDockerUnavailableError_ErrorMethods(t *testing.T) {
nilBaseErr := NewDockerUnavailableError(nil)
assert.Equal(t, "docker unavailable", nilBaseErr.Error())
assert.Nil(t, nilBaseErr.Unwrap())
assert.Equal(t, "", nilBaseErr.Details())
}
func TestIsDockerConnectivityError(t *testing.T) {
@@ -165,3 +173,44 @@ func TestIsDockerConnectivityError_NetErrorTimeout(t *testing.T) {
result := isDockerConnectivityError(netErr)
assert.True(t, result, "net.Error with Timeout() should return true")
}
func TestResolveLocalDockerHost_IgnoresRemoteTCPEnv(t *testing.T) {
t.Setenv("DOCKER_HOST", "tcp://docker-proxy:2375")
host := resolveLocalDockerHost()
assert.Equal(t, "unix:///var/run/docker.sock", host)
}
func TestResolveLocalDockerHost_UsesExistingUnixSocketFromEnv(t *testing.T) {
tmpDir := t.TempDir()
socketFile := filepath.Join(tmpDir, "docker.sock")
require.NoError(t, os.WriteFile(socketFile, []byte(""), 0o600))
t.Setenv("DOCKER_HOST", "unix://"+socketFile)
host := resolveLocalDockerHost()
assert.Equal(t, "unix://"+socketFile, host)
}
func TestBuildLocalDockerUnavailableDetails_PermissionDeniedIncludesGroupHint(t *testing.T) {
err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES}
details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock")
assert.Contains(t, details, "not accessible")
assert.Contains(t, details, "uid=")
assert.Contains(t, details, "gid=")
assert.NotContains(t, strings.ToLower(details), "token")
}
func TestBuildLocalDockerUnavailableDetails_MissingSocket(t *testing.T) {
err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT}
host := "unix:///tmp/nonexistent-docker.sock"
details := buildLocalDockerUnavailableDetails(err, host)
assert.Contains(t, details, "not found")
assert.Contains(t, details, "/tmp/nonexistent-docker.sock")
assert.Contains(t, details, host)
}