751 lines
24 KiB
Go
751 lines
24 KiB
Go
package main
|
|
|
|
// docker.go — worker container spawner (clone → ensure image → run).
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/docker/docker/api/types/build"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/filters"
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/docker/docker/api/types/volume"
|
|
"github.com/docker/docker/client"
|
|
)
|
|
|
|
// Spawn job states and docker wiring constants.
|
|
const (
|
|
stateCloning string = "cloning"
|
|
stateBuilding string = "building"
|
|
stateCreating string = "creating"
|
|
stateRunning string = "running"
|
|
stateError string = "error"
|
|
|
|
imageRefWorker string = "lvmh-worker:latest"
|
|
labelSession string = "lvmh.session"
|
|
volumeRepoPrefix string = "lvmh-repo-"
|
|
volumeSessions string = "lvmh-sessions"
|
|
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
|
cacheMount string = "/root/.pi/agent/cache"
|
|
authMountTarget string = "/root/.pi/agent/auth.json"
|
|
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
|
|
envSecretsDir string = "LVMH_SECRETS_DIR"
|
|
envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR"
|
|
envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR"
|
|
sshMountTarget string = "/root/.ssh"
|
|
gitconfigMountTarget string = "/root/.gitconfig"
|
|
workspaceMount string = "/workspace"
|
|
sessionsMount string = "/pi-sessions"
|
|
|
|
envWorkerDockerfile string = "LVMH_WORKER_DOCKERFILE"
|
|
defaultDockerfile string = "/app/build/docker/worker.Dockerfile"
|
|
envWorkerContext string = "LVMH_WORKER_CONTEXT"
|
|
envRepoDir string = "LVMH_REPO_DIR"
|
|
defaultRepoDir string = "/data/repos"
|
|
envContainerLVMHURL string = "LVMH_CONTAINER_LVMH_URL"
|
|
defaultContainerURL string = "ws://lvmh:8686/agent/ws"
|
|
envContainerNetwork string = "LVMH_CONTAINER_NETWORK"
|
|
defaultNetwork string = "lvmh-net"
|
|
envProviderAPIKey string = "ZAI_RENAUD_API_KEY"
|
|
envLVMHSessionID string = "LVMH_SESSION_ID"
|
|
envLVMHAgent string = "LVMH_AGENT"
|
|
envLVMHRepo string = "LVMH_REPO"
|
|
stopTimeoutSeconds int = 10
|
|
buildContextReadLimit int64 = 64 << 20
|
|
maxSpawnJobs int = 50 // jobs map pruned to this many entries
|
|
)
|
|
|
|
var errNoContainer = errors.New("no container for session")
|
|
|
|
var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
|
|
|
|
// branchRe guards spawn branch names against git option injection.
|
|
var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
|
|
|
|
// validRepoPath accepts "group/project" style paths (at least two segments).
|
|
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
|
|
|
|
// repoSlug maps a repo path to a collision-free filesystem/volume name:
|
|
// the `/`→`--` form alone is ambiguous ("a/b--c" vs "a/b/c"), so the
|
|
// first 6 hex chars of the sha256 of the full path disambiguate. Changing
|
|
// this changes existing volume names (one-time fresh clone per repo).
|
|
func repoSlug(repo string) string {
|
|
sum := sha256.Sum256([]byte(repo))
|
|
return strings.ReplaceAll(repo, "/", "--") + "-" + hex.EncodeToString(sum[:3])
|
|
}
|
|
|
|
// newUUID returns a random RFC 4122 v4 UUID string.
|
|
func newUUID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand failure is fatal-grade; never expected in practice.
|
|
panic("newUUID: crypto/rand unavailable: " + err.Error())
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
dst := make([]byte, 36)
|
|
hex.Encode(dst[0:8], b[0:4])
|
|
dst[8] = '-'
|
|
hex.Encode(dst[9:13], b[4:6])
|
|
dst[13] = '-'
|
|
hex.Encode(dst[14:18], b[6:8])
|
|
dst[18] = '-'
|
|
hex.Encode(dst[19:23], b[8:10])
|
|
dst[23] = '-'
|
|
hex.Encode(dst[24:36], b[10:16])
|
|
return string(dst)
|
|
}
|
|
|
|
// SpawnJob is one async spawn pipeline run, exposed via /api/spawn/status.
|
|
type SpawnJob struct {
|
|
Repo string `json:"repo"`
|
|
State string `json:"state"`
|
|
ContainerID string `json:"containerId"`
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
UpdatedAt int64 `json:"-"`
|
|
}
|
|
|
|
// SpawnResult is the immediate POST /api/spawn response. ImageUsed is the
|
|
// image the job will create the container from (registry lookup; the build
|
|
// itself may still fail on a missing custom image).
|
|
type SpawnResult struct {
|
|
SessionID string `json:"sessionId"`
|
|
ImageUsed string `json:"imageUsed"`
|
|
}
|
|
|
|
// Spawner owns the docker client, per-repo clone serialization and job state.
|
|
type Spawner struct {
|
|
ctx context.Context // base ctx for async jobs (cancelled on shutdown)
|
|
store *Store
|
|
hub *Hub
|
|
cli *client.Client
|
|
baseURL string // gitlab base, for clone URLs
|
|
|
|
reposDir string
|
|
dockerfile string
|
|
controlDockerfile string
|
|
buildContext string
|
|
containerURL string
|
|
network string
|
|
|
|
mu sync.Mutex
|
|
jobs map[string]*SpawnJob // keyed by sessionId
|
|
jobOrder []string // insertion order of jobs, for pruning
|
|
slugLocks map[string]*sync.Mutex
|
|
}
|
|
|
|
func NewSpawner(ctx context.Context, store *Store, hub *Hub, baseURL string) (*Spawner, error) {
|
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docker client: %w", err)
|
|
}
|
|
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
|
|
return &Spawner{
|
|
ctx: ctx,
|
|
store: store,
|
|
hub: hub,
|
|
cli: cli,
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
reposDir: envOr(envRepoDir, defaultRepoDir),
|
|
dockerfile: dockerfile,
|
|
controlDockerfile: envOr(envControlDockerfile, defaultControlDockerfile),
|
|
buildContext: envOr(envWorkerContext, filepath.Dir(filepath.Dir(dockerfile))),
|
|
containerURL: envOr(envContainerLVMHURL, defaultContainerURL),
|
|
network: envOr(envContainerNetwork, defaultNetwork),
|
|
jobs: make(map[string]*SpawnJob),
|
|
slugLocks: make(map[string]*sync.Mutex),
|
|
}, nil
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// JobsSnapshot returns current spawn jobs for /api/spawn/status and /ws pushes.
|
|
func (s *Spawner) JobsSnapshot() []SpawnJob {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]SpawnJob, 0, len(s.jobs))
|
|
for _, j := range s.jobs {
|
|
out = append(out, *j)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
|
|
s.mu.Lock()
|
|
j, ok := s.jobs[sessionID]
|
|
if !ok {
|
|
j = &SpawnJob{SessionID: sessionID, Repo: repo}
|
|
s.jobs[sessionID] = j
|
|
s.jobOrder = append(s.jobOrder, sessionID)
|
|
if len(s.jobOrder) > maxSpawnJobs {
|
|
oldest := s.jobOrder[0]
|
|
copy(s.jobOrder, s.jobOrder[1:])
|
|
s.jobOrder = s.jobOrder[:len(s.jobOrder)-1]
|
|
delete(s.jobs, oldest)
|
|
}
|
|
}
|
|
j.Repo = repo
|
|
j.State = state
|
|
j.ContainerID = containerID
|
|
j.Message = message
|
|
j.UpdatedAt = time.Now().UnixMilli()
|
|
s.mu.Unlock()
|
|
s.hub.BroadcastSpawnStatus()
|
|
}
|
|
|
|
// deleteJob drops a session's job from the map and the insertion order.
|
|
func (s *Spawner) deleteJob(sessionID string) {
|
|
s.mu.Lock()
|
|
if _, ok := s.jobs[sessionID]; !ok {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
delete(s.jobs, sessionID)
|
|
for i, id := range s.jobOrder {
|
|
if id == sessionID {
|
|
s.jobOrder = append(s.jobOrder[:i], s.jobOrder[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Start launches the async spawn pipeline and returns the new sessionId.
|
|
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
|
|
exists, err := s.imageExists(ctx)
|
|
if err != nil {
|
|
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
|
}
|
|
if !exists {
|
|
if _, statErr := os.Stat(s.dockerfile); statErr != nil {
|
|
return SpawnResult{}, fmt.Errorf(
|
|
"image %s not found and no worker Dockerfile at %s (set %s or run `make worker-image`)",
|
|
imageRefWorker, s.dockerfile, envWorkerDockerfile)
|
|
}
|
|
}
|
|
sessionID := newUUID()
|
|
s.setJob(sessionID, repo, stateCloning, "", "")
|
|
go s.runJob(repo, branch, sessionID)
|
|
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
|
|
}
|
|
|
|
// resolveImage returns the image a repo's spawns use: the registered custom
|
|
// image when present, else the default worker image.
|
|
func (s *Spawner) resolveImage(repo string) string {
|
|
if custom, ok, _ := s.store.GetRepoImage(repo); ok && custom != "" {
|
|
return custom
|
|
}
|
|
return imageRefWorker
|
|
}
|
|
|
|
func (s *Spawner) imageExists(ctx context.Context) (bool, error) {
|
|
return s.imageRefExists(ctx, imageRefWorker)
|
|
}
|
|
|
|
// imageRefExists reports whether ref is present in the local docker store.
|
|
func (s *Spawner) imageRefExists(ctx context.Context, ref string) (bool, error) {
|
|
args := filters.NewArgs(filters.Arg("reference", ref))
|
|
summaries, err := s.cli.ImageList(ctx, image.ListOptions{Filters: args})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return len(summaries) > 0, nil
|
|
}
|
|
|
|
func (s *Spawner) slugLock(slug string) *sync.Mutex {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
l, ok := s.slugLocks[slug]
|
|
if !ok {
|
|
l = &sync.Mutex{}
|
|
s.slugLocks[slug] = l
|
|
}
|
|
return l
|
|
}
|
|
|
|
// runJob is the async clone→build→create→start pipeline.
|
|
func (s *Spawner) runJob(repo, branch, sessionID string) {
|
|
slug := repoSlug(repo)
|
|
lock := s.slugLock(slug)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
|
return
|
|
}
|
|
s.setJob(sessionID, repo, stateBuilding, "", "")
|
|
if err := s.ensureImage(s.ctx); err != nil {
|
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
|
return
|
|
}
|
|
s.setJob(sessionID, repo, stateCreating, "", "")
|
|
containerID, image, err := s.createAndStart(s.ctx, repo, slug, sessionID)
|
|
if err != nil {
|
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
|
return
|
|
}
|
|
if err := s.store.UpsertContainer(sessionID, containerID, repo); err != nil {
|
|
s.setJob(sessionID, repo, stateError, containerID, "container started but not persisted: "+err.Error())
|
|
return
|
|
}
|
|
s.setJob(sessionID, repo, stateRunning, containerID, "image "+image)
|
|
}
|
|
|
|
// cloneOrUpdate clones the repo into reposDir/<slug> or fast-forwards it.
|
|
// Credentials never appear in the clone URL (which git persists into
|
|
// .git/config); auth is passed per-invocation via http.extraHeader.
|
|
func (s *Spawner) cloneOrUpdate(ctx context.Context, repo, branch, slug string) error {
|
|
dir := filepath.Join(s.reposDir, slug)
|
|
cloneURL, err := s.cloneURL(repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
|
|
auth := s.gitAuthArgs()
|
|
if branch != "" && gitBranch(ctx, dir) != branch {
|
|
if err := gitRun(ctx, dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
|
|
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
|
|
}
|
|
if err := gitRun(ctx, dir, "checkout", branch); err != nil {
|
|
return fmt.Errorf("git checkout %s: %w", repo, err)
|
|
}
|
|
}
|
|
if err := gitRun(ctx, dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
|
|
return fmt.Errorf("git pull %s: %w", repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
args := append(s.gitAuthArgs(), "clone")
|
|
if branch != "" {
|
|
args = append(args, "--branch", branch)
|
|
}
|
|
args = append(args, "--", cloneURL, dir)
|
|
if err := gitRun(ctx, "", args...); err != nil {
|
|
return fmt.Errorf("git clone %s: %w", repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// giteaTokenEnv returns LVMH_GITEA_TOKEN=<pat> when a PAT is stored.
|
|
func giteaTokenEnv(store *Store) string {
|
|
if pat, ok, _ := store.GetSetting(settingGitLabToken); ok && pat != "" {
|
|
return "LVMH_GITEA_TOKEN=" + pat
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// workerEnv assembles the env for a spawned worker container.
|
|
func workerEnv(s *Spawner, repo, sessionID string) []string {
|
|
env := []string{
|
|
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
|
envToken + "=" + daemonToken,
|
|
"LVMH_URL=" + s.containerURL,
|
|
envLVMHSessionID + "=" + sessionID,
|
|
envLVMHAgent + "=1",
|
|
// other provider keys (empty ones are harmless)
|
|
"OPENAI_API_KEY=" + os.Getenv("OPENAI_API_KEY"),
|
|
"GEMINI_API_KEY=" + os.Getenv("GEMINI_API_KEY"),
|
|
"DEEPSEEK_KEY=" + os.Getenv("DEEPSEEK_KEY"),
|
|
"ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"),
|
|
"PLAYWRIGHT_BROWSERS_PATH=/pw-browsers",
|
|
envLVMHRepo + "=" + repo,
|
|
}
|
|
// Gitea token (write scope) so agents can push and open PRs.
|
|
if e := giteaTokenEnv(s.store); e != "" {
|
|
env = append(env, e)
|
|
}
|
|
return env
|
|
}
|
|
|
|
// cloneURL builds the clean https clone URL (never credential-bearing).
|
|
func (s *Spawner) cloneURL(repo string) (string, error) {
|
|
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return u.String(), nil
|
|
}
|
|
|
|
// gitAuthArgs returns per-invocation git args carrying the stored PAT via
|
|
// an HTTP header, or nil when no PAT is stored.
|
|
func (s *Spawner) gitAuthArgs() []string {
|
|
token, ok, _ := s.store.GetSetting(settingGitLabToken)
|
|
if !ok || token == "" {
|
|
return nil
|
|
}
|
|
return []string{"-c", "http.extraHeader=Authorization: token " + token}
|
|
}
|
|
|
|
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
|
|
func gitBranch(ctx context.Context, dir string) string {
|
|
out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func gitRun(ctx context.Context, dir string, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(out.String())
|
|
if len(msg) > 500 {
|
|
msg = msg[len(msg)-500:]
|
|
}
|
|
if msg != "" {
|
|
return fmt.Errorf("%w: %s", err, msg)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureImage builds lvmh-worker:latest when missing.
|
|
func (s *Spawner) ensureImage(ctx context.Context) error {
|
|
exists, err := s.imageExists(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(s.dockerfile); err != nil {
|
|
return fmt.Errorf("worker Dockerfile missing at %s: %w", s.dockerfile, err)
|
|
}
|
|
return s.buildImage(ctx, s.buildContext, s.dockerfile, imageRefWorker)
|
|
}
|
|
|
|
// buildImage streams a tar of buildContext into docker ImageBuild, building
|
|
// tag from dockerfile (relative to buildContext). Shared by the worker and
|
|
// ops image paths.
|
|
func (s *Spawner) buildImage(ctx context.Context, buildContext, dockerfile, tag string) error {
|
|
relDockerfile, err := filepath.Rel(buildContext, dockerfile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pr, pw := io.Pipe()
|
|
tarDone := make(chan error, 1)
|
|
go func() {
|
|
err := tarDir(pw, buildContext)
|
|
_ = pw.CloseWithError(err)
|
|
tarDone <- err
|
|
}()
|
|
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
|
|
Tags: []string{tag},
|
|
Dockerfile: relDockerfile,
|
|
Remove: true,
|
|
})
|
|
if buildErr != nil {
|
|
_ = pr.CloseWithError(buildErr) // unblock the tar goroutine
|
|
}
|
|
if tarErr := <-tarDone; tarErr != nil {
|
|
if resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
return fmt.Errorf("build context %s: %w", buildContext, tarErr)
|
|
}
|
|
if buildErr != nil {
|
|
return fmt.Errorf("docker build: %w", buildErr)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
|
|
if msg := extractBuildError(body); msg != "" {
|
|
return fmt.Errorf("docker build failed: %s", msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractBuildError scans the build output stream for an "error" field.
|
|
func extractBuildError(body []byte) string {
|
|
for _, line := range bytes.Split(body, []byte("\n")) {
|
|
var msg struct {
|
|
Error string `json:"error"`
|
|
ErrorDetail struct {
|
|
Message string `json:"message"`
|
|
} `json:"errorDetail"`
|
|
}
|
|
if json.Unmarshal(line, &msg) != nil {
|
|
continue
|
|
}
|
|
if msg.Error != "" || msg.ErrorDetail.Message != "" {
|
|
if msg.ErrorDetail.Message != "" {
|
|
return msg.ErrorDetail.Message
|
|
}
|
|
return msg.Error
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// createAndStart provisions volumes, creates and starts the worker container.
|
|
// A repo-registered custom image (see /api/repos) overrides the default
|
|
// worker image; the ops agent builds and registers those.
|
|
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID string) (string, string, error) {
|
|
image := s.resolveImage(repo)
|
|
if image != imageRefWorker {
|
|
exists, err := s.imageRefExists(ctx, image)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("docker unavailable: %w", err)
|
|
}
|
|
if !exists {
|
|
return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
|
|
}
|
|
}
|
|
repoVolume := volumeRepoPrefix + slug
|
|
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if fresh {
|
|
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
|
|
// drop the half-seeded volume so the next spawn retries fresh
|
|
// instead of silently booting into an empty workspace.
|
|
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
|
|
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
|
|
}
|
|
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
|
|
}
|
|
}
|
|
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
|
|
return "", "", fmt.Errorf("volume %s: %w", volumeSessions, err)
|
|
}
|
|
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumePiCache}); err != nil {
|
|
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
|
|
}
|
|
|
|
binds := []string{
|
|
repoVolume + ":" + workspaceMount,
|
|
volumeSessions + ":" + sessionsMount,
|
|
volumePiCache + ":" + cacheMount,
|
|
}
|
|
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
|
|
// spawned agents can use every model the catalog offers.
|
|
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
|
|
binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro")
|
|
}
|
|
// Shared playwright browser cache (host path, read-only); the env var
|
|
// below makes every playwright-based MCP use it instead of downloading.
|
|
if pw := os.Getenv(envPlaywrightCacheDir); pw != "" {
|
|
binds = append(binds, pw+":/pw-browsers:ro")
|
|
}
|
|
// Shared cloakbrowser chromium cache (host path, read-only): browsers
|
|
// are ~700MB; one copy serves every container. CLOAKBROWSER_CACHE_DIR
|
|
// points the MCP at it (see docker/mcp.json).
|
|
if cb := os.Getenv(envCloakCacheDir); cb != "" {
|
|
binds = append(binds, cb+":/cloakbrowser-cache:ro")
|
|
}
|
|
// Deploy secrets (ssh key + known_hosts + config, gitconfig), read-only.
|
|
// Bind sources resolve on the HOST (docker.sock semantics), so this env
|
|
// must carry the host path of the secrets dir.
|
|
if sec := os.Getenv(envSecretsDir); sec != "" {
|
|
binds = append(
|
|
binds,
|
|
sec+":"+sshMountTarget+":ro",
|
|
sec+"/gitconfig:"+gitconfigMountTarget+":ro",
|
|
)
|
|
}
|
|
cfg := &container.Config{
|
|
Image: image,
|
|
Env: workerEnv(s, repo, sessionID),
|
|
Labels: map[string]string{labelSession: sessionID},
|
|
}
|
|
hostCfg := &container.HostConfig{
|
|
Binds: binds,
|
|
NetworkMode: container.NetworkMode(s.network),
|
|
AutoRemove: false,
|
|
Init: &[]bool{true}[0], // tini reaps bridge setup-hook zombies
|
|
}
|
|
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
|
|
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("docker create: %w", err)
|
|
}
|
|
if err := s.cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
|
s.removeContainer(context.Background(), created.ID)
|
|
return "", "", fmt.Errorf("docker start: %w", err)
|
|
}
|
|
return created.ID, image, nil
|
|
}
|
|
|
|
// ensureRepoVolume creates the per-repo volume; reports whether it is fresh.
|
|
// Only a definitive "no such volume" (docker errdefs NotFound) counts as
|
|
// fresh: a transient inspect error must never re-seed over an existing
|
|
// volume, so it propagates instead.
|
|
func (s *Spawner) ensureRepoVolume(ctx context.Context, name string) (bool, error) {
|
|
if _, err := s.cli.VolumeInspect(ctx, name); err == nil {
|
|
return false, nil
|
|
} else if !cerrdefs.IsNotFound(err) {
|
|
return false, fmt.Errorf("volume %s: %w", name, err)
|
|
}
|
|
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: name}); err != nil {
|
|
return false, fmt.Errorf("volume %s: %w", name, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// seedVolume populates the fresh named volume with the cloned repo by
|
|
// streaming a tar of the clone into a paused container via the docker API
|
|
// (CopyToContainer). No bind mounts: bind sources are HOST paths, but the
|
|
// clone lives inside the daemon container's filesystem.
|
|
func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume, sessionID string) error {
|
|
repoDir := filepath.Join(s.reposDir, slug)
|
|
cfg := &container.Config{
|
|
Image: imageRefWorker,
|
|
Cmd: []string{"true"}, // never started; created only as a volume mount point
|
|
}
|
|
hostCfg := &container.HostConfig{
|
|
Binds: []string{repoVolume + ":" + workspaceMount},
|
|
Init: &[]bool{true}[0],
|
|
}
|
|
// Unique name per session: a fixed name would conflict forever after a
|
|
// crash between create and the deferred remove.
|
|
unq := strings.ReplaceAll(sessionID, "-", "")
|
|
if len(unq) > 8 {
|
|
unq = unq[:8]
|
|
}
|
|
name := "lvmh-seed-" + slug + "-" + unq
|
|
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.removeContainer(context.Background(), created.ID)
|
|
pr, pw := io.Pipe()
|
|
tarDone := make(chan error, 1)
|
|
go func() {
|
|
err := tarDir(pw, repoDir)
|
|
_ = pw.CloseWithError(err)
|
|
tarDone <- err
|
|
}()
|
|
copyErr := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, pr, container.CopyToContainerOptions{})
|
|
if copyErr != nil {
|
|
_ = pr.CloseWithError(copyErr) // unblock the tar goroutine
|
|
}
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
if tarErr := <-tarDone; tarErr != nil {
|
|
return fmt.Errorf("tar clone: %w", tarErr)
|
|
}
|
|
if copyErr != nil {
|
|
return fmt.Errorf("copy into volume: %w", copyErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Spawner) removeContainer(ctx context.Context, id string) {
|
|
_ = s.cli.ContainerRemove(ctx, id, container.RemoveOptions{Force: true})
|
|
}
|
|
|
|
// RemoveSession stops and removes the container spawned for a session.
|
|
// Stop and remove run on background contexts on purpose: a caller hanging up
|
|
// mid-request must not leave an orphaned container behind a deleted DB row.
|
|
func (s *Spawner) RemoveSession(_ context.Context, sessionID string) error {
|
|
row, ok, err := s.store.GetContainer(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errNoContainer
|
|
}
|
|
stopCtx, cancel := context.WithTimeout(context.Background(), time.Duration(stopTimeoutSeconds)*time.Second)
|
|
if err := s.cli.ContainerStop(stopCtx, row.ContainerID, container.StopOptions{}); err != nil {
|
|
cancel()
|
|
return fmt.Errorf("docker stop: %w", err)
|
|
}
|
|
cancel()
|
|
s.removeContainer(context.Background(), row.ContainerID)
|
|
if err := s.store.DeleteContainer(sessionID); err != nil {
|
|
return err
|
|
}
|
|
s.deleteJob(sessionID)
|
|
s.hub.BroadcastSpawnStatus()
|
|
return nil
|
|
}
|
|
|
|
// tarDir writes a tar archive of dir (nothing excluded, .git included) into w.
|
|
// Symlinks are archived as symlinks with their target in Linkname so docker's
|
|
// untar can recreate them; only regular files carry content.
|
|
func tarDir(w io.Writer, dir string) error {
|
|
tw := tar.NewWriter(w)
|
|
defer tw.Close()
|
|
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
name := filepath.ToSlash(rel)
|
|
link := ""
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
link, err = os.Readlink(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, link)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr.Name = name
|
|
if info.IsDir() {
|
|
hdr.Name += "/"
|
|
}
|
|
hdr.Uname = "root"
|
|
hdr.Gname = "root"
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(tw, f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|