Skip to content

Commit dd58bb7

Browse files
committed
add retries to csaf enricher
1 parent 25e02cc commit dd58bb7

2 files changed

Lines changed: 154 additions & 37 deletions

File tree

scanner/enricher/csaf/fetcher.go

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ import (
2222
"github.com/klauspost/compress/snappy"
2323
"github.com/quay/claircore/libvuln/driver"
2424
"github.com/quay/claircore/pkg/tmp"
25+
"github.com/stackrox/rox/pkg/retry"
2526
"github.com/stackrox/rox/pkg/utils"
2627
"github.com/stackrox/rox/scanner/enricher/csaf/internal/zreader"
2728
)
2829

2930
var (
3031
// compressedFileTimeout matches Claircore's VEX https://github.com/quay/claircore/blob/v1.5.34/rhel/vex/fetcher.go.
3132
compressedFileTimeout = 2 * time.Minute
33+
34+
advisoryFetchRetries = 5
35+
advisoryFetchRetryBaseDelay = 500 * time.Millisecond
3236
)
3337

3438
// fingerprint is used to track the state of the changes.csv and deletions.csv endpoints.
@@ -339,49 +343,22 @@ func (e *Enricher) processChanges(ctx context.Context, w io.Writer, fp *fingerpr
339343
continue
340344
}
341345

342-
changed[path.Base(cvePath)] = true
343-
344346
advisoryURI, err := e.base.Parse(cvePath)
345347
if err != nil {
346348
return err
347349
}
348-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, advisoryURI.String(), nil)
349-
if err != nil {
350-
return fmt.Errorf("error creating advisory request %w", err)
351-
}
352-
353-
// Use a func here as we're in a loop and want to make sure the
354-
// body is closed in all events.
355-
err = func() error {
356-
res, err := e.c.Do(req)
357-
if err != nil {
358-
return fmt.Errorf("error making advisory request %w", err)
359-
}
360-
defer utils.IgnoreError(res.Body.Close)
361-
err = checkResponse(res, http.StatusOK)
362-
if err != nil {
363-
return fmt.Errorf("unexpected response: %w", err)
364-
}
365350

366-
// Add compacted JSON to buffer.
367-
_, err = buf.ReadFrom(res.Body)
368-
if err != nil {
369-
return fmt.Errorf("error reading from buffer: %w", err)
370-
}
371-
slog.DebugContext(ctx, "copying body to file", "url", advisoryURI.String())
372-
err = json.Compact(&bc, buf.Bytes())
373-
if err != nil {
374-
return fmt.Errorf("error compressing JSON: %w", err)
375-
}
376-
377-
bc.WriteByte('\n')
378-
_, _ = w.Write(bc.Bytes())
379-
l++
380-
return nil
381-
}()
382-
if !errors.Is(err, nil) {
383-
return err
351+
// Fetch individual advisory with retries. If the advisory is
352+
// unavailable after all attempts, skip it — the compressed
353+
// archive (downloaded next) will likely container a version of it.
354+
advisoryErr := e.fetchAdvisory(ctx, advisoryURI, &buf, &bc, w)
355+
if advisoryErr != nil {
356+
slog.WarnContext(ctx, "skipping advisory due to fetch failure; will fall back to archive",
357+
"advisory", cvePath, "reason", advisoryErr)
358+
continue
384359
}
360+
changed[path.Base(cvePath)] = true
361+
l++
385362
}
386363

387364
if !errors.Is(err, io.EOF) {
@@ -390,6 +367,46 @@ func (e *Enricher) processChanges(ctx context.Context, w io.Writer, fp *fingerpr
390367
return nil
391368
}
392369

370+
func (e *Enricher) fetchAdvisory(ctx context.Context, advisoryURI *url.URL, buf, bc *bytes.Buffer, w io.Writer) error {
371+
fetch := func() error {
372+
buf.Reset()
373+
bc.Reset()
374+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, advisoryURI.String(), nil)
375+
if err != nil {
376+
return err
377+
}
378+
res, err := e.c.Do(req)
379+
if err != nil {
380+
return err
381+
}
382+
defer utils.IgnoreError(res.Body.Close)
383+
if err := checkResponse(res, http.StatusOK); err != nil {
384+
return err
385+
}
386+
if _, err := buf.ReadFrom(res.Body); err != nil {
387+
return fmt.Errorf("error reading response body: %w", err)
388+
}
389+
if err := json.Compact(bc, buf.Bytes()); err != nil {
390+
return fmt.Errorf("error compacting JSON: %w", err)
391+
}
392+
bc.WriteByte('\n')
393+
_, _ = w.Write(bc.Bytes())
394+
return nil
395+
}
396+
397+
return retry.WithRetry(fetch,
398+
retry.WithContext(ctx),
399+
retry.Tries(advisoryFetchRetries),
400+
retry.BetweenAttempts(func(attempt int) {
401+
time.Sleep(advisoryFetchRetryBaseDelay * time.Duration(1<<attempt))
402+
}),
403+
retry.OnFailedAttempts(func(err error) {
404+
slog.WarnContext(ctx, "advisory fetch attempt failed",
405+
"advisory", advisoryURI, "reason", err)
406+
}),
407+
)
408+
}
409+
393410
// checkResponse takes a http.Response and a variadic of ints representing
394411
// acceptable http status codes. The error returned will attempt to include
395412
// some content from the server's response.

scanner/enricher/csaf/fetcher_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@ package csaf
33
import (
44
"bufio"
55
"bytes"
6+
"context"
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/url"
11+
"sync/atomic"
612
"testing"
13+
"time"
714

815
"github.com/klauspost/compress/snappy"
916
"github.com/quay/claircore/test"
1017
testvex "github.com/quay/claircore/test/vex"
1118
"github.com/quay/claircore/toolkit/types/csaf"
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
1221
)
1322

1423
func TestFetchEnrichment(t *testing.T) {
@@ -57,3 +66,94 @@ func TestFetchEnrichment(t *testing.T) {
5766
t.Errorf("got %d entries but expected %d", lnCt, expectedLnCt)
5867
}
5968
}
69+
70+
func TestFetchAdvisory_RetriesOnFailure(t *testing.T) {
71+
origRetries := advisoryFetchRetries
72+
origBaseDelay := advisoryFetchRetryBaseDelay
73+
advisoryFetchRetries = 3
74+
advisoryFetchRetryBaseDelay = 1 * time.Millisecond
75+
t.Cleanup(func() {
76+
advisoryFetchRetries = origRetries
77+
advisoryFetchRetryBaseDelay = origBaseDelay
78+
})
79+
80+
validJSON := `{"document":{"tracking":{"id":"RHSA-2024:0001"}}}`
81+
82+
t.Run("succeeds after transient failure", func(t *testing.T) {
83+
var attempts atomic.Int32
84+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
85+
if attempts.Add(1) < 3 {
86+
w.WriteHeader(http.StatusInternalServerError)
87+
return
88+
}
89+
fmt.Fprint(w, validJSON)
90+
}))
91+
t.Cleanup(srv.Close)
92+
93+
e := &Enricher{c: srv.Client()}
94+
u, _ := url.Parse(srv.URL + "/advisory.json")
95+
var buf, bc bytes.Buffer
96+
var w bytes.Buffer
97+
err := e.fetchAdvisory(context.Background(), u, &buf, &bc, &w)
98+
require.NoError(t, err)
99+
assert.Equal(t, int32(3), attempts.Load())
100+
assert.Contains(t, w.String(), "RHSA-2024:0001")
101+
})
102+
103+
t.Run("returns error after all retries exhausted", func(t *testing.T) {
104+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
105+
w.WriteHeader(http.StatusNotFound)
106+
fmt.Fprint(w, "not found")
107+
}))
108+
t.Cleanup(srv.Close)
109+
110+
e := &Enricher{c: srv.Client()}
111+
u, _ := url.Parse(srv.URL + "/advisory.json")
112+
var buf, bc bytes.Buffer
113+
var w bytes.Buffer
114+
err := e.fetchAdvisory(context.Background(), u, &buf, &bc, &w)
115+
require.Error(t, err)
116+
assert.Contains(t, err.Error(), "404")
117+
assert.Empty(t, w.String())
118+
})
119+
}
120+
121+
func TestProcessChanges_SkipsUnavailableAdvisory(t *testing.T) {
122+
origRetries := advisoryFetchRetries
123+
advisoryFetchRetries = 1
124+
t.Cleanup(func() {
125+
advisoryFetchRetries = origRetries
126+
})
127+
128+
validJSON := `{"document":{"tracking":{"id":"RHSA-2024:0001"}}}`
129+
mux := http.NewServeMux()
130+
mux.HandleFunc("/changes.csv", func(w http.ResponseWriter, _ *http.Request) {
131+
w.Header().Set("Etag", "test-etag")
132+
// Two advisories: one available, one returning 404.
133+
fmt.Fprint(w,
134+
`"2024/rhsa-2024_0001.json","2025-01-10T18:37:32+00:00"`+"\n"+
135+
`"2024/rhsa-2024_0002.json","2025-01-10T18:37:32+00:00"`)
136+
})
137+
mux.HandleFunc("/2024/rhsa-2024_0001.json", func(w http.ResponseWriter, _ *http.Request) {
138+
fmt.Fprint(w, validJSON)
139+
})
140+
mux.HandleFunc("/2024/rhsa-2024_0002.json", func(w http.ResponseWriter, _ *http.Request) {
141+
w.WriteHeader(http.StatusNotFound)
142+
fmt.Fprint(w, "<!DOCTYPE html><html>not found</html>")
143+
})
144+
srv := httptest.NewServer(mux)
145+
t.Cleanup(srv.Close)
146+
147+
u, _ := url.Parse(srv.URL + "/")
148+
e := &Enricher{c: srv.Client(), base: u}
149+
fp := &fingerprint{}
150+
changed := map[string]bool{}
151+
var w bytes.Buffer
152+
153+
err := e.processChanges(context.Background(), &w, fp, changed)
154+
require.NoError(t, err)
155+
assert.True(t, changed["rhsa-2024_0001.json"], "available advisory should be marked as changed")
156+
assert.False(t, changed["rhsa-2024_0002.json"], "404'd advisory should not be marked as changed")
157+
assert.Contains(t, w.String(), "RHSA-2024:0001")
158+
assert.NotContains(t, w.String(), "RHSA-2024:0002")
159+
}

0 commit comments

Comments
 (0)