Files
teabot/internal/docker/docker.go
T
unkinben 3be3f4cc46
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fix errcheck lint findings in config, docker, and gitea
Handle Fprintf/Close/RemoveAll return values so golangci-lint passes.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-26 23:43:30 +10:00

273 lines
7.9 KiB
Go

package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
// jobScript is the entrypoint executed inside the container. It configures the
// git identity and credentials, clones the target repo, and runs Claude in
// non-interactive print mode reading the prompt from a mounted file. All inputs
// arrive via environment variables so the script itself is static.
const jobScript = `#!/usr/bin/env bash
set -euo pipefail
export HOME="${TEABOT_HOME}"
mkdir -p "$HOME/.config"
git config --global user.name "${TEABOT_GIT_NAME}"
git config --global user.email "${TEABOT_GIT_EMAIL}"
git config --global credential.helper store
git config --global init.defaultBranch main
umask 077
printf 'https://%s:%s@%s\n' "${TEABOT_GIT_USER}" "${TEABOT_TOKEN}" "${TEABOT_GIT_HOST}" > "$HOME/.git-credentials"
WORK="$HOME/work"
mkdir -p "$WORK"
cd "$WORK"
echo "teabot: cloning ${TEABOT_CLONE_URL}"
git clone --quiet "${TEABOT_CLONE_URL}" repo
cd repo
echo "teabot: starting claude session"
claude --print --dangerously-skip-permissions < /teabot/prompt.txt
`
// DockerRunner runs jobs with the local docker CLI.
type DockerRunner struct {
// DockerPath is the docker binary (default "docker").
DockerPath string
// SELinuxLabel is the volume relabel suffix. On Fedora this must be "z"
// (shared) or "Z" (private) so bind mounts are accessible under SELinux.
SELinuxLabel string
// WorkRoot is where per-job scratch directories are created
// (default os.TempDir()).
WorkRoot string
// Stdout receives streamed container output (nil discards the stream; the
// captured output is always returned in Result regardless).
Stdout io.Writer
}
// NewDockerRunner builds a runner with sensible defaults for this host.
func NewDockerRunner() *DockerRunner {
return &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
}
// Run implements Runner.
func (r *DockerRunner) Run(ctx context.Context, job Job) (Result, error) {
if job.Image == "" {
return Result{}, errors.New("job image is empty")
}
jobDir, err := r.prepareJobDir(job)
if err != nil {
return Result{}, err
}
defer func() { _ = os.RemoveAll(jobDir) }()
if job.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, job.Timeout)
defer cancel()
}
args := r.buildArgs(job, jobDir)
start := time.Now()
cmd := exec.CommandContext(ctx, r.DockerPath, args...)
var buf bytes.Buffer
if r.Stdout != nil {
cmd.Stdout = io.MultiWriter(&buf, r.Stdout)
cmd.Stderr = io.MultiWriter(&buf, r.Stdout)
} else {
cmd.Stdout = &buf
cmd.Stderr = &buf
}
runErr := cmd.Run()
res := Result{Output: buf.String(), Duration: time.Since(start)}
if ctx.Err() == context.DeadlineExceeded {
res.TimedOut = true
res.ExitCode = -1
return res, fmt.Errorf("job %q timed out after %s", job.Label, job.Timeout)
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, nil
}
if runErr != nil {
return res, fmt.Errorf("running docker: %w", runErr)
}
res.ExitCode = 0
return res, nil
}
// prepareJobDir materialises the mounted files for a job: the prompt, the job
// script, a private copy of the Claude config (so token refreshes never touch
// the host's real config), and a copy of the personality's tea config.
func (r *DockerRunner) prepareJobDir(job Job) (string, error) {
root := r.WorkRoot
if root == "" {
root = os.TempDir()
}
if err := os.MkdirAll(root, 0o700); err != nil {
return "", err
}
jobDir, err := os.MkdirTemp(root, "teabot-job-")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "prompt.txt"), []byte(job.Prompt), 0o600); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(jobDir, "job.sh"), []byte(jobScript), 0o700); err != nil {
return "", err
}
// Copy the Claude config dir so the container can refresh subscription
// tokens without mutating the host's ~/.claude.
if job.ClaudeConfigDir != "" {
if _, statErr := os.Stat(job.ClaudeConfigDir); statErr == nil {
if err := copyTree(job.ClaudeConfigDir, filepath.Join(jobDir, "claude")); err != nil {
return "", fmt.Errorf("copying claude config: %w", err)
}
}
}
// Copy the personality's tea config to the mounted XDG location.
if job.TeaConfigPath != "" {
teaDir := filepath.Join(jobDir, "tea")
if err := os.MkdirAll(teaDir, 0o700); err != nil {
return "", err
}
if err := copyFile(job.TeaConfigPath, filepath.Join(teaDir, "config.yml")); err != nil {
return "", fmt.Errorf("copying tea config: %w", err)
}
}
return jobDir, nil
}
// buildArgs assembles the full `docker run` argument list for a job. It is pure
// (given jobDir) so it can be unit-tested without invoking docker.
func (r *DockerRunner) buildArgs(job Job, jobDir string) []string {
home := job.ContainerHome
if home == "" {
home = "/home/agent"
}
label := r.SELinuxLabel
mount := func(host, container string, ro bool) string {
spec := host + ":" + container
if ro {
spec += ":ro"
if label != "" {
spec += "," + label
}
} else if label != "" {
spec += ":" + label
}
return spec
}
args := []string{"run", "--rm", "--entrypoint", "/bin/bash"}
// Mount the job scratch (prompt + script) read-only.
args = append(args, "-v", mount(filepath.Join(jobDir, "prompt.txt"), "/teabot/prompt.txt", true))
args = append(args, "-v", mount(filepath.Join(jobDir, "job.sh"), "/teabot/job.sh", true))
// Mount the private Claude config copy read-write (token refresh).
if job.ClaudeConfigDir != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "claude"), home+"/.claude", false))
}
// Mount the tea config read-only at the XDG path.
if job.TeaConfigPath != "" {
args = append(args, "-v", mount(filepath.Join(jobDir, "tea", "config.yml"), home+"/.config/tea/config.yml", true))
}
// Environment consumed by job.sh.
env := map[string]string{
"TEABOT_HOME": home,
"TEABOT_GIT_NAME": job.GitName,
"TEABOT_GIT_EMAIL": job.GitEmail,
"TEABOT_GIT_USER": job.GitUser,
"TEABOT_TOKEN": job.Token,
"TEABOT_GIT_HOST": job.GitHost,
"TEABOT_CLONE_URL": job.CloneURL,
"XDG_CONFIG_HOME": home + "/.config",
}
if job.AnthropicAPIKey != "" {
env["ANTHROPIC_API_KEY"] = job.AnthropicAPIKey
}
if job.AnthropicBaseURL != "" {
env["ANTHROPIC_BASE_URL"] = job.AnthropicBaseURL
}
for _, k := range sortedKeys(env) {
args = append(args, "-e", k+"="+env[k])
}
args = append(args, job.Image, "/teabot/job.sh")
return args
}
// sortedKeys returns map keys in deterministic order (stable docker args ease
// testing and logging).
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// simple insertion sort avoids importing sort for a tiny map
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
return keys
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
// Close explicitly (not deferred) so a flush error surfaces to the caller.
return out.Close()
}
// copyTree recursively copies a directory tree (regular files, dirs, and
// symlink targets are dereferenced by copyFile via Open).
func copyTree(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o700)
}
if !info.Mode().IsRegular() {
return nil // skip sockets/devices; symlinks are followed by Walk's lstat -> handle below
}
return copyFile(path, target)
})
}