Skip to content

Commit 8808ed2

Browse files
🌱 Retry external network calls when publishing results (#1191)
* 🌱 Retries for signing the results with rekor. Signed-off-by: Spencer Schrock <[email protected]> Co-authored-by: naveensrinivasan <[email protected]> * add retry for webapp post Signed-off-by: Spencer Schrock <[email protected]> * add package comment to silence linter. Signed-off-by: Spencer Schrock <[email protected]> * Switch e2e test to unit test. And test backoff feature. Signed-off-by: Spencer Schrock <[email protected]> * Add test which counts number of retries. Signed-off-by: Spencer Schrock <[email protected]> --------- Signed-off-by: Spencer Schrock <[email protected]> Co-authored-by: naveensrinivasan <[email protected]>
1 parent 0eed6cb commit 8808ed2

File tree

2 files changed

+170
-26
lines changed

2 files changed

+170
-26
lines changed

signing/signing.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//
1515
// SPDX-License-Identifier: Apache-2.0
1616

17+
// Package signing provides functionality to sign and upload results to the Scorecard API.
1718
package signing
1819

1920
import (
@@ -23,6 +24,7 @@ import (
2324
"errors"
2425
"fmt"
2526
"io"
27+
"log"
2628
"net/http"
2729
"net/url"
2830
"os"
@@ -39,6 +41,13 @@ import (
3941
var (
4042
errorEmptyToken = errors.New("error token empty")
4143
errorInvalidToken = errors.New("invalid token")
44+
45+
// backoff schedule for interactions with cosign/rekor and our web API.
46+
backoffSchedule = []time.Duration{
47+
1 * time.Second,
48+
3 * time.Second,
49+
10 * time.Second,
50+
}
4251
)
4352

4453
// Signing is a signing structure.
@@ -79,10 +88,22 @@ func (s *Signing) SignScorecardResult(scorecardResultsFile string) error {
7988
SkipConfirmation: true, // skip cosign's privacy confirmation prompt as we run non-interactively
8089
}
8190

82-
// This command will use the provided OIDCIssuer to authenticate into Fulcio, which will generate the
83-
// signing certificate on the scorecard result. This attestation is then uploaded to the Rekor transparency log.
84-
// The output bytes (signature) and certificate are discarded since verification can be done with just the payload.
85-
if _, err := sign.SignBlobCmd(rootOpts, keyOpts, scorecardResultsFile, true, "", "", true); err != nil {
91+
var err error
92+
for _, backoff := range backoffSchedule {
93+
// This command will use the provided OIDCIssuer to authenticate into Fulcio, which will generate the
94+
// signing certificate on the scorecard result. This attestation is then uploaded to the Rekor transparency log.
95+
// The output bytes (signature) and certificate are discarded since verification can be done with just the payload.
96+
_, err = sign.SignBlobCmd(rootOpts, keyOpts, scorecardResultsFile, true, "", "", true)
97+
if err == nil {
98+
break
99+
}
100+
log.Printf("error signing scorecard results: %v\n", err)
101+
log.Printf("retrying in %v...\n", backoff)
102+
time.Sleep(backoff)
103+
}
104+
105+
// retries failed
106+
if err != nil {
86107
return fmt.Errorf("error signing payload: %w", err)
87108
}
88109

@@ -133,15 +154,34 @@ func (s *Signing) ProcessSignature(jsonPayload []byte, repoName, repoRef string)
133154
return fmt.Errorf("marshalling json results: %w", err)
134155
}
135156

136-
// Call scorecard-webapp-api to process and upload signature.
137-
// Setup HTTP request and context.
138157
apiURL := os.Getenv(options.EnvInputInternalPublishBaseURL)
139158
rawURL := fmt.Sprintf("%s/projects/github.com/%s", apiURL, repoName)
140-
parsedURL, err := url.Parse(rawURL)
159+
postURL, err := url.Parse(rawURL)
141160
if err != nil {
142161
return fmt.Errorf("parsing Scorecard API endpoint: %w", err)
143162
}
144-
req, err := http.NewRequest("POST", parsedURL.String(), bytes.NewBuffer(payloadBytes))
163+
164+
for _, backoff := range backoffSchedule {
165+
// Call scorecard-webapp-api to process and upload signature.
166+
err = postResults(postURL, payloadBytes)
167+
if err == nil {
168+
break
169+
}
170+
log.Printf("error sending scorecard results to webapp: %v\n", err)
171+
log.Printf("retrying in %v...\n", backoff)
172+
time.Sleep(backoff)
173+
}
174+
175+
// retries failed
176+
if err != nil {
177+
return fmt.Errorf("error sending scorecard results to webapp: %w", err)
178+
}
179+
180+
return nil
181+
}
182+
183+
func postResults(endpoint *url.URL, payload []byte) error {
184+
req, err := http.NewRequest("POST", endpoint.String(), bytes.NewBuffer(payload))
145185
if err != nil {
146186
return fmt.Errorf("creating HTTP request: %w", err)
147187
}

signing/signing_test.go

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
package signing
1818

1919
import (
20-
"fmt"
20+
"net/http"
21+
"net/http/httptest"
2122
"os"
2223
"testing"
24+
"time"
2325

2426
"github.com/ossf/scorecard-action/options"
2527
)
@@ -75,26 +77,128 @@ import (
7577
// }
7678
// }
7779

78-
// Test using scorecard results that have already been signed & uploaded.
79-
func Test_ProcessSignature(t *testing.T) {
80-
t.Parallel()
81-
82-
jsonPayload, err := os.ReadFile("testdata/results.json")
83-
repoName := "ossf-tests/scorecard-action"
84-
repoRef := "refs/heads/main"
85-
accessToken := os.Getenv("GITHUB_AUTH_TOKEN")
86-
os.Setenv(options.EnvInputInternalPublishBaseURL, "https://api.securityscorecards.dev")
80+
//nolint:paralleltest // we are using t.Setenv
81+
func TestProcessSignature(t *testing.T) {
82+
tests := []struct {
83+
name string
84+
payloadPath string
85+
status int
86+
wantErr bool
87+
}{
88+
{
89+
name: "post succeeded",
90+
status: http.StatusCreated,
91+
payloadPath: "testdata/results.json",
92+
wantErr: false,
93+
},
94+
{
95+
name: "post failed",
96+
status: http.StatusBadRequest,
97+
payloadPath: "testdata/results.json",
98+
wantErr: true,
99+
},
100+
}
101+
// use smaller backoffs for the test so they run faster
102+
setBackoffs(t, []time.Duration{0, time.Millisecond, 2 * time.Millisecond})
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
jsonPayload, err := os.ReadFile(tt.payloadPath)
106+
if err != nil {
107+
t.Fatalf("Unexpected error reading testdata: %v", err)
108+
}
109+
repoName := "ossf-tests/scorecard-action"
110+
repoRef := "refs/heads/main"
111+
//nolint:gosec // dummy credentials
112+
accessToken := "ghs_foo"
113+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+
w.WriteHeader(tt.status)
115+
}))
116+
t.Setenv(options.EnvInputInternalPublishBaseURL, server.URL)
117+
t.Cleanup(server.Close)
87118

88-
if err != nil {
89-
t.Errorf("Error reading testdata:, %v", err)
119+
s, err := New(accessToken)
120+
if err != nil {
121+
t.Fatalf("Unexpected error New: %v", err)
122+
}
123+
err = s.ProcessSignature(jsonPayload, repoName, repoRef)
124+
if (err != nil) != tt.wantErr {
125+
t.Errorf("ProcessSignature() error: %v, wantErr: %v", err, tt.wantErr)
126+
}
127+
})
90128
}
129+
}
91130

92-
s, err := New(accessToken)
93-
if err != nil {
94-
panic(fmt.Sprintf("error SigningNew: %v", err))
131+
//nolint:paralleltest // we are using t.Setenv
132+
func TestProcessSignature_retries(t *testing.T) {
133+
tests := []struct {
134+
name string
135+
nFailures int
136+
wantNRequests int
137+
wantErr bool
138+
}{
139+
{
140+
name: "succeeds immediately",
141+
nFailures: 0,
142+
wantNRequests: 1,
143+
wantErr: false,
144+
},
145+
{
146+
name: "one retry",
147+
nFailures: 1,
148+
wantNRequests: 2,
149+
wantErr: false,
150+
},
151+
{
152+
// limit corresponds to backoffs set in test body
153+
name: "retry limit exceeded",
154+
nFailures: 4,
155+
wantNRequests: 3,
156+
wantErr: true,
157+
},
95158
}
96-
if err := s.ProcessSignature(jsonPayload, repoName, repoRef); err != nil {
97-
t.Errorf("ProcessSignature() error:, %v", err)
98-
return
159+
// use smaller backoffs for the test so they run faster
160+
setBackoffs(t, []time.Duration{0, time.Millisecond, 2 * time.Millisecond})
161+
for _, tt := range tests {
162+
t.Run(tt.name, func(t *testing.T) {
163+
var jsonPayload []byte
164+
repoName := "ossf-tests/scorecard-action"
165+
repoRef := "refs/heads/main"
166+
//nolint:gosec // dummy credentials
167+
accessToken := "ghs_foo"
168+
var nRequests int
169+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
170+
nRequests++
171+
status := http.StatusCreated
172+
if tt.nFailures > 0 {
173+
status = http.StatusBadRequest
174+
tt.nFailures--
175+
}
176+
w.WriteHeader(status)
177+
}))
178+
t.Setenv(options.EnvInputInternalPublishBaseURL, server.URL)
179+
t.Cleanup(server.Close)
180+
181+
s, err := New(accessToken)
182+
if err != nil {
183+
t.Fatalf("Unexpected error New: %v", err)
184+
}
185+
err = s.ProcessSignature(jsonPayload, repoName, repoRef)
186+
if (err != nil) != tt.wantErr {
187+
t.Errorf("ProcessSignature() error: %v, wantErr: %v", err, tt.wantErr)
188+
}
189+
if nRequests != tt.wantNRequests {
190+
t.Errorf("ProcessSignature() made %d requests, wanted %d", nRequests, tt.wantNRequests)
191+
}
192+
})
99193
}
100194
}
195+
196+
// temporarily sets the backoffs for a given test.
197+
func setBackoffs(t *testing.T, newBackoffs []time.Duration) {
198+
t.Helper()
199+
old := backoffSchedule
200+
backoffSchedule = newBackoffs
201+
t.Cleanup(func() {
202+
backoffSchedule = old
203+
})
204+
}

0 commit comments

Comments
 (0)