Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,7 @@ proxy mirror --concurrency 8 pkg:npm/lodash@4.17.21
proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20
```

The mirror command accepts the same storage and database flags as `serve`. Already-cached artifacts are skipped.
The mirror command accepts the same storage and database flags as `serve`. Already-cached artifacts are skipped. If any package version fails to mirror, the command lists the errors and exits with status 1, so a CI step that warms the cache fails on a partial mirror.

A mirror API is also available when the server is running:

Expand Down
16 changes: 16 additions & 0 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,22 @@ func runMirror() {
fmt.Fprintf(os.Stderr, " %s/%s@%s: %s\n", e.Ecosystem, e.Name, e.Version, e.Error)
}
}

// A partial mirror has to be visible in the exit status, or a CI step
// that prewarms the cache passes even when every download failed.
if code := mirrorExitCode(progress); code != 0 {
_ = db.Close()
os.Exit(code) //nolint:gocritic // db closed above
}
}

// mirrorExitCode returns the exit status for a finished mirror run: 1 when any
// package version failed to mirror, 0 otherwise.
func mirrorExitCode(progress *mirror.Progress) int {
if progress != nil && progress.Failed > 0 {
return 1
}
return 0
}

func printStats(db *database.DB, popular, recent int, asJSON bool) error {
Expand Down
74 changes: 74 additions & 0 deletions cmd/proxy/mirror_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/git-pkgs/proxy/internal/mirror"
)

func TestMirrorExitCode(t *testing.T) {
tests := []struct {
name string
progress *mirror.Progress
want int
}{
{"nil progress", nil, 0},
{"everything mirrored", &mirror.Progress{Total: 2, Completed: 2}, 0},
{"everything cached", &mirror.Progress{Total: 2, Skipped: 2}, 0},
{"one failure", &mirror.Progress{Total: 2, Completed: 1, Failed: 1}, 1},
{"all failed", &mirror.Progress{Total: 3, Failed: 3}, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mirrorExitCode(tt.progress); got != tt.want {
t.Errorf("mirrorExitCode() = %d, want %d", got, tt.want)
}
})
}
}

// proxy mirror used to exit 0 after printing "N failed", so a CI step that
// prewarms the cache passed even when every download failed (#500). An
// ecosystem the resolver rejects fails without touching the network.
func TestMirrorExitsNonZeroWhenDownloadsFail(t *testing.T) {
dir := t.TempDir()
cmd := exec.Command(os.Args[0], "-test.run=^TestMirrorFailureProcess$")
cmd.Env = append(os.Environ(),
"PROXY_TEST_MIRROR_FAILURE=1",
"PROXY_TEST_MIRROR_DIR="+dir,
)
output, err := cmd.CombinedOutput()

var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("mirror with a failed download exited with %v, want status 1\n%s", err, output)
}
if code := exitErr.ExitCode(); code != 1 {
t.Fatalf("exit status = %d, want 1\n%s", code, output)
}
if !strings.Contains(string(output), "1 failed") {
t.Errorf("output does not report the failure:\n%s", output)
}
if !strings.Contains(string(output), "bogus/foo@1.0.0") {
t.Errorf("output does not list the failed package:\n%s", output)
}
}

func TestMirrorFailureProcess(*testing.T) {
if os.Getenv("PROXY_TEST_MIRROR_FAILURE") != "1" {
return
}
dir := os.Getenv("PROXY_TEST_MIRROR_DIR")
os.Args = []string{
"proxy", "mirror",
"--storage-url", "file://" + filepath.Join(dir, "store"),
"--database-path", filepath.Join(dir, "cache.db"),
"pkg:bogus/foo@1.0.0",
}
main()
}
Loading