Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🌱 Retry external network calls when publishing results #1191

Merged
merged 5 commits into from Jun 22, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
56 changes: 48 additions & 8 deletions signing/signing.go
Expand Up @@ -14,6 +14,7 @@
//
// SPDX-License-Identifier: Apache-2.0

// Package signing provides functionality to sign and upload results to the Scorecard API.
package signing

import (
Expand All @@ -23,6 +24,7 @@
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
Expand All @@ -39,6 +41,13 @@
var (
errorEmptyToken = errors.New("error token empty")
errorInvalidToken = errors.New("invalid token")

// backoff schedule for interactions with cosign/rekor and our web API.
backoffSchedule = []time.Duration{
1 * time.Second,
3 * time.Second,
10 * time.Second,
}
)

// Signing is a signing structure.
Expand Down Expand Up @@ -79,10 +88,22 @@
SkipConfirmation: true, // skip cosign's privacy confirmation prompt as we run non-interactively
}

// This command will use the provided OIDCIssuer to authenticate into Fulcio, which will generate the
// signing certificate on the scorecard result. This attestation is then uploaded to the Rekor transparency log.
// The output bytes (signature) and certificate are discarded since verification can be done with just the payload.
if _, err := sign.SignBlobCmd(rootOpts, keyOpts, scorecardResultsFile, true, "", "", true); err != nil {
var err error
for _, backoff := range backoffSchedule {
// This command will use the provided OIDCIssuer to authenticate into Fulcio, which will generate the
// signing certificate on the scorecard result. This attestation is then uploaded to the Rekor transparency log.
// The output bytes (signature) and certificate are discarded since verification can be done with just the payload.
_, err = sign.SignBlobCmd(rootOpts, keyOpts, scorecardResultsFile, true, "", "", true)
if err == nil {
break

Check warning on line 98 in signing/signing.go

View check run for this annotation

Codecov / codecov/patch

signing/signing.go#L91-L98

Added lines #L91 - L98 were not covered by tests
}
log.Printf("error signing scorecard results: %v\n", err)
log.Printf("retrying in %v...\n", backoff)
time.Sleep(backoff)

Check warning on line 102 in signing/signing.go

View check run for this annotation

Codecov / codecov/patch

signing/signing.go#L100-L102

Added lines #L100 - L102 were not covered by tests
}

// retries failed
if err != nil {

Check warning on line 106 in signing/signing.go

View check run for this annotation

Codecov / codecov/patch

signing/signing.go#L106

Added line #L106 was not covered by tests
return fmt.Errorf("error signing payload: %w", err)
}

Expand Down Expand Up @@ -133,15 +154,34 @@
return fmt.Errorf("marshalling json results: %w", err)
}

// Call scorecard-webapp-api to process and upload signature.
// Setup HTTP request and context.
apiURL := os.Getenv(options.EnvInputInternalPublishBaseURL)
rawURL := fmt.Sprintf("%s/projects/github.com/%s", apiURL, repoName)
parsedURL, err := url.Parse(rawURL)
postURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parsing Scorecard API endpoint: %w", err)
}
req, err := http.NewRequest("POST", parsedURL.String(), bytes.NewBuffer(payloadBytes))

for _, backoff := range backoffSchedule {
// Call scorecard-webapp-api to process and upload signature.
err = postResults(postURL, payloadBytes)
if err == nil {
break
}
log.Printf("error sending scorecard results to webapp: %v\n", err)
log.Printf("retrying in %v...\n", backoff)
time.Sleep(backoff)
}

// retries failed
if err != nil {
return fmt.Errorf("error sending scorecard results to webapp: %w", err)
}

return nil
}

func postResults(endpoint *url.URL, payload []byte) error {
req, err := http.NewRequest("POST", endpoint.String(), bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
Expand Down
140 changes: 122 additions & 18 deletions signing/signing_test.go
Expand Up @@ -17,9 +17,11 @@
package signing

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/ossf/scorecard-action/options"
)
Expand Down Expand Up @@ -75,26 +77,128 @@ import (
// }
// }

// Test using scorecard results that have already been signed & uploaded.
func Test_ProcessSignature(t *testing.T) {
t.Parallel()

jsonPayload, err := os.ReadFile("testdata/results.json")
repoName := "ossf-tests/scorecard-action"
repoRef := "refs/heads/main"
accessToken := os.Getenv("GITHUB_AUTH_TOKEN")
os.Setenv(options.EnvInputInternalPublishBaseURL, "https://api.securityscorecards.dev")
//nolint:paralleltest // we are using t.Setenv
func TestProcessSignature(t *testing.T) {
tests := []struct {
name string
payloadPath string
status int
wantErr bool
}{
{
name: "post succeeded",
status: http.StatusCreated,
payloadPath: "testdata/results.json",
wantErr: false,
},
{
name: "post failed",
status: http.StatusBadRequest,
payloadPath: "testdata/results.json",
wantErr: true,
},
}
// use smaller backoffs for the test so they run faster
setBackoffs(t, []time.Duration{0, time.Millisecond, 2 * time.Millisecond})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jsonPayload, err := os.ReadFile(tt.payloadPath)
if err != nil {
t.Fatalf("Unexpected error reading testdata: %v", err)
}
repoName := "ossf-tests/scorecard-action"
repoRef := "refs/heads/main"
//nolint:gosec // dummy credentials
accessToken := "ghs_foo"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
}))
t.Setenv(options.EnvInputInternalPublishBaseURL, server.URL)
t.Cleanup(server.Close)

if err != nil {
t.Errorf("Error reading testdata:, %v", err)
s, err := New(accessToken)
if err != nil {
t.Fatalf("Unexpected error New: %v", err)
}
err = s.ProcessSignature(jsonPayload, repoName, repoRef)
if (err != nil) != tt.wantErr {
t.Errorf("ProcessSignature() error: %v, wantErr: %v", err, tt.wantErr)
}
})
}
}

s, err := New(accessToken)
if err != nil {
panic(fmt.Sprintf("error SigningNew: %v", err))
//nolint:paralleltest // we are using t.Setenv
func TestProcessSignature_retries(t *testing.T) {
tests := []struct {
name string
nFailures int
wantNRequests int
wantErr bool
}{
{
name: "succeeds immediately",
nFailures: 0,
wantNRequests: 1,
wantErr: false,
},
{
name: "one retry",
nFailures: 1,
wantNRequests: 2,
wantErr: false,
},
{
// limit corresponds to backoffs set in test body
name: "retry limit exceeded",
nFailures: 4,
wantNRequests: 3,
wantErr: true,
},
}
if err := s.ProcessSignature(jsonPayload, repoName, repoRef); err != nil {
t.Errorf("ProcessSignature() error:, %v", err)
return
// use smaller backoffs for the test so they run faster
setBackoffs(t, []time.Duration{0, time.Millisecond, 2 * time.Millisecond})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var jsonPayload []byte
repoName := "ossf-tests/scorecard-action"
repoRef := "refs/heads/main"
//nolint:gosec // dummy credentials
accessToken := "ghs_foo"
var nRequests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nRequests++
status := http.StatusCreated
if tt.nFailures > 0 {
status = http.StatusBadRequest
tt.nFailures--
}
w.WriteHeader(status)
}))
t.Setenv(options.EnvInputInternalPublishBaseURL, server.URL)
t.Cleanup(server.Close)

s, err := New(accessToken)
if err != nil {
t.Fatalf("Unexpected error New: %v", err)
}
err = s.ProcessSignature(jsonPayload, repoName, repoRef)
if (err != nil) != tt.wantErr {
t.Errorf("ProcessSignature() error: %v, wantErr: %v", err, tt.wantErr)
}
if nRequests != tt.wantNRequests {
t.Errorf("ProcessSignature() made %d requests, wanted %d", nRequests, tt.wantNRequests)
}
})
}
}

// temporarily sets the backoffs for a given test.
func setBackoffs(t *testing.T, newBackoffs []time.Duration) {
t.Helper()
old := backoffSchedule
backoffSchedule = newBackoffs
t.Cleanup(func() {
backoffSchedule = old
})
}