Skip to content

Commit 1ace6df

Browse files
ycombinatorclaude
andcommitted
e2e: extract shared elastic-agent download helpers from AgentInstallSuite
Extract downloadElasticAgent, extractAgentArchive (and internal tar/zip helpers) into a new agent_download.go file so they can be reused by other E2E tests without duplication. Improvements over the original inline methods: - Caching: the downloaded archive is stored in os.UserCacheDir() and reused on subsequent runs if the remote .sha512 checksum matches, avoiding repeated 600 MB downloads - ExtractFilter callback: lets callers limit which entries are written to disk (complementing the existing FileReplacer) - Explicit chmod after extraction: ensures execute bits are preserved regardless of the process umask AgentInstallSuite is updated to call the shared helpers; behaviour is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d99459e commit 1ace6df

File tree

2 files changed

+344
-183
lines changed

2 files changed

+344
-183
lines changed

testing/e2e/agent_download.go

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
//go:build e2e && !requirefips
6+
7+
package e2e
8+
9+
import (
10+
"archive/tar"
11+
"archive/zip"
12+
"bytes"
13+
"compress/gzip"
14+
"context"
15+
"crypto/sha512"
16+
"encoding/hex"
17+
"encoding/json"
18+
"errors"
19+
"fmt"
20+
"io"
21+
"net/http"
22+
"os"
23+
"path/filepath"
24+
"runtime"
25+
"strings"
26+
"testing"
27+
)
28+
29+
// SearchResp is the response body for the artifacts search API.
30+
type SearchResp struct {
31+
Packages map[string]Artifact `json:"packages"`
32+
}
33+
34+
// Artifact describes an elastic artifact available through the API.
35+
type Artifact struct {
36+
URL string `json:"url"`
37+
}
38+
39+
// agentCacheDir returns the directory used to cache downloaded elastic-agent archives.
40+
func agentCacheDir() (string, error) {
41+
base, err := os.UserCacheDir()
42+
if err != nil {
43+
return "", err
44+
}
45+
return filepath.Join(base, "fleet-server-e2e"), nil
46+
}
47+
48+
// downloadElasticAgent searches the artifacts API for the snapshot version
49+
// specified by ELASTICSEARCH_VERSION and returns a ReadCloser for the
50+
// elastic-agent archive matching the current OS and architecture.
51+
//
52+
// The archive is cached on disk. The remote .sha512 file is fetched first; if
53+
// it matches the cached file's checksum the download is skipped.
54+
func downloadElasticAgent(ctx context.Context, t *testing.T, client *http.Client) io.ReadCloser {
55+
t.Helper()
56+
// Use version associated with latest DRA instead of fleet-server's version to avoid breaking on fleet-server version bumps
57+
draVersion, ok := os.LookupEnv("ELASTICSEARCH_VERSION")
58+
if !ok || draVersion == "" {
59+
t.Fatal("ELASTICSEARCH_VERSION is not set")
60+
}
61+
draSplit := strings.Split(draVersion, "-")
62+
if len(draSplit) == 3 {
63+
draVersion = draSplit[0] + "-" + draSplit[2] // remove hash
64+
} else if len(draSplit) > 3 {
65+
t.Fatalf("Unsupported ELASTICSEARCH_VERSION format, expected 3 segments got: %s", draVersion)
66+
}
67+
t.Logf("Using ELASTICSEARCH_VERSION=%s for agent download", draVersion)
68+
69+
req, err := http.NewRequestWithContext(ctx, "GET", "https://artifacts-api.elastic.co/v1/search/"+draVersion, nil)
70+
if err != nil {
71+
t.Fatalf("failed to create search request: %v", err)
72+
}
73+
resp, err := client.Do(req)
74+
if err != nil {
75+
t.Fatalf("failed to query artifacts API: %v", err)
76+
}
77+
78+
var body SearchResp
79+
err = json.NewDecoder(resp.Body).Decode(&body)
80+
resp.Body.Close()
81+
if err != nil {
82+
t.Fatalf("failed to decode artifacts API response: %v", err)
83+
}
84+
85+
fType := "tar.gz"
86+
if runtime.GOOS == "windows" {
87+
fType = "zip"
88+
}
89+
arch := runtime.GOARCH
90+
if arch == "amd64" {
91+
arch = "x86_64"
92+
}
93+
if arch == "arm64" && runtime.GOOS == "darwin" {
94+
arch = "aarch64"
95+
}
96+
97+
fileName := fmt.Sprintf("elastic-agent-%s-%s-%s.%s", draVersion, runtime.GOOS, arch, fType)
98+
pkg, ok := body.Packages[fileName]
99+
if !ok {
100+
t.Fatalf("unable to find package download for fileName=%s", fileName)
101+
}
102+
103+
cacheDir, err := agentCacheDir()
104+
if err != nil {
105+
t.Fatalf("failed to determine cache dir: %v", err)
106+
}
107+
if err := os.MkdirAll(cacheDir, 0755); err != nil {
108+
t.Fatalf("failed to create cache dir: %v", err)
109+
}
110+
cachePath := filepath.Join(cacheDir, fileName)
111+
112+
// Fetch the remote SHA512 checksum (small file, always fetched).
113+
remoteSHA := fetchRemoteSHA512(ctx, t, client, pkg.URL+".sha512")
114+
115+
// If the cached file exists and matches, use it directly.
116+
if localSHA, err := sha512OfFile(cachePath); err == nil && strings.EqualFold(localSHA, remoteSHA) {
117+
t.Logf("Using cached elastic-agent from %s", cachePath)
118+
f, err := os.Open(cachePath)
119+
if err != nil {
120+
t.Fatalf("failed to open cached elastic-agent: %v", err)
121+
}
122+
return f
123+
}
124+
125+
// Download to a temp file first so a partial download never poisons the cache.
126+
t.Logf("Downloading elastic-agent from %s", pkg.URL)
127+
tmp, err := os.CreateTemp(cacheDir, fileName+".tmp-*")
128+
if err != nil {
129+
t.Fatalf("failed to create temp file for download: %v", err)
130+
}
131+
tmpName := tmp.Name()
132+
133+
req, err = http.NewRequestWithContext(ctx, "GET", pkg.URL, nil)
134+
if err != nil {
135+
tmp.Close()
136+
os.Remove(tmpName)
137+
t.Fatalf("failed to create download request: %v", err)
138+
}
139+
downloadResp, err := client.Do(req)
140+
if err != nil {
141+
tmp.Close()
142+
os.Remove(tmpName)
143+
t.Fatalf("failed to download elastic-agent: %v", err)
144+
}
145+
defer downloadResp.Body.Close()
146+
147+
h := sha512.New()
148+
if _, err := io.Copy(tmp, io.TeeReader(downloadResp.Body, h)); err != nil {
149+
tmp.Close()
150+
os.Remove(tmpName)
151+
t.Fatalf("failed to write elastic-agent download: %v", err)
152+
}
153+
tmp.Close()
154+
155+
// Verify the downloaded file's checksum before caching.
156+
downloadedSHA := hex.EncodeToString(h.Sum(nil))
157+
if !strings.EqualFold(downloadedSHA, remoteSHA) {
158+
os.Remove(tmpName)
159+
t.Fatalf("elastic-agent checksum mismatch: got %s, want %s", downloadedSHA, remoteSHA)
160+
}
161+
162+
if err := os.Rename(tmpName, cachePath); err != nil {
163+
os.Remove(tmpName)
164+
t.Fatalf("failed to move downloaded file to cache: %v", err)
165+
}
166+
167+
f, err := os.Open(cachePath)
168+
if err != nil {
169+
t.Fatalf("failed to open cached elastic-agent after download: %v", err)
170+
}
171+
return f
172+
}
173+
174+
// fetchRemoteSHA512 downloads the .sha512 file at url and returns the hex checksum.
175+
// The .sha512 file format is "<hex> <filename>" (sha512sum output), so only the
176+
// first whitespace-delimited field is returned.
177+
func fetchRemoteSHA512(ctx context.Context, t *testing.T, client *http.Client, url string) string {
178+
t.Helper()
179+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
180+
if err != nil {
181+
t.Fatalf("failed to create sha512 request: %v", err)
182+
}
183+
resp, err := client.Do(req)
184+
if err != nil {
185+
t.Fatalf("failed to fetch sha512 file: %v", err)
186+
}
187+
defer resp.Body.Close()
188+
data, err := io.ReadAll(resp.Body)
189+
if err != nil {
190+
t.Fatalf("failed to read sha512 file: %v", err)
191+
}
192+
return strings.Fields(string(data))[0]
193+
}
194+
195+
// sha512OfFile returns the hex-encoded SHA-512 checksum of the file at path.
196+
func sha512OfFile(path string) (string, error) {
197+
f, err := os.Open(path)
198+
if err != nil {
199+
return "", err
200+
}
201+
defer f.Close()
202+
h := sha512.New()
203+
if _, err := io.Copy(h, f); err != nil {
204+
return "", err
205+
}
206+
return hex.EncodeToString(h.Sum(nil)), nil
207+
}
208+
209+
// FileReplacer is an optional callback invoked during archive extraction.
210+
// If it handles the entry (writes to w and returns true) the normal copy is skipped.
211+
// name is the archive-relative path; w is the already-opened destination file.
212+
type FileReplacer func(name string, w io.WriteCloser) bool
213+
214+
// extractAgentArchive extracts the elastic-agent archive from r into destDir.
215+
// An optional replacer may intercept individual entries (e.g. to swap in a
216+
// locally compiled binary). It returns a map of base binary names → absolute paths.
217+
func extractAgentArchive(t *testing.T, r io.Reader, destDir string, replacer FileReplacer) map[string]string {
218+
t.Helper()
219+
paths := make(map[string]string)
220+
switch runtime.GOOS {
221+
case "windows":
222+
extractAgentZip(t, r, destDir, paths, replacer)
223+
default:
224+
extractAgentTar(t, r, destDir, paths, replacer)
225+
}
226+
return paths
227+
}
228+
229+
func extractAgentTar(t *testing.T, r io.Reader, destDir string, paths map[string]string, replacer FileReplacer) {
230+
t.Helper()
231+
gs, err := gzip.NewReader(r)
232+
if err != nil {
233+
t.Fatalf("failed to create gzip reader: %v", err)
234+
}
235+
tarReader := tar.NewReader(gs)
236+
for {
237+
header, err := tarReader.Next()
238+
if errors.Is(err, io.EOF) {
239+
break
240+
}
241+
if err != nil {
242+
t.Fatalf("tar read error: %v", err)
243+
}
244+
245+
path := filepath.Join(destDir, header.Name)
246+
mode := header.FileInfo().Mode()
247+
switch {
248+
case mode.IsDir():
249+
if err := os.MkdirAll(path, 0755); err != nil {
250+
t.Fatalf("mkdir %s: %v", path, err)
251+
}
252+
case mode.IsRegular():
253+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
254+
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
255+
}
256+
w, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm())
257+
if err != nil {
258+
t.Fatalf("open %s: %v", path, err)
259+
}
260+
if replacer != nil && replacer(header.Name, w) {
261+
continue
262+
}
263+
if _, err := io.Copy(w, tarReader); err != nil {
264+
t.Fatalf("copy %s: %v", path, err)
265+
}
266+
w.Close()
267+
paths[filepath.Base(header.Name)] = path
268+
case mode.Type()&os.ModeSymlink == os.ModeSymlink:
269+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
270+
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
271+
}
272+
if err := os.Symlink(header.Linkname, path); err != nil {
273+
t.Fatalf("symlink %s → %s: %v", path, header.Linkname, err)
274+
}
275+
paths[filepath.Base(header.Linkname)] = path
276+
default:
277+
t.Logf("unable to untar type=%c in file=%s", header.Typeflag, path)
278+
}
279+
}
280+
}
281+
282+
func extractAgentZip(t *testing.T, r io.Reader, destDir string, paths map[string]string, replacer FileReplacer) {
283+
t.Helper()
284+
var b bytes.Buffer
285+
n, err := io.Copy(&b, r)
286+
if err != nil {
287+
t.Fatalf("failed to buffer zip: %v", err)
288+
}
289+
zipReader, err := zip.NewReader(bytes.NewReader(b.Bytes()), n)
290+
if err != nil {
291+
t.Fatalf("failed to create zip reader: %v", err)
292+
}
293+
for _, file := range zipReader.File {
294+
path := filepath.Join(destDir, file.Name)
295+
mode := file.FileInfo().Mode()
296+
switch {
297+
case mode.IsDir():
298+
if err := os.MkdirAll(path, 0755); err != nil {
299+
t.Fatalf("mkdir %s: %v", path, err)
300+
}
301+
case mode.IsRegular():
302+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
303+
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
304+
}
305+
w, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
306+
if err != nil {
307+
t.Fatalf("open %s: %v", path, err)
308+
}
309+
if replacer != nil && replacer(file.Name, w) {
310+
continue
311+
}
312+
f, err := file.Open()
313+
if err != nil {
314+
t.Fatalf("zip open %s: %v", file.Name, err)
315+
}
316+
if _, err := io.Copy(w, f); err != nil {
317+
t.Fatalf("copy %s: %v", path, err)
318+
}
319+
w.Close()
320+
f.Close()
321+
paths[filepath.Base(file.Name)] = path
322+
default:
323+
t.Logf("unable to unzip type=%+v in file=%s", mode, path)
324+
}
325+
}
326+
}

0 commit comments

Comments
 (0)