lazydocker/pkg/commands/docker.go
DeiAsPie cb517d9e09
Implement proper detection with Podman support and enhance error handling across platforms
Signed-off-by: DeiAsPie <93835541+DeiAsPie@users.noreply.github.com>
2025-12-27 13:19:14 +05:30

364 lines
10 KiB
Go

package commands
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
ogLog "log"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/imdario/mergo"
"github.com/jesseduffield/lazydocker/pkg/commands/ssh"
"github.com/jesseduffield/lazydocker/pkg/config"
"github.com/jesseduffield/lazydocker/pkg/i18n"
"github.com/jesseduffield/lazydocker/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
const (
dockerHostEnvKey = "DOCKER_HOST"
)
// DockerCommand is our main docker interface
type DockerCommand struct {
Log *logrus.Entry
OSCommand *OSCommand
Tr *i18n.TranslationSet
Config *config.AppConfig
Client *client.Client
InDockerComposeProject bool
ErrorChan chan error
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex
Closers []io.Closer
}
var _ io.Closer = &DockerCommand{}
// LimitedDockerCommand is a stripped-down DockerCommand with just the methods the container/service/image might need
type LimitedDockerCommand interface {
NewCommandObject(CommandObject) CommandObject
}
// CommandObject is what we pass to our template resolvers when we are running a custom command. We do not guarantee that all fields will be populated: just the ones that make sense for the current context
type CommandObject struct {
DockerCompose string
Service *Service
Container *Container
Image *Image
Volume *Volume
Network *Network
}
// NewCommandObject takes a command object and returns a default command object with the passed command object merged in
func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
defaultObj := CommandObject{DockerCompose: c.Config.UserConfig.CommandTemplates.DockerCompose}
_ = mergo.Merge(&defaultObj, obj)
return defaultObj
}
// NewDockerCommand it runs docker commands
func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) {
// Use new detection with caching and validation
dockerHost, runtime, err := DetectDockerHost(log)
if err != nil {
return nil, fmt.Errorf("cannot connect to container runtime: %w", err)
}
log.Infof("Detected %s runtime at %s", runtime, dockerHost)
// NOTE: Inject the determined docker host to the environment. This allows the
// `SSHHandler.HandleSSHDockerHost()` to create a local unix socket tunneled
// over SSH to the specified ssh host.
if strings.HasPrefix(dockerHost, "ssh://") {
os.Setenv(dockerHostEnvKey, dockerHost)
}
tunnelCloser, err := ssh.NewSSHHandler(osCommand).HandleSSHDockerHost()
if err != nil {
ogLog.Fatal(err)
}
// Retrieve the docker host from the environment which could have been set by
// the `SSHHandler.HandleSSHDockerHost()` and override `dockerHost`.
dockerHostFromEnv := os.Getenv(dockerHostEnvKey)
if dockerHostFromEnv != "" {
dockerHost = dockerHostFromEnv
}
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation(), client.WithHost(dockerHost))
if err != nil {
ogLog.Fatal(err)
}
dockerCommand := &DockerCommand{
Log: log,
OSCommand: osCommand,
Tr: tr,
Config: config,
Client: cli,
ErrorChan: errorChan,
InDockerComposeProject: true,
Closers: []io.Closer{tunnelCloser},
}
dockerCommand.setDockerComposeCommand(config)
err = osCommand.RunCommand(
utils.ApplyTemplate(
config.UserConfig.CommandTemplates.CheckDockerComposeConfig,
dockerCommand.NewCommandObject(CommandObject{}),
),
)
if err != nil {
dockerCommand.InDockerComposeProject = false
log.Warn(err.Error())
}
return dockerCommand, nil
}
func (c *DockerCommand) setDockerComposeCommand(config *config.AppConfig) {
if config.UserConfig.CommandTemplates.DockerCompose != "docker compose" {
return
}
// it's possible that a user is still using docker-compose, so we'll check if 'docker comopose' is available, and if not, we'll fall back to 'docker-compose'
err := c.OSCommand.RunCommand("docker compose version")
if err != nil {
config.UserConfig.CommandTemplates.DockerCompose = "docker-compose"
}
}
func (c *DockerCommand) Close() error {
return utils.CloseMany(c.Closers)
}
func (c *DockerCommand) CreateClientStatMonitor(container *Container) {
container.MonitoringStats = true
stream, err := c.Client.ContainerStats(context.Background(), container.ID, true)
if err != nil {
// not creating error panel because if we've disconnected from docker we'll
// have already created an error panel
c.Log.Error(err)
container.MonitoringStats = false
return
}
defer stream.Body.Close()
scanner := bufio.NewScanner(stream.Body)
for scanner.Scan() {
data := scanner.Bytes()
var stats ContainerStats
_ = json.Unmarshal(data, &stats)
recordedStats := &RecordedStats{
ClientStats: stats,
DerivedStats: DerivedStats{
CPUPercentage: stats.CalculateContainerCPUPercentage(),
MemoryPercentage: stats.CalculateContainerMemoryUsage(),
},
RecordedAt: time.Now(),
}
container.appendStats(recordedStats, c.Config.UserConfig.Stats.MaxDuration)
}
container.MonitoringStats = false
}
func (c *DockerCommand) RefreshContainersAndServices(currentServices []*Service, currentContainers []*Container) ([]*Container, []*Service, error) {
c.ServiceMutex.Lock()
defer c.ServiceMutex.Unlock()
containers, err := c.GetContainers(currentContainers)
if err != nil {
return nil, nil, err
}
var services []*Service
// we only need to get these services once because they won't change in the runtime of the program
if currentServices != nil {
services = currentServices
} else {
services, err = c.GetServices()
if err != nil {
return nil, nil, err
}
}
c.assignContainersToServices(containers, services)
return containers, services, nil
}
func (c *DockerCommand) assignContainersToServices(containers []*Container, services []*Service) {
L:
for _, service := range services {
for _, ctr := range containers {
if !ctr.OneOff && ctr.ServiceName == service.Name {
service.Container = ctr
continue L
}
}
service.Container = nil
}
}
// GetContainers gets the docker containers
func (c *DockerCommand) GetContainers(existingContainers []*Container) ([]*Container, error) {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
containers, err := c.Client.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return nil, err
}
ownContainers := make([]*Container, len(containers))
for i, ctr := range containers {
var newContainer *Container
// check if we already have data stored against the container
for _, existingContainer := range existingContainers {
if existingContainer.ID == ctr.ID {
newContainer = existingContainer
break
}
}
// initialise the container if it's completely new
if newContainer == nil {
newContainer = &Container{
ID: ctr.ID,
Client: c.Client,
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
Tr: c.Tr,
}
}
newContainer.Container = ctr
// if the container is made with a name label we will use that
if name, ok := ctr.Labels["name"]; ok {
newContainer.Name = name
} else {
if len(ctr.Names) > 0 {
newContainer.Name = strings.TrimLeft(ctr.Names[0], "/")
} else {
newContainer.Name = ctr.ID
}
}
newContainer.ServiceName = ctr.Labels["com.docker.compose.service"]
newContainer.ProjectName = ctr.Labels["com.docker.compose.project"]
newContainer.ContainerNumber = ctr.Labels["com.docker.compose.container"]
newContainer.OneOff = ctr.Labels["com.docker.compose.oneoff"] == "True"
ownContainers[i] = newContainer
}
c.SetContainerDetails(ownContainers)
return ownContainers, nil
}
// GetServices gets services
func (c *DockerCommand) GetServices() ([]*Service, error) {
if !c.InDockerComposeProject {
return nil, nil
}
composeCommand := c.Config.UserConfig.CommandTemplates.DockerCompose
output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand))
if err != nil {
return nil, err
}
// output looks like:
// service1
// service2
lines := utils.SplitLines(output)
services := make([]*Service, len(lines))
for i, str := range lines {
services[i] = &Service{
Name: str,
ID: str,
OSCommand: c.OSCommand,
Log: c.Log,
DockerCommand: c,
}
}
return services, nil
}
func (c *DockerCommand) RefreshContainerDetails(containers []*Container) error {
c.ContainerMutex.Lock()
defer c.ContainerMutex.Unlock()
c.SetContainerDetails(containers)
return nil
}
// Attaches the details returned from docker inspect to each of the containers
// this contains a bit more info than what you get from the go-docker client
func (c *DockerCommand) SetContainerDetails(containers []*Container) {
wg := sync.WaitGroup{}
for _, ctr := range containers {
ctr := ctr
wg.Add(1)
go func() {
details, err := c.Client.ContainerInspect(context.Background(), ctr.ID)
if err != nil {
c.Log.Error(err)
} else {
ctr.Details = details
}
wg.Done()
}()
}
wg.Wait()
}
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose
func (c *DockerCommand) ViewAllLogs() (*exec.Cmd, error) {
cmd := c.OSCommand.ExecutableFromString(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs,
c.NewCommandObject(CommandObject{}),
),
)
c.OSCommand.PrepareForChildren(cmd)
return cmd, nil
}
// DockerComposeConfig returns the result of 'docker-compose config'
func (c *DockerCommand) DockerComposeConfig() string {
output, err := c.OSCommand.RunCommandWithOutput(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig,
c.NewCommandObject(CommandObject{}),
),
)
if err != nil {
output = err.Error()
}
return output
}