3be3f4cc46
Handle Fprintf/Close/RemoveAll return values so golangci-lint passes. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
273 lines
7.9 KiB
Go
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)
|
|
})
|
|
}
|