fix(review round 2): daemon symlink tars, orphan-safe removes, unique seed names, git ctx, IsErrNotFound, collision-free slugs, branch validation; web first-run WS, draft reset, IME guard, churn fix, test swap repair; 106 daemon + 190 web tests green

This commit is contained in:
Raphael Westphal
2026-08-18 19:13:51 +02:00
parent 95d2b5da4b
commit 66c90e48bb
22 changed files with 1476 additions and 964 deletions
+4
View File
@@ -280,6 +280,10 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
if body.Branch != "" && !branchRe.MatchString(body.Branch) {
writeError(w, http.StatusBadRequest, "invalid branch name")
return
}
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+26 -9
View File
@@ -106,16 +106,33 @@ func TestAPIEventsReplayShape(t *testing.T) {
func TestAPISpawnValidation(t *testing.T) {
ts, _ := newTestServer(t)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/spawn",
strings.NewReader(`{"repo":"no-slash"}`))
req.Header.Set("Authorization", "Bearer "+testToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("spawn: %v", err)
post := func(body string) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/spawn", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("spawn: %v", err)
}
resp.Body.Close()
return resp.StatusCode
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("bad repo → %d, want 400", resp.StatusCode)
if code := post(`{"repo":"no-slash"}`); code != http.StatusBadRequest {
t.Fatalf("bad repo → %d, want 400", code)
}
// branch is passed to git: anything outside the safe charset → 400
for _, branch := range []string{
"main; rm -rf /",
"feature one", // space
"-oProxyCommand=x", // leading option-ish
"main$(id)",
} {
if code := post(`{"repo":"group/project","branch":"` + branch + `"}`); code != http.StatusBadRequest {
t.Fatalf("branch %q → %d, want 400", branch, code)
}
}
// empty branch (default) stays accepted at this validation layer
if code := post(`{"repo":"group/project","branch":""}`); code == http.StatusBadRequest {
t.Fatal("empty branch must not be rejected as invalid")
}
}
+57 -22
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -22,6 +23,7 @@ import (
"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"
@@ -69,10 +71,20 @@ 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) }
func repoSlug(repo string) string { return strings.ReplaceAll(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 {
@@ -214,11 +226,11 @@ func (s *Spawner) deleteJob(sessionID string) {
// 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 {
exists, err := s.imageExists(ctx)
if err != nil {
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
}
exists, err := s.imageExists(ctx)
if err == nil && !exists {
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`)",
@@ -258,7 +270,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
lock.Lock()
defer lock.Unlock()
if err := s.cloneOrUpdate(repo, branch, slug); err != nil {
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
@@ -283,7 +295,7 @@ 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 {
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 {
@@ -291,15 +303,15 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
}
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
auth := s.gitAuthArgs()
if branch != "" && gitBranch(dir) != branch {
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
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(dir, "checkout", branch); err != nil {
if err := gitRun(ctx, 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 {
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
@@ -312,7 +324,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
args = append(args, "--branch", branch)
}
args = append(args, "--", cloneURL, dir)
if err := gitRun("", args...); err != nil {
if err := gitRun(ctx, "", args...); err != nil {
return fmt.Errorf("git clone %s: %w", repo, err)
}
return nil
@@ -338,16 +350,16 @@ func (s *Spawner) gitAuthArgs() []string {
}
// 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()
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(dir string, args ...string) error {
cmd := exec.Command("git", args...)
func gitRun(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
@@ -445,7 +457,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
return "", err
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
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 {
@@ -497,9 +509,14 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
}
// 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)
@@ -511,7 +528,7 @@ func (s *Spawner) ensureRepoVolume(ctx context.Context, name string) (bool, erro
// 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 string) error {
func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume, sessionID string) error {
repoDir := filepath.Join(s.reposDir, slug)
cfg := &container.Config{
Image: imageRefWorker,
@@ -521,7 +538,14 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
Binds: []string{repoVolume + ":" + workspaceMount},
Init: &[]bool{true}[0],
}
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
// 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
}
@@ -554,7 +578,9 @@ func (s *Spawner) removeContainer(ctx context.Context, id string) {
}
// RemoveSession stops and removes the container spawned for a session.
func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
// 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
@@ -568,7 +594,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
return fmt.Errorf("docker stop: %w", err)
}
cancel()
s.removeContainer(ctx, row.ContainerID)
s.removeContainer(context.Background(), row.ContainerID)
if err := s.store.DeleteContainer(sessionID); err != nil {
return err
}
@@ -577,7 +603,9 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
return nil
}
// tarDir writes a tar archive of dir (excluding .git-heavy junk) into w.
// 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()
@@ -593,7 +621,14 @@ func tarDir(w io.Writer, dir string) error {
return nil
}
name := filepath.ToSlash(rel)
hdr, err := tar.FileInfoHeader(info, "")
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
}
+20 -12
View File
@@ -53,17 +53,18 @@ type fakeDocker struct {
create []recordedCreate
archives []int // sizes of accepted CopyToContainer tar streams
failBuild bool
failBuildHTTP bool
failCreate bool
failStart bool
failStop bool
failWait bool
failVolumeCreate bool
failVolumeDelete bool
failArchive bool
archiveHang bool
waitHang bool
failBuild bool
failBuildHTTP bool
failCreate bool
failStart bool
failStop bool
failWait bool
failVolumeCreate bool
failVolumeDelete bool
failVolumeInspect bool
failArchive bool
archiveHang bool
waitHang bool
unknown []string
}
@@ -203,6 +204,10 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
exists := f.volume[name]
f.mu.Unlock()
if f.failVolumeInspect {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"transient docker error"}`)
return
}
if exists {
writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name))
return
@@ -288,10 +293,12 @@ const (
fakeGitModeFail string = "fail"
fakeGitModeNoisy string = "noisy"
fakeGitModeSilent string = "silent"
fakeGitModeHang string = "hang"
)
// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests).
// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests),
// hang (sleeps; only ctx cancellation can end it).
func useFakeGit(t *testing.T, mode string) string {
t.Helper()
dir := t.TempDir()
@@ -302,6 +309,7 @@ func useFakeGit(t *testing.T, mode string) string {
" fail) echo 'fatal: repository not found'; exit 1;;\n" +
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
" silent) exit 1;;\n" +
" hang) exec sleep 30;;\n" +
"esac\n" +
"# branch probe: emit the configured HEAD name\n" +
"if [ \"$1\" = '-C' ] && [ \"$3\" = rev-parse ]; then printf '%s' \"$FAKE_GIT_HEAD\"; exit 0; fi\n" +
+1 -1
View File
@@ -3,6 +3,7 @@ module lvmh-daemon
go 1.25.0
require (
github.com/containerd/errdefs v1.0.0
github.com/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.5.3
modernc.org/sqlite v1.56.0
@@ -11,7 +12,6 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
+243 -23
View File
@@ -6,6 +6,8 @@ import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -14,6 +16,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"time"
@@ -75,7 +78,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
t.Fatalf("missing env %v", wantEnv)
}
wantBinds := []string{
"lvmh-repo-group--project:" + workspaceMount,
volumeRepoPrefix + repoSlug("group/project") + ":" + workspaceMount,
volumeSessions + ":" + sessionsMount,
volumePiCache + ":" + cacheMount,
}
@@ -102,7 +105,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
if seed[0].Image != imageRefWorker {
t.Fatalf("seed create = %+v", seed[0])
}
wantSeedBinds := []string{"lvmh-repo-group--project:" + workspaceMount}
wantSeedBinds := []string{volumeRepoPrefix + repoSlug("group/project") + ":" + workspaceMount}
if !reflect.DeepEqual(seed[0].HostConfig.Binds, wantSeedBinds) {
t.Fatalf("seed binds = %v, want %v (no host-path binds)", seed[0].HostConfig.Binds, wantSeedBinds)
}
@@ -260,7 +263,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err != nil {
if err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project")); err != nil {
t.Fatalf("cloneOrUpdate: %v", err)
}
calls := readGitLog(t, gitLog)
@@ -343,7 +346,9 @@ func TestSpawnerSeedVolumeCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project") }()
go func() {
done <- sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1")
}()
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPut, "/archive") })
cancel()
select {
@@ -386,12 +391,24 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
}
func TestRepoSlugAndUUID(t *testing.T) {
if got := repoSlug("a/b/c"); got != "a--b--c" {
t.Fatalf("repoSlug = %q", got)
slugRe := regexp.MustCompile(`^a--b--c-[0-9a-f]{6}$`)
if got := repoSlug("a/b/c"); !slugRe.MatchString(got) {
t.Fatalf("repoSlug = %q, want a--b--c-<6 hex>", got)
}
// "a/b/c" and "a/b-c" must map to distinct slugs (no collision).
if repoSlug("a/b/c") == repoSlug("a/b-c") {
t.Fatalf("slug collision: %q", repoSlug("a/b/c"))
// sanitized path + first 6 hex of sha256(full repo path)
sum := sha256.Sum256([]byte("a/b/c"))
want := "a--b--c-" + hex.EncodeToString(sum[:3])
if got := repoSlug("a/b/c"); got != want {
t.Fatalf("repoSlug = %q, want %q", got, want)
}
// "/"→"--" alone is ambiguous: distinct repo paths must never collide.
seen := map[string]bool{}
for _, repo := range []string{"a/b/c", "a/b-c", "a/b--c", "a/b--c/d"} {
s := repoSlug(repo)
if seen[s] {
t.Fatalf("slug collision: %q", s)
}
seen[s] = true
}
id := newUUID()
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
@@ -473,10 +490,10 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
if !strings.Contains(job.Message, "seed") {
t.Fatalf("job message = %q, want seed failure", job.Message)
}
if !f.hasCall(http.MethodDelete, "/volumes/lvmh-repo-group--project") {
if !f.hasCall(http.MethodDelete, "/volumes/"+volumeRepoPrefix+repoSlug("group/project")) {
t.Fatal("failed seed must force-remove the repo volume for a fresh retry")
}
if f.volumeExists("lvmh-repo-group--project") {
if f.volumeExists(volumeRepoPrefix + repoSlug("group/project")) {
t.Fatal("repo volume must not linger half-seeded")
}
}
@@ -506,7 +523,7 @@ func TestSpawnerCloneOrUpdateBranchFetchFails(t *testing.T) {
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
err := sp.cloneOrUpdate("group/project", "dev", slug)
err := sp.cloneOrUpdate(context.Background(), "group/project", "dev", slug)
if err == nil || !strings.Contains(err.Error(), "git fetch") {
t.Fatalf("cloneOrUpdate branch fetch failure = %v, want git fetch error", err)
}
@@ -564,7 +581,7 @@ func TestSpawnerCloneOrUpdateSwitchesBranch(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := sp.cloneOrUpdate("group/project", tc.branch, slug); err != nil {
if err := sp.cloneOrUpdate(context.Background(), "group/project", tc.branch, slug); err != nil {
t.Fatalf("cloneOrUpdate: %v", err)
}
calls := readGitLog(t, gitLog)
@@ -674,7 +691,7 @@ func TestTarDir(t *testing.T) {
func TestGitRunSilentFailure(t *testing.T) {
useFakeGit(t, fakeGitModeSilent)
err := gitRun("", "clone", "x")
err := gitRun(context.Background(), "", "clone", "x")
if err == nil || strings.Contains(err.Error(), "clone x") {
// silent failure surfaces the bare exec error, not a padded message
t.Fatalf("gitRun silent failure = %v", err)
@@ -723,7 +740,7 @@ func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
sp, _ := newTestSpawner(t, f)
// unparseable repo path (control char) → cloneURL parse error
if err := sp.cloneOrUpdate("bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
if err := sp.cloneOrUpdate(context.Background(), "bad\nrepo", "", repoSlug("bad\nrepo")); err == nil {
t.Fatal("cloneOrUpdate with control-char repo must fail")
}
@@ -733,7 +750,7 @@ func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
t.Fatalf("write file: %v", err)
}
sp.reposDir = file
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err == nil {
if err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project")); err == nil {
t.Fatal("cloneOrUpdate with file reposDir must fail")
}
}
@@ -770,7 +787,7 @@ func TestSpawnerEnsureImageErrors(t *testing.T) {
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group--project"] = true // repo volume exists → skip seed
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
f.failVolumeCreate = true
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
@@ -790,12 +807,12 @@ func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
ctx := context.Background()
f.failCreate = true
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
if err := sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1"); err == nil {
t.Fatal("seedVolume with failing create must fail")
}
f.failCreate = false
f.failStart = true
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
if err := sp.seedVolume(ctx, repoSlug("group/project"), volumeRepoPrefix+repoSlug("group/project"), "s1"); err == nil {
t.Fatal("seedVolume with failing start must fail")
}
}
@@ -836,7 +853,7 @@ func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project"))
err := sp.cloneOrUpdate(context.Background(), "group/project", "", repoSlug("group/project"))
if err == nil || !strings.Contains(err.Error(), "git pull") {
t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err)
}
@@ -847,7 +864,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
// worker container create/start instead of at the seed container.
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group--project"] = true
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true
sp, _ := newTestSpawner(t, f)
f.failCreate = true
@@ -959,11 +976,214 @@ func TestTarDirUnreadableFile(t *testing.T) {
func TestGitRunUnderivableExit(t *testing.T) {
// gitRun is the exec seam: with real git present, invoking a nonexistent
// subcommand must surface stderr, and trimming applies to long output.
if err := gitRun("", "version"); err != nil {
if err := gitRun(context.Background(), "", "version"); err != nil {
t.Fatalf("git version: %v", err)
}
err := gitRun("", "this-subcommand-does-not-exist")
err := gitRun(context.Background(), "", "this-subcommand-does-not-exist")
if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") {
t.Fatalf("gitRun unknown subcommand = %v", err)
}
}
// --- round-2 regression tests ---
// TestTarDirSymlinks pins the symlink contract: headers carry TypeSymlink,
// the real target in Linkname, and no content body (Size 0). Docker's untar
// recreates symlinks via os.Symlink(Linkname, path); an empty Linkname made
// every symlink-bearing repo fail to seed permanently.
func TestTarDirSymlinks(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Symlink("file.txt", filepath.Join(dir, "rel-link")); err != nil {
t.Fatalf("rel symlink: %v", err)
}
if err := os.Symlink(filepath.Join(dir, "file.txt"), filepath.Join(dir, "abs-link")); err != nil {
t.Fatalf("abs symlink: %v", err)
}
var buf bytes.Buffer
if err := tarDir(&buf, dir); err != nil {
t.Fatalf("tarDir: %v", err)
}
type linkInfo struct {
linkname string
size int64
}
links := map[string]linkInfo{}
regulars := 0
tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
if hdr.Typeflag == tar.TypeSymlink {
links[hdr.Name] = linkInfo{hdr.Linkname, hdr.Size}
continue
}
if hdr.Typeflag == tar.TypeReg {
regulars++
}
}
if regulars != 1 {
t.Fatalf("regular files = %d, want 1", regulars)
}
want := map[string]string{
"rel-link": "file.txt",
"abs-link": filepath.Join(dir, "file.txt"),
}
for name, target := range want {
got, ok := links[name]
if !ok {
t.Fatalf("symlink %q missing from archive (have %v)", name, links)
}
if got.linkname != target {
t.Fatalf("symlink %q Linkname = %q, want %q", name, got.linkname, target)
}
if got.size != 0 {
t.Fatalf("symlink %q has Size %d, want 0 (no content body)", name, got.size)
}
}
}
// TestSpawnerRemoveSessionSurvivesClientHangup: a cancelled request context
// (client hangup) must not skip the container remove — the DB row is deleted
// either way, so a skipped remove would orphan the container forever.
func TestSpawnerRemoveSessionSurvivesClientHangup(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := store.UpsertContainer("s1", "cid-9", "group/project"); err != nil {
t.Fatalf("upsert container: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // client already gone
if err := sp.RemoveSession(ctx, "s1"); err != nil {
t.Fatalf("RemoveSession with cancelled ctx: %v", err)
}
if !f.hasCall(http.MethodDelete, "/containers/cid-9") {
t.Fatal("remove must run on a background ctx despite the cancelled request ctx")
}
if _, ok, _ := store.GetContainer("s1"); ok {
t.Fatal("container row must be deleted")
}
f.assertNoUnknown(t)
}
// TestSpawnerSeedVolumeUniqueNames: two seeds for the same repo must not
// reuse one container name — a crash between create and the deferred remove
// would then block every future spawn with a name conflict.
func TestSpawnerSeedVolumeUniqueNames(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
slug := repoSlug("group/project")
repoVolume := volumeRepoPrefix + slug
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug), 0o755); err != nil {
t.Fatalf("mkdir clone dir: %v", err)
}
if err := os.WriteFile(filepath.Join(sp.reposDir, slug, "README.md"), []byte("x"), 0o644); err != nil {
t.Fatalf("write README: %v", err)
}
for _, sid := range []string{"aaaaaaaa-1111-2222-3333-444444444444", "bbbbbbbb-1111-2222-3333-444444444444"} {
if err := sp.seedVolume(context.Background(), slug, repoVolume, sid); err != nil {
t.Fatalf("seedVolume(%s): %v", sid, err)
}
}
seeds := f.createsByName("lvmh-seed-")
if len(seeds) != 2 {
t.Fatalf("seed containers = %+v, want 2", seeds)
}
if seeds[0].Name == seeds[1].Name {
t.Fatalf("seed names must differ per session: %q", seeds[0].Name)
}
for _, s := range seeds {
if !strings.HasPrefix(s.Name, "lvmh-seed-"+slug+"-") {
t.Fatalf("seed name %q, want prefix lvmh-seed-%s-", s.Name, slug)
}
}
f.assertNoUnknown(t)
}
// TestSpawnerEnsureRepoVolumeTransientInspectError: a non-NotFound volume
// inspect error must propagate, never be treated as "fresh" (which would
// seed over an existing volume).
func TestSpawnerEnsureRepoVolumeTransientInspectError(t *testing.T) {
f := newFakeDocker()
f.failVolumeInspect = true
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // volume exists
sp, _ := newTestSpawner(t, f)
fresh, err := sp.ensureRepoVolume(context.Background(), volumeRepoPrefix+repoSlug("group/project"))
if err == nil || !strings.Contains(err.Error(), "transient docker error") {
t.Fatalf("ensureRepoVolume = fresh:%v err:%v, want transient error propagated", fresh, err)
}
if fresh {
t.Fatal("transient inspect error must never report a fresh volume")
}
if f.countCalls(http.MethodPost, "/volumes/create") != 0 {
t.Fatal("no volume create may follow a transient inspect error")
}
f.assertNoUnknown(t)
}
// TestSpawnerCloneOrUpdateRespectsCtx: git spawned via CommandContext must
// die when the context is cancelled (daemon shutdown must not hang on git).
func TestSpawnerCloneOrUpdateRespectsCtx(t *testing.T) {
useFakeGit(t, fakeGitModeHang) // clone sleeps 30s
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
err := sp.cloneOrUpdate(ctx, "group/project", "", repoSlug("group/project"))
if err == nil {
t.Fatal("cloneOrUpdate must fail when ctx expires mid-clone")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("cloneOrUpdate took %v, want prompt cancellation", elapsed)
}
if ctx.Err() == nil {
t.Fatal("expected the context to be the failure cause")
}
}
// TestGitBranchRespectsCtx: the branch probe must honour cancellation too.
func TestGitBranchRespectsCtx(t *testing.T) {
useFakeGit(t, fakeGitModeHang)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
if got := gitBranch(ctx, t.TempDir()); got != "" {
t.Fatalf("gitBranch under cancelled ctx = %q, want \"\"", got)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("gitBranch took %v, want prompt cancellation", elapsed)
}
}
// TestSpawnerStartDedupesImageList: Start must list images exactly once
// (plus once more inside ensureImage) — a regression guard for the removed
// double imageExists call.
func TestSpawnerStartDedupesImageList(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
if n := f.countCalls(http.MethodGet, "/images/json"); n != 2 {
t.Fatalf("image list calls = %d, want 2 (Start + ensureImage)", n)
}
f.assertNoUnknown(t)
}