Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ gh api \
| `--server-url` | No | `SERVER_URL`, `BIFROST_SERVER_URL` | URL to the bifrost server. |
| `--retry-attempts` | No | | Number of retry attempts for transient upload failures. |
| `--retry-delay` | No | | Delay between upload retry attempts. |
| `--http-timeout` | No | | Maximum duration to wait for an HTTP upload request. Defaults to 30 seconds. |
| `--git-branch` | No | | Git branch name to attach to the upload. |
| `--git-commit-sha` | No | | Git commit SHA to attach to the upload. |
| `--git-origin` | No | | Git origin URL to attach to the upload. |
Expand Down
12 changes: 10 additions & 2 deletions internal/bifrost/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
DefaultServerURL = "https://portal.bifrostsec.com"
DefaultRetryAttempts = 3
DefaultRetryDelay = 2 * time.Second
DefaultHTTPTimeout = 30 * time.Second
userAgentProduct = "bifrost-cli"
)

Expand All @@ -30,6 +31,7 @@ type APIConfig struct {
Token string
RetryAttempts int
RetryDelay time.Duration
HTTPTimeout time.Duration
RetryOutput io.Writer
GitBranch string
GitCommitSHA string
Expand All @@ -50,11 +52,14 @@ func NewAPI(cfg APIConfig) API {
if cfg.RetryDelay < 0 {
cfg.RetryDelay = 0
}
if cfg.HTTPTimeout <= 0 {
cfg.HTTPTimeout = DefaultHTTPTimeout
}
if cfg.RetryOutput == nil {
cfg.RetryOutput = os.Stderr
}
return &api{
client: http.Client{},
client: http.Client{Timeout: cfg.HTTPTimeout},
cfg: cfg,
Comment thread
alexanderbsingh marked this conversation as resolved.
Comment thread
alexanderbsingh marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -91,6 +96,9 @@ func (a *api) uploadSBOM(ctx context.Context, service string, serviceVersion str
if err == nil {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
if attempt == a.cfg.RetryAttempts || !shouldRetry(err) {
return err
}
Expand Down Expand Up @@ -214,7 +222,7 @@ func (e *requestError) Unwrap() error {
}

func shouldRetry(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
if errors.Is(err, context.Canceled) {
return false
}
var uploadErr *uploadError
Expand Down
81 changes: 79 additions & 2 deletions internal/bifrost/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,70 @@ func TestAPI_UploadSBOM_DoesNotRetryClientFailure(t *testing.T) {
assert.EqualValues(t, 1, attempts.Load())
}

func TestAPI_UploadSBOM_RetriesHTTPTimeout(t *testing.T) {
var attempts atomic.Int32
releaseFirstRequest := make(chan struct{})
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if attempts.Add(1) == 1 {
select {
case <-r.Context().Done():
case <-releaseFirstRequest:
}
return
}
w.WriteHeader(http.StatusOK)
}))
defer httpServer.Close()
defer close(releaseFirstRequest)

path := "test-sbom.json"
assert.NoError(t, os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644))
defer func() {
_ = os.Remove(path)
}()

cfg := newTestAPIConfig(httpServer.URL)
cfg.HTTPTimeout = 100 * time.Millisecond
cfg.RetryAttempts = 1
cfg.RetryDelay = time.Millisecond
api := NewAPI(cfg)
Comment thread
alexanderbsingh marked this conversation as resolved.

err := api.UploadSBOMFile(context.Background(), "test-service", "test-version", path)
assert.NoError(t, err)
assert.EqualValues(t, 2, attempts.Load())
}

func TestAPI_UploadSBOM_DoesNotRetryExpiredContext(t *testing.T) {
var attempts atomic.Int32
releaseRequest := make(chan struct{})
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
select {
case <-r.Context().Done():
case <-releaseRequest:
}
}))
defer httpServer.Close()
defer close(releaseRequest)

path := "test-sbom.json"
assert.NoError(t, os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644))
defer func() {
_ = os.Remove(path)
}()

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
cfg := newTestAPIConfig(httpServer.URL)
cfg.HTTPTimeout = time.Second
cfg.RetryAttempts = 1
api := NewAPI(cfg)

err := api.UploadSBOMFile(ctx, "test-service", "test-version", path)
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.EqualValues(t, 1, attempts.Load())
}

func TestAPI_NewAPI_NormalizesNegativeRetryConfiguration(t *testing.T) {
client := NewAPI(APIConfig{
ServerURL: "https://example.com",
Expand All @@ -312,16 +376,29 @@ func TestAPI_NewAPI_NormalizesNegativeRetryConfiguration(t *testing.T) {
assert.True(t, ok)
assert.Equal(t, 0, internalAPI.cfg.RetryAttempts)
assert.Equal(t, time.Duration(0), internalAPI.cfg.RetryDelay)
assert.Equal(t, DefaultHTTPTimeout, internalAPI.cfg.HTTPTimeout)
assert.Equal(t, DefaultHTTPTimeout, internalAPI.client.Timeout)
}

func TestAPI_NewAPI_UsesConfiguredHTTPTimeout(t *testing.T) {
client := NewAPI(APIConfig{
ServerURL: "https://example.com",
Token: "test-token",
HTTPTimeout: 5 * time.Second,
})
internalAPI, ok := client.(*api)
assert.True(t, ok)
assert.Equal(t, 5*time.Second, internalAPI.client.Timeout)
}

func TestShouldRetry_ContextCancellationIsNotRetryable(t *testing.T) {
assert.False(t, shouldRetry(&requestError{cause: context.Canceled}))
assert.False(t, shouldRetry(&requestError{cause: context.DeadlineExceeded}))
assert.True(t, shouldRetry(&requestError{cause: context.DeadlineExceeded}))
}

func TestShouldRetry_WrappedContextCancellationIsNotRetryable(t *testing.T) {
assert.False(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.Canceled)}))
assert.False(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.DeadlineExceeded)}))
assert.True(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.DeadlineExceeded)}))
}

func TestShouldRetry_NonContextRequestErrorIsRetryable(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion internal/bifrost/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ import (
"fmt"
"io"
"os"
"os/signal"
"runtime"
"syscall"
)

type Task interface {
Run(ctx context.Context) error
}

func CLI(version, gitCommit string, args []string) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

return runCLI(ctx, version, gitCommit, args)
}

func runCLI(ctx context.Context, version, gitCommit string, args []string) int {
fl := NewAliasedFlagSet("", flag.ContinueOnError)
showHelp := false
fl.BoolVar(&showHelp, "help", false, "show this help and exit", "h")
Expand Down Expand Up @@ -65,7 +74,7 @@ func CLI(version, gitCommit string, args []string) int {
return 2
}

err = task.Run(context.Background())
err = task.Run(ctx)
if err != nil {
Comment thread
alexanderbsingh marked this conversation as resolved.
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err)
return 2
Expand Down
28 changes: 28 additions & 0 deletions internal/bifrost/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package bifrost

import (
"errors"
"flag"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -924,3 +925,30 @@ func TestCLI_InvalidRetryDelay(t *testing.T) {
exitCode := CLI("1.0", "commit", args)
assert.Equal(t, 2, exitCode)
}

func TestCLI_InvalidHTTPTimeout(t *testing.T) {
for _, timeout := range []string{"0s", "-1s"} {
t.Run(timeout, func(t *testing.T) {
args := []string{
"--server-url=https://portal.bifrostsec.com",
"--service=test-service",
"--service-version=1.0",
"--api-key=test-token",
"--http-timeout=" + timeout,
"sbom", "upload", "test-sbom.json",
}

exitCode := CLI("1.0", "commit", args)
assert.Equal(t, 2, exitCode)
})
}
}

func TestRegisterOptions_ParsesHTTPTimeout(t *testing.T) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
options := Options{}
RegisterOptions(fl, &options)

assert.NoError(t, fl.Parse([]string{"--http-timeout=5s"}))
assert.Equal(t, 5*time.Second, options.httpTimeout)
}
6 changes: 6 additions & 0 deletions internal/bifrost/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Options struct {
image string
retryAttempts int
retryDelay time.Duration
httpTimeout time.Duration
gitBranch string
gitCommitSHA string
gitOrigin string
Expand All @@ -46,6 +47,7 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) {
fl.StringVar(&opts.image, "image", "", "Container image reference for the uploaded SBOM (or IMAGE environment variable); required unless a service version is provided")
fl.IntVar(&opts.retryAttempts, "retry-attempts", DefaultRetryAttempts, "Number of retry attempts for transient upload failures")
fl.DurationVar(&opts.retryDelay, "retry-delay", DefaultRetryDelay, "Delay between upload retry attempts")
fl.DurationVar(&opts.httpTimeout, "http-timeout", DefaultHTTPTimeout, "Maximum duration to wait for an HTTP upload request")
fl.StringVar(&opts.gitBranch, "git-branch", "", "Optional Git branch name for the uploaded SBOM")
fl.StringVar(&opts.gitCommitSHA, "git-commit-sha", "", "Optional Git commit SHA for the uploaded SBOM")
fl.StringVar(&opts.gitOrigin, "git-origin", "", "Optional Git origin URL for the uploaded SBOM")
Expand Down Expand Up @@ -77,6 +79,10 @@ func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error {
if opts.retryDelay < 0 {
return fmt.Errorf("retry delay must be zero or greater")
}
if opts.httpTimeout <= 0 {
return fmt.Errorf("HTTP timeout must be greater than zero")
}
Comment thread
alexanderbsingh marked this conversation as resolved.
// An explicitly passed flag takes precedence over the environment variable.
if opts.gitRepoPath == "" {
opts.gitRepoPath = os.Getenv(gitRepoPathEnvironmentVariable)
}
Expand Down
17 changes: 16 additions & 1 deletion internal/bifrost/sbom_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func (t sbomUploadTask) Run(ctx context.Context) error {
Token: t.apiKey,
RetryAttempts: t.retryAttempts,
RetryDelay: t.retryDelay,
HTTPTimeout: t.httpTimeout,
GitBranch: t.gitBranch,
GitCommitSHA: t.gitCommitSHA,
GitOrigin: t.gitOrigin,
Expand Down Expand Up @@ -134,7 +135,7 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error {
_ = os.Remove(tmpPath)
}()

if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
if err := copyStdinWithContext(ctx, tmpFile, os.Stdin); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to read SBOM from stdin: %w", err)
}
Expand All @@ -144,3 +145,17 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error {

return api.UploadSBOMFile(ctx, t.service, t.serviceVersion, tmpPath)
}

func copyStdinWithContext(ctx context.Context, destination io.Writer, stdin *os.File) error {
done := make(chan error, 1)
go func() {
_, err := io.Copy(destination, stdin)
done <- err
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
Loading
Loading