vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Run CI jobs in a container chosen by the runner
7da9111ecabb236d48fc48592cd3d1d45f9afe78 · Keenan Nemetz · 15d ago · parent 474a812d
Run CI jobs in a container chosen by the runner Each lease gets a disposable checkout and, when --image is set, its own container from that image. The runner keeps the SSH key and trusted Git cache outside the job, so workflow code cannot reach either; older caches are rebuilt because a previous job may have written to them. The record names the image and runtime, and the queue mirrors them for the run page. Without --image the runner still executes on the host and says so.
ci/agent/worktree_test.go +107
| @@ -0,0 +1,107 @@ | |||
| 1 | package agent | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "os/exec" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // prepareWorktree has two jobs that must not be confused: keep a trusted cache for | ||
| 12 | // the runner's record push, and give the workflow a disposable tree with its own | ||
| 13 | // .git. The test plays the SSH transport with a script so the whole path runs | ||
| 14 | // without a server, including a commit that is not reachable from any branch. | ||
| 15 | func TestPrepareWorktreeSeparatesTrustedCacheFromJobTree(t *testing.T) { | ||
| 16 | tmp := t.TempDir() | ||
| 17 | src := filepath.Join(tmp, "src") | ||
| 18 | remote := filepath.Join(tmp, "demo.git") | ||
| 19 | cache := filepath.Join(tmp, "cache") | ||
| 20 | |||
| 21 | mustGit(t, "", "init", "-q", src) | ||
| 22 | mustGit(t, src, "config", "user.email", "a@example.com") | ||
| 23 | mustGit(t, src, "config", "user.name", "a") | ||
| 24 | if err := os.WriteFile(filepath.Join(src, "file"), []byte("one\n"), 0o644); err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | mustGit(t, src, "add", "file") | ||
| 28 | mustGit(t, src, "commit", "-qm", "one") | ||
| 29 | mustGit(t, "", "clone", "-q", "--bare", src, remote) | ||
| 30 | |||
| 31 | // A second commit reaches the remote's object store but no ref. Fetching it by | ||
| 32 | // id is what the fallback in prepareWorktree has to handle. | ||
| 33 | if err := os.WriteFile(filepath.Join(src, "file"), []byte("two\n"), 0o644); err != nil { | ||
| 34 | t.Fatal(err) | ||
| 35 | } | ||
| 36 | mustGit(t, src, "commit", "-qam", "two") | ||
| 37 | mustGit(t, src, "push", "-q", remote, "HEAD:refs/heads/tmp") | ||
| 38 | sha := strings.TrimSpace(mustGit(t, src, "rev-parse", "HEAD")) | ||
| 39 | mustGit(t, remote, "update-ref", "-d", "refs/heads/tmp") | ||
| 40 | mustGit(t, remote, "config", "uploadpack.allowAnySHA1InWant", "true") | ||
| 41 | |||
| 42 | // Git talks to our script instead of ssh. The script serves the bare repository | ||
| 43 | // for the upload-pack request and fails the protocol probe so Git falls back to | ||
| 44 | // the ordinary transport. | ||
| 45 | ssh := filepath.Join(tmp, "ssh") | ||
| 46 | script := "#!/bin/sh\n" + | ||
| 47 | "for a in \"$@\"; do\n" + | ||
| 48 | " case \"$a\" in\n" + | ||
| 49 | " *git-upload-pack*) exec git-upload-pack \"$VOLE_TEST_REPO\" ;;\n" + | ||
| 50 | " esac\n" + | ||
| 51 | "done\n" + | ||
| 52 | "exit 1\n" | ||
| 53 | if err := os.WriteFile(ssh, []byte(script), 0o755); err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | t.Setenv("GIT_SSH_COMMAND", ssh) | ||
| 57 | t.Setenv("GIT_SSH_VARIANT", "ssh") | ||
| 58 | t.Setenv("VOLE_TEST_REPO", remote) | ||
| 59 | |||
| 60 | co, err := prepareWorktree("ssh://git@example.invalid", cache, "demo", sha, "run1", 1) | ||
| 61 | if err != nil { | ||
| 62 | t.Fatalf("prepareWorktree: %v", err) | ||
| 63 | } | ||
| 64 | defer co.cleanup() | ||
| 65 | |||
| 66 | got := strings.TrimSpace(mustGit(t, co.Work, "rev-parse", "HEAD")) | ||
| 67 | if got != sha { | ||
| 68 | t.Fatalf("job tree is at %s, want %s", got, sha) | ||
| 69 | } | ||
| 70 | if _, err := os.Stat(filepath.Join(co.Work, ".git")); err != nil { | ||
| 71 | t.Fatalf("the job tree has no .git: %v", err) | ||
| 72 | } | ||
| 73 | |||
| 74 | // The job may edit its own Git directory. That must not reach the cache the | ||
| 75 | // runner later uses to push the record. | ||
| 76 | hook := filepath.Join(co.Work, ".git", "hooks", "pre-push") | ||
| 77 | if err := os.MkdirAll(filepath.Dir(hook), 0o755); err != nil { | ||
| 78 | t.Fatal(err) | ||
| 79 | } | ||
| 80 | if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | cacheHook := filepath.Join(cache, "demo", ".git", "hooks", "pre-push") | ||
| 84 | if _, err := os.Stat(cacheHook); !os.IsNotExist(err) { | ||
| 85 | t.Fatalf("a job hook reached the trusted cache: %v", err) | ||
| 86 | } | ||
| 87 | |||
| 88 | // The temporary ref used to carry an unreachable commit must be gone too. | ||
| 89 | out := mustGit(t, filepath.Join(cache, "demo"), "for-each-ref", "--format=%(refname)", | ||
| 90 | "refs/vole/jobs") | ||
| 91 | if strings.TrimSpace(out) != "" { | ||
| 92 | t.Fatalf("temporary job refs were left in the cache:\n%s", out) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | func mustGit(t *testing.T, dir string, args ...string) string { | ||
| 97 | t.Helper() | ||
| 98 | cmd := exec.Command("git", args...) | ||
| 99 | if dir != "" { | ||
| 100 | cmd.Dir = dir | ||
| 101 | } | ||
| 102 | out, err := cmd.CombinedOutput() | ||
| 103 | if err != nil { | ||
| 104 | t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) | ||
| 105 | } | ||
| 106 | return string(out) | ||
| 107 | } | ||