Merge pull request #10943 from malancas/gh-attestation-tuf-client-retry

Add retry logic when fetching TUF content in `gh attestation` commands
This commit is contained in:
Meredith Lancaster 2025-05-28 13:16:18 -06:00 committed by GitHub
commit c50cdbd0c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 115 additions and 92 deletions

4
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cenkalti/backoff/v5 v5.0.2
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3
github.com/charmbracelet/huh v0.7.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
@ -48,6 +49,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/theupdateframework/go-tuf/v2 v2.1.1
github.com/yuin/goldmark v1.7.12
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.38.0
@ -73,7 +75,6 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
@ -167,7 +168,6 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/theupdateframework/go-tuf v0.7.0 // indirect
github.com/theupdateframework/go-tuf/v2 v2.1.1 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/transparency-dev/merkle v0.0.2 // indirect

View file

@ -77,13 +77,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
opts.Hostname, _ = ghauth.DefaultHost()
}
err := auth.IsHostSupported(opts.Hostname)
if err := auth.IsHostSupported(opts.Hostname); err != nil {
return err
}
hc, err := f.HttpClient()
if err != nil {
return err
}
config := verification.SigstoreConfig{
Logger: opts.Logger,
HttpClient: hc,
Logger: opts.Logger,
}
if ghauth.IsTenancy(opts.Hostname) {

View file

@ -0,0 +1,45 @@
//go:build integration
package inspect
import (
"bytes"
"fmt"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) {
testIO, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: testIO,
HttpClient: func() (*http.Client, error) {
return http.DefaultClient, nil
},
}
t.Run("Print output in JSON format", func(t *testing.T) {
var opts *Options
cmd := NewInspectCmd(f, func(o *Options) error {
opts = o
return nil
})
argv := strings.Split(fmt.Sprintf("%s --format json", bundlePath), " ")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, bundlePath, opts.BundlePath)
assert.NotNil(t, opts.Logger)
assert.NotNil(t, opts.exporter)
})
}

View file

@ -1,11 +1,7 @@
package inspect
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
@ -14,7 +10,6 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
@ -26,66 +21,7 @@ const (
SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/"
)
var (
bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
)
func TestNewInspectCmd(t *testing.T) {
testIO, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: testIO,
HttpClient: func() (*http.Client, error) {
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return client, nil
},
}
testcases := []struct {
name string
cli string
wants Options
wantsErr bool
wantsExporter bool
}{
{
name: "Prints output in JSON format",
cli: fmt.Sprintf("%s --format json", bundlePath),
wants: Options{
BundlePath: bundlePath,
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsExporter: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
var opts *Options
cmd := NewInspectCmd(f, func(o *Options) error {
opts = o
return nil
})
argv := strings.Split(tc.cli, " ")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tc.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.wants.BundlePath, opts.BundlePath)
assert.NotNil(t, opts.Logger)
assert.Equal(t, tc.wantsExporter, opts.exporter != nil)
})
}
}
var bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
func TestRunInspect(t *testing.T) {
opts := Options{

View file

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -68,6 +69,10 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
return err
}
hc, err := f.HttpClient()
if err != nil {
return err
}
if ghauth.IsTenancy(opts.Hostname) {
c, err := f.Config()
if err != nil {
@ -77,11 +82,6 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
if !c.Authentication().HasActiveToken(opts.Hostname) {
return fmt.Errorf("not authenticated with %s", opts.Hostname)
}
hc, err := f.HttpClient()
if err != nil {
return err
}
logger := io.NewHandler(f.IOStreams)
apiClient := api.NewLiveClient(hc, opts.Hostname, logger)
td, err := apiClient.GetTrustDomain()
@ -95,7 +95,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
return runF(opts)
}
if err := getTrustedRoot(tuf.New, opts); err != nil {
if err := getTrustedRoot(tuf.New, opts, hc); err != nil {
return fmt.Errorf("Failed to verify the TUF repository: %w", err)
}
@ -118,11 +118,11 @@ type tufConfig struct {
targets []string
}
func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options, hc *http.Client) error {
var tufOptions []tufConfig
var defaultTR = "trusted_root.json"
tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string]())
tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), hc)
// Disable local caching, so we get up-to-date response from TUF repository
tufOpt.CacheValidity = 0
@ -151,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
targets: []string{defaultTR},
})
tufOpt = verification.GitHubTUFOptions(o.None[string]())
tufOpt = verification.GitHubTUFOptions(o.None[string](), hc)
tufOpt.CacheValidity = 0
tufOptions = append(tufOptions, tufConfig{
tufOptions: tufOpt,

View file

@ -28,6 +28,12 @@ func TestNewTrustedRootCmd(t *testing.T) {
Config: func() (gh.Config, error) {
return &ghmock.ConfigMock{}, nil
},
HttpClient: func() (*http.Client, error) {
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return client, nil
},
}
testcases := []struct {
@ -113,6 +119,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) {
},
}, nil
},
HttpClient: httpClientFunc,
}
cmd := NewTrustedRootCmd(f, func(_ *Options) error {
@ -171,15 +178,19 @@ func TestGetTrustedRoot(t *testing.T) {
TufRootPath: root,
}
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
t.Run("failed to create TUF root", func(t *testing.T) {
err := getTrustedRoot(newTUFErrClient, opts)
err := getTrustedRoot(newTUFErrClient, opts, client)
require.Error(t, err)
require.ErrorContains(t, err, "failed to create TUF client")
})
t.Run("fails because the root cannot be found", func(t *testing.T) {
opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json")
err := getTrustedRoot(tuf.New, opts)
err := getTrustedRoot(tuf.New, opts, client)
require.Error(t, err)
require.ErrorContains(t, err, "failed to read root file")
})

View file

@ -6,6 +6,7 @@ import (
"crypto/x509"
"errors"
"fmt"
"net/http"
"os"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -33,6 +34,7 @@ type SigstoreConfig struct {
TrustedRoot string
Logger *io.Handler
NoPublicGood bool
HttpClient *http.Client
// If tenancy mode is not used, trust domain is empty
TrustDomain string
// TUFMetadataDir
@ -71,13 +73,13 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro
return liveVerifier, nil
}
if !config.NoPublicGood {
publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir)
publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient)
if err != nil {
return nil, err
}
liveVerifier.PublicGood = publicGoodVerifier
}
github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir)
github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient)
if err != nil {
return nil, err
}
@ -314,10 +316,10 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.Verifier, error)
return gv, nil
}
func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.Verifier, error) {
func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) {
var tr string
opts := GitHubTUFOptions(tufMetadataDir)
opts := GitHubTUFOptions(tufMetadataDir, hc)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)
@ -348,8 +350,8 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Ve
return gv, nil
}
func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.Verifier, error) {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
func newPublicGoodVerifier(tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)

View file

@ -3,6 +3,7 @@
package verification
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -51,6 +52,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -71,6 +73,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("with 2/3 verified attestations", func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -89,6 +92,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -114,6 +118,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -128,6 +133,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
TUFMetadataDir: o.Some(t.TempDir()),

View file

@ -2,12 +2,15 @@ package verification
import (
_ "embed"
"net/http"
"os"
"path/filepath"
"github.com/cenkalti/backoff/v5"
o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/sigstore/sigstore-go/pkg/tuf"
"github.com/theupdateframework/go-tuf/v2/metadata/fetcher"
)
//go:embed embed/tuf-repo.github.com/root.json
@ -15,7 +18,7 @@ var githubRoot []byte
const GitHubTUFMirror = "https://tuf-repo.github.com"
func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Options {
func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options {
opts := tuf.DefaultOptions()
// The CODESPACES environment variable will be set to true in a Codespaces workspace
@ -32,11 +35,18 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Option
// Allow TUF cache for 1 day
opts.CacheValidity = 1
// configure fetcher timeout and retry
f := fetcher.NewDefaultFetcher()
f.SetHTTPClient(hc)
retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)}
f.SetRetryOptions(retryOptions...)
opts.WithFetcher(f)
return opts
}
func GitHubTUFOptions(tufMetadataDir o.Option[string]) *tuf.Options {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
func GitHubTUFOptions(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc)
opts.Root = githubRoot
opts.RepositoryBaseURL = GitHubTUFMirror

View file

@ -12,7 +12,7 @@ import (
func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) {
os.Setenv("CODESPACES", "true")
opts := GitHubTUFOptions(o.None[string]())
opts := GitHubTUFOptions(o.None[string](), nil)
require.Equal(t, GitHubTUFMirror, opts.RepositoryBaseURL)
require.NotNil(t, opts.Root)
@ -21,6 +21,6 @@ func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) {
}
func TestGitHubTUFOptionsWithMetadataDir(t *testing.T) {
opts := GitHubTUFOptions(o.Some("anything"))
opts := GitHubTUFOptions(o.Some("anything"), nil)
require.Equal(t, "anything", opts.CachePath)
}

View file

@ -3,6 +3,7 @@
package verify
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -26,6 +27,7 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
func TestVerifyAttestations(t *testing.T) {
sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})

View file

@ -186,9 +186,10 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger)
config := verification.SigstoreConfig{
TrustedRoot: opts.TrustedRoot,
HttpClient: hc,
Logger: opts.Logger,
NoPublicGood: opts.NoPublicGood,
TrustedRoot: opts.TrustedRoot,
}
// Prepare for tenancy if detected

View file

@ -3,6 +3,7 @@
package verify
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -20,6 +21,7 @@ func TestVerifyIntegration(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -136,6 +138,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -209,6 +212,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -301,6 +305,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}