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...) }