-
Notifications
You must be signed in to change notification settings - Fork 7.4k
Update gh attestation attestation bundle fetching logic
#10185
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
Changes from 34 commits
bfd140c
7160f7e
5a6a796
e51b4ef
6b95175
45121f6
8f5d710
fb020f2
e4431a3
ab4912f
706314b
9051da3
fc984c9
311f2b2
070b67e
e03a36e
b1af4b0
0202ca8
fb4fc7e
6986511
9ecd90c
e34e188
ecf55c6
9d88ca8
7838e91
3c0280c
37e0969
0a602fa
258c69c
f46cccb
d4f3fbc
42cb254
51a74ae
8d89dd9
33d0002
8ad877b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,11 @@ import ( | |
| "github.com/cenkalti/backoff/v4" | ||
| "github.com/cli/cli/v2/api" | ||
| ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io" | ||
| "github.com/golang/snappy" | ||
| v1 "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" | ||
| "github.com/sigstore/sigstore-go/pkg/bundle" | ||
| "golang.org/x/sync/errgroup" | ||
| "google.golang.org/protobuf/encoding/protojson" | ||
| ) | ||
|
|
||
| const ( | ||
|
|
@@ -19,28 +24,39 @@ const ( | |
| maxLimitForFetch = 100 | ||
| ) | ||
|
|
||
| type apiClient interface { | ||
| // Allow injecting backoff interval in tests. | ||
| var getAttestationRetryInterval = time.Millisecond * 200 | ||
|
|
||
| // githubApiClient makes REST calls to the GitHub API | ||
| type githubApiClient interface { | ||
| REST(hostname, method, p string, body io.Reader, data interface{}) error | ||
| RESTWithNext(hostname, method, p string, body io.Reader, data interface{}) (string, error) | ||
| } | ||
|
|
||
| // httpClient makes HTTP calls to all non-GitHub API endpoints | ||
| type httpClient interface { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because the bundle URL we use to fetch the bundle is not part of the GitHub API, we need to use regular HTTP client. |
||
| Get(url string) (*http.Response, error) | ||
| } | ||
|
|
||
| type Client interface { | ||
| GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) | ||
| GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) | ||
| GetTrustDomain() (string, error) | ||
| } | ||
|
|
||
| type LiveClient struct { | ||
| api apiClient | ||
| host string | ||
| logger *ioconfig.Handler | ||
| githubAPI githubApiClient | ||
| httpClient httpClient | ||
| host string | ||
| logger *ioconfig.Handler | ||
| } | ||
|
|
||
| func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient { | ||
| return &LiveClient{ | ||
| api: api.NewClientFromHTTP(hc), | ||
| host: strings.TrimSuffix(host, "/"), | ||
| logger: l, | ||
| githubAPI: api.NewClientFromHTTP(hc), | ||
| host: strings.TrimSuffix(host, "/"), | ||
| httpClient: hc, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use the same http.Client provided by the Factory object when the subcommand is invoked to make requests with bundle URLs. |
||
| logger: l, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -52,7 +68,17 @@ func (c *LiveClient) BuildRepoAndDigestURL(repo, digest string) string { | |
| // GetByRepoAndDigest fetches the attestation by repo and digest | ||
| func (c *LiveClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) { | ||
| url := c.BuildRepoAndDigestURL(repo, digest) | ||
| return c.getAttestations(url, repo, digest, limit) | ||
| attestations, err := c.getAttestations(url, repo, digest, limit) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| bundles, err := c.fetchBundlesByURL(attestations) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch bundle with URL: %w", err) | ||
| } | ||
|
|
||
| return bundles, nil | ||
| } | ||
|
|
||
| func (c *LiveClient) BuildOwnerAndDigestURL(owner, digest string) string { | ||
|
|
@@ -63,7 +89,21 @@ func (c *LiveClient) BuildOwnerAndDigestURL(owner, digest string) string { | |
| // GetByOwnerAndDigest fetches attestation by owner and digest | ||
| func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) { | ||
| url := c.BuildOwnerAndDigestURL(owner, digest) | ||
| return c.getAttestations(url, owner, digest, limit) | ||
| attestations, err := c.getAttestations(url, owner, digest, limit) | ||
malancas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if len(attestations) == 0 { | ||
| return nil, newErrNoAttestations(owner, digest) | ||
| } | ||
|
|
||
| bundles, err := c.fetchBundlesByURL(attestations) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch bundle with URL: %w", err) | ||
| } | ||
|
|
||
| return bundles, nil | ||
| } | ||
|
|
||
| // GetTrustDomain returns the current trust domain. If the default is used | ||
|
|
@@ -72,9 +112,6 @@ func (c *LiveClient) GetTrustDomain() (string, error) { | |
| return c.getTrustDomain(MetaPath) | ||
| } | ||
|
|
||
| // Allow injecting backoff interval in tests. | ||
| var getAttestationRetryInterval = time.Millisecond * 200 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved this to the top of the file alongside other variables and consts. |
||
|
|
||
| func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*Attestation, error) { | ||
| c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) | ||
|
|
||
|
|
@@ -97,7 +134,7 @@ func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*At | |
| // if no attestation or less than limit, then keep fetching | ||
| for url != "" && len(attestations) < limit { | ||
| err := backoff.Retry(func() error { | ||
| newURL, restErr := c.api.RESTWithNext(c.host, http.MethodGet, url, nil, &resp) | ||
| newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp) | ||
|
|
||
| if restErr != nil { | ||
| if shouldRetry(restErr) { | ||
|
|
@@ -130,6 +167,69 @@ func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*At | |
| return attestations, nil | ||
| } | ||
|
|
||
| func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([]*Attestation, error) { | ||
| fetched := make([]*Attestation, len(attestations)) | ||
| g := errgroup.Group{} | ||
| for i, a := range attestations { | ||
|
||
| g.Go(func() error { | ||
| b, err := c.GetBundle(url) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to fetch bundle with URL: %w", err) | ||
| } | ||
| fetched[i] = &Attestation{ | ||
| Bundle: b, | ||
| } | ||
| return nil | ||
| }) | ||
| } | ||
|
|
||
| if err := g.Wait(); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For my own edification: if any of these go funcs errors out, then we bail on everything. |
||
| return nil, err | ||
| } | ||
|
|
||
| return fetched, nil | ||
| } | ||
|
|
||
| func (c *LiveClient) fetchBundleByURL(a *Attestation) (*bundle.Bundle, error) { | ||
| // for now, we fallback to the bundle field if the bundle URL is empty | ||
| if a.BundleURL == "" { | ||
malancas marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| c.logger.VerbosePrintf("Bundle URL is empty. Falling back to bundle field\n\n") | ||
| return a.Bundle, nil | ||
malancas marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| c.logger.VerbosePrintf("Fetching attestation bundle with bundle URL\n\n") | ||
|
|
||
| resp, err := c.httpClient.Get(a.BundleURL) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| defer resp.Body.Close() | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) | ||
| } | ||
|
|
||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read blob storage response body: %w", err) | ||
| } | ||
|
|
||
| var out []byte | ||
| decompressed, err := snappy.Decode(out, body) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to decompress with snappy: %w", err) | ||
| } | ||
|
|
||
| var pbBundle v1.Bundle | ||
| if err = protojson.Unmarshal(decompressed, &pbBundle); err != nil { | ||
| return nil, fmt.Errorf("failed to unmarshal to bundle: %w", err) | ||
| } | ||
|
|
||
| c.logger.VerbosePrintf("Successfully fetched bundle\n\n") | ||
|
|
||
| return bundle.NewBundle(&pbBundle) | ||
| } | ||
|
|
||
| func shouldRetry(err error) bool { | ||
| var httpError api.HTTPError | ||
| if errors.As(err, &httpError) { | ||
|
|
@@ -146,7 +246,7 @@ func (c *LiveClient) getTrustDomain(url string) (string, error) { | |
|
|
||
| bo := backoff.NewConstantBackOff(getAttestationRetryInterval) | ||
| err := backoff.Retry(func() error { | ||
| restErr := c.api.REST(c.host, http.MethodGet, url, nil, &resp) | ||
| restErr := c.githubAPI.REST(c.host, http.MethodGet, url, nil, &resp) | ||
| if restErr != nil { | ||
| if shouldRetry(restErr) { | ||
| return restErr | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the client that implements this interface should only be used for making requests against the GitHub API, I'm renaming this interface to more explicitly reference the GitHub API.