Files
ihasmail-oneshot/internal/docker/docker.go
T
jcoffey-dev 3fd909cba3
ci / test (pull_request) Successful in 58s
ci / release (pull_request) Skipped
Point links at git.coffeylabs.org after the move from GitHub
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.
2026-09-22 09:08:08 -07:00

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
}