Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ jobs:
go vet ./...

- name: Test
working-directory: ./operator
working-directory: ./${{ matrix.component }}
run: task test

- name: Build Container Image
Expand Down
5 changes: 5 additions & 0 deletions lurker/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
# SPDX-License-Identifier: Apache-2.0

main
lurker
bin

# Output of the go coverage tool
*.out
38 changes: 38 additions & 0 deletions lurker/Taskfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: the secureCodeBox authors
#
# SPDX-License-Identifier: Apache-2.0

version: "3.48.0"

tasks:
fmt:
desc: "Run go fmt against code"
dir: '{{ .TASKFILE_DIR }}'
cmds:
- go fmt ./...

vet:
desc: "Run go vet against code"
dir: '{{ .TASKFILE_DIR }}'
cmds:
- go vet ./...

test:
desc: "Run all tests"
deps: [fmt, vet]
dir: '{{ .TASKFILE_DIR }}'
cmds:
- go test ./... -coverprofile cover.out

view-coverage:
desc: "View test coverage in browser"
dir: '{{ .TASKFILE_DIR }}'
cmds:
- go tool cover -html=cover.out

build:
desc: "Build the lurker binary"
deps: [fmt, vet]
dir: '{{ .TASKFILE_DIR }}'
cmds:
- go build -o bin/lurker main.go
74 changes: 56 additions & 18 deletions lurker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@

import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"time"

corev1 "k8s.io/api/core/v1"
errors "k8s.io/apimachinery/pkg/api/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
Expand Down Expand Up @@ -55,34 +58,66 @@

log.Printf("Uploading result files.")
log.Printf("Uploading %s", filePath)
err = uploadFile(filePath, uploadURL)
err = uploadFileWithRetries(filePath, uploadURL)
if err != nil {
log.Fatal(err)
}
log.Printf("Uploaded file successfully")
}

func uploadFile(path, url string) error {
// delays waited before each retry of a failed upload. The number of entries
// defines how often the upload is retried after the initial attempt.
var uploadBackoffs = []time.Duration{1 * time.Second, 3 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}

// indirection to allow tests to run without actually waiting
var sleep = time.Sleep

// uploadFileWithRetries uploads the file and retries network failures using the
// uploadBackoffs schedule.
func uploadFileWithRetries(path, url string) error {
attempts := len(uploadBackoffs) + 1

var err error
for attempt := 1; attempt <= attempts; attempt++ {
var retryable bool
err, retryable = uploadFile(path, url)
if err == nil {
return nil
}

if !retryable || attempt == attempts {
break
}

backoff := uploadBackoffs[attempt-1]
log.Printf("Upload attempt %d of %d failed: %v. Retrying in %s", attempt, attempts, err, backoff)
sleep(backoff)
}

return fmt.Errorf("lurker failed to upload scan result file after %d attempts: %w", attempts, err)
}

func uploadFile(path, url string) (error, bool) {
file, err := os.Open(path)
if err != nil {
log.Printf("Failed to open file: %v", err)
return err
return err, false
}
defer file.Close()

fileInfo, err := file.Stat()
if err != nil {
log.Printf("Failed to get file stats: %v", err)
return err
return err, false
}
size := fileInfo.Size()
log.Printf("Scan result file has a size of %d bytes", size)

// Create a new file upload request
req, err := http.NewRequest("PUT", url, file)
if err != nil {
log.Fatalf("Failed to create request: %v", err)
return err
log.Printf("Failed to create request: %v", err)
Comment thread
Weltraumschaf marked this conversation as resolved.
return err, false
}

req.ContentLength = size
Expand All @@ -96,28 +131,31 @@

res, err := client.Do(req)
if err != nil {
return err
var networkErr net.Error
return err, errors.As(err, &networkErr)
}
defer res.Body.Close()

// Check the response status code
if res.StatusCode >= 200 && res.StatusCode < 300 {
// all good
return nil
return nil, false
}

log.Printf("File upload returned non 2xx status code (%d)", res.StatusCode)

// Dump response for debugging purposes
resultBytes, err := httputil.DumpResponse(res, true)
if err != nil {
log.Fatal(fmt.Errorf("failed to dump out failed requests to upload scan report to the s3 bucket: %w", err))
resultBytes, dumpErr := httputil.DumpResponse(res, true)

Check warning on line 148 in lurker/main.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this debug feature is deactivated before delivering the code in production.

See more on https://sonarcloud.io/project/issues?id=secureCodeBox_secureCodeBox&issues=AaA5PEaTS_a-7DRiGpH3&open=AaA5PEaTS_a-7DRiGpH3&pullRequest=3785
Comment thread
J12934 marked this conversation as resolved.
Dismissed
if dumpErr != nil {
log.Printf("failed to dump out failed requests to upload scan report to the s3 bucket: %v", dumpErr)
// drain the body so that the connection can be reused by the next attempt
io.Copy(io.Discard, res.Body)
} else {
log.Println("Response of Failed Request:")
log.Println(string(resultBytes))
}

log.Println("Response of Failed Request:")
log.Println(string(resultBytes))

return fmt.Errorf("lurker failed to upload scan result file. File upload returned non 2xx status code (%d)", res.StatusCode)
return fmt.Errorf("lurker failed to upload scan result file. File upload returned non 2xx status code (%d)", res.StatusCode), false
}

func waitForMainContainerToEnd(container, pod, namespace string) {
Expand All @@ -140,9 +178,9 @@

func keepWaitingForMainContainerToExit(context context.Context, container string, podName string, namespace string, clientset *kubernetes.Clientset) bool {
pod, err := clientset.CoreV1().Pods(namespace).Get(context, podName, metav1.GetOptions{})
if errors.IsNotFound(err) {
if apierrors.IsNotFound(err) {
log.Printf("Pod %s not found in namespace %s", pod, namespace)
Comment thread
Weltraumschaf marked this conversation as resolved.
} else if statusError, isStatus := err.(*errors.StatusError); isStatus {
} else if statusError, isStatus := err.(*apierrors.StatusError); isStatus {
log.Printf("Error getting pod %v", statusError.ErrStatus.Message)
} else if err != nil {
panic(err.Error())
Expand Down
127 changes: 127 additions & 0 deletions lurker/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: the secureCodeBox authors
//
// SPDX-License-Identifier: Apache-2.0

package main

import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)

const testFileContents = `{"findings": []}`

// writeResultFile creates a temporary scan result file and returns its path.
func writeResultFile(t *testing.T) string {
t.Helper()

path := filepath.Join(t.TempDir(), "findings.json")
if err := os.WriteFile(path, []byte(testFileContents), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
return path
}

// stubSleep replaces the backoff sleep with a recorder, so that tests don't
// have to actually wait. Returns a pointer to the recorded delays.
func stubSleep(t *testing.T) *[]time.Duration {
t.Helper()

original := sleep
delays := []time.Duration{}
sleep = func(d time.Duration) {
delays = append(delays, d)
}
t.Cleanup(func() { sleep = original })

return &delays
}

func TestUploadFileWithRetriesSucceedsOnFirstAttempt(t *testing.T) {
delays := stubSleep(t)

requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

if err := uploadFileWithRetries(writeResultFile(t), server.URL); err != nil {
t.Fatalf("expected upload to succeed, got: %v", err)
}
if requests != 1 {
t.Errorf("expected 1 request, got %d", requests)
}
if len(*delays) != 0 {
t.Errorf("expected no backoff, got %v", *delays)
}
}

func TestUploadFileWithRetriesDoesNotRetryHTTPFailures(t *testing.T) {
delays := stubSleep(t)

requests := 0
receivedBodies := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("failed to read request body: %v", err)
}
receivedBodies = append(receivedBodies, string(body))

w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

if err := uploadFileWithRetries(writeResultFile(t), server.URL); err == nil {
t.Fatal("expected upload to fail")
}
if requests != 1 {
t.Errorf("expected 1 request, got %d", requests)
}

// every attempt, especially the last one, has to send the complete file
for attempt, body := range receivedBodies {
if body != testFileContents {
t.Errorf("attempt %d uploaded %q, expected %q", attempt+1, body, testFileContents)
}
}

if len(*delays) != 0 {
t.Errorf("expected no backoff, got %v", *delays)
}
}

func TestUploadFileWithRetriesRetriesTransportErrors(t *testing.T) {
delays := stubSleep(t)

// closing the server up front makes every attempt fail on the transport level
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := server.URL
server.Close()

if err := uploadFileWithRetries(writeResultFile(t), url); err == nil {
t.Fatal("expected upload to fail")
}
if len(*delays) != len(uploadBackoffs) {
t.Errorf("expected %d backoffs, got %v", len(uploadBackoffs), *delays)
}
}

func TestUploadFileWithRetriesFailsForMissingFile(t *testing.T) {
delays := stubSleep(t)

if err := uploadFileWithRetries(filepath.Join(t.TempDir(), "does-not-exist.json"), "http://127.0.0.1:1"); err == nil {
t.Fatal("expected upload of a missing file to fail")
}
if len(*delays) != 0 {
t.Errorf("expected no backoff, got %v", *delays)
}
}
Loading