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
+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)
}