package main import ( "bytes" "context" "encoding/binary" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "regexp" "sort" "strings" "sync" "time" ) // Docker is a minimal Engine API client: plain HTTP over the daemon socket. type Docker struct { c *http.Client base string Host string } func newDocker() (*Docker, error) { host := os.Getenv("DOCKER_HOST") if host == "" { host = "unix:///var/run/docker.sock" } scheme, addr, _ := strings.Cut(host, "://") tr := &http.Transport{MaxIdleConnsPerHost: 64} d := &Docker{c: &http.Client{Transport: tr, Timeout: 60 * time.Second}, Host: host} switch scheme { case "unix": d.base = "http://docker" tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { var dl net.Dialer return dl.DialContext(ctx, "unix", addr) } case "tcp": // ponytail: no TLS; use a unix socket or an ssh tunnel if the daemon needs it. if os.Getenv("DOCKER_TLS_VERIFY") != "" { return nil, errors.New("DOCKER_TLS_VERIFY is set but TLS is not supported; tunnel the socket instead") } d.base = "http://" + addr default: return nil, fmt.Errorf("unsupported DOCKER_HOST %q (unix:// or tcp:// only)", host) } return d, nil } // do sends a request and decodes a JSON reply into out (*[]byte takes the raw body). func (d *Docker) do(method, path string, out any) error { req, err := http.NewRequest(method, d.base+path, nil) if err != nil { return err } resp, err := d.c.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { // 304 (already started/stopped) is fine var e struct{ Message string } json.NewDecoder(resp.Body).Decode(&e) if e.Message == "" { e.Message = resp.Status } return errors.New(e.Message) } switch o := out.(type) { case nil: return nil case *[]byte: *o, err = io.ReadAll(resp.Body) return err } return json.NewDecoder(resp.Body).Decode(out) } // ---- containers and their stats ---- type Container struct { ID, Name, Image, Command string State, Status string // "running", "Up 3 hours (healthy)" Project string // compose project, "" if none Created int64 Tree string // tree-view prefix, display only Group bool // pseudo row: a compose project with its containers summed HasStats bool // running or paused, stats read HasRate bool // a previous sample exists, so CPU and rates are valid CPU float64 // percent of one core Mem, MemLimit uint64 // bytes Pids, PidsLimit uint64 NetRx, NetTx float64 // bytes/s BlkR, BlkW float64 // bytes/s NetRxTot, NetTxTot uint64 BlkRTot, BlkWTot uint64 cpuNs uint64 at time.Time Online int } func (c Container) MemPct() float64 { if c.MemLimit == 0 { return 0 } return float64(c.Mem) * 100 / float64(c.MemLimit) } type Info struct { Name, ServerVersion, OperatingSystem, CgroupDriver, CgroupVersion string NCPU int MemTotal uint64 Images int } type stats struct { Read time.Time CPU struct { Usage struct { Total uint64 `json:"total_usage"` } `json:"cpu_usage"` Online int `json:"online_cpus"` } `json:"cpu_stats"` Mem struct { Usage, Limit uint64 Stats map[string]uint64 } `json:"memory_stats"` Networks map[string]struct { Rx uint64 `json:"rx_bytes"` Tx uint64 `json:"tx_bytes"` } Pids struct{ Current, Limit uint64 } `json:"pids_stats"` Blkio struct { IO []struct { Op string Value uint64 } `json:"io_service_bytes_recursive"` } `json:"blkio_stats"` } // collect lists every container and reads stats for the running ones in // parallel. prev (the last result, by ID) is only read, to compute rates. func (d *Docker) collect(prev map[string]Container) ([]Container, error) { var list []struct { ID string `json:"Id"` Names []string Image string Command string Created int64 Labels map[string]string State string Status string } if err := d.do("GET", "/containers/json?all=1", &list); err != nil { return nil, err } cs := make([]Container, len(list)) var wg sync.WaitGroup for i, l := range list { name := l.ID[:12] if len(l.Names) > 0 { name = strings.TrimPrefix(l.Names[0], "/") } cs[i] = Container{ID: l.ID, Name: name, Image: l.Image, Command: l.Command, State: l.State, Status: l.Status, Project: l.Labels["com.docker.compose.project"], Created: l.Created} if l.State != "running" && l.State != "paused" { continue } wg.Add(1) go func(c *Container) { defer wg.Done() var s stats if d.do("GET", "/containers/"+c.ID+"/stats?stream=false&one-shot=true", &s) == nil { applyStats(c, s, prev[c.ID]) } }(&cs[i]) } wg.Wait() return cs, nil } func applyStats(c *Container, s stats, p Container) { c.HasStats = true c.at = s.Read if c.at.IsZero() { c.at = time.Now() } c.cpuNs, c.Online = s.CPU.Usage.Total, s.CPU.Online // Same as `docker stats`: page cache that can be dropped doesn't count. inactive := s.Mem.Stats["inactive_file"] // cgroup v2 if v, ok := s.Mem.Stats["total_inactive_file"]; ok { inactive = v // cgroup v1 } c.Mem, c.MemLimit = s.Mem.Usage, s.Mem.Limit if inactive < c.Mem { c.Mem -= inactive } c.Pids, c.PidsLimit = s.Pids.Current, s.Pids.Limit for _, n := range s.Networks { c.NetRxTot += n.Rx c.NetTxTot += n.Tx } for _, b := range s.Blkio.IO { switch strings.ToLower(b.Op) { case "read": c.BlkRTot += b.Value case "write": c.BlkWTot += b.Value } } if !p.HasStats || p.at.IsZero() { return } dt := c.at.Sub(p.at).Seconds() if dt <= 0 { return } c.HasRate = true c.CPU = rate(c.cpuNs, p.cpuNs, dt) / 1e7 // ns/s -> percent of a core c.NetRx, c.NetTx = rate(c.NetRxTot, p.NetRxTot, dt), rate(c.NetTxTot, p.NetTxTot, dt) c.BlkR, c.BlkW = rate(c.BlkRTot, p.BlkRTot, dt), rate(c.BlkWTot, p.BlkWTot, dt) } // rate is (cur-old)/dt, or 0 when the counter went backwards (container restarted). func rate(cur, old uint64, dt float64) float64 { if cur < old { return 0 } return float64(cur-old) / dt } func (d *Docker) info() (Info, error) { var i Info err := d.do("GET", "/info", &i) return i, err } // ---- inspect ---- type Inspect struct { ID string `json:"Id"` Name string Created time.Time Path string Args []string Image string State struct { Status string Pid int ExitCode int OOMKilled bool StartedAt, FinishedAt time.Time Error string Health *struct { Status string FailingStreak int Log []struct { ExitCode int Output string } } } RestartCount int Config struct { Image, User, WorkingDir, Hostname string Tty bool Env, Entrypoint, Cmd []string Labels map[string]string } HostConfig struct { NanoCpus, CpuQuota, CpuPeriod, Memory, MemorySwap int64 PidsLimit *int64 CpusetCpus, NetworkMode string Privileged, ReadonlyRootfs bool RestartPolicy struct{ Name string } } Mounts []struct { Type, Source, Destination, Mode string RW bool } NetworkSettings struct { Ports map[string][]struct{ HostIp, HostPort string } Networks map[string]struct{ IPAddress, Gateway, MacAddress string } } } func (d *Docker) inspect(id string) (*Inspect, error) { var i Inspect if err := d.do("GET", "/containers/"+id+"/json", &i); err != nil { return nil, err } return &i, nil } // CPULimit is the container's CPU allowance in percent of one core, 0 if unlimited. func (i *Inspect) CPULimit() float64 { h := i.HostConfig switch { case h.NanoCpus > 0: return float64(h.NanoCpus) / 1e7 case h.CpuQuota > 0 && h.CpuPeriod > 0: return float64(h.CpuQuota) * 100 / float64(h.CpuPeriod) } return 0 } // details renders inspect output as the lines of the details view. func (i *Inspect) details(c Container, showEnv bool) []string { l := []string{} add := func(f string, a ...any) { l = append(l, fmt.Sprintf(f, a...)) } when := func(t time.Time) string { if t.IsZero() || t.Year() < 2000 { return "-" } return t.Local().Format("2006-01-02 15:04:05") + " (" + ago(time.Since(t)) + " ago)" } add("Name %s", strings.TrimPrefix(i.Name, "/")) add("ID %s", i.ID) add("Image %s (%s)", i.Config.Image, shortID(i.Image)) add("Command %s", strings.TrimSpace(i.Path+" "+strings.Join(i.Args, " "))) if i.Config.User != "" || i.Config.WorkingDir != "" { add("User %s workdir %s", or(i.Config.User, "root"), or(i.Config.WorkingDir, "/")) } add("Created %s", when(i.Created)) st := i.State add("State %s pid %d restarts %d policy %s", st.Status, st.Pid, i.RestartCount, or(i.HostConfig.RestartPolicy.Name, "no")) add("Started %s", when(st.StartedAt)) if st.Status != "running" { add("Finished %s exit code %d OOM killed %t", when(st.FinishedAt), st.ExitCode, st.OOMKilled) } if st.Error != "" { add("Error %s", st.Error) } if h := st.Health; h != nil { add("Health %s failing streak %d", h.Status, h.FailingStreak) if n := len(h.Log); n > 0 { add(" last check: exit %d %s", h.Log[n-1].ExitCode, oneLine(h.Log[n-1].Output)) } } add("") add("Limits") hc := i.HostConfig cpu := "unlimited" if lim := i.CPULimit(); lim > 0 { cpu = fmt.Sprintf("%.2f CPUs", lim/100) } if hc.CpusetCpus != "" { cpu += " on cpus " + hc.CpusetCpus } mem := "unlimited" if hc.Memory > 0 { mem = human(uint64(hc.Memory)) } pids := "unlimited" if hc.PidsLimit != nil && *hc.PidsLimit > 0 { pids = fmt.Sprint(*hc.PidsLimit) } add(" CPU %s memory %s pids %s privileged %t read-only root %t", cpu, mem, pids, hc.Privileged, hc.ReadonlyRootfs) if c.HasStats { add("Usage") add(" CPU %.1f%% memory %s / %s pids %d", c.CPU, human(c.Mem), human(c.MemLimit), c.Pids) add(" net rx %s tx %s block read %s write %s (since start)", human(c.NetRxTot), human(c.NetTxTot), human(c.BlkRTot), human(c.BlkWTot)) } add("") add("Network mode %s hostname %s", hc.NetworkMode, i.Config.Hostname) for _, n := range sortedKeys(i.NetworkSettings.Networks) { v := i.NetworkSettings.Networks[n] add(" %-24s ip %-16s gw %-16s mac %s", n, or(v.IPAddress, "-"), or(v.Gateway, "-"), or(v.MacAddress, "-")) } for _, p := range sortedKeys(i.NetworkSettings.Ports) { b := i.NetworkSettings.Ports[p] if len(b) == 0 { add(" port %-12s (not published)", p) } for _, h := range b { add(" port %-12s -> %s:%s", p, or(h.HostIp, "*"), h.HostPort) } } if len(i.Mounts) > 0 { add("") add("Mounts") for _, m := range i.Mounts { rw := "ro" if m.RW { rw = "rw" } add(" %-6s %s %s -> %s", m.Type, rw, m.Source, m.Destination) } } if len(i.Config.Labels) > 0 { add("") add("Labels") for _, k := range sortedKeys(i.Config.Labels) { add(" %s = %s", k, i.Config.Labels[k]) } } add("") if showEnv { add("Environment (e to hide)") for _, e := range i.Config.Env { add(" %s", e) } } else { add("Environment %d variables hidden, they may hold secrets (e to show)", len(i.Config.Env)) } return l } // ---- processes, logs, actions ---- type Top struct { Titles []string Processes [][]string } func (d *Docker) top(id string) (*Top, error) { var t Top err := d.do("GET", "/containers/"+id+"/top", &t) return &t, err } var ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07`) func (d *Docker) logs(id string, tty bool, tail int) ([]string, error) { var b []byte if err := d.do("GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1&tail=%d", id, tail), &b); err != nil { return nil, err } if !tty { b = demux(b) } s := strings.ReplaceAll(ansi.ReplaceAllString(string(b), ""), "\r", "") s = strings.ReplaceAll(s, "\t", " ") return strings.Split(strings.TrimRight(s, "\n"), "\n"), nil } // demux strips the 8-byte stream headers docker puts on non-TTY log output. func demux(b []byte) []byte { var out bytes.Buffer for len(b) >= 8 { n := int(binary.BigEndian.Uint32(b[4:8])) b = b[8:] n = min(n, len(b)) out.Write(b[:n]) b = b[n:] } return out.Bytes() } // action POSTs a lifecycle call: start, stop, restart, pause, unpause, kill?signal=X. func (d *Docker) action(id, what string) error { return d.do("POST", "/containers/"+id+"/"+what, nil) } // ---- small helpers ---- func sortedKeys[V any](m map[string]V) []string { k := make([]string, 0, len(m)) for s := range m { k = append(k, s) } sort.Strings(k) return k } func or(s, def string) string { if s == "" { return def } return s } func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } func shortID(s string) string { s = strings.TrimPrefix(s, "sha256:") if len(s) > 12 { s = s[:12] } return s } func ago(d time.Duration) string { switch { case d < time.Minute: return fmt.Sprintf("%ds", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 48*time.Hour: return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) } return fmt.Sprintf("%dd%02dh", int(d.Hours())/24, int(d.Hours())%24) }