GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo, release, raw-file and clone links now go to Gitea at git.coffeylabs.org, container images to registry.coffeylabs.org, and GitLab-style /-/blob paths to Gitea's /src/branch form. Go module paths are identifiers and stay as they are; links to GitHub issues and pull requests are left as history.
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
// Package docker drives the docker CLI. The CLI rather than the Engine API,
|
|
// because compose is the thing being driven and the CLI is how it ships: the
|
|
// deployment the tool leaves behind is one an operator manages with the same
|
|
// commands.
|
|
package docker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// Output runs docker with args and returns its standard output.
|
|
func Output(ctx context.Context, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg)
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// Pull pulls one image. Quiet, because it runs before the plan is confirmed
|
|
// and the step already says what it is fetching.
|
|
func Pull(ctx context.Context, image string) error {
|
|
_, err := Output(ctx, "pull", "--quiet", image)
|
|
return err
|
|
}
|
|
|
|
// ImageID is the local ID of an image, which is the same for two references
|
|
// only when they are the same image.
|
|
func ImageID(ctx context.Context, image string) (string, error) {
|
|
return Output(ctx, "image", "inspect", "--format", "{{.Id}}", image)
|
|
}
|
|
|
|
// ImageEnv is the value an image's configuration gives an environment
|
|
// variable, or "" when it sets none.
|
|
func ImageEnv(ctx context.Context, image, name string) (string, error) {
|
|
out, err := Output(ctx, "image", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", image)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return envValue(out, name), nil
|
|
}
|
|
|
|
func envValue(env, name string) string {
|
|
for _, line := range strings.Split(env, "\n") {
|
|
if v, ok := strings.CutPrefix(line, name+"="); ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RepoDigest is an image's by-digest reference in one repository, e.g.
|
|
// registry.coffeylabs.org/coffey-labs/ihasmail@sha256:..., which names exactly that image for
|
|
// as long as the registry keeps it.
|
|
func RepoDigest(ctx context.Context, image, repository string) (string, error) {
|
|
out, err := Output(ctx, "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", image)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, d := range strings.Fields(out) {
|
|
if strings.HasPrefix(d, repository+"@") {
|
|
return d, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s has no digest from %s", image, repository)
|
|
}
|
|
|
|
// Versions returns the engine and compose versions, which is also the check
|
|
// that both are installed and this user may use them.
|
|
func Versions(ctx context.Context) (engine, compose string, err error) {
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
return "", "", errors.New("docker is not installed, or not on PATH")
|
|
}
|
|
if engine, err = Output(ctx, "version", "--format", "{{.Server.Version}}"); err != nil {
|
|
return "", "", fmt.Errorf("cannot talk to the Docker daemon -- is it running, and may this user use it? (%w)", err)
|
|
}
|
|
if compose, err = Output(ctx, "compose", "version", "--short"); err != nil {
|
|
return engine, "", fmt.Errorf("the docker compose plugin is not installed (%w)", err)
|
|
}
|
|
return engine, compose, nil
|
|
}
|
|
|
|
// ProjectLeftovers lists containers, volumes and networks already labeled
|
|
// with a compose project name. Any at all means an earlier run of the same
|
|
// project, and its volumes would hand a "fresh" deployment an old server.
|
|
func ProjectLeftovers(ctx context.Context, project string) ([]string, error) {
|
|
filter := "label=com.docker.compose.project=" + project
|
|
var found []string
|
|
for _, kind := range []struct{ name, format string }{
|
|
{"container", "{{.Names}}"},
|
|
{"volume", "{{.Name}}"},
|
|
{"network", "{{.Name}}"},
|
|
} {
|
|
args := []string{kind.name, "ls", "--filter", filter, "--format", kind.format}
|
|
if kind.name == "container" {
|
|
args = []string{"ps", "-a", "--filter", filter, "--format", kind.format}
|
|
}
|
|
out, err := Output(ctx, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, line := range strings.Fields(out) {
|
|
found = append(found, kind.name+" "+line)
|
|
}
|
|
}
|
|
return found, nil
|
|
}
|
|
|
|
// Compose runs docker compose against one deployment directory.
|
|
type Compose struct {
|
|
Dir string
|
|
Files []string // extra -f files after compose.yaml, e.g. the bootstrap override
|
|
Env []string // added to the environment compose interpolates from
|
|
Out io.Writer
|
|
}
|
|
|
|
// Run runs a compose command with its output passed through: pulling images
|
|
// takes long enough that silence would look like a hang. Everything but a pull
|
|
// is quiet, because without a terminal compose prints each container's every
|
|
// state change twice and the tool already says what step it is on.
|
|
func (c Compose) Run(ctx context.Context, args ...string) error {
|
|
full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"}
|
|
if len(args) > 0 && args[0] != "pull" {
|
|
full = append(full, "--progress", "quiet")
|
|
}
|
|
for _, f := range c.Files {
|
|
full = append(full, "-f", f)
|
|
}
|
|
full = append(full, args...)
|
|
cmd := exec.CommandContext(ctx, "docker", full...)
|
|
cmd.Env = append(os.Environ(), c.Env...)
|
|
cmd.Stdout, cmd.Stderr = c.Out, c.Out
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Running reports whether a service has a running container.
|
|
func (c Compose) Running(ctx context.Context, service string) bool {
|
|
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
|
|
"ps", "--status", "running", "--services")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, s := range strings.Fields(out) {
|
|
if s == service {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Logs returns the last lines of one service's log, for a failure report.
|
|
func (c Compose) Logs(ctx context.Context, service string, lines int) string {
|
|
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
|
|
"logs", "--no-color", "--tail", fmt.Sprint(lines), service)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SystemCABundle reads the CA bundle out of an image, so a private CA can be
|
|
// added to the roots the image already trusts rather than replacing them.
|
|
func SystemCABundle(ctx context.Context, image string) ([]byte, error) {
|
|
out, err := Output(ctx, "run", "--rm", "--entrypoint", "cat", image, "/etc/ssl/certs/ca-certificates.crt")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(out + "\n"), nil
|
|
}
|