Skip to content

Commit 83cef08

Browse files
Add ability to purge cached results from a CDN
Signed-off-by: Spencer Schrock <[email protected]>
1 parent c363f9f commit 83cef08

6 files changed

Lines changed: 175 additions & 1 deletion

File tree

cron/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const (
5656
apiResultsBucketURL string = "SCORECARD_API_RESULTS_BUCKET_URL"
5757
inputBucketURL string = "SCORECARD_INPUT_BUCKET_URL"
5858
inputBucketPrefix string = "SCORECARD_INPUT_BUCKET_PREFIX"
59+
apiBaseURL string = "SCORECARD_API_BASE_URL"
5960
)
6061

6162
var (
@@ -357,3 +358,8 @@ func GetScorecardValues() (map[string]string, error) {
357358
func GetCriticalityValues() (map[string]string, error) {
358359
return GetAdditionalParams("criticality")
359360
}
361+
362+
// GetAPIBaseURL returns the base URL for the Scorecard API.
363+
func GetAPIBaseURL() (string, error) {
364+
return getStringConfigValue(apiBaseURL, configYAML, "APIBaseURL", "api-base-url")
365+
}

cron/config/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ additional-params:
3939
prefix-file:
4040

4141
scorecard:
42+
api-base-url: https://cdn.scorecard.dev
4243
# API results bucket
4344
api-results-bucket-url: gs://ossf-scorecard-cron-results
4445
# TODO: Temporarily remove SAST and CI-Tests which require lot of GitHub API tokens.

cron/internal/cdn/client.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2026 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package cdn implements clients for CDN operations.
16+
package cdn
17+
18+
import (
19+
"context"
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
"strings"
24+
)
25+
26+
var errPurgeFailed = errors.New("purge failed")
27+
28+
// Purger is the interface for purging URLs from a CDN.
29+
type Purger interface {
30+
Purge(ctx context.Context, url string) error
31+
}
32+
33+
// FastlyClient implements Purger for Fastly.
34+
type FastlyClient struct {
35+
token string
36+
baseURL string
37+
}
38+
39+
// NewFastlyClient creates a new FastlyClient.
40+
func NewFastlyClient(token, baseURL string) *FastlyClient {
41+
baseURL = strings.TrimSuffix(baseURL, "/")
42+
return &FastlyClient{token: token, baseURL: baseURL}
43+
}
44+
45+
// Purge purges the given URL from Fastly.
46+
// It sends a PURGE request to the given URL with the Fastly-Key header.
47+
func (c *FastlyClient) Purge(ctx context.Context, path string) error {
48+
req, err := http.NewRequestWithContext(ctx, "PURGE", c.baseURL+path, nil)
49+
if err != nil {
50+
return fmt.Errorf("http.NewRequest: %w", err)
51+
}
52+
req.Header.Set("Fastly-Key", c.token)
53+
54+
resp, err := http.DefaultClient.Do(req)
55+
if err != nil {
56+
return fmt.Errorf("http.Do: %w", err)
57+
}
58+
defer resp.Body.Close()
59+
60+
if resp.StatusCode != http.StatusOK {
61+
return fmt.Errorf("%w: %s", errPurgeFailed, resp.Status)
62+
}
63+
return nil
64+
}
65+
66+
// NoOpClient is a no-op implementation of PurgeClient.
67+
type NoOpClient struct{}
68+
69+
// NewNoOpClient creates a new NoOpClient.
70+
func NewNoOpClient() *NoOpClient {
71+
return &NoOpClient{}
72+
}
73+
74+
// Purge does nothing.
75+
func (c *NoOpClient) Purge(ctx context.Context, url string) error {
76+
return nil
77+
}

cron/internal/cdn/client_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cdn
16+
17+
import (
18+
"context"
19+
"net/http"
20+
"net/http/httptest"
21+
"testing"
22+
)
23+
24+
const token = "test-token"
25+
26+
func TestFastlyClient_Purge(t *testing.T) {
27+
t.Parallel()
28+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
if r.Method != "PURGE" {
30+
t.Errorf("expected method PURGE, got %s", r.Method)
31+
}
32+
if r.Header.Get("Fastly-Key") != token {
33+
t.Errorf("expected Fastly-Key header %s, got %s", token, r.Header.Get("Fastly-Key"))
34+
}
35+
w.WriteHeader(http.StatusOK)
36+
}))
37+
defer server.Close()
38+
client := NewFastlyClient(token, server.URL)
39+
if err := client.Purge(context.Background(), "/foo"); err != nil {
40+
t.Errorf("unexpected error: %v", err)
41+
}
42+
}
43+
44+
func TestFastlyClient_Purge_Error(t *testing.T) {
45+
t.Parallel()
46+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
w.WriteHeader(http.StatusInternalServerError)
48+
}))
49+
defer server.Close()
50+
client := NewFastlyClient(token, server.URL)
51+
if err := client.Purge(context.Background(), "/foo"); err == nil {
52+
t.Error("expected error, got nil")
53+
}
54+
}

cron/internal/worker/main.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"fmt"
2424
"net/http"
2525
_ "net/http/pprof" //nolint:gosec
26+
"os"
2627
"strings"
2728

2829
"go.opencensus.io/stats/view"
@@ -35,6 +36,7 @@ import (
3536
"github.com/ossf/scorecard/v5/clients/ossfuzz"
3637
"github.com/ossf/scorecard/v5/cron/config"
3738
"github.com/ossf/scorecard/v5/cron/data"
39+
"github.com/ossf/scorecard/v5/cron/internal/cdn"
3840
format "github.com/ossf/scorecard/v5/cron/internal/format"
3941
"github.com/ossf/scorecard/v5/cron/monitoring"
4042
"github.com/ossf/scorecard/v5/cron/worker"
@@ -89,6 +91,7 @@ type ScorecardWorker struct {
8991
ciiClient clients.CIIBestPracticesClient
9092
ossFuzzRepoClient clients.RepoClient
9193
vulnsClient clients.VulnerabilitiesClient
94+
purgeClient cdn.Purger
9295
apiBucketURL string
9396
rawBucketURL string
9497
blacklistedChecks []string
@@ -131,6 +134,25 @@ func newScorecardWorker() (*ScorecardWorker, error) {
131134
}
132135
sw.vulnsClient = clients.DefaultVulnerabilitiesClient()
133136

137+
// Use STORAGE_EMULATOR_HOST to determine if we're testing the worker locally,
138+
// in which case we don't want to purge the CDN.
139+
if os.Getenv("STORAGE_EMULATOR_HOST") != "" {
140+
sw.logger.Info("API result CDN purging disabled, STORAGE_EMULATOR_HOST is set")
141+
sw.purgeClient = cdn.NewNoOpClient()
142+
} else {
143+
apiBaseURL, err := config.GetAPIBaseURL()
144+
if err != nil {
145+
sw.logger.Info("API result CDN purging disabled, SCORECARD_API_BASE_URL not set")
146+
sw.purgeClient = cdn.NewNoOpClient()
147+
} else if purgeToken := os.Getenv("FASTLY_PURGE_TOKEN"); purgeToken == "" {
148+
sw.logger.Info("API result CDN purging disabled, FASTLY_PURGE_TOKEN not set")
149+
sw.purgeClient = cdn.NewNoOpClient()
150+
} else {
151+
sw.logger.Info("API result CDN purging enabled for " + apiBaseURL)
152+
sw.purgeClient = cdn.NewFastlyClient(purgeToken, apiBaseURL)
153+
}
154+
}
155+
134156
if sw.exporter, err = startMetricsExporter(); err != nil {
135157
return nil, fmt.Errorf("startMetricsExporter: %w", err)
136158
}
@@ -152,7 +174,7 @@ func (sw *ScorecardWorker) Close() {
152174
func (sw *ScorecardWorker) Process(ctx context.Context, req *data.ScorecardBatchRequest, bucketURL string) error {
153175
return processRequest(ctx, req, sw.blacklistedChecks, bucketURL, sw.rawBucketURL, sw.apiBucketURL,
154176
sw.checkDocs, sw.githubClient, sw.gitlabClient, sw.ossFuzzRepoClient, sw.ciiClient,
155-
sw.vulnsClient, sw.logger)
177+
sw.vulnsClient, sw.purgeClient, sw.logger)
156178
}
157179

158180
func (sw *ScorecardWorker) PostProcess() {
@@ -167,6 +189,7 @@ func processRequest(ctx context.Context,
167189
githubClient, gitlabClient clients.RepoClient, ossFuzzRepoClient clients.RepoClient,
168190
ciiClient clients.CIIBestPracticesClient,
169191
vulnsClient clients.VulnerabilitiesClient,
192+
purgeClient cdn.Purger,
170193
logger *log.Logger,
171194
) error {
172195
filename := worker.ResultFilename(batchRequest)
@@ -276,10 +299,18 @@ func processRequest(ctx context.Context,
276299
if err := data.WriteToBlobStore(ctx, apiBucketURL, exportPath, exportBuffer.Bytes()); err != nil {
277300
return fmt.Errorf("error during writing to exportBucketURL: %w", err)
278301
}
302+
path := fmt.Sprintf("/projects/%s", repo.URI())
303+
if err := purgeClient.Purge(ctx, path); err != nil {
304+
logger.Info(fmt.Sprintf("failed to purge CDN for %s: %v", path, err))
305+
}
279306
// Export result based on commitSHA.
280307
if err := data.WriteToBlobStore(ctx, apiBucketURL, exportCommitSHAPath, exportBuffer.Bytes()); err != nil {
281308
return fmt.Errorf("error during exportBucketURL with commit SHA: %w", err)
282309
}
310+
path = fmt.Sprintf("/projects/%s?commit=%s", repo.URI(), result.Repo.CommitSHA)
311+
if err := purgeClient.Purge(ctx, path); err != nil {
312+
logger.Info(fmt.Sprintf("failed to purge CDN for %s: %v", path, err))
313+
}
283314
// Export raw result.
284315
if err := data.WriteToBlobStore(ctx, apiBucketURL, exportRawPath, exportRawBuffer.Bytes()); err != nil {
285316
return fmt.Errorf("error during writing to exportBucketURL for raw results: %w", err)

cron/k8s/worker.release.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ spec:
5757
secretKeyRef:
5858
name: gitlab
5959
key: auth_token
60+
- name: FASTLY_PURGE_TOKEN
61+
valueFrom:
62+
secretKeyRef:
63+
name: fastly
64+
key: purge_token
6065
- name: "SCORECARD_API_RESULTS_BUCKET_URL"
6166
value: "gs://ossf-scorecard-cron-releasetest-results"
6267
volumeMounts:

0 commit comments

Comments
 (0)