Files
mesh-catalog/modules/docker-compose/cmd/docker-compose-tools/compose.go
T
jochen 45befbbd02 docker-compose: the package, and its projects as tools (hq to-be 42 phase 2.9)
Compose and nothing else, for the two workstations, where it is already
installed by hand; the runtime, buildx and the group stay the docker
module's. Nine Go tools: projects (with their directories from the
containers' labels), ps, logs, a rendered config with secret-looking values
redacted, and up, down, restart and pull by directory or name. Acts run as
jobs inside the bundle, waited on for 18 s and followed with
docker_compose_job, because an up that pulls outlasts a call. down never
removes volumes.
2026-10-04 13:02:23 +02:00

456 lines
14 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Project is one compose project as docker knows it.
type Project struct {
Name string `json:"name"`
Status string `json:"status,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
ConfigFiles []string `json:"config_files"`
Services []string `json:"services"`
Running int `json:"running"`
Containers int `json:"containers"`
}
// ProjectsAnswer is what docker_compose_projects answers.
type ProjectsAnswer struct {
Count int `json:"count"`
Projects []Project `json:"projects"`
}
// composeFiles are the names compose looks for in a directory, in its order.
var composeFiles = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
// statDir says whether a path is a directory. Tests replace it.
var statDir = func(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
// statFile says whether a path is a regular file. Tests replace it.
var statFile = func(p string) bool {
info, err := os.Stat(p)
return err == nil && info.Mode().IsRegular()
}
// docker runs one docker command and names a daemon the account cannot reach as such.
func docker(args ...string) (Result, error) {
r, err := call(Cmd{Name: "docker", Args: args})
if err != nil && strings.Contains(r.Stderr, "permission denied") && strings.Contains(r.Stderr, "docker.sock") {
return r, fmt.Errorf("the account cannot reach the container runtime's socket (permission denied): it is not in the docker group, or has not logged in since it was added. The docker module owns the group's members")
}
if err != nil && strings.Contains(r.Stderr, "Cannot connect to the Docker daemon") {
return r, fmt.Errorf("the container runtime is not running on this machine: %s", firstLine(r.Stderr))
}
return r, err
}
// Projects merges what compose lists with what the containers' labels say.
func Projects() (ProjectsAnswer, error) {
r, err := docker("compose", "ls", "--all", "--format", "json")
if err != nil {
return ProjectsAnswer{}, err
}
var listed []struct {
Name string `json:"Name"`
Status string `json:"Status"`
ConfigFiles string `json:"ConfigFiles"`
}
if s := strings.TrimSpace(r.Stdout); s != "" {
if err := json.Unmarshal([]byte(s), &listed); err != nil {
return ProjectsAnswer{}, fmt.Errorf("docker compose ls answered what is not JSON: %v", err)
}
}
by := map[string]*Project{}
get := func(name string) *Project {
if by[name] == nil {
by[name] = &Project{Name: name, ConfigFiles: []string{}, Services: []string{}}
}
return by[name]
}
for _, l := range listed {
p := get(l.Name)
p.Status = l.Status
p.ConfigFiles = splitFiles(l.ConfigFiles)
}
r, err = docker("ps", "-a", "--filter", "label=com.docker.compose.project", "--format",
`{{.Label "com.docker.compose.project"}}`+"\t"+`{{.Label "com.docker.compose.project.working_dir"}}`+"\t"+
`{{.Label "com.docker.compose.project.config_files"}}`+"\t"+`{{.Label "com.docker.compose.service"}}`+"\t{{.State}}")
if err != nil {
return ProjectsAnswer{}, err
}
services := map[string]map[string]bool{}
for _, l := range lines(r.Stdout) {
f := strings.Split(l, "\t")
if len(f) < 5 || f[0] == "" {
continue
}
p := get(f[0])
if p.WorkingDir == "" {
p.WorkingDir = f[1]
}
if len(p.ConfigFiles) == 0 {
p.ConfigFiles = splitFiles(f[2])
}
if services[f[0]] == nil {
services[f[0]] = map[string]bool{}
}
if f[3] != "" {
services[f[0]][f[3]] = true
}
p.Containers++
if f[4] == "running" {
p.Running++
}
}
out := ProjectsAnswer{Projects: []Project{}}
for name, p := range by {
for s := range services[name] {
p.Services = append(p.Services, s)
}
sort.Strings(p.Services)
if p.WorkingDir == "" && len(p.ConfigFiles) > 0 {
p.WorkingDir = filepath.Dir(p.ConfigFiles[0])
}
out.Projects = append(out.Projects, *p)
}
sort.Slice(out.Projects, func(i, k int) bool { return out.Projects[i].Name < out.Projects[k].Name })
out.Count = len(out.Projects)
return out, nil
}
func splitFiles(s string) []string {
out := []string{}
for _, f := range strings.Split(s, ",") {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
// Target is the project a tool acts on, and how compose is told which it is.
type Target struct {
Project string `json:"project,omitempty"`
Dir string `json:"dir,omitempty"`
Files []string `json:"files,omitempty"`
}
// args are compose's own options naming the target.
func (t Target) args() []string {
out := []string{"compose"}
if t.Dir != "" {
out = append(out, "--project-directory", t.Dir)
}
for _, f := range t.Files {
out = append(out, "-f", f)
}
if t.Project != "" {
out = append(out, "-p", t.Project)
}
return out
}
var projectName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// targetOf reads dir or project. A directory docker already knows a project for is that project,
// with the files it was started from; otherwise it must hold a compose file. needFiles says the
// tool reads the files (config, up, pull), so a project known only by its containers is not enough.
func targetOf(args map[string]any, needFiles bool) (Target, error) {
dir, err := optText(args, "dir", "")
if err != nil {
return Target{}, err
}
name, err := optText(args, "project", "")
if err != nil {
return Target{}, err
}
if dir == "" && name == "" {
return Target{}, fmt.Errorf("give dir, the project's directory, or project, its name")
}
if dir != "" {
if !filepath.IsAbs(dir) {
return Target{}, fmt.Errorf("dir must be an absolute path, not %q", dir)
}
dir = filepath.Clean(dir)
if !statDir(dir) {
return Target{}, fmt.Errorf("%s is not a directory on this machine", dir)
}
}
if name != "" && !projectName.MatchString(name) {
return Target{}, fmt.Errorf("%q is not a compose project name", name)
}
known, err := Projects()
if err != nil {
return Target{}, err
}
for _, p := range known.Projects {
if (dir != "" && p.WorkingDir == dir) || (dir == "" && p.Name == name) {
if name != "" && p.Name != name {
continue
}
t := Target{Project: p.Name, Dir: p.WorkingDir}
present := len(p.ConfigFiles) > 0
for _, f := range p.ConfigFiles {
present = present && statFile(f)
}
if present {
t.Files = p.ConfigFiles
} else if needFiles && !hasComposeFile(t.Dir) {
return Target{}, fmt.Errorf("project %s was started from %s, which is no longer there", p.Name, strings.Join(p.ConfigFiles, ", "))
}
return t, nil
}
}
if dir == "" {
return Target{}, fmt.Errorf("no compose project named %s is known to docker here: give dir, its directory", name)
}
if !hasComposeFile(dir) {
return Target{}, fmt.Errorf("%s holds no compose file (%s)", dir, strings.Join(composeFiles, ", "))
}
return Target{Dir: dir, Project: name}, nil
}
func hasComposeFile(dir string) bool {
for _, f := range composeFiles {
if statFile(filepath.Join(dir, f)) {
return true
}
}
return false
}
// Container is one of a project's containers.
type Container struct {
Name string `json:"name"`
Service string `json:"service"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health,omitempty"`
ExitCode int `json:"exit_code"`
Image string `json:"image"`
Ports []string `json:"ports"`
}
// PsAnswer is what docker_compose_ps answers.
type PsAnswer struct {
Target Target `json:"target"`
Containers []Container `json:"containers"`
}
// jsonObjects reads compose's JSON output, which is one array or one object per line by version.
func jsonObjects(s string, into any) error {
s = strings.TrimSpace(s)
if s == "" {
s = "[]"
}
if !strings.HasPrefix(s, "[") {
s = "[" + strings.Join(lines(s), ",") + "]"
}
return json.Unmarshal([]byte(s), into)
}
// Ps answers a project's containers.
func Ps(t Target) (PsAnswer, error) {
r, err := docker(append(t.args(), "ps", "-a", "--format", "json")...)
if err != nil {
return PsAnswer{}, err
}
var raw []struct {
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
Status string `json:"Status"`
Health string `json:"Health"`
ExitCode int `json:"ExitCode"`
Image string `json:"Image"`
Publishers []struct {
URL string `json:"URL"`
TargetPort int `json:"TargetPort"`
PublishedPort int `json:"PublishedPort"`
Protocol string `json:"Protocol"`
} `json:"Publishers"`
}
if err := jsonObjects(r.Stdout, &raw); err != nil {
return PsAnswer{}, fmt.Errorf("docker compose ps answered what is not JSON: %v", err)
}
out := PsAnswer{Target: t, Containers: []Container{}}
for _, c := range raw {
ports := []string{}
for _, p := range c.Publishers {
if p.PublishedPort == 0 {
continue
}
ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.URL, p.PublishedPort, p.TargetPort, p.Protocol))
}
out.Containers = append(out.Containers, Container{Name: c.Name, Service: c.Service, State: c.State, Status: c.Status,
Health: c.Health, ExitCode: c.ExitCode, Image: c.Image, Ports: ports})
}
return out, nil
}
// LogsAnswer is what docker_compose_logs answers.
type LogsAnswer struct {
Target Target `json:"target"`
Lines []string `json:"lines"`
Truncated bool `json:"truncated,omitempty"`
}
var since = regexp.MustCompile(`^[0-9A-Za-z:.+-]+$`)
// Logs answers a project's last lines.
func Logs(t Target, services []string, n int, from string) (LogsAnswer, error) {
args := append(t.args(), "logs", "--no-color", "--timestamps", "--tail", fmt.Sprint(n))
if from != "" {
if !since.MatchString(from) {
return LogsAnswer{}, fmt.Errorf("since %q is neither a duration nor a timestamp", from)
}
args = append(args, "--since", from)
}
for _, s := range services {
if err := plainName("service", s); err != nil {
return LogsAnswer{}, err
}
}
r, err := docker(append(args, services...)...)
if err != nil {
return LogsAnswer{}, err
}
// compose writes the containers' output on its stdout, and its own complaints on stderr.
return LogsAnswer{Target: t, Lines: lines(r.Stdout), Truncated: r.Truncated}, nil
}
// ConfigAnswer is what docker_compose_config answers.
type ConfigAnswer struct {
Target Target `json:"target"`
Services []string `json:"services"`
Redacted int `json:"redacted"`
Rendered map[string]any `json:"rendered"`
}
// secretish is a name whose value is not shown.
var secretish = regexp.MustCompile(`(?i)(pass|secret|token|key|credential|auth|private|cert|cookie|session|salt|dsn|api)`)
// redact replaces the values of secret-looking names in the maps compose renders.
func redact(v any, count *int) {
switch x := v.(type) {
case map[string]any:
for k, child := range x {
switch k {
case "environment", "args", "labels", "build_args":
if m, ok := child.(map[string]any); ok {
for name, val := range m {
if val != nil && secretish.MatchString(name) {
m[name] = "[redacted]"
*count++
}
}
continue
}
case "content":
// An inline config or secret: its content is the secret itself.
if _, ok := child.(string); ok {
x[k] = "[redacted]"
*count++
continue
}
}
redact(child, count)
}
case []any:
for _, child := range x {
redact(child, count)
}
}
}
// Config answers the rendered configuration.
func Config(t Target) (ConfigAnswer, error) {
r, err := docker(append(t.args(), "config", "--format", "json")...)
if err != nil {
return ConfigAnswer{}, err
}
var rendered map[string]any
if err := json.Unmarshal([]byte(r.Stdout), &rendered); err != nil {
return ConfigAnswer{}, fmt.Errorf("docker compose config answered what is not JSON: %v", err)
}
out := ConfigAnswer{Target: t, Services: []string{}, Rendered: rendered}
if s, ok := rendered["services"].(map[string]any); ok {
for name := range s {
out.Services = append(out.Services, name)
}
sort.Strings(out.Services)
}
redact(rendered, &out.Redacted)
return out, nil
}
// ActAnswer is what an act answers: the target and the job that carries it.
type ActAnswer struct {
Act string `json:"act"`
Target Target `json:"target"`
Job Job `json:"job"`
}
func act(name string, t Target, extra ...string) (ActAnswer, error) {
j, err := actAsJob(Cmd{Name: "docker", Args: append(append(t.args(), name), extra...)})
if err != nil {
return ActAnswer{}, err
}
return ActAnswer{Act: name, Target: t, Job: j}, nil
}
func checkServices(services []string) error {
for _, s := range services {
if err := plainName("service", s); err != nil {
return err
}
}
return nil
}
// Up brings a project up, detached.
func Up(t Target, services []string, build bool, pull string) (ActAnswer, error) {
if err := oneOf("pull", pull, "missing", "always", "never"); err != nil {
return ActAnswer{}, err
}
if err := checkServices(services); err != nil {
return ActAnswer{}, err
}
extra := []string{"--detach", "--pull", pull}
if build {
extra = append(extra, "--build")
}
return act("up", t, append(extra, services...)...)
}
// Down stops and removes a project's containers and networks, keeping its volumes.
func Down(t Target) (ActAnswer, error) {
return act("down", t)
}
// Restart restarts a project's containers.
func Restart(t Target, services []string) (ActAnswer, error) {
if err := checkServices(services); err != nil {
return ActAnswer{}, err
}
return act("restart", t, services...)
}
// Pull pulls a project's images.
func Pull(t Target, services []string) (ActAnswer, error) {
if err := checkServices(services); err != nil {
return ActAnswer{}, err
}
return act("pull", t, services...)
}