Skip to content

Commit e2b38c0

Browse files
authored
feat: add archive test source for ZIP and tar.gz files (#179)
## Summary - Add new `archive` source type under `tests.source` that downloads and extracts ZIP or tar.gz archives from URLs or local paths, then discovers tests via glob patterns within extracted contents - Automatically detect and convert GitHub Actions artifact browser URLs to API calls with bearer token auth - Support parallel range-request downloads (8 workers, 25 MiB chunks) with automatic fallback to sequential when the server doesn't support range requests - Cache downloaded archives locally so repeated runs with the same URL skip the download - Auto-extract inner tarballs found inside ZIP files (e.g. GitHub Actions artifacts) - Pass `BENCHMARKOOR_RUNNER_GITHUB_TOKEN` into the Docker container in the GitHub Action - Fix null `tests` array crash in UI suite cell tooltip ## Example config ```yaml tests: source: archive: file: https://github.com/NethermindEth/gas-benchmarks/actions/runs/123/artifacts/456 pre_run_steps: - "perf-devnet-3/gas-bump.txt" steps: setup: - "perf-devnet-3/tests/setup/*.txt" test: - "perf-devnet-3/tests/test/*.txt" cleanup: - "perf-devnet-3/tests/cleanup/*.txt" ``` ## Test plan - [x] Config validation tests (valid archive, missing file, multiple sources) - [x] `IsConfigured()` test for archive source - [x] Local ZIP and tar.gz extraction tests - [x] Inner tarball auto-extraction test - [x] GitHub artifact URL detection and conversion test - [x] Download caching test (second run skips download) - [x] Parallel range-request download test with httptest server - [x] Sequential fallback test - [x] Bearer token forwarding test - [x] Format detection by extension and magic bytes - [x] Race detector clean on parallel download
1 parent 80dfbdd commit e2b38c0

13 files changed

Lines changed: 1533 additions & 149 deletions

File tree

action.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ runs:
203203
DOCKER_CMD="$DOCKER_CMD -v /proc/sys/vm/drop_caches:/host_drop_caches"
204204
DOCKER_CMD="$DOCKER_CMD -v /sys/devices/system/cpu:/host_sys_cpu"
205205
DOCKER_CMD="$DOCKER_CMD -v /sys/fs/cgroup:/sys/fs/cgroup:ro"
206+
DOCKER_CMD="$DOCKER_CMD -e BENCHMARKOOR_RUNNER_GITHUB_TOKEN=${{ inputs.github-token }}"
206207
DOCKER_CMD="$DOCKER_CMD -e BENCHMARKOOR_RUNNER_DROP_CACHES_PATH=/host_drop_caches"
207208
DOCKER_CMD="$DOCKER_CMD -e BENCHMARKOOR_RUNNER_CPU_SYSFS_PATH=/host_sys_cpu"
208209
DOCKER_CMD="$DOCKER_CMD -e BENCHMARKOOR_RUNNER_BENCHMARK_RESULTS_DIR=/app/results"

pkg/config/config.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,10 @@ type TestsConfig struct {
148148
// SourceConfig defines where to find test files.
149149
type SourceConfig struct {
150150
// New unified source options.
151-
Git *GitSourceV2 `yaml:"git,omitempty" mapstructure:"git"`
152-
Local *LocalSourceV2 `yaml:"local,omitempty" mapstructure:"local"`
153-
EESTFixtures *EESTFixturesSource `yaml:"eest_fixtures,omitempty" mapstructure:"eest_fixtures"`
151+
Git *GitSourceV2 `yaml:"git,omitempty" mapstructure:"git"`
152+
Local *LocalSourceV2 `yaml:"local,omitempty" mapstructure:"local"`
153+
Archive *ArchiveSourceConfig `yaml:"archive,omitempty" mapstructure:"archive"`
154+
EESTFixtures *EESTFixturesSource `yaml:"eest_fixtures,omitempty" mapstructure:"eest_fixtures"`
154155
}
155156

156157
// EESTFixturesSource defines an EEST fixtures source from GitHub releases, artifacts,
@@ -330,6 +331,14 @@ type LocalSourceV2 struct {
330331
Steps *StepsConfig `yaml:"steps,omitempty" mapstructure:"steps"`
331332
}
332333

334+
// ArchiveSourceConfig defines an archive file source for tests.
335+
// The file can be a local path or a URL (HTTP/HTTPS) to a ZIP or tar.gz archive.
336+
type ArchiveSourceConfig struct {
337+
File string `yaml:"file" mapstructure:"file"`
338+
PreRunSteps []string `yaml:"pre_run_steps,omitempty" mapstructure:"pre_run_steps"`
339+
Steps *StepsConfig `yaml:"steps,omitempty" mapstructure:"steps"`
340+
}
341+
333342
// StepsConfig defines glob patterns for each step type.
334343
type StepsConfig struct {
335344
Setup []string `yaml:"setup,omitempty" mapstructure:"setup"`
@@ -339,7 +348,7 @@ type StepsConfig struct {
339348

340349
// IsConfigured returns true if any test source is configured.
341350
func (s *SourceConfig) IsConfigured() bool {
342-
return s.Git != nil || s.Local != nil || s.EESTFixtures != nil
351+
return s.Git != nil || s.Local != nil || s.Archive != nil || s.EESTFixtures != nil
343352
}
344353

345354
// DefaultContainerDir is the default container mount path for data directories.
@@ -1096,12 +1105,16 @@ func (s *SourceConfig) Validate() error {
10961105
count++
10971106
}
10981107

1108+
if s.Archive != nil {
1109+
count++
1110+
}
1111+
10991112
if s.EESTFixtures != nil {
11001113
count++
11011114
}
11021115

11031116
if count > 1 {
1104-
return fmt.Errorf("cannot specify multiple sources (git, local, eest_fixtures)")
1117+
return fmt.Errorf("cannot specify multiple sources (git, local, archive, eest_fixtures)")
11051118
}
11061119

11071120
if s.Git != nil {
@@ -1124,6 +1137,12 @@ func (s *SourceConfig) Validate() error {
11241137
}
11251138
}
11261139

1140+
if s.Archive != nil {
1141+
if s.Archive.File == "" {
1142+
return fmt.Errorf("archive.file is required")
1143+
}
1144+
}
1145+
11271146
if s.EESTFixtures != nil {
11281147
if err := s.EESTFixtures.validate(); err != nil {
11291148
return err

pkg/config/config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,46 @@ func TestSourceConfig_Validate(t *testing.T) {
609609
wantErr: true,
610610
errSubstr: "does not exist",
611611
},
612+
{
613+
name: "valid archive source with URL",
614+
source: SourceConfig{
615+
Archive: &ArchiveSourceConfig{
616+
File: "https://example.com/fixtures.zip",
617+
},
618+
},
619+
wantErr: false,
620+
},
621+
{
622+
name: "valid archive source with local file",
623+
source: SourceConfig{
624+
Archive: &ArchiveSourceConfig{
625+
File: fixturesTarball,
626+
},
627+
},
628+
wantErr: false,
629+
},
630+
{
631+
name: "archive missing file",
632+
source: SourceConfig{
633+
Archive: &ArchiveSourceConfig{},
634+
},
635+
wantErr: true,
636+
errSubstr: "archive.file is required",
637+
},
638+
{
639+
name: "multiple sources not allowed - archive and git",
640+
source: SourceConfig{
641+
Archive: &ArchiveSourceConfig{
642+
File: "https://example.com/fixtures.zip",
643+
},
644+
Git: &GitSourceV2{
645+
Repo: "https://github.com/test/repo",
646+
Version: "v1.0.0",
647+
},
648+
},
649+
wantErr: true,
650+
errSubstr: "cannot specify multiple sources",
651+
},
612652
}
613653

614654
for _, tt := range tests {
@@ -927,6 +967,15 @@ func TestSourceConfig_IsConfigured(t *testing.T) {
927967
},
928968
expected: true,
929969
},
970+
{
971+
name: "archive source",
972+
source: SourceConfig{
973+
Archive: &ArchiveSourceConfig{
974+
File: "https://example.com/fixtures.zip",
975+
},
976+
},
977+
expected: true,
978+
},
930979
}
931980

932981
for _, tt := range tests {

pkg/executor/archive_source.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"regexp"
11+
"strings"
12+
13+
"github.com/ethpandaops/benchmarkoor/pkg/config"
14+
"github.com/sirupsen/logrus"
15+
)
16+
17+
// ghArtifactURLPattern matches GitHub Actions artifact browser URLs:
18+
// https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
19+
var ghArtifactURLPattern = regexp.MustCompile(
20+
`^https://github\.com/([^/]+/[^/]+)/actions/runs/\d+/artifacts/(\d+)$`,
21+
)
22+
23+
// ArchiveSource downloads and extracts an archive file, then discovers tests
24+
// from the extracted contents using glob patterns.
25+
type ArchiveSource struct {
26+
log logrus.FieldLogger
27+
cfg *config.ArchiveSourceConfig
28+
cacheDir string
29+
filter string
30+
githubToken string
31+
basePath string // temp directory where archive was extracted
32+
}
33+
34+
// Prepare downloads (if URL) and extracts the archive, then discovers tests.
35+
func (s *ArchiveSource) Prepare(ctx context.Context) (*PreparedSource, error) {
36+
// Create temp directory for extraction.
37+
parentDir := s.cacheDir
38+
if parentDir == "" {
39+
parentDir = os.TempDir()
40+
}
41+
42+
tmpDir, err := os.MkdirTemp(parentDir, "archive-*")
43+
if err != nil {
44+
return nil, fmt.Errorf("creating temp directory: %w", err)
45+
}
46+
47+
s.basePath = tmpDir
48+
49+
// Determine the archive file path.
50+
archivePath, err := s.resolveFile(ctx)
51+
if err != nil {
52+
_ = os.RemoveAll(s.basePath)
53+
s.basePath = ""
54+
55+
return nil, fmt.Errorf("resolving archive file: %w", err)
56+
}
57+
58+
// Detect format and extract.
59+
if err := s.extractArchive(archivePath); err != nil {
60+
_ = os.RemoveAll(s.basePath)
61+
s.basePath = ""
62+
63+
return nil, fmt.Errorf("extracting archive: %w", err)
64+
}
65+
66+
s.log.WithField("path", s.basePath).Info("Extracted archive")
67+
68+
return discoverTestsFromConfig(
69+
s.basePath, s.cfg.PreRunSteps, s.cfg.Steps, s.filter, s.log,
70+
)
71+
}
72+
73+
// Cleanup removes the temporary extraction directory.
74+
func (s *ArchiveSource) Cleanup() error {
75+
if s.basePath != "" {
76+
return os.RemoveAll(s.basePath)
77+
}
78+
79+
return nil
80+
}
81+
82+
// GetSourceInfo returns source information for the suite summary.
83+
func (s *ArchiveSource) GetSourceInfo() (*SuiteSource, error) {
84+
info := &ArchiveSourceInfo{
85+
File: s.cfg.File,
86+
PreRunSteps: s.cfg.PreRunSteps,
87+
}
88+
89+
if s.cfg.Steps != nil {
90+
info.Steps = &SourceStepsGlobs{
91+
Setup: s.cfg.Steps.Setup,
92+
Test: s.cfg.Steps.Test,
93+
Cleanup: s.cfg.Steps.Cleanup,
94+
}
95+
}
96+
97+
return &SuiteSource{Archive: info}, nil
98+
}
99+
100+
// resolveFile returns the local path to the archive file. For URLs, it checks
101+
// the cache directory first and only downloads if the file is not already cached.
102+
func (s *ArchiveSource) resolveFile(ctx context.Context) (string, error) {
103+
file := s.cfg.File
104+
105+
if strings.HasPrefix(file, "http://") || strings.HasPrefix(file, "https://") {
106+
cachedPath := s.cachedArchivePath()
107+
108+
if _, err := os.Stat(cachedPath); err == nil {
109+
s.log.WithFields(logrus.Fields{
110+
"url": file,
111+
"path": cachedPath,
112+
}).Info("Using cached archive")
113+
114+
return cachedPath, nil
115+
}
116+
117+
s.log.WithField("url", file).Info("Downloading archive")
118+
119+
downloadURL, token := s.resolveDownloadURL(file)
120+
121+
// Download to a temp file first, then rename for atomic cache writes.
122+
tmpPath := cachedPath + ".tmp"
123+
124+
if err := os.MkdirAll(filepath.Dir(cachedPath), 0755); err != nil {
125+
return "", fmt.Errorf("creating cache directory: %w", err)
126+
}
127+
128+
if err := downloadToFile(ctx, downloadURL, tmpPath, token, s.log); err != nil {
129+
_ = os.Remove(tmpPath)
130+
131+
return "", err
132+
}
133+
134+
if err := os.Rename(tmpPath, cachedPath); err != nil {
135+
_ = os.Remove(tmpPath)
136+
137+
return "", fmt.Errorf("caching archive: %w", err)
138+
}
139+
140+
s.log.WithField("path", cachedPath).Info("Archive cached")
141+
142+
return cachedPath, nil
143+
}
144+
145+
// Local file path — resolve relative paths.
146+
if !filepath.IsAbs(file) {
147+
absPath, err := filepath.Abs(file)
148+
if err != nil {
149+
return "", fmt.Errorf("resolving path %q: %w", file, err)
150+
}
151+
152+
file = absPath
153+
}
154+
155+
if _, err := os.Stat(file); os.IsNotExist(err) {
156+
return "", fmt.Errorf("archive file %q does not exist", file)
157+
}
158+
159+
return file, nil
160+
}
161+
162+
// cachedArchivePath returns a stable file path in the cache directory derived
163+
// from the configured URL, so repeated runs reuse the same downloaded file.
164+
func (s *ArchiveSource) cachedArchivePath() string {
165+
hash := sha256.Sum256([]byte(s.cfg.File))
166+
name := "archive-" + hex.EncodeToString(hash[:8])
167+
168+
cacheDir := s.cacheDir
169+
if cacheDir == "" {
170+
cacheDir = os.TempDir()
171+
}
172+
173+
return filepath.Join(cacheDir, name)
174+
}
175+
176+
// resolveDownloadURL converts browser URLs to API URLs where needed and returns
177+
// the appropriate auth token. For GitHub Actions artifact URLs, it converts to
178+
// the GitHub API download endpoint with bearer token auth.
179+
func (s *ArchiveSource) resolveDownloadURL(rawURL string) (string, string) {
180+
matches := ghArtifactURLPattern.FindStringSubmatch(rawURL)
181+
if matches != nil {
182+
repo := matches[1]
183+
artifactID := matches[2]
184+
apiURL := fmt.Sprintf(
185+
"https://api.github.com/repos/%s/actions/artifacts/%s/zip",
186+
repo, artifactID,
187+
)
188+
189+
s.log.WithFields(logrus.Fields{
190+
"repo": repo,
191+
"artifact_id": artifactID,
192+
}).Info("Detected GitHub artifact URL, using API endpoint")
193+
194+
if s.githubToken == "" {
195+
s.log.Warn(
196+
"GitHub token is required for artifact downloads. " +
197+
"Set runner.github_token in config or " +
198+
"BENCHMARKOOR_RUNNER_GITHUB_TOKEN env var",
199+
)
200+
}
201+
202+
return apiURL, s.githubToken
203+
}
204+
205+
return rawURL, ""
206+
}
207+
208+
// extractArchive detects the archive format and extracts it to the base path.
209+
// For ZIP archives, inner tarballs are also extracted automatically.
210+
func (s *ArchiveSource) extractArchive(archivePath string) error {
211+
format, err := detectArchiveFormat(archivePath)
212+
if err != nil {
213+
return err
214+
}
215+
216+
switch format {
217+
case archiveFormatZip:
218+
if err := extractZipFile(archivePath, s.basePath); err != nil {
219+
return fmt.Errorf("extracting zip: %w", err)
220+
}
221+
222+
// Auto-extract inner tarballs (e.g. GitHub Actions artifacts).
223+
if err := extractInnerTarballs(s.basePath, s.log); err != nil {
224+
return fmt.Errorf("extracting inner tarballs: %w", err)
225+
}
226+
case archiveFormatTarGz:
227+
if err := extractTarGzFile(archivePath, s.basePath); err != nil {
228+
return fmt.Errorf("extracting tar.gz: %w", err)
229+
}
230+
default:
231+
return fmt.Errorf("unsupported archive format: %s", format)
232+
}
233+
234+
return nil
235+
}

0 commit comments

Comments
 (0)