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.
456 lines
14 KiB
Go
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...)
|
|
}
|