fix: enhance DockerUnavailableError to include detailed error messages and improve handling in ListContainers
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user