fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+
This commit is contained in:
+111
-29
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -61,6 +62,7 @@ const (
|
||||
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")
|
||||
@@ -70,7 +72,7 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
|
||||
// validRepoPath accepts "group/project" style paths (at least two segments).
|
||||
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
|
||||
|
||||
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "-") }
|
||||
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "--") }
|
||||
|
||||
// newUUID returns a random RFC 4122 v4 UUID string.
|
||||
func newUUID() string {
|
||||
@@ -112,6 +114,7 @@ type SpawnResult struct {
|
||||
|
||||
// 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
|
||||
@@ -125,16 +128,18 @@ type Spawner struct {
|
||||
|
||||
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(store *Store, hub *Hub, baseURL string) (*Spawner, error) {
|
||||
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,
|
||||
@@ -173,6 +178,13 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
|
||||
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
|
||||
@@ -183,6 +195,23 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
|
||||
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) {
|
||||
if _, err := s.imageExists(ctx); err != nil {
|
||||
@@ -234,12 +263,12 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
||||
return
|
||||
}
|
||||
s.setJob(sessionID, repo, stateBuilding, "", "")
|
||||
if err := s.ensureImage(context.Background()); err != nil {
|
||||
if err := s.ensureImage(s.ctx); err != nil {
|
||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||
return
|
||||
}
|
||||
s.setJob(sessionID, repo, stateCreating, "", "")
|
||||
containerID, err := s.createAndStart(context.Background(), repo, slug, sessionID)
|
||||
containerID, err := s.createAndStart(s.ctx, repo, slug, sessionID)
|
||||
if err != nil {
|
||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||
return
|
||||
@@ -252,6 +281,8 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
||||
}
|
||||
|
||||
// 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(repo, branch, slug string) error {
|
||||
dir := filepath.Join(s.reposDir, slug)
|
||||
cloneURL, err := s.cloneURL(repo)
|
||||
@@ -259,7 +290,16 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
||||
return err
|
||||
}
|
||||
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
|
||||
if err := gitRun(dir, "pull", "--ff-only"); err != nil {
|
||||
auth := s.gitAuthArgs()
|
||||
if branch != "" && gitBranch(dir) != branch {
|
||||
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
|
||||
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
|
||||
}
|
||||
if err := gitRun(dir, "checkout", branch); err != nil {
|
||||
return fmt.Errorf("git checkout %s: %w", repo, err)
|
||||
}
|
||||
}
|
||||
if err := gitRun(dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
|
||||
return fmt.Errorf("git pull %s: %w", repo, err)
|
||||
}
|
||||
return nil
|
||||
@@ -267,7 +307,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
||||
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"clone"}
|
||||
args := append(s.gitAuthArgs(), "clone")
|
||||
if branch != "" {
|
||||
args = append(args, "--branch", branch)
|
||||
}
|
||||
@@ -278,18 +318,34 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneURL builds an authenticated https clone URL when a PAT is stored.
|
||||
// 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
|
||||
}
|
||||
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil {
|
||||
u.User = url.User(token)
|
||||
}
|
||||
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(dir string) string {
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func gitRun(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
if dir != "" {
|
||||
@@ -327,17 +383,29 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tarDir(&buf, s.buildContext); err != nil {
|
||||
return fmt.Errorf("build context %s: %w", s.buildContext, err)
|
||||
}
|
||||
resp, err := s.cli.ImageBuild(ctx, &buf, build.ImageBuildOptions{
|
||||
pr, pw := io.Pipe()
|
||||
tarDone := make(chan error, 1)
|
||||
go func() {
|
||||
err := tarDir(pw, s.buildContext)
|
||||
_ = pw.CloseWithError(err)
|
||||
tarDone <- err
|
||||
}()
|
||||
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
|
||||
Tags: []string{imageRefWorker},
|
||||
Dockerfile: relDockerfile,
|
||||
Remove: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker build: %w", err)
|
||||
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", s.buildContext, tarErr)
|
||||
}
|
||||
if buildErr != nil {
|
||||
return fmt.Errorf("docker build: %w", buildErr)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
|
||||
@@ -378,6 +446,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
||||
}
|
||||
if fresh {
|
||||
if err := s.seedVolume(ctx, slug, repoVolume); 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)
|
||||
}
|
||||
}
|
||||
@@ -409,6 +482,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
||||
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)
|
||||
@@ -445,18 +519,32 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
|
||||
}
|
||||
hostCfg := &container.HostConfig{
|
||||
Binds: []string{repoVolume + ":" + workspaceMount},
|
||||
Init: &[]bool{true}[0],
|
||||
}
|
||||
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.removeContainer(context.Background(), created.ID)
|
||||
var buf bytes.Buffer
|
||||
if err := tarDir(&buf, repoDir); err != nil {
|
||||
return fmt.Errorf("tar clone: %w", err)
|
||||
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 err := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, &buf, container.CopyToContainerOptions{}); err != nil {
|
||||
return fmt.Errorf("copy into volume: %w", err)
|
||||
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
|
||||
}
|
||||
@@ -484,13 +572,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
|
||||
if err := s.store.DeleteContainer(sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if j, ok := s.jobs[sessionID]; ok {
|
||||
j.State = stateError
|
||||
j.Message = "container removed"
|
||||
j.UpdatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.deleteJob(sessionID)
|
||||
s.hub.BroadcastSpawnStatus()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user