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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user