mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
After the binary swap succeeds, zot update now walks $ZOT_HOME/extensions/ and runs git pull --ff-only on every extension that is a git checkout. Per-extension behaviour: - disabled extensions: skipped - no .git/ directory: skipped (no remote to pull from) - dirty worktree: stashed (--include-untracked) before the pull, popped after; conflict on pop leaves markers in place with a warning rather than discarding the runtime state - diverged / offline / any git failure: reported as failed and the next extension is processed - timeout per extension: 60s - no build step is ever executed; authors commit the runnable artifact, or the user rebuilds manually and /reload-ext zot update itself never aborts because of an extension. The binary swap is the source of truth for success. Implementation in packages/agent/extupdate.go (~150 LoC), 13 unit tests covering each branch including stash+pop with untracked runtime files, diverged history, unreachable remote, and the mixed-state scenario. README's Extensions section documents the new behaviour.
356 lines
12 KiB
Go
356 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// gitAvailable lets the whole suite no-op on machines without git
|
|
// (the goreleaser cross-build is one). All these tests genuinely need
|
|
// the git binary; the agent runtime itself never requires it.
|
|
func gitAvailable(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not on PATH; skipping extension-update tests")
|
|
}
|
|
}
|
|
|
|
// initBareRepo creates a bare repo at path. Used as the "remote" for
|
|
// the clone-and-pull tests.
|
|
func initBareRepo(t *testing.T, path string) {
|
|
t.Helper()
|
|
mustRun(t, "", "git", "init", "--bare", "--initial-branch=main", path)
|
|
}
|
|
|
|
// initWorkRepo creates a working repo at path with one commit so it
|
|
// has a real default branch. Returns the path. We commit a single
|
|
// README so the worktree isn't empty.
|
|
func initWorkRepo(t *testing.T, path string) {
|
|
t.Helper()
|
|
mustRun(t, "", "git", "init", "--initial-branch=main", path)
|
|
configRepo(t, path)
|
|
writeFile(t, filepath.Join(path, "README.md"), "v1\n")
|
|
mustRun(t, path, "git", "add", ".")
|
|
mustRun(t, path, "git", "commit", "-q", "-m", "init")
|
|
}
|
|
|
|
func configRepo(t *testing.T, dir string) {
|
|
t.Helper()
|
|
mustRun(t, dir, "git", "config", "user.email", "test@example.com")
|
|
mustRun(t, dir, "git", "config", "user.name", "Test")
|
|
mustRun(t, dir, "git", "config", "commit.gpgsign", "false")
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func mustRun(t *testing.T, dir, name string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command(name, args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
|
}
|
|
}
|
|
|
|
// makeFakeZotHome scaffolds $ZOT_HOME/extensions/ and returns the path.
|
|
func makeFakeZotHome(t *testing.T) string {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil {
|
|
t.Fatalf("mkdir extensions: %v", err)
|
|
}
|
|
return home
|
|
}
|
|
|
|
// extWithManifest creates a directory with an extension.json so
|
|
// updateOneExtension treats it as a real extension. content is the
|
|
// JSON to write; pass "" for the default minimal manifest.
|
|
func extWithManifest(t *testing.T, root, name, content string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(root, "extensions", name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
if content == "" {
|
|
content = `{"name":"` + name + `","version":"1.0.0","exec":"./x","enabled":true}`
|
|
}
|
|
writeFile(t, filepath.Join(dir, "extension.json"), content)
|
|
return dir
|
|
}
|
|
|
|
// ----- tests -----
|
|
|
|
func TestUpdateAllExtensions_NoExtensionsDirectory(t *testing.T) {
|
|
// Brand new $ZOT_HOME with no extensions/ directory at all.
|
|
// Must not panic and must print nothing alarming.
|
|
home := t.TempDir()
|
|
updateAllExtensions(home) // no-op
|
|
}
|
|
|
|
func TestUpdateAllExtensions_EmptyDirectory(t *testing.T) {
|
|
home := makeFakeZotHome(t)
|
|
updateAllExtensions(home) // no-op, no panic
|
|
}
|
|
|
|
func TestUpdateOneExtension_NoManifest(t *testing.T) {
|
|
home := makeFakeZotHome(t)
|
|
dir := filepath.Join(home, "extensions", "noman")
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
got := updateOneExtension(dir, "noman")
|
|
if got != "skipped" {
|
|
t.Fatalf("want skipped, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_Disabled(t *testing.T) {
|
|
home := makeFakeZotHome(t)
|
|
dir := extWithManifest(t, home, "off",
|
|
`{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`)
|
|
got := updateOneExtension(dir, "off")
|
|
if got != "skipped" {
|
|
t.Fatalf("want skipped, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_NotAGitCheckout(t *testing.T) {
|
|
home := makeFakeZotHome(t)
|
|
dir := extWithManifest(t, home, "plain", "")
|
|
got := updateOneExtension(dir, "plain")
|
|
if got != "skipped" {
|
|
t.Fatalf("want skipped, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_UpToDate(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
// Set up a remote and clone it as the "installed" extension.
|
|
remote := filepath.Join(tmp, "remote.git")
|
|
initBareRepo(t, remote)
|
|
|
|
source := filepath.Join(tmp, "source")
|
|
initWorkRepo(t, source)
|
|
mustRun(t, source, "git", "remote", "add", "origin", remote)
|
|
mustRun(t, source, "git", "push", "-u", "origin", "main")
|
|
|
|
extDir := filepath.Join(home, "extensions", "up2date")
|
|
mustRun(t, "", "git", "clone", "-q", remote, extDir)
|
|
configRepo(t, extDir)
|
|
// Overwrite the cloned README with a manifest in addition.
|
|
writeFile(t, filepath.Join(extDir, "extension.json"),
|
|
`{"name":"up2date","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
|
|
got := updateOneExtension(extDir, "up2date")
|
|
if got != "up-to-date" {
|
|
t.Fatalf("want up-to-date, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_PullsNewCommit(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
remote := filepath.Join(tmp, "remote.git")
|
|
initBareRepo(t, remote)
|
|
|
|
source := filepath.Join(tmp, "source")
|
|
initWorkRepo(t, source)
|
|
writeFile(t, filepath.Join(source, "extension.json"),
|
|
`{"name":"pulls","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
mustRun(t, source, "git", "add", "extension.json")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
|
|
mustRun(t, source, "git", "remote", "add", "origin", remote)
|
|
mustRun(t, source, "git", "push", "-u", "origin", "main")
|
|
|
|
extDir := filepath.Join(home, "extensions", "pulls")
|
|
mustRun(t, "", "git", "clone", "-q", remote, extDir)
|
|
configRepo(t, extDir)
|
|
|
|
// Push a new commit to the remote so pulling sees something new.
|
|
writeFile(t, filepath.Join(source, "NEW.md"), "added later\n")
|
|
mustRun(t, source, "git", "add", "NEW.md")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "add NEW")
|
|
mustRun(t, source, "git", "push", "origin", "main")
|
|
|
|
got := updateOneExtension(extDir, "pulls")
|
|
if got != "updated" {
|
|
t.Fatalf("want updated, got %s", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil {
|
|
t.Fatalf("expected NEW.md to be pulled in: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_StashesDirtyWorktree(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
remote := filepath.Join(tmp, "remote.git")
|
|
initBareRepo(t, remote)
|
|
source := filepath.Join(tmp, "source")
|
|
initWorkRepo(t, source)
|
|
writeFile(t, filepath.Join(source, "extension.json"),
|
|
`{"name":"dirty","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
mustRun(t, source, "git", "add", "extension.json")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
|
|
mustRun(t, source, "git", "remote", "add", "origin", remote)
|
|
mustRun(t, source, "git", "push", "-u", "origin", "main")
|
|
|
|
extDir := filepath.Join(home, "extensions", "dirty")
|
|
mustRun(t, "", "git", "clone", "-q", remote, extDir)
|
|
configRepo(t, extDir)
|
|
|
|
// Simulate runtime state: an untracked file the extension wrote.
|
|
writeFile(t, filepath.Join(extDir, "runtime.json"), `{"state":"hi"}`)
|
|
|
|
// Advance the remote.
|
|
writeFile(t, filepath.Join(source, "NEW.md"), "added later\n")
|
|
mustRun(t, source, "git", "add", "NEW.md")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "add NEW")
|
|
mustRun(t, source, "git", "push", "origin", "main")
|
|
|
|
got := updateOneExtension(extDir, "dirty")
|
|
if got != "updated" {
|
|
t.Fatalf("want updated, got %s", got)
|
|
}
|
|
// Runtime file must still be there after the pull+stash cycle.
|
|
if _, err := os.Stat(filepath.Join(extDir, "runtime.json")); err != nil {
|
|
t.Fatalf("runtime.json was clobbered by the update: %v", err)
|
|
}
|
|
// And the pulled commit must be present too.
|
|
if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil {
|
|
t.Fatalf("expected NEW.md to be pulled in: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_DivergedFails(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
remote := filepath.Join(tmp, "remote.git")
|
|
initBareRepo(t, remote)
|
|
source := filepath.Join(tmp, "source")
|
|
initWorkRepo(t, source)
|
|
writeFile(t, filepath.Join(source, "extension.json"),
|
|
`{"name":"div","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
mustRun(t, source, "git", "add", "extension.json")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
|
|
mustRun(t, source, "git", "remote", "add", "origin", remote)
|
|
mustRun(t, source, "git", "push", "-u", "origin", "main")
|
|
|
|
extDir := filepath.Join(home, "extensions", "div")
|
|
mustRun(t, "", "git", "clone", "-q", remote, extDir)
|
|
configRepo(t, extDir)
|
|
|
|
// Local diverging commit (committed, not just dirty).
|
|
writeFile(t, filepath.Join(extDir, "LOCAL.md"), "local commit\n")
|
|
mustRun(t, extDir, "git", "add", "LOCAL.md")
|
|
mustRun(t, extDir, "git", "commit", "-q", "-m", "local")
|
|
|
|
// Conflicting remote commit on the same path.
|
|
writeFile(t, filepath.Join(source, "LOCAL.md"), "remote commit\n")
|
|
mustRun(t, source, "git", "add", "LOCAL.md")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "remote")
|
|
mustRun(t, source, "git", "push", "origin", "main")
|
|
|
|
got := updateOneExtension(extDir, "div")
|
|
if got != "failed" {
|
|
t.Fatalf("want failed (diverged), got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateOneExtension_BadRemoteFails(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
// Init a repo with an "origin" that points at a non-existent path.
|
|
extDir := filepath.Join(home, "extensions", "bad")
|
|
if err := os.MkdirAll(extDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustRun(t, extDir, "git", "init", "--initial-branch=main")
|
|
configRepo(t, extDir)
|
|
writeFile(t, filepath.Join(extDir, "README.md"), "hi\n")
|
|
mustRun(t, extDir, "git", "add", ".")
|
|
mustRun(t, extDir, "git", "commit", "-q", "-m", "init")
|
|
writeFile(t, filepath.Join(extDir, "extension.json"),
|
|
`{"name":"bad","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
mustRun(t, extDir, "git", "add", "extension.json")
|
|
mustRun(t, extDir, "git", "commit", "-q", "-m", "manifest")
|
|
mustRun(t, extDir, "git", "remote", "add", "origin",
|
|
filepath.Join(tmp, "does-not-exist.git"))
|
|
|
|
// Tracking branch is needed for `git pull` to even attempt fetch.
|
|
// Without one, `git pull` errors out with "no tracking info" which
|
|
// is also a perfectly valid failure for our purposes.
|
|
got := updateOneExtension(extDir, "bad")
|
|
if got != "failed" {
|
|
t.Fatalf("want failed, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAllExtensions_MixedSet(t *testing.T) {
|
|
gitAvailable(t)
|
|
tmp := t.TempDir()
|
|
home := makeFakeZotHome(t)
|
|
|
|
// 1 plain non-git extension (skipped)
|
|
extWithManifest(t, home, "plain", "")
|
|
// 1 disabled
|
|
extWithManifest(t, home, "off",
|
|
`{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`)
|
|
// 1 healthy git clone
|
|
remote := filepath.Join(tmp, "remote.git")
|
|
initBareRepo(t, remote)
|
|
source := filepath.Join(tmp, "source")
|
|
initWorkRepo(t, source)
|
|
writeFile(t, filepath.Join(source, "extension.json"),
|
|
`{"name":"ok","version":"1.0.0","exec":"./x","enabled":true}`)
|
|
mustRun(t, source, "git", "add", "extension.json")
|
|
mustRun(t, source, "git", "commit", "-q", "-m", "manifest")
|
|
mustRun(t, source, "git", "remote", "add", "origin", remote)
|
|
mustRun(t, source, "git", "push", "-u", "origin", "main")
|
|
extDir := filepath.Join(home, "extensions", "ok")
|
|
mustRun(t, "", "git", "clone", "-q", remote, extDir)
|
|
configRepo(t, extDir)
|
|
|
|
// Must not panic, must not propagate any error to the caller.
|
|
updateAllExtensions(home)
|
|
}
|
|
|
|
func TestSummariseGitError_PrefersErrorOverHint(t *testing.T) {
|
|
out := "hint: try git pull\nfatal: refusing to merge unrelated histories\nhint: see git-merge"
|
|
got := summariseGitError(out, exec.Command("false").Run())
|
|
if !strings.Contains(got, "fatal:") {
|
|
t.Fatalf("want fatal line, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunGit_HonoursContextTimeout(t *testing.T) {
|
|
gitAvailable(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
// `git rev-parse HEAD` in a non-repo errors quickly; we just want
|
|
// to confirm we don't hang.
|
|
_, _ = runGit(ctx, t.TempDir(), "rev-parse", "HEAD")
|
|
}
|