diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 740a5b56..df859c2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,6 @@ name: Continuous Integration -on: - push: +on: [push] permissions: contents: write @@ -9,8 +8,8 @@ permissions: security-events: write jobs: - rel: - name: Build, scan & push Snapshot + snapshot: + name: Build Snapshot runs-on: ubuntu-latest steps: - name: Checkout repository @@ -64,17 +63,17 @@ jobs: docker push ghcr.io/${{ github.repository }}:${{ steps.version.outputs.value }} docker push mtr.devops.telekom.de/sparrow/sparrow:${{ steps.version.outputs.value }} - helm: + name: Build Helm Chart runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@v4 - with: - fetch-tags: true + # We don't use checkout/fetch-tags: true because it's broken + # For more information see: https://github.com/actions/checkout/issues/1471 - name: Fetch tags explicitly - run: git fetch --tags + run: git fetch --prune --unshallow --tags - name: Get App Version id: appVersion diff --git a/.github/workflows/e2e_checks.yml b/.github/workflows/e2e_checks.yml deleted file mode 100644 index d26f3b56..00000000 --- a/.github/workflows/e2e_checks.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: E2E - Test checks - -on: - push: - -permissions: - contents: read - -jobs: - test_e2e: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - sudo add-apt-repository ppa:katharaframework/kathara - sudo apt-get update - sudo apt-get install -y jq kathara - - name: Setup kathara - run: | - echo '{ - "image": "kathara/base", - "manager_type": "docker", - "terminal": "/usr/bin/xterm", - "open_terminals": false, - "device_shell": "/bin/bash", - "net_prefix": "kathara", - "device_prefix": "kathara", - "debug_level": "INFO", - "print_startup_log": true, - "enable_ipv6": false, - "last_checked": 1721834897.2415252, - "hosthome_mount": false, - "shared_mount": true, - "image_update_policy": "Prompt", - "shared_cds": 1, - "remote_url": null, - "cert_path": null, - "network_plugin": "kathara/katharanp_vde" - }' > ~/.config/kathara.conf - - - name: Build binary for e2e - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: build --single-target --clean --snapshot --config .goreleaser-ci.yaml - - - name: Run e2e tests - run: | - ./scripts/run_e2e_tests.sh diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml deleted file mode 100644 index 38ecbf12..00000000 --- a/.github/workflows/end2end.yml +++ /dev/null @@ -1,87 +0,0 @@ -# This workflow installs 1 instance of sparrow and -# verify the API output - -name: End2End Testing -on: - push: - -jobs: - end2end: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - name: Set up K3S - uses: debianmaster/actions-k3s@master - id: k3s - with: - version: 'v1.26.9-k3s1' - - name: Check Cluster - run: | - kubectl get nodes - - name: Check Coredns Deployment - run: | - kubectl -n kube-system rollout status deployment/coredns --timeout=60s - STATUS=$(kubectl -n kube-system get deployment coredns -o jsonpath={.status.readyReplicas}) - if [[ $STATUS -ne 1 ]] - then - echo "Deployment coredns not ready" - kubectl -n kube-system get events - exit 1 - else - echo "Deployment coredns OK" - fi - - name: Check Metricsserver Deployment - run: | - kubectl -n kube-system rollout status deployment/metrics-server --timeout=60s - STATUS=$(kubectl -n kube-system get deployment metrics-server -o jsonpath={.status.readyReplicas}) - if [[ $STATUS -ne 1 ]] - then - echo "Deployment metrics-server not ready" - kubectl -n kube-system get events - exit 1 - else - echo "Deployment metrics-server OK" - fi - - name: Setup Helm - run: | - curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - helm version - - name: Get Image Tag - id: version - run: echo "value=commit-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - name: Install Sparrow - run: | - helm upgrade -i sparrow \ - --atomic \ - --timeout 300s \ - --set image.tag=${{ steps.version.outputs.value }} \ - --set sparrowConfig.name=the-sparrow.com \ - --set sparrowConfig.loader.type=file \ - --set sparrowConfig.loader.interval=5s \ - --set sparrowConfig.loader.file.path=/config/.sparrow.yaml \ - --set checksConfig.health.interval=1s \ - --set checksConfig.health.timeout=1s \ - ./chart - - - name: Check Pods - run: | - kubectl get pods - - name: Wait for Sparrow - run: | - sleep 60 - - name: Healthcheck - run: | - kubectl create job curl --image=quay.io/curl/curl:latest -- curl -f -v -H 'Content-Type: application/json' http://sparrow:8080/v1/metrics/health - kubectl wait --for=condition=complete job/curl - STATUS=$(kubectl get job curl -o jsonpath={.status.succeeded}) - if [[ $STATUS -ne 1 ]] - then - echo "Job failed" - kubectl logs -ljob-name=curl - kubectl delete job curl - exit 1 - else - echo "Job OK" - kubectl delete job curl - fi diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index bc19f374..2693e2b9 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -4,6 +4,7 @@ on: [pull_request] jobs: pre-commit: + name: Run pre-commit hooks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/prune.yml b/.github/workflows/prune.yml index 98eb770c..0b6892d4 100644 --- a/.github/workflows/prune.yml +++ b/.github/workflows/prune.yml @@ -10,10 +10,9 @@ permissions: security-events: write jobs: - prune_images: - name: Prune old sparrow images + prune: + name: Images and Charts runs-on: ubuntu-latest - steps: - name: Prune Images uses: vlaurin/action-ghcr-prune@v0.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca399963..ccad7786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,10 @@ permissions: packages: write jobs: - main: + release: name: Release Sparrow runs-on: ubuntu-latest steps: - - name: Checkout repository uses: actions/checkout@v4 @@ -45,8 +44,8 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - helm: + name: Release Helm Chart runs-on: ubuntu-latest steps: - name: Checkout Repo @@ -66,4 +65,4 @@ jobs: - name: Push helm package run: | helm push $(ls ./chart/*.tgz| head -1) oci://ghcr.io/${{ github.repository_owner }}/charts - helm push $(ls ./chart/*.tgz| head -1) oci://mtr.devops.telekom.de/sparrow/charts \ No newline at end of file + helm push $(ls ./chart/*.tgz| head -1) oci://mtr.devops.telekom.de/sparrow/charts diff --git a/.github/workflows/test-sast.yml b/.github/workflows/test-sast.yml new file mode 100644 index 00000000..f66bc03b --- /dev/null +++ b/.github/workflows/test-sast.yml @@ -0,0 +1,28 @@ +name: SAST + +on: + push: + schedule: + - cron: "0 0 * * 0" + +permissions: + contents: read + security-events: write + +jobs: + go: + name: Go - Tests + runs-on: ubuntu-latest + env: + GO111MODULE: on + GOFLAGS: "-buildvcs=false" + steps: + - uses: actions/checkout@v4 + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: "-no-fail -fmt sarif -out results.sarif ./..." + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..c55b769d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,231 @@ +name: Tests + +on: + push: + workflow_dispatch: + inputs: + log_format: + description: "Log format" + required: false + default: "text" + type: string + log_level: + description: "Log level" + required: false + default: "info" + type: string + +permissions: + contents: read + +jobs: + go-unit: + name: Unit - Go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies + run: | + go install github.com/mfridman/tparse@latest + go mod download + + - name: Set log format and level + id: inputs + run: | + if [ -z "${{ inputs.log_format }}" ]; then + echo "LOG_FORMAT=text" >> $GITHUB_OUTPUT + else + echo "LOG_FORMAT=${{ inputs.log_format }}" >> $GITHUB_OUTPUT + fi + if [ -z "${{ inputs.log_level }}" ]; then + echo "LOG_LEVEL=info" >> $GITHUB_OUTPUT + else + echo "LOG_LEVEL=${{ inputs.log_level }}" >> $GITHUB_OUTPUT + fi + + - name: Run go unit tests + env: + LOG_FORMAT: ${{ steps.inputs.outputs.LOG_FORMAT }} + LOG_LEVEL: ${{ steps.inputs.outputs.LOG_LEVEL }} + run: | + go test -v -count=1 -test.short -race ./... -json -coverpkg ./... \ + | tee output.jsonl | tparse -notests -follow -all || true + tparse -format markdown -file output.jsonl -all -slow 20 > $GITHUB_STEP_SUMMARY + + go-e2e: + name: E2E - Go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies + run: | + go install github.com/mfridman/tparse@latest + go mod download + + - name: Set log format and level + id: inputs + run: | + if [ -z "${{ inputs.log_format }}" ]; then + echo "No log format provided, using default: text" + echo "LOG_FORMAT=text" >> $GITHUB_OUTPUT + else + echo "Log format provided: ${{ inputs.log_format }}" + echo "LOG_FORMAT=${{ inputs.log_format }}" >> $GITHUB_OUTPUT + fi + if [ -z "${{ inputs.log_level }}" ]; then + echo "No log level provided, using default: info" + echo "LOG_LEVEL=info" >> $GITHUB_OUTPUT + else + echo "Log level provided: ${{ inputs.log_level }}" + echo "LOG_LEVEL=${{ inputs.log_level }}" >> $GITHUB_OUTPUT + fi + + - name: Run go e2e tests + env: + LOG_FORMAT: ${{ steps.inputs.outputs.LOG_FORMAT }} + LOG_LEVEL: ${{ steps.inputs.outputs.LOG_LEVEL }} + run: | + go test -v -count=1 -race ./... -json -coverpkg ./... \ + | tee output.jsonl | tparse -notests -follow -all || true + tparse -format markdown -file output.jsonl -all -slow 20 > $GITHUB_STEP_SUMMARY + + traceroute: + name: E2E - Traceroute + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + sudo add-apt-repository ppa:katharaframework/kathara + sudo apt-get update + sudo apt-get install -y jq kathara + + - name: Setup kathara + run: | + echo '{ + "image": "kathara/base", + "manager_type": "docker", + "terminal": "/usr/bin/xterm", + "open_terminals": false, + "device_shell": "/bin/bash", + "net_prefix": "kathara", + "device_prefix": "kathara", + "debug_level": "INFO", + "print_startup_log": true, + "enable_ipv6": false, + "last_checked": 1721834897.2415252, + "hosthome_mount": false, + "shared_mount": true, + "image_update_policy": "Prompt", + "shared_cds": 1, + "remote_url": null, + "cert_path": null, + "network_plugin": "kathara/katharanp_vde" + }' > ~/.config/kathara.conf + + - name: Build binary for e2e + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: build --single-target --clean --snapshot --config .goreleaser-ci.yaml + + - name: Run e2e tests + run: | + ./scripts/run_e2e_tests.sh + + k8s: + name: E2E - Kubernetes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up K3S + uses: debianmaster/actions-k3s@master + id: k3s + with: + version: "v1.31.2-k3s1" + + - name: Check Cluster + run: kubectl get nodes + + - name: Check Coredns Deployment + run: | + kubectl -n kube-system rollout status deployment/coredns --timeout=60s + STATUS=$(kubectl -n kube-system get deployment coredns -o jsonpath={.status.readyReplicas}) + if [[ $STATUS -ne 1 ]] + then + echo "Deployment coredns not ready" + kubectl -n kube-system get events + exit 1 + else + echo "Deployment coredns OK" + fi + + - name: Check Metricsserver Deployment + run: | + kubectl -n kube-system rollout status deployment/metrics-server --timeout=60s + STATUS=$(kubectl -n kube-system get deployment metrics-server -o jsonpath={.status.readyReplicas}) + if [[ $STATUS -ne 1 ]] + then + echo "Deployment metrics-server not ready" + kubectl -n kube-system get events + exit 1 + else + echo "Deployment metrics-server OK" + fi + + - name: Setup Helm + run: | + curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + helm version + + - name: Get Image Tag + id: version + run: echo "value=commit-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Install Sparrow + run: | + helm upgrade -i sparrow \ + --atomic \ + --timeout 300s \ + --set image.tag=${{ steps.version.outputs.value }} \ + --set sparrowConfig.name=the-sparrow.com \ + --set sparrowConfig.loader.type=file \ + --set sparrowConfig.loader.interval=5s \ + --set sparrowConfig.loader.file.path=/config/.sparrow.yaml \ + --set checksConfig.health.interval=1s \ + --set checksConfig.health.timeout=1s \ + ./chart + + - name: Check Pods + run: kubectl get pods + + - name: Wait for Sparrow + run: sleep 45 + + - name: Healthcheck + run: | + kubectl create job curl --image=quay.io/curl/curl:latest -- curl -f -v -H 'Content-Type: application/json' http://sparrow:8080/v1/metrics/health + kubectl wait --for=condition=complete job/curl + STATUS=$(kubectl get job curl -o jsonpath={.status.succeeded}) + if [[ $STATUS -ne 1 ]] + then + echo "Job failed" + kubectl logs -ljob-name=curl + kubectl delete job curl + exit 1 + else + echo "Job OK" + kubectl delete job curl + fi diff --git a/.github/workflows/test_sast.yml b/.github/workflows/test_sast.yml deleted file mode 100644 index 811828f3..00000000 --- a/.github/workflows/test_sast.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Test - SAST - -on: - push: - -permissions: - contents: read - -jobs: - tests: - runs-on: ubuntu-latest - - env: - GO111MODULE: on - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Run Gosec Security Scanner - uses: securego/gosec@master - with: - args: ./... diff --git a/.github/workflows/test_unit.yml b/.github/workflows/test_unit.yml deleted file mode 100644 index 5a77885f..00000000 --- a/.github/workflows/test_unit.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Test - Unit - -on: - push: - -permissions: - contents: read - -jobs: - test_go: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Test - run: | - go mod download - go test --race --count=1 --coverprofile cover.out -v ./... diff --git a/.gitignore b/.gitignore index 22485668..2528e247 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +output.jsonl # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f542987..6c20bb99 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: go-mod-tidy-repo - id: go-test-repo-mod - args: [-race, -count=1, -timeout 30s] + args: [-race, -count=1, -timeout 30s, "-test.short"] - id: go-vet-repo-mod - id: go-fumpt-repo args: [-l, -w] diff --git a/.vscode/settings.json b/.vscode/settings.json index d35d0a6b..9234684f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,13 @@ "go.testFlags": [ "-race", "-cover", - "-count=1" - ] + "-count=1", + "-timeout=240s", + // "-test.short", + "-v", + ], + "go.testEnvVars": { + // "LOG_LEVEL": "debug", + "LOG_FORMAT": "text", + } } \ No newline at end of file diff --git a/go.mod b/go.mod index ba704b29..90eb7aed 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23 require ( github.com/getkin/kin-openapi v0.128.0 github.com/go-chi/chi/v5 v5.2.0 + github.com/goccy/go-yaml v1.13.8 github.com/google/go-cmp v0.6.0 github.com/jarcoal/httpmock v1.3.1 github.com/prometheus/client_golang v1.20.5 @@ -20,7 +21,6 @@ require ( golang.org/x/net v0.34.0 golang.org/x/sys v0.29.0 google.golang.org/grpc v1.69.4 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -36,6 +36,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.3.1 // indirect @@ -66,4 +67,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 21168f15..cf344643 100644 --- a/go.sum +++ b/go.sum @@ -29,12 +29,16 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w= github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.13.8 h1:ftugzaplJyFaFwfyVNeq1XQOBxmlp8zazmuiobaCXbk= +github.com/goccy/go-yaml v1.13.8/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/helper/retry_test.go b/internal/helper/retry_test.go index 28230d6c..1d83239f 100644 --- a/internal/helper/retry_test.go +++ b/internal/helper/retry_test.go @@ -24,9 +24,13 @@ import ( "fmt" "testing" "time" + + "github.com/telekom/sparrow/test" ) func TestRetry(t *testing.T) { + test.MarkAsShort(t) + effectorFuncCallCounter := 0 ctx, cancel := context.WithCancel(context.Background()) @@ -127,6 +131,8 @@ func TestRetry(t *testing.T) { } func Test_getExpBackoff(t *testing.T) { + test.MarkAsShort(t) + type args struct { initialDelay time.Duration iteration int diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 6707f435..b8c37de0 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -26,9 +26,13 @@ import ( "os" "reflect" "testing" + + "github.com/telekom/sparrow/test" ) func TestNewLogger(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string handlers []slog.Handler @@ -81,6 +85,8 @@ func TestNewLogger(t *testing.T) { } func TestNewContextWithLogger(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string parentCtx context.Context @@ -112,6 +118,8 @@ func TestNewContextWithLogger(t *testing.T) { } func TestFromContext(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string ctx context.Context @@ -145,6 +153,8 @@ func TestFromContext(t *testing.T) { } func TestMiddleware(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string parentCtx context.Context @@ -181,6 +191,8 @@ func TestMiddleware(t *testing.T) { } func TestNewHandler(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string format string @@ -239,6 +251,8 @@ func TestNewHandler(t *testing.T) { } func TestGetLevel(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string input string diff --git a/main.go b/main.go index a2e1b088..75e9e91f 100644 --- a/main.go +++ b/main.go @@ -20,12 +20,17 @@ package main import ( "github.com/telekom/sparrow/cmd" + "github.com/telekom/sparrow/pkg" ) // version is the current version of sparrow // It is set at build time by using -ldflags "-X main.version=x.x.x" var version string +func init() { //nolint:gochecknoinits // Required for version to be set on build + pkg.Version = version +} + func main() { cmd.Execute(version) } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 1637e16e..ae429388 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -27,9 +27,12 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/telekom/sparrow/test" ) func TestAPI_Run(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string want struct { @@ -96,6 +99,8 @@ func TestAPI_Run(t *testing.T) { } func TestAPI_RegisterRoutes(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string routes []Route @@ -196,6 +201,8 @@ func TestAPI_RegisterRoutes(t *testing.T) { } func TestAPI_ShutdownWhenContextCanceled(t *testing.T) { + test.MarkAsShort(t) + a := api{ router: chi.NewRouter(), server: &http.Server{}, //nolint:gosec @@ -213,8 +220,9 @@ func TestAPI_ShutdownWhenContextCanceled(t *testing.T) { } func TestAPI_OkHandler(t *testing.T) { - ctx := context.Background() + test.MarkAsShort(t) + ctx := context.Background() req, err := http.NewRequestWithContext(ctx, "GET", "/okHandler", http.NoBody) if err != nil { t.Fatal(err) @@ -238,6 +246,8 @@ func TestAPI_OkHandler(t *testing.T) { } func TestConfig_Validate(t *testing.T) { + test.MarkAsShort(t) + cases := []struct { name string config Config diff --git a/pkg/checks/base.go b/pkg/checks/base.go index 25062237..c850a13b 100644 --- a/pkg/checks/base.go +++ b/pkg/checks/base.go @@ -42,32 +42,54 @@ type Check interface { // run until the context is canceled and handle problems itself. // Returning a non-nil error will cause the shutdown of the check. Run(ctx context.Context, cResult chan ResultDTO) error - // Shutdown is called once when the check is unregistered or sparrow shuts down + // Shutdown is called once when the check is unregistered or sparrow shuts down. Shutdown() - // UpdateConfig is called once when the check is registered - // This is also called while the check is running, if the remote config is updated - // This should return an error if the config is invalid + // UpdateConfig updates the configuration of the check. + // It is called when the runtime configuration is updated. + // The check should handle the update itself. + // Returns an error if the configuration is invalid. UpdateConfig(config Runtime) error - // GetConfig returns the current configuration of the check + // GetConfig returns the current configuration of the check. GetConfig() Runtime - // Name returns the name of the check + // Name returns the name of the check. Name() string - // Schema returns an openapi3.SchemaRef of the result type returned by the check + // Schema returns an openapi3.SchemaRef of the result type returned by the check. Schema() (*openapi3.SchemaRef, error) - // GetMetricCollectors allows the check to provide prometheus metric collectors + // GetMetricCollectors allows the check to provide prometheus metric collectors. GetMetricCollectors() []prometheus.Collector // RemoveLabelledMetrics allows the check to remove the prometheus metrics - // of the check whose `target` label matches the passed value + // of the check whose `target` label matches the passed value. RemoveLabelledMetrics(target string) error } -// CheckBase is a struct providing common fields used by implementations of the Check interface. +// Base is a struct providing common fields and methods used by implementations of the [Check] interface. // It serves as a foundational structure that should be embedded in specific check implementations. -type CheckBase struct { - // Mutex for thread-safe access to shared resources within the check implementation - Mu sync.Mutex - // Signal channel used to notify about shutdown of a check - DoneChan chan struct{} +type Base struct { + // Mutex for thread-safe access to shared resources within the check implementation. + Mutex sync.Mutex + // Done channel is used to notify about shutdown of a check. + Done chan struct{} + // closed is a flag indicating if the check has been shut down. + closed bool +} + +// NewBase creates a new instance of the [Base] struct. +func NewBase() Base { + return Base{ + Mutex: sync.Mutex{}, + Done: make(chan struct{}, 1), + closed: false, + } +} + +// Shutdown closes the DoneChan to signal the check to stop running. +func (b *Base) Shutdown() { + b.Mutex.Lock() + defer b.Mutex.Unlock() + if !b.closed { + close(b.Done) + b.closed = true + } } // Runtime is the interface that all check configurations must implement diff --git a/pkg/checks/base_test.go b/pkg/checks/base_test.go new file mode 100644 index 00000000..9f003643 --- /dev/null +++ b/pkg/checks/base_test.go @@ -0,0 +1,42 @@ +package checks + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/telekom/sparrow/test" +) + +func TestBase_Shutdown(t *testing.T) { + test.MarkAsShort(t) + + tests := []struct { + name string + base *Base + }{ + { + name: "shutdown", + base: &Base{Done: make(chan struct{}, 1)}, + }, + { + name: "already shutdown", + base: &Base{Done: make(chan struct{}, 1), closed: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.base.closed { + close(tt.base.Done) + } + tt.base.Shutdown() + + if !tt.base.closed { + t.Error("Base.Shutdown() should close Base.Done") + } + + assert.Panics(t, func() { + tt.base.Done <- struct{}{} + }, "Base.Done should be closed") + }) + } +} diff --git a/pkg/checks/dns/config_test.go b/pkg/checks/dns/config_test.go index 5cbc2767..ae524e0c 100644 --- a/pkg/checks/dns/config_test.go +++ b/pkg/checks/dns/config_test.go @@ -21,9 +21,12 @@ package dns import ( "testing" "time" + + "github.com/telekom/sparrow/test" ) func TestConfig_Validate(t *testing.T) { + test.MarkAsShort(t) tests := []struct { name string config Config diff --git a/pkg/checks/dns/dns.go b/pkg/checks/dns/dns.go index fea07cb7..a65446a0 100644 --- a/pkg/checks/dns/dns.go +++ b/pkg/checks/dns/dns.go @@ -21,6 +21,7 @@ package dns import ( "context" "net" + "reflect" "slices" "sync" "time" @@ -33,101 +34,100 @@ import ( ) var ( - _ checks.Check = (*DNS)(nil) + _ checks.Check = (*check)(nil) _ checks.Runtime = (*Config)(nil) ) const CheckName = "dns" -// DNS is a check that resolves the names and addresses -type DNS struct { - checks.CheckBase +// check is the implementation of the dns check. +// It resolves DNS names and IP addresses for a list of targets. +type check struct { + checks.Base config Config metrics metrics client Resolver } -func (d *DNS) GetConfig() checks.Runtime { - d.Mu.Lock() - defer d.Mu.Unlock() - return &d.config +func (ch *check) GetConfig() checks.Runtime { + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + return &ch.config } -func (d *DNS) Name() string { +func (*check) Name() string { return CheckName } // NewCheck creates a new instance of the dns check func NewCheck() checks.Check { - return &DNS{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, + return &check{ + Base: checks.NewBase(), config: Config{ Retry: checks.DefaultRetry, }, metrics: newMetrics(), - client: NewResolver(), + client: newResolver(), } } // result represents the result of a single DNS check for a specific target type result struct { - Resolved []string - Error *string - Total float64 + Resolved []string `json:"resolved"` + Error *string `json:"error"` + Total float64 `json:"total"` } // Run starts the dns check -func (d *DNS) Run(ctx context.Context, cResult chan checks.ResultDTO) error { +func (ch *check) Run(ctx context.Context, cResult chan checks.ResultDTO) error { ctx, cancel := logger.NewContextWithLogger(ctx) defer cancel() log := logger.FromContext(ctx) - log.Info("Starting dns check", "interval", d.config.Interval.String()) + timer := time.NewTimer(ch.config.Interval) + log.InfoContext(ctx, "Starting dns check", "interval", ch.config.Interval.String()) for { select { case <-ctx.Done(): - log.Error("Context canceled", "err", ctx.Err()) + log.ErrorContext(ctx, "Context canceled", "err", ctx.Err()) return ctx.Err() - case <-d.DoneChan: + case <-ch.Done: return nil - case <-time.After(d.config.Interval): - res := d.check(ctx) - + case <-timer.C: + res := ch.check(ctx) cResult <- checks.ResultDTO{ - Name: d.Name(), + Name: ch.Name(), Result: &checks.Result{ Data: res, Timestamp: time.Now(), }, } - log.Debug("Successfully finished dns check run") + log.DebugContext(ctx, "Successfully finished dns check run") + ch.Mutex.Lock() + timer.Reset(ch.config.Interval) + ch.Mutex.Unlock() } } } -func (d *DNS) Shutdown() { - d.DoneChan <- struct{}{} - close(d.DoneChan) -} - -func (d *DNS) UpdateConfig(cfg checks.Runtime) error { +func (ch *check) UpdateConfig(cfg checks.Runtime) error { if c, ok := cfg.(*Config); ok { - d.Mu.Lock() - defer d.Mu.Unlock() + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + if c == nil || reflect.DeepEqual(&ch.config, c) { + return nil + } - for _, target := range d.config.Targets { + for _, target := range ch.config.Targets { if !slices.Contains(c.Targets, target) { - err := d.metrics.Remove(target) + err := ch.metrics.Remove(target) if err != nil { return err } } } - d.config = *c + ch.config = *c return nil } @@ -139,27 +139,31 @@ func (d *DNS) UpdateConfig(cfg checks.Runtime) error { // Schema provides the schema of the data that will be provided // by the dns check -func (d *DNS) Schema() (*openapi3.SchemaRef, error) { - return checks.OpenapiFromPerfData(make(map[string]result)) +func (ch *check) Schema() (*openapi3.SchemaRef, error) { + return checks.OpenapiFromPerfData(map[string]result{}) } // GetMetricCollectors returns all metric collectors of check -func (d *DNS) GetMetricCollectors() []prometheus.Collector { - return d.metrics.GetCollectors() +func (ch *check) GetMetricCollectors() []prometheus.Collector { + return ch.metrics.GetCollectors() } // RemoveLabelledMetrics removes the metrics which have the passed // target as a label -func (d *DNS) RemoveLabelledMetrics(target string) error { - return d.metrics.Remove(target) +func (ch *check) RemoveLabelledMetrics(target string) error { + return ch.metrics.Remove(target) } // check performs DNS checks for all configured targets using a custom net.Resolver. // Returns a map where each target is associated with its DNS check result. -func (d *DNS) check(ctx context.Context) map[string]result { +func (ch *check) check(ctx context.Context) map[string]result { log := logger.FromContext(ctx) log.Debug("Checking dns") - if len(d.config.Targets) == 0 { + ch.Mutex.Lock() + cfg := ch.config + ch.Mutex.Unlock() + + if len(cfg.Targets) == 0 { log.Debug("No targets defined") return map[string]result{} } @@ -168,18 +172,18 @@ func (d *DNS) check(ctx context.Context) map[string]result { var wg sync.WaitGroup results := map[string]result{} - d.client.SetDialer(&net.Dialer{ - Timeout: d.config.Timeout, + ch.client.SetDialer(&net.Dialer{ + Timeout: cfg.Timeout, }) - log.Debug("Getting dns status for each target in separate routine", "amount", len(d.config.Targets)) - for _, t := range d.config.Targets { + log.Debug("Getting dns status for each target in separate routine", "amount", len(cfg.Targets)) + for _, t := range cfg.Targets { target := t wg.Add(1) lo := log.With("target", target) getDNSRetry := helper.Retry(func(ctx context.Context) error { - res, err := getDNS(ctx, d.client, target) + res, err := getDNS(ctx, ch.client, target) mu.Lock() defer mu.Unlock() results[target] = res @@ -187,7 +191,7 @@ func (d *DNS) check(ctx context.Context) map[string]result { return err } return nil - }, d.config.Retry) + }, cfg.Retry) go func() { defer wg.Done() @@ -202,7 +206,7 @@ func (d *DNS) check(ctx context.Context) map[string]result { mu.Lock() defer mu.Unlock() - d.metrics.Set(target, results, float64(status)) + ch.metrics.Set(target, results, float64(status)) }() } wg.Wait() diff --git a/pkg/checks/dns/dns_test.go b/pkg/checks/dns/dns_test.go index 9bac405a..70ddee91 100644 --- a/pkg/checks/dns/dns_test.go +++ b/pkg/checks/dns/dns_test.go @@ -23,12 +23,12 @@ import ( "fmt" "net" "reflect" - "sync" "testing" "time" "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/checks/health" + "github.com/telekom/sparrow/test" "github.com/stretchr/testify/assert" ) @@ -41,20 +41,19 @@ const ( ) func TestDNS_Run(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string - mockSetup func() *DNS + mockSetup func() *check targets []string want checks.Result }{ { name: "success with no targets", - mockSetup: func() *DNS { - return &DNS{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, + mockSetup: func() *check { + return &check{ + Base: checks.NewBase(), } }, targets: []string{}, @@ -64,7 +63,7 @@ func TestDNS_Run(t *testing.T) { }, { name: "success with one target lookup", - mockSetup: func() *DNS { + mockSetup: func() *check { c := newCommonDNS() c.client = &ResolverMock{ LookupHostFunc: func(ctx context.Context, addr string) ([]string, error) { @@ -83,7 +82,7 @@ func TestDNS_Run(t *testing.T) { }, { //nolint:dupl // normal lookup name: "success with multiple target lookups", - mockSetup: func() *DNS { + mockSetup: func() *check { c := newCommonDNS() c.client = &ResolverMock{ LookupHostFunc: func(ctx context.Context, addr string) ([]string, error) { @@ -103,7 +102,7 @@ func TestDNS_Run(t *testing.T) { }, { //nolint:dupl // reverse lookup name: "success with multiple target reverse lookups", - mockSetup: func() *DNS { + mockSetup: func() *check { c := newCommonDNS() c.client = &ResolverMock{ LookupAddrFunc: func(ctx context.Context, addr string) ([]string, error) { @@ -123,7 +122,7 @@ func TestDNS_Run(t *testing.T) { }, { name: "error - lookup failure for a target", - mockSetup: func() *DNS { + mockSetup: func() *check { c := newCommonDNS() c.client = &ResolverMock{ LookupHostFunc: func(ctx context.Context, addr string) ([]string, error) { @@ -142,7 +141,7 @@ func TestDNS_Run(t *testing.T) { }, { name: "error - timeout scenario for a target", - mockSetup: func() *DNS { + mockSetup: func() *check { c := newCommonDNS() c.client = &ResolverMock{ LookupHostFunc: func(ctx context.Context, addr string) ([]string, error) { @@ -212,8 +211,9 @@ func TestDNS_Run(t *testing.T) { } func TestDNS_Run_Context_Done(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) + test.MarkAsShort(t) + ctx, cancel := context.WithCancel(context.Background()) c := NewCheck() cResult := make(chan checks.ResultDTO, 1) defer close(cResult) @@ -241,22 +241,9 @@ func TestDNS_Run_Context_Done(t *testing.T) { time.Sleep(time.Millisecond * 30) } -func TestDNS_Shutdown(t *testing.T) { - cDone := make(chan struct{}, 1) - c := DNS{ - CheckBase: checks.CheckBase{ - DoneChan: cDone, - }, - } - c.Shutdown() - - _, ok := <-cDone - if !ok { - t.Error("Shutdown() should be ok") - } -} - func TestDNS_UpdateConfig(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string input checks.Runtime @@ -293,13 +280,13 @@ func TestDNS_UpdateConfig(t *testing.T) { exampleURL, }, }, - want: Config{}, + want: Config{Retry: checks.DefaultRetry}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := &DNS{} + c := NewCheck().(*check) if err := c.UpdateConfig(tt.input); (err != nil) != tt.wantErr { t.Errorf("DNS.UpdateConfig() error = %v, wantErr %v", err, tt.wantErr) @@ -310,6 +297,8 @@ func TestDNS_UpdateConfig(t *testing.T) { } func TestNewCheck(t *testing.T) { + test.MarkAsShort(t) + c := NewCheck() if c == nil { t.Error("NewLatencyCheck() should not be nil") @@ -320,12 +309,9 @@ func stringPointer(s string) *string { return &s } -func newCommonDNS() *DNS { - return &DNS{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, +func newCommonDNS() *check { + return &check{ + Base: checks.NewBase(), metrics: newMetrics(), } } diff --git a/pkg/checks/dns/metrics_test.go b/pkg/checks/dns/metrics_test.go index e3930088..b7827972 100644 --- a/pkg/checks/dns/metrics_test.go +++ b/pkg/checks/dns/metrics_test.go @@ -22,9 +22,12 @@ import ( "testing" "github.com/prometheus/client_golang/prometheus" + "github.com/telekom/sparrow/test" ) func TestMetrics_GetCollectors(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string metrics metrics diff --git a/pkg/checks/dns/resolver.go b/pkg/checks/dns/resolver.go index 63cd1ea9..c4c47213 100644 --- a/pkg/checks/dns/resolver.go +++ b/pkg/checks/dns/resolver.go @@ -34,7 +34,7 @@ type resolver struct { *net.Resolver } -func NewResolver() Resolver { +func newResolver() Resolver { return &resolver{ Resolver: &net.Resolver{ // We need to set this so the custom dialer is used diff --git a/pkg/checks/health/config_test.go b/pkg/checks/health/config_test.go index 2e7d1199..6b7bfbb3 100644 --- a/pkg/checks/health/config_test.go +++ b/pkg/checks/health/config_test.go @@ -21,9 +21,13 @@ package health import ( "testing" "time" + + "github.com/telekom/sparrow/test" ) func TestConfig_Validate(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string config Config diff --git a/pkg/checks/health/health.go b/pkg/checks/health/health.go index a5baec63..96aaf550 100644 --- a/pkg/checks/health/health.go +++ b/pkg/checks/health/health.go @@ -21,8 +21,8 @@ package health import ( "context" "fmt" - "io" "net/http" + "reflect" "slices" "sync" "time" @@ -36,7 +36,7 @@ import ( ) var ( - _ checks.Check = (*Health)(nil) + _ checks.Check = (*check)(nil) _ checks.Runtime = (*Config)(nil) stateMapping = map[int]string{ 0: "unhealthy", @@ -46,20 +46,18 @@ var ( const CheckName = "health" -// Health is a check that measures the availability of an endpoint -type Health struct { - checks.CheckBase +// check is the implementation of the health check. +// It measures the availability of a list of targets. +type check struct { + checks.Base config Config metrics metrics } // NewCheck creates a new instance of the health check func NewCheck() checks.Check { - return &Health{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, + return &check{ + Base: checks.NewBase(), config: Config{ Retry: checks.DefaultRetry, }, @@ -68,57 +66,56 @@ func NewCheck() checks.Check { } // Run starts the health check -func (h *Health) Run(ctx context.Context, cResult chan checks.ResultDTO) error { +func (ch *check) Run(ctx context.Context, cResult chan checks.ResultDTO) error { ctx, cancel := logger.NewContextWithLogger(ctx) defer cancel() log := logger.FromContext(ctx) - log.Info("Starting healthcheck", "interval", h.config.Interval.String()) + timer := time.NewTimer(ch.config.Interval) + log.InfoContext(ctx, "Starting health check", "interval", ch.config.Interval.String()) for { select { case <-ctx.Done(): - log.Error("Context canceled", "err", ctx.Err()) + log.ErrorContext(ctx, "Context canceled", "err", ctx.Err()) return ctx.Err() - case <-h.DoneChan: - log.Debug("Soft shut down") + case <-ch.Done: return nil - case <-time.After(h.config.Interval): - res := h.check(ctx) - + case <-timer.C: + res := ch.check(ctx) cResult <- checks.ResultDTO{ - Name: h.Name(), + Name: ch.Name(), Result: &checks.Result{ Data: res, Timestamp: time.Now(), }, } - log.Debug("Successfully finished health check run") + log.DebugContext(ctx, "Successfully finished health check run") + ch.Mutex.Lock() + timer.Reset(ch.config.Interval) + ch.Mutex.Unlock() } } } -// Shutdown is called once when the check is unregistered or sparrow shuts down -func (h *Health) Shutdown() { - h.DoneChan <- struct{}{} - close(h.DoneChan) -} - // UpdateConfig sets the configuration for the health check -func (h *Health) UpdateConfig(cfg checks.Runtime) error { +func (ch *check) UpdateConfig(cfg checks.Runtime) error { if c, ok := cfg.(*Config); ok { - h.Mu.Lock() - defer h.Mu.Unlock() + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + if c == nil || reflect.DeepEqual(&ch.config, c) { + return nil + } - for _, target := range h.config.Targets { + for _, target := range ch.config.Targets { if !slices.Contains(c.Targets, target) { - err := h.metrics.Remove(target) + err := ch.metrics.Remove(target) if err != nil { return err } } } - h.config = *c + ch.config = *c return nil } @@ -129,62 +126,66 @@ func (h *Health) UpdateConfig(cfg checks.Runtime) error { } // GetConfig returns the current configuration of the check -func (h *Health) GetConfig() checks.Runtime { - h.Mu.Lock() - defer h.Mu.Unlock() - return &h.config +func (ch *check) GetConfig() checks.Runtime { + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + return &ch.config } // Name returns the name of the check -func (h *Health) Name() string { +func (*check) Name() string { return CheckName } // Schema provides the schema of the data that will be provided // by the health check -func (h *Health) Schema() (*openapi3.SchemaRef, error) { - return checks.OpenapiFromPerfData[map[string]string](map[string]string{}) +func (ch *check) Schema() (*openapi3.SchemaRef, error) { + return checks.OpenapiFromPerfData(map[string]string{}) } // GetMetricCollectors returns all metric collectors of check -func (h *Health) GetMetricCollectors() []prometheus.Collector { +func (ch *check) GetMetricCollectors() []prometheus.Collector { return []prometheus.Collector{ - h.metrics, + ch.metrics, } } // RemoveLabelledMetrics removes the metrics which have the passed // target as a label -func (h *Health) RemoveLabelledMetrics(target string) error { - return h.metrics.Remove(target) +func (ch *check) RemoveLabelledMetrics(target string) error { + return ch.metrics.Remove(target) } // check performs a health check using a retry function // to get the health status for all targets -func (h *Health) check(ctx context.Context) map[string]string { +func (ch *check) check(ctx context.Context) map[string]string { log := logger.FromContext(ctx) log.Debug("Checking health") - if len(h.config.Targets) == 0 { + ch.Mutex.Lock() + cfg := ch.config + ch.Mutex.Unlock() + + if len(cfg.Targets) == 0 { log.Debug("No targets defined") return map[string]string{} } - log.Debug("Getting health status for each target in separate routine", "amount", len(h.config.Targets)) + log.Debug("Getting health status for each target in separate routine", "amount", len(cfg.Targets)) var wg sync.WaitGroup var mu sync.Mutex results := map[string]string{} client := &http.Client{ - Timeout: h.config.Timeout, + Timeout: cfg.Timeout, } - for _, t := range h.config.Targets { + for _, t := range cfg.Targets { target := t wg.Add(1) l := log.With("target", target) getHealthRetry := helper.Retry(func(ctx context.Context) error { return getHealth(ctx, client, target) - }, h.config.Retry) + }, cfg.Retry) go func() { defer wg.Done() @@ -193,7 +194,7 @@ func (h *Health) check(ctx context.Context) map[string]string { l.Debug("Starting retry routine to get health status") if err := getHealthRetry(ctx); err != nil { state = 0 - l.Warn(fmt.Sprintf("Health check failed after %d retries", h.config.Retry.Count), "error", err) + l.Warn(fmt.Sprintf("Health check failed after %d retries", cfg.Retry.Count), "error", err) } l.Debug("Successfully got health status of target", "status", stateMapping[state]) @@ -201,7 +202,7 @@ func (h *Health) check(ctx context.Context) map[string]string { defer mu.Unlock() results[target] = stateMapping[state] - h.metrics.WithLabelValues(target).Set(float64(state)) + ch.metrics.WithLabelValues(target).Set(float64(state)) }() } @@ -222,17 +223,12 @@ func getHealth(ctx context.Context, client *http.Client, url string) error { return err } - resp, err := client.Do(req) //nolint:bodyclose // Closed in defer below + resp, err := client.Do(req) if err != nil { log.Error("Error while requesting health", "error", err) return err } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Error("Failed to close response body", "error", err.Error()) - } - }(resp.Body) + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Warn("Health request was not ok (HTTP Status 200)", "status", resp.Status) diff --git a/pkg/checks/health/health_test.go b/pkg/checks/health/health_test.go index c1e6c0fd..2cf584df 100644 --- a/pkg/checks/health/health_test.go +++ b/pkg/checks/health/health_test.go @@ -26,12 +26,15 @@ import ( "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/checks/latency" + "github.com/telekom/sparrow/test" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" ) func TestHealth_UpdateConfig(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string inputConfig checks.Runtime @@ -63,15 +66,13 @@ func TestHealth_UpdateConfig(t *testing.T) { inputConfig: &latency.Config{ Targets: []string{"test"}, }, - expectedConfig: Config{}, + expectedConfig: Config{Retry: checks.DefaultRetry}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - h := &Health{ - metrics: newMetrics(), - } + h := NewCheck().(*check) if err := h.UpdateConfig(tt.inputConfig); (err != nil) != tt.wantErr { t.Errorf("Health.UpdateConfig() error = %v, wantErr %v", err, tt.wantErr) @@ -82,6 +83,8 @@ func TestHealth_UpdateConfig(t *testing.T) { } func Test_getHealth(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() endpoint := "https://api.test.com/test" @@ -150,6 +153,8 @@ func Test_getHealth(t *testing.T) { } func TestHealth_Check(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -227,7 +232,7 @@ func TestHealth_Check(t *testing.T) { ) } - h := &Health{ + h := &check{ config: Config{ Targets: tt.targets, Timeout: 30, @@ -247,33 +252,3 @@ func TestHealth_Check(t *testing.T) { }) } } - -func TestHealth_Shutdown(t *testing.T) { - cDone := make(chan struct{}, 1) - c := Health{ - CheckBase: checks.CheckBase{ - DoneChan: cDone, - }, - } - c.Shutdown() - - if _, ok := <-cDone; !ok { - t.Error("Channel should be done") - } - - assert.Panics(t, func() { - cDone <- struct{}{} - }, "Channel is closed, should panic") - - hc := NewCheck() - hc.Shutdown() - - _, ok := <-hc.(*Health).DoneChan - if !ok { - t.Error("Channel should be done") - } - - assert.Panics(t, func() { - hc.(*Health).DoneChan <- struct{}{} - }, "Channel is closed, should panic") -} diff --git a/pkg/checks/latency/config_test.go b/pkg/checks/latency/config_test.go index 4deb128f..15482d5d 100644 --- a/pkg/checks/latency/config_test.go +++ b/pkg/checks/latency/config_test.go @@ -21,9 +21,13 @@ package latency import ( "testing" "time" + + "github.com/telekom/sparrow/test" ) func TestConfig_Validate(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string config Config diff --git a/pkg/checks/latency/latency.go b/pkg/checks/latency/latency.go index d4a9c6ca..1b578151 100644 --- a/pkg/checks/latency/latency.go +++ b/pkg/checks/latency/latency.go @@ -20,8 +20,8 @@ package latency import ( "context" - "io" "net/http" + "reflect" "slices" "sync" "time" @@ -35,26 +35,24 @@ import ( ) var ( - _ checks.Check = (*Latency)(nil) + _ checks.Check = (*check)(nil) _ checks.Runtime = (*Config)(nil) ) const CheckName = "latency" -// Latency is a check that measures the latency to an endpoint -type Latency struct { - checks.CheckBase +// check is the implementation of the latency check. +// It measures the latency to a list of targets. +type check struct { + checks.Base config Config metrics metrics } // NewCheck creates a new instance of the latency check func NewCheck() checks.Check { - return &Latency{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, + return &check{ + Base: checks.NewBase(), config: Config{ Retry: checks.DefaultRetry, }, @@ -70,55 +68,56 @@ type result struct { } // Run starts the latency check -func (l *Latency) Run(ctx context.Context, cResult chan checks.ResultDTO) error { +func (ch *check) Run(ctx context.Context, cResult chan checks.ResultDTO) error { ctx, cancel := logger.NewContextWithLogger(ctx) defer cancel() log := logger.FromContext(ctx) - log.Info("Starting latency check", "interval", l.config.Interval.String()) + timer := time.NewTimer(ch.config.Interval) + log.InfoContext(ctx, "Starting latency check", "interval", ch.config.Interval.String()) for { select { case <-ctx.Done(): - log.Error("Context canceled", "err", ctx.Err()) + log.ErrorContext(ctx, "Context canceled", "err", ctx.Err()) return ctx.Err() - case <-l.DoneChan: + case <-ch.Done: return nil - case <-time.After(l.config.Interval): - res := l.check(ctx) - + case <-timer.C: + res := ch.check(ctx) cResult <- checks.ResultDTO{ - Name: l.Name(), + Name: ch.Name(), Result: &checks.Result{ Data: res, Timestamp: time.Now(), }, } - log.Debug("Successfully finished latency check run") + log.DebugContext(ctx, "Successfully finished latency check run") + ch.Mutex.Lock() + timer.Reset(ch.config.Interval) + ch.Mutex.Unlock() } } } -func (l *Latency) Shutdown() { - l.DoneChan <- struct{}{} - close(l.DoneChan) -} - // UpdateConfig sets the configuration for the latency check -func (l *Latency) UpdateConfig(cfg checks.Runtime) error { +func (ch *check) UpdateConfig(cfg checks.Runtime) error { if c, ok := cfg.(*Config); ok { - l.Mu.Lock() - defer l.Mu.Unlock() + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + if c == nil || reflect.DeepEqual(&ch.config, c) { + return nil + } - for _, target := range l.config.Targets { + for _, target := range ch.config.Targets { if !slices.Contains(c.Targets, target) { - err := l.metrics.Remove(target) + err := ch.metrics.Remove(target) if err != nil { return err } } } - l.config = *c + ch.config = *c return nil } @@ -129,56 +128,60 @@ func (l *Latency) UpdateConfig(cfg checks.Runtime) error { } // GetConfig returns the current configuration of the latency Check -func (l *Latency) GetConfig() checks.Runtime { - l.Mu.Lock() - defer l.Mu.Unlock() - return &l.config +func (ch *check) GetConfig() checks.Runtime { + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + return &ch.config } // Name returns the name of the check -func (l *Latency) Name() string { +func (*check) Name() string { return CheckName } // Schema provides the schema of the data that will be provided // by the latency check -func (l *Latency) Schema() (*openapi3.SchemaRef, error) { - return checks.OpenapiFromPerfData[map[string]result](make(map[string]result)) +func (ch *check) Schema() (*openapi3.SchemaRef, error) { + return checks.OpenapiFromPerfData(map[string]result{}) } // GetMetricCollectors returns all metric collectors of check -func (l *Latency) GetMetricCollectors() []prometheus.Collector { +func (ch *check) GetMetricCollectors() []prometheus.Collector { return []prometheus.Collector{ - l.metrics.totalDuration, - l.metrics.count, - l.metrics.histogram, + ch.metrics.totalDuration, + ch.metrics.count, + ch.metrics.histogram, } } // RemoveLabelledMetrics removes the metrics which have the passed target as a label -func (l *Latency) RemoveLabelledMetrics(target string) error { - return l.metrics.Remove(target) +func (ch *check) RemoveLabelledMetrics(target string) error { + return ch.metrics.Remove(target) } // check performs a latency check using a retry function // to get the latency to all targets -func (l *Latency) check(ctx context.Context) map[string]result { +func (ch *check) check(ctx context.Context) map[string]result { log := logger.FromContext(ctx) log.Debug("Checking latency") - if len(l.config.Targets) == 0 { + ch.Mutex.Lock() + cfg := ch.config + ch.Mutex.Unlock() + + if len(cfg.Targets) == 0 { log.Debug("No targets defined") return map[string]result{} } - log.Debug("Getting latency status for each target in separate routine", "amount", len(l.config.Targets)) + log.Debug("Getting latency status for each target in separate routine", "amount", len(cfg.Targets)) var mu sync.Mutex var wg sync.WaitGroup results := map[string]result{} client := &http.Client{ - Timeout: l.config.Timeout, + Timeout: cfg.Timeout, } - for _, t := range l.config.Targets { + for _, t := range cfg.Targets { target := t wg.Add(1) lo := log.With("target", target) @@ -192,7 +195,7 @@ func (l *Latency) check(ctx context.Context) map[string]result { return err } return nil - }, l.config.Retry) + }, cfg.Retry) go func() { defer wg.Done() @@ -206,9 +209,9 @@ func (l *Latency) check(ctx context.Context) map[string]result { mu.Lock() defer mu.Unlock() - l.metrics.totalDuration.WithLabelValues(target).Set(results[target].Total) - l.metrics.count.WithLabelValues(target).Inc() - l.metrics.histogram.WithLabelValues(target).Observe(results[target].Total) + ch.metrics.totalDuration.WithLabelValues(target).Set(results[target].Total) + ch.metrics.count.WithLabelValues(target).Inc() + ch.metrics.histogram.WithLabelValues(target).Observe(results[target].Total) }() } @@ -233,7 +236,7 @@ func getLatency(ctx context.Context, c *http.Client, url string) (result, error) } start := time.Now() - resp, err := c.Do(req) //nolint:bodyclose // Closed in defer below + resp, err := c.Do(req) if err != nil { log.Error("Error while checking latency", "error", err) errval := err.Error() @@ -241,12 +244,9 @@ func getLatency(ctx context.Context, c *http.Client, url string) (result, error) return res, err } end := time.Now() + defer resp.Body.Close() res.Code = resp.StatusCode - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(resp.Body) - res.Total = end.Sub(start).Seconds() return res, nil } diff --git a/pkg/checks/latency/latency_test.go b/pkg/checks/latency/latency_test.go index cde34d3d..51cd0f72 100644 --- a/pkg/checks/latency/latency_test.go +++ b/pkg/checks/latency/latency_test.go @@ -27,6 +27,7 @@ import ( "time" "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/test" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" @@ -43,6 +44,8 @@ func stringPointer(s string) *string { } func TestLatency_Run(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -180,9 +183,10 @@ func TestLatency_Run(t *testing.T) { } func TestLatency_check(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) - tests := []struct { name string registeredEndpoints []struct { @@ -274,7 +278,7 @@ func TestLatency_check(t *testing.T) { } } - l := &Latency{ + l := &check{ config: Config{Targets: tt.targets, Interval: time.Second * 120, Timeout: time.Second * 1}, metrics: newMetrics(), } @@ -305,23 +309,10 @@ func TestLatency_check(t *testing.T) { } } -func TestLatency_Shutdown(t *testing.T) { - cDone := make(chan struct{}, 1) - c := Latency{ - CheckBase: checks.CheckBase{ - DoneChan: cDone, - }, - } - c.Shutdown() - - _, ok := <-cDone - if !ok { - t.Error("Shutdown() should be ok") - } -} - func TestLatency_UpdateConfig(t *testing.T) { - c := Latency{} + test.MarkAsShort(t) + + c := NewCheck().(*check) wantCfg := Config{ Targets: []string{"http://localhost:9090"}, } @@ -336,6 +327,8 @@ func TestLatency_UpdateConfig(t *testing.T) { } func TestNewLatencyCheck(t *testing.T) { + test.MarkAsShort(t) + c := NewCheck() if c == nil { t.Error("NewLatencyCheck() should not be nil") diff --git a/pkg/checks/oapi_test.go b/pkg/checks/oapi_test.go index 3085f2ed..fa09ed9f 100644 --- a/pkg/checks/oapi_test.go +++ b/pkg/checks/oapi_test.go @@ -23,9 +23,12 @@ import ( "testing" "github.com/getkin/kin-openapi/openapi3" + "github.com/telekom/sparrow/test" ) func TestOpenapiFromPerfData(t *testing.T) { + test.MarkAsShort(t) + type args[T any] struct { perfData T } diff --git a/pkg/checks/traceroute/check.go b/pkg/checks/traceroute/check.go index f541ab21..338a0a1c 100644 --- a/pkg/checks/traceroute/check.go +++ b/pkg/checks/traceroute/check.go @@ -21,6 +21,7 @@ package traceroute import ( "context" "fmt" + "reflect" "slices" "sync" "time" @@ -36,7 +37,7 @@ import ( "go.opentelemetry.io/otel/trace" ) -var _ checks.Check = (*Traceroute)(nil) +var _ checks.Check = (*check)(nil) const CheckName = "traceroute" @@ -51,13 +52,20 @@ func (t Target) String() string { return fmt.Sprintf("%s:%d", t.Addr, t.Port) } +// check is the implementation of the traceroute check. +// It traces the path to a list of targets. +type check struct { + checks.Base + config Config + traceroute tracerouteFactory + metrics metrics + tracer trace.Tracer +} + func NewCheck() checks.Check { - c := &Traceroute{ - CheckBase: checks.CheckBase{ - Mu: sync.Mutex{}, - DoneChan: make(chan struct{}, 1), - }, - config: Config{}, + c := &check{ + Base: checks.NewBase(), + config: Config{Retry: checks.DefaultRetry}, traceroute: TraceRoute, metrics: newMetrics(), } @@ -65,14 +73,6 @@ func NewCheck() checks.Check { return c } -type Traceroute struct { - checks.CheckBase - config Config - traceroute tracerouteFactory - metrics metrics - tracer trace.Tracer -} - type tracerouteConfig struct { Dest string Port int @@ -91,79 +91,86 @@ type result struct { } // Run runs the check in a loop sending results to the provided channel -func (tr *Traceroute) Run(ctx context.Context, cResult chan checks.ResultDTO) error { +func (ch *check) Run(ctx context.Context, cResult chan checks.ResultDTO) error { ctx, cancel := logger.NewContextWithLogger(ctx) defer cancel() log := logger.FromContext(ctx) - log.InfoContext(ctx, "Starting traceroute check", "interval", tr.config.Interval.String()) + timer := time.NewTimer(ch.config.Interval) + log.InfoContext(ctx, "Starting traceroute check", "interval", ch.config.Interval.String()) for { select { case <-ctx.Done(): log.ErrorContext(ctx, "Context canceled", "error", ctx.Err()) return ctx.Err() - case <-tr.DoneChan: + case <-ch.Done: return nil - case <-time.After(tr.config.Interval): - res := tr.check(ctx) - tr.metrics.MinHops(res) + case <-timer.C: + res := ch.check(ctx) + ch.metrics.MinHops(res) cResult <- checks.ResultDTO{ - Name: tr.Name(), + Name: ch.Name(), Result: &checks.Result{ Data: res, Timestamp: time.Now(), }, } log.DebugContext(ctx, "Successfully finished traceroute check run") + ch.Mutex.Lock() + timer.Reset(ch.config.Interval) + ch.Mutex.Unlock() } } } // GetConfig returns the current configuration of the check -func (tr *Traceroute) GetConfig() checks.Runtime { - tr.Mu.Lock() - defer tr.Mu.Unlock() - return &tr.config +func (ch *check) GetConfig() checks.Runtime { + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + return &ch.config } -func (tr *Traceroute) check(ctx context.Context) map[string]result { +func (ch *check) check(ctx context.Context) map[string]result { res := make(map[string]result) log := logger.FromContext(ctx) + ch.Mutex.Lock() + cfg := ch.config + ch.Mutex.Unlock() type internalResult struct { addr string res result } - cResult := make(chan internalResult, len(tr.config.Targets)) + cResult := make(chan internalResult, len(cfg.Targets)) var wg sync.WaitGroup start := time.Now() - wg.Add(len(tr.config.Targets)) + wg.Add(len(cfg.Targets)) - for _, t := range tr.config.Targets { + for _, t := range cfg.Targets { go func(t Target) { defer wg.Done() l := log.With("target", t.String()) l.DebugContext(ctx, "Running traceroute") - c, span := tr.tracer.Start(ctx, t.String(), trace.WithAttributes( + c, span := ch.tracer.Start(ctx, t.String(), trace.WithAttributes( attribute.String("target.addr", t.Addr), attribute.Int("target.port", t.Port), - attribute.Stringer("config.interval", tr.config.Interval), - attribute.Stringer("config.timeout", tr.config.Timeout), - attribute.Int("config.max_hops", tr.config.MaxHops), - attribute.Int("config.retry.count", tr.config.Retry.Count), - attribute.Stringer("config.retry.delay", tr.config.Retry.Delay), + attribute.Stringer("config.interval", cfg.Interval), + attribute.Stringer("config.timeout", cfg.Timeout), + attribute.Int("config.max_hops", cfg.MaxHops), + attribute.Int("config.retry.count", cfg.Retry.Count), + attribute.Stringer("config.retry.delay", cfg.Retry.Delay), )) defer span.End() s := time.Now() - hops, err := tr.traceroute(c, tracerouteConfig{ + hops, err := ch.traceroute(c, tracerouteConfig{ Dest: t.Addr, Port: t.Port, - Timeout: tr.config.Timeout, - MaxHops: tr.config.MaxHops, - Rc: tr.config.Retry, + Timeout: cfg.Timeout, + MaxHops: cfg.MaxHops, + Rc: cfg.Retry, }) elapsed := time.Since(s) @@ -175,12 +182,12 @@ func (tr *Traceroute) check(ctx context.Context) map[string]result { span.SetStatus(codes.Ok, "success") } - tr.metrics.CheckDuration(t.Addr, elapsed) + ch.metrics.CheckDuration(t.Addr, elapsed) l.DebugContext(ctx, "Ran traceroute", "result", hops, "duration", elapsed) res := result{ Hops: hops, - MinHops: tr.config.MaxHops, + MinHops: cfg.MaxHops, } for ttl, hop := range hops { for _, attempt := range hop { @@ -212,30 +219,25 @@ func (tr *Traceroute) check(ctx context.Context) map[string]result { return res } -// Shutdown is called once when the check is unregistered or sparrow shuts down -func (tr *Traceroute) Shutdown() { - tr.DoneChan <- struct{}{} - close(tr.DoneChan) -} - -// UpdateConfig is called once when the check is registered -// This is also called while the check is running, if the remote config is updated -// This should return an error if the config is invalid -func (tr *Traceroute) UpdateConfig(cfg checks.Runtime) error { +// UpdateConfig updates the configuration of the check. +func (ch *check) UpdateConfig(cfg checks.Runtime) error { if c, ok := cfg.(*Config); ok { - tr.Mu.Lock() - defer tr.Mu.Unlock() + ch.Mutex.Lock() + defer ch.Mutex.Unlock() + if c == nil || reflect.DeepEqual(&ch.config, c) { + return nil + } - for _, target := range tr.config.Targets { + for _, target := range ch.config.Targets { if !slices.Contains(c.Targets, target) { - err := tr.metrics.Remove(target.Addr) + err := ch.metrics.Remove(target.Addr) if err != nil { return err } } } - tr.config = *c + ch.config = *c return nil } @@ -246,22 +248,22 @@ func (tr *Traceroute) UpdateConfig(cfg checks.Runtime) error { } // Schema returns an openapi3.SchemaRef of the result type returned by the check -func (tr *Traceroute) Schema() (*openapi3.SchemaRef, error) { +func (ch *check) Schema() (*openapi3.SchemaRef, error) { return checks.OpenapiFromPerfData(map[string]result{}) } // GetMetricCollectors allows the check to provide prometheus metric collectors -func (tr *Traceroute) GetMetricCollectors() []prometheus.Collector { - return tr.metrics.List() +func (ch *check) GetMetricCollectors() []prometheus.Collector { + return ch.metrics.List() } // Name returns the name of the check -func (tr *Traceroute) Name() string { +func (*check) Name() string { return CheckName } // RemoveLabelledMetrics removes the metrics which have the passed // target as a label -func (tr *Traceroute) RemoveLabelledMetrics(target string) error { - return tr.metrics.Remove(target) +func (ch *check) RemoveLabelledMetrics(target string) error { + return ch.metrics.Remove(target) } diff --git a/pkg/checks/traceroute/check_test.go b/pkg/checks/traceroute/check_test.go index d10020d6..b3a055c7 100644 --- a/pkg/checks/traceroute/check_test.go +++ b/pkg/checks/traceroute/check_test.go @@ -21,19 +21,21 @@ package traceroute import ( "context" "net" - "sync" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/test" "go.opentelemetry.io/otel" ) func TestCheck(t *testing.T) { + test.MarkAsShort(t) + cases := []struct { name string - c *Traceroute + c *check want map[string]result }{ { @@ -73,13 +75,13 @@ func TestCheck(t *testing.T) { } } -func newForTest(f tracerouteFactory, maxHops int, targets []string) *Traceroute { +func newForTest(f tracerouteFactory, maxHops int, targets []string) *check { t := make([]Target, len(targets)) for i, target := range targets { t[i] = Target{Addr: target} } - return &Traceroute{ - CheckBase: checks.CheckBase{Mu: sync.Mutex{}, DoneChan: make(chan struct{})}, + return &check{ + Base: checks.NewBase(), config: Config{Targets: t, MaxHops: maxHops}, traceroute: f, metrics: newMetrics(), @@ -138,6 +140,8 @@ func ipFromInt(i int) string { } func TestIpFromInt(t *testing.T) { + test.MarkAsShort(t) + cases := []struct { In int Expected string diff --git a/pkg/checks/traceroute/traceroute_test.go b/pkg/checks/traceroute/traceroute_test.go index 2b6f3bb9..7f2057d1 100644 --- a/pkg/checks/traceroute/traceroute_test.go +++ b/pkg/checks/traceroute/traceroute_test.go @@ -22,9 +22,13 @@ import ( "net" "reflect" "testing" + + "github.com/telekom/sparrow/test" ) func TestHopAddress_String(t *testing.T) { + test.MarkAsShort(t) + type fields struct { IP string Port int @@ -51,6 +55,8 @@ func TestHopAddress_String(t *testing.T) { } func Test_newHopAddress(t *testing.T) { + test.MarkAsShort(t) + type args struct { addr net.Addr } diff --git a/pkg/config/file.go b/pkg/config/file.go index 81efe969..3db1a5cb 100644 --- a/pkg/config/file.go +++ b/pkg/config/file.go @@ -28,9 +28,9 @@ import ( "path/filepath" "time" + "github.com/goccy/go-yaml" "github.com/telekom/sparrow/internal/logger" "github.com/telekom/sparrow/pkg/checks/runtime" - "gopkg.in/yaml.v3" ) var _ Loader = (*FileLoader)(nil) @@ -120,7 +120,7 @@ func (f *FileLoader) getRuntimeConfig(ctx context.Context) (cfg runtime.Config, return cfg, fmt.Errorf("failed to read config file: %w", err) } - if err := yaml.Unmarshal(b, &cfg); err != nil { + if err := yaml.UnmarshalContext(ctx, b, &cfg); err != nil { log.Error("Failed to parse config file", "error", err) return cfg, fmt.Errorf("failed to parse config file: %w", err) } diff --git a/pkg/config/file_test.go b/pkg/config/file_test.go index 37ffd085..cc3c19e2 100644 --- a/pkg/config/file_test.go +++ b/pkg/config/file_test.go @@ -26,15 +26,17 @@ import ( "testing" "time" + "github.com/goccy/go-yaml" "github.com/telekom/sparrow/pkg/checks/health" "github.com/telekom/sparrow/pkg/checks/runtime" "github.com/telekom/sparrow/pkg/config/test" - "gopkg.in/yaml.v3" + testUtils "github.com/telekom/sparrow/test" ) func TestNewFileLoader(t *testing.T) { - l := NewFileLoader(&Config{Loader: LoaderConfig{File: FileLoaderConfig{Path: "config.yaml"}}}, make(chan runtime.Config, 1)) + testUtils.MarkAsShort(t) + l := NewFileLoader(&Config{Loader: LoaderConfig{File: FileLoaderConfig{Path: "config.yaml"}}}, make(chan runtime.Config, 1)) if l.config.File.Path != "config.yaml" { t.Errorf("Expected path to be config.yaml, got %s", l.config.File.Path) } @@ -47,6 +49,8 @@ func TestNewFileLoader(t *testing.T) { } func TestFileLoader_Run(t *testing.T) { + testUtils.MarkAsShort(t) + tests := []struct { name string config LoaderConfig @@ -119,6 +123,8 @@ func TestFileLoader_Run(t *testing.T) { } func TestFileLoader_getRuntimeConfig(t *testing.T) { + testUtils.MarkAsShort(t) + tests := []struct { name string config LoaderConfig diff --git a/pkg/config/http.go b/pkg/config/http.go index 737a655d..0db56d39 100644 --- a/pkg/config/http.go +++ b/pkg/config/http.go @@ -26,10 +26,10 @@ import ( "net/http" "time" + "github.com/goccy/go-yaml" "github.com/telekom/sparrow/internal/helper" "github.com/telekom/sparrow/internal/logger" "github.com/telekom/sparrow/pkg/checks/runtime" - "gopkg.in/yaml.v3" ) type HttpLoader struct { @@ -138,7 +138,7 @@ func (hl *HttpLoader) getRuntimeConfig(ctx context.Context) (cfg runtime.Config, } log.Debug("Successfully got response") - if err := yaml.Unmarshal(b, &cfg); err != nil { + if err := yaml.UnmarshalContext(ctx, b, &cfg); err != nil { log.Error("Could not unmarshal response", "error", err.Error()) return cfg, err } diff --git a/pkg/config/http_test.go b/pkg/config/http_test.go index 72159817..a2498b8d 100644 --- a/pkg/config/http_test.go +++ b/pkg/config/http_test.go @@ -29,16 +29,19 @@ import ( "testing" "time" + "github.com/goccy/go-yaml" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" "github.com/telekom/sparrow/internal/helper" "github.com/telekom/sparrow/internal/logger" "github.com/telekom/sparrow/pkg/checks/health" "github.com/telekom/sparrow/pkg/checks/runtime" - "gopkg.in/yaml.v3" + "github.com/telekom/sparrow/test" ) func TestHttpLoader_GetRuntimeConfig(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -155,6 +158,8 @@ func TestHttpLoader_GetRuntimeConfig(t *testing.T) { // The test runs the Run method for a while // and then shuts it down via a goroutine func TestHttpLoader_Run(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -255,6 +260,8 @@ func TestHttpLoader_Run(t *testing.T) { } func TestHttpLoader_Shutdown(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string }{ @@ -283,6 +290,8 @@ func TestHttpLoader_Shutdown(t *testing.T) { // TestHttpLoader_Run_config_sent_to_channel tests if the config is sent to the channel // when the Run method is called and the remote endpoint returns a valid response func TestHttpLoader_Run_config_sent_to_channel(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -345,6 +354,8 @@ func TestHttpLoader_Run_config_sent_to_channel(t *testing.T) { // when the Run method is called // and the remote endpoint returns a non-200 response func TestHttpLoader_Run_empty_config_sent_to_channel_500(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -396,6 +407,8 @@ func TestHttpLoader_Run_empty_config_sent_to_channel_500(t *testing.T) { // when the Run method is called // and the client can't execute the requests func TestHttpLoader_Run_empty_config_sent_to_channel_client_error(t *testing.T) { + test.MarkAsShort(t) + httpmock.Activate() defer httpmock.DeactivateAndReset() diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 033b714f..f3051568 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -25,11 +25,13 @@ import ( "github.com/telekom/sparrow/internal/helper" "github.com/telekom/sparrow/pkg/api" + "github.com/telekom/sparrow/test" ) func TestConfig_Validate(t *testing.T) { - ctx := context.Background() + test.MarkAsShort(t) + ctx := context.Background() tests := []struct { name string config Config @@ -170,6 +172,8 @@ func TestConfig_Validate(t *testing.T) { } func Test_isDNSName(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string dnsName string diff --git a/pkg/db/db_test.go b/pkg/db/db_test.go index 42d4980a..98d6b42c 100644 --- a/pkg/db/db_test.go +++ b/pkg/db/db_test.go @@ -24,9 +24,12 @@ import ( "testing" "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/test" ) func TestInMemory_Save(t *testing.T) { + test.MarkAsShort(t) + type fields struct { data map[string]checks.Result } @@ -63,6 +66,8 @@ func TestInMemory_Save(t *testing.T) { } func TestNewInMemory(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string want *InMemory @@ -79,6 +84,8 @@ func TestNewInMemory(t *testing.T) { } func TestInMemory_Get(t *testing.T) { + test.MarkAsShort(t) + type fields struct { data map[string]*checks.Result } @@ -121,6 +128,8 @@ func TestInMemory_Get(t *testing.T) { } func TestInMemory_List(t *testing.T) { + test.MarkAsShort(t) + type fields struct { data map[string]*checks.Result } @@ -172,6 +181,8 @@ func TestInMemory_List(t *testing.T) { } func TestInMemory_ListThreadsafe(t *testing.T) { + test.MarkAsShort(t) + db := NewInMemory() db.Save(checks.ResultDTO{Name: "alpha", Result: &checks.Result{Data: 0}}) db.Save(checks.ResultDTO{Name: "beta", Result: &checks.Result{Data: 1}}) diff --git a/pkg/factory/factory_test.go b/pkg/factory/factory_test.go index 4111d9b6..eabe5ecd 100644 --- a/pkg/factory/factory_test.go +++ b/pkg/factory/factory_test.go @@ -27,6 +27,7 @@ import ( "github.com/telekom/sparrow/pkg/checks/health" "github.com/telekom/sparrow/pkg/checks/latency" "github.com/telekom/sparrow/pkg/checks/runtime" + "github.com/telekom/sparrow/test" ) var ( @@ -43,6 +44,8 @@ var ( ) func TestNewChecksFromConfig(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string cfg runtime.Config @@ -121,6 +124,8 @@ func newLatencyCheck() checks.Check { } func TestNewCheck(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string cfg checks.Runtime diff --git a/pkg/sparrow/controller.go b/pkg/sparrow/controller.go index 06508338..4dd8565b 100644 --- a/pkg/sparrow/controller.go +++ b/pkg/sparrow/controller.go @@ -23,9 +23,11 @@ import ( "errors" "fmt" "net/http" + stdruntime "runtime" "github.com/getkin/kin-openapi/openapi3" "github.com/telekom/sparrow/internal/logger" + "github.com/telekom/sparrow/pkg" "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/checks/runtime" "github.com/telekom/sparrow/pkg/db" @@ -72,7 +74,6 @@ func (cc *ChecksController) Run(ctx context.Context) error { case <-ctx.Done(): return ctx.Err() case <-cc.done: - cc.cErr <- nil return nil } } @@ -86,7 +87,6 @@ func (cc *ChecksController) Shutdown(ctx context.Context) { for _, c := range cc.checks.Iter() { cc.UnregisterCheck(ctx, c) } - cc.done <- struct{}{} close(cc.done) close(cc.cResult) } @@ -170,16 +170,29 @@ func (cc *ChecksController) UnregisterCheck(ctx context.Context, check checks.Ch } var oapiBoilerplate = openapi3.T{ - // this object should probably be user defined OpenAPI: "3.0.0", Info: &openapi3.Info{ - Title: "Sparrow Metrics API", + Title: "Sparrow Metrics API", + Version: func() string { + version := pkg.Version + if version == "" { + return fmt.Sprintf("0.0.0-dev-%s", stdruntime.Version()) + } + if version[0] == 'v' { + return version[1:] + } + return version + }(), Description: "Serves metrics collected by sparrows checks", Contact: &openapi3.Contact{ URL: "https://caas.telekom.de", Email: "caas-request@telekom.de", Name: "CaaS Team", }, + License: &openapi3.License{ + Name: "Apache 2.0", + URL: "http://www.apache.org/licenses/LICENSE-2.0", + }, }, Paths: &openapi3.Paths{ Extensions: make(map[string]any), diff --git a/pkg/sparrow/controller_test.go b/pkg/sparrow/controller_test.go index f3b22e20..389013f1 100644 --- a/pkg/sparrow/controller_test.go +++ b/pkg/sparrow/controller_test.go @@ -36,9 +36,12 @@ import ( "github.com/telekom/sparrow/pkg/checks/runtime" "github.com/telekom/sparrow/pkg/db" "github.com/telekom/sparrow/pkg/sparrow/metrics" + "github.com/telekom/sparrow/test" ) func TestRun_CheckRunError(t *testing.T) { + test.MarkAsShort(t) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -80,8 +83,10 @@ func TestRun_CheckRunError(t *testing.T) { } func TestRun_ContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) + test.MarkAsShort(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() cc := NewChecksController(db.NewInMemory(), metrics.New(metrics.Config{})) done := make(chan struct{}) @@ -104,6 +109,8 @@ func TestRun_ContextCancellation(t *testing.T) { } func TestChecksController_Reconcile(t *testing.T) { + test.MarkAsShort(t) + ctx, cancel := logger.NewContextWithLogger(context.Background()) defer cancel() rtcfg := &runtime.Config{} @@ -236,6 +243,8 @@ func TestChecksController_Reconcile(t *testing.T) { } func TestChecksController_RegisterCheck(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string setup func() *ChecksController @@ -262,6 +271,8 @@ func TestChecksController_RegisterCheck(t *testing.T) { } func TestChecksController_UnregisterCheck(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string check checks.Check @@ -286,6 +297,8 @@ func TestChecksController_UnregisterCheck(t *testing.T) { } func TestGenerateCheckSpecs(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string checks []checks.Check diff --git a/pkg/sparrow/handlers.go b/pkg/sparrow/handlers.go index 06a8379b..445119b2 100644 --- a/pkg/sparrow/handlers.go +++ b/pkg/sparrow/handlers.go @@ -27,9 +27,9 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/go-chi/chi/v5" + "github.com/goccy/go-yaml" "github.com/telekom/sparrow/internal/logger" "github.com/telekom/sparrow/pkg/api" - "gopkg.in/yaml.v3" ) type encoder interface { @@ -123,6 +123,7 @@ func (s *Sparrow) handleCheckMetrics(w http.ResponseWriter, r *http.Request) { return } + w.Header().Add("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -135,5 +136,4 @@ func (s *Sparrow) handleCheckMetrics(w http.ResponseWriter, r *http.Request) { } return } - w.Header().Add("Content-Type", "application/json") } diff --git a/pkg/sparrow/handlers_test.go b/pkg/sparrow/handlers_test.go index 32793f80..040f2b3f 100644 --- a/pkg/sparrow/handlers_test.go +++ b/pkg/sparrow/handlers_test.go @@ -31,13 +31,16 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/goccy/go-yaml" "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/checks/runtime" "github.com/telekom/sparrow/pkg/db" - "gopkg.in/yaml.v3" + "github.com/telekom/sparrow/test" ) func TestSparrow_handleOpenAPI(t *testing.T) { + test.MarkAsShort(t) + s := Sparrow{ controller: &ChecksController{ checks: runtime.Checks{}, @@ -91,6 +94,8 @@ func TestSparrow_handleOpenAPI(t *testing.T) { } func TestSparrow_handleCheckMetrics(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string want []byte @@ -127,12 +132,18 @@ func TestSparrow_handleCheckMetrics(t *testing.T) { if tt.wantCode == http.StatusBadRequest { r = chiRequest(httptest.NewRequest(http.MethodGet, "/v1/metrics/", bytes.NewBuffer([]byte{})), "") } + r.Header.Add("Accept", "application/json") s.handleCheckMetrics(w, r) - resp := w.Result() //nolint:bodyclose + resp := w.Result() + defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if tt.wantCode == http.StatusOK { + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", w.Header().Get("Content-Type"), "application/json") + } + if tt.wantCode != resp.StatusCode { t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", resp.StatusCode, tt.wantCode) } @@ -150,13 +161,14 @@ func TestSparrow_handleCheckMetrics(t *testing.T) { if reflect.DeepEqual(got, want) { t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", got, want) } - } else { - if tt.wantCode != resp.StatusCode { - t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", resp.StatusCode, tt.wantCode) - } - if !reflect.DeepEqual(body, tt.want) { - t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", body, tt.want) - } + return + } + + if tt.wantCode != resp.StatusCode { + t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", resp.StatusCode, tt.wantCode) + } + if !reflect.DeepEqual(body, tt.want) { + t.Errorf("Sparrow.getCheckMetrics() = %v, want %v", body, tt.want) } }) } diff --git a/pkg/sparrow/metrics/metrics_test.go b/pkg/sparrow/metrics/metrics_test.go index 80c0f566..7236afc0 100644 --- a/pkg/sparrow/metrics/metrics_test.go +++ b/pkg/sparrow/metrics/metrics_test.go @@ -24,11 +24,14 @@ import ( "testing" "github.com/prometheus/client_golang/prometheus" + "github.com/telekom/sparrow/test" "go.opentelemetry.io/otel" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func TestPrometheusMetrics_GetRegistry(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string registry *prometheus.Registry @@ -53,6 +56,8 @@ func TestPrometheusMetrics_GetRegistry(t *testing.T) { } func TestNewMetrics(t *testing.T) { + test.MarkAsShort(t) + testMetrics := New(Config{}) testGauge := prometheus.NewGauge( prometheus.GaugeOpts{ @@ -68,6 +73,8 @@ func TestNewMetrics(t *testing.T) { } func TestMetrics_InitTracing(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string config Config diff --git a/pkg/sparrow/run_test.go b/pkg/sparrow/run_test.go index 66fd1546..b58924b6 100644 --- a/pkg/sparrow/run_test.go +++ b/pkg/sparrow/run_test.go @@ -35,11 +35,14 @@ import ( "github.com/telekom/sparrow/pkg/sparrow/targets/interactor" "github.com/telekom/sparrow/pkg/sparrow/targets/remote/gitlab" managermock "github.com/telekom/sparrow/pkg/sparrow/targets/test" + "github.com/telekom/sparrow/test" ) // TestSparrow_Run_FullComponentStart tests that the Run method starts the API, // loader and a targetManager all start. func TestSparrow_Run_FullComponentStart(t *testing.T) { + test.MarkAsShort(t) + c := &config.Config{ Api: api.Config{ListeningAddress: ":9090"}, Loader: config.LoaderConfig{ @@ -81,6 +84,8 @@ func TestSparrow_Run_FullComponentStart(t *testing.T) { // TestSparrow_Run_ContextCancel tests that after a context cancels the Run method // will return an error and all started components will be shut down. func TestSparrow_Run_ContextCancel(t *testing.T) { + test.MarkAsShort(t) + c := &config.Config{ Api: api.Config{ListeningAddress: ":9090"}, Loader: config.LoaderConfig{ @@ -112,6 +117,8 @@ func TestSparrow_Run_ContextCancel(t *testing.T) { // TestSparrow_enrichTargets tests that the enrichTargets method // updates the targets of the configured checks. func TestSparrow_enrichTargets(t *testing.T) { + test.MarkAsShort(t) + t.Parallel() now := time.Now() testTarget := "https://localhost.de" diff --git a/pkg/sparrow/targets/manager_test.go b/pkg/sparrow/targets/manager_test.go index 2608c3c2..0990f0ab 100644 --- a/pkg/sparrow/targets/manager_test.go +++ b/pkg/sparrow/targets/manager_test.go @@ -27,6 +27,7 @@ import ( "time" "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/test" remotemock "github.com/telekom/sparrow/pkg/sparrow/targets/remote/test" ) @@ -42,6 +43,8 @@ const ( // targets list. When an unhealthyTheshold is set, it will also unregister // unhealthy targets func Test_gitlabTargetManager_refreshTargets(t *testing.T) { + test.MarkAsShort(t) + now := time.Now() tooOld := now.Add(-time.Hour * 2) @@ -138,6 +141,8 @@ func Test_gitlabTargetManager_refreshTargets(t *testing.T) { // refreshTargets method will not unregister unhealthy targets if the // unhealthyThreshold is 0 func Test_gitlabTargetManager_refreshTargets_No_Threshold(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string mockTargets []checks.GlobalTarget @@ -208,6 +213,8 @@ func Test_gitlabTargetManager_refreshTargets_No_Threshold(t *testing.T) { } func Test_gitlabTargetManager_GetTargets(t *testing.T) { + test.MarkAsShort(t) + now := time.Now() tests := []struct { name string @@ -284,6 +291,8 @@ func Test_gitlabTargetManager_GetTargets(t *testing.T) { // Test_gitlabTargetManager_registerSparrow tests that the register method will // register the sparrow instance in the remote instance func Test_gitlabTargetManager_register(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string wantErr bool @@ -324,6 +333,8 @@ func Test_gitlabTargetManager_register(t *testing.T) { // Test_gitlabTargetManager_update tests that the update // method will update the registration of the sparrow instance in the remote instance func Test_gitlabTargetManager_update(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string wantPutError bool @@ -358,6 +369,8 @@ func Test_gitlabTargetManager_update(t *testing.T) { // will register the target if it is not registered yet and update the // registration if it is already registered func Test_gitlabTargetManager_Reconcile_success(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string registered bool @@ -418,6 +431,8 @@ func Test_gitlabTargetManager_Reconcile_success(t *testing.T) { // method will register the sparrow, and then update the registration after the // registration interval has passed func Test_gitlabTargetManager_Reconcile_Registration_Update(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -478,6 +493,8 @@ func Test_gitlabTargetManager_Reconcile_Registration_Update(t *testing.T) { // Test_gitlabTargetManager_Reconcile_failure tests that the Reconcile method // will handle API failures gracefully func Test_gitlabTargetManager_Reconcile_failure(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string registered bool @@ -542,6 +559,8 @@ func Test_gitlabTargetManager_Reconcile_failure(t *testing.T) { // Test_gitlabTargetManager_Reconcile_Context_Canceled tests that the Reconcile // method will shutdown gracefully when the context is canceled. func Test_gitlabTargetManager_Reconcile_Context_Canceled(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -582,6 +601,8 @@ func Test_gitlabTargetManager_Reconcile_Context_Canceled(t *testing.T) { // Test_gitlabTargetManager_Reconcile_Context_Done tests that the Reconcile // method will shut down gracefully when the context is done. func Test_gitlabTargetManager_Reconcile_Context_Done(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -620,6 +641,8 @@ func Test_gitlabTargetManager_Reconcile_Context_Done(t *testing.T) { // Test_gitlabTargetManager_Reconcile_Shutdown tests that the Reconcile // method will shut down gracefully when the Shutdown method is called. func Test_gitlabTargetManager_Reconcile_Shutdown(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -663,6 +686,8 @@ func Test_gitlabTargetManager_Reconcile_Shutdown(t *testing.T) { // method will fail the graceful shutdown when the Shutdown method is called // and the unregistering fails. func Test_gitlabTargetManager_Reconcile_Shutdown_Fail_Unregister(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -707,6 +732,8 @@ func Test_gitlabTargetManager_Reconcile_Shutdown_Fail_Unregister(t *testing.T) { // Test_gitlabTargetManager_Reconcile_No_Registration tests that the Reconcile // method will not register the instance if the registration interval is 0 func Test_gitlabTargetManager_Reconcile_No_Registration(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -745,6 +772,8 @@ func Test_gitlabTargetManager_Reconcile_No_Registration(t *testing.T) { // Test_gitlabTargetManager_Reconcile_No_Update tests that the Reconcile // method will not update the registration if the update interval is 0 func Test_gitlabTargetManager_Reconcile_No_Update(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { @@ -787,6 +816,8 @@ func Test_gitlabTargetManager_Reconcile_No_Update(t *testing.T) { // method will not register the instance if the registration interval is 0 // and will not update the registration if the update interval is 0 func Test_gitlabTargetManager_Reconcile_No_Registration_No_Update(t *testing.T) { + test.MarkAsShort(t) + glmock := remotemock.New( []checks.GlobalTarget{ { diff --git a/pkg/sparrow/targets/remote/gitlab/gitlab_test.go b/pkg/sparrow/targets/remote/gitlab/gitlab_test.go index 8cc934f7..9fbaf146 100644 --- a/pkg/sparrow/targets/remote/gitlab/gitlab_test.go +++ b/pkg/sparrow/targets/remote/gitlab/gitlab_test.go @@ -29,9 +29,12 @@ import ( "github.com/jarcoal/httpmock" "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/sparrow/targets/remote" + "github.com/telekom/sparrow/test" ) func Test_gitlab_fetchFileList(t *testing.T) { + test.MarkAsShort(t) + type file struct { Name string `json:"name"` } @@ -132,6 +135,8 @@ func Test_gitlab_fetchFileList(t *testing.T) { // The filelist and url are the same, so we HTTP responders can // be created without much hassle func Test_gitlab_FetchFiles(t *testing.T) { + test.MarkAsShort(t) + type file struct { Name string `json:"name"` } @@ -231,6 +236,8 @@ func Test_gitlab_FetchFiles(t *testing.T) { } func Test_gitlab_fetchFiles_error_cases(t *testing.T) { + test.MarkAsShort(t) + type file struct { Name string `json:"name"` } @@ -315,6 +322,8 @@ func Test_gitlab_fetchFiles_error_cases(t *testing.T) { } func TestClient_PutFile(t *testing.T) { //nolint:dupl // no need to refactor yet + test.MarkAsShort(t) + now := time.Now() tests := []struct { name string @@ -390,6 +399,8 @@ func TestClient_PutFile(t *testing.T) { //nolint:dupl // no need to refactor yet } func TestClient_PostFile(t *testing.T) { //nolint:dupl // no need to refactor yet + test.MarkAsShort(t) + now := time.Now() tests := []struct { name string @@ -465,6 +476,8 @@ func TestClient_PostFile(t *testing.T) { //nolint:dupl // no need to refactor ye } func TestClient_DeleteFile(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string fileName string @@ -521,6 +534,8 @@ func TestClient_DeleteFile(t *testing.T) { } func TestClient_fetchDefaultBranch(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string code int diff --git a/pkg/sparrow/targets/targetmanager_test.go b/pkg/sparrow/targets/targetmanager_test.go index 324b8204..8abf50f2 100644 --- a/pkg/sparrow/targets/targetmanager_test.go +++ b/pkg/sparrow/targets/targetmanager_test.go @@ -22,9 +22,13 @@ import ( "context" "testing" "time" + + "github.com/telekom/sparrow/test" ) func TestTargetManagerConfig_Validate(t *testing.T) { + test.MarkAsShort(t) + tests := []struct { name string cfg TargetManagerConfig diff --git a/pkg/version.go b/pkg/version.go new file mode 100644 index 00000000..0134fe93 --- /dev/null +++ b/pkg/version.go @@ -0,0 +1,24 @@ +// sparrow +// (C) 2024, Deutsche Telekom IT GmbH +// +// Deutsche Telekom IT GmbH and all other contributors / +// copyright owners license this file to you under the Apache +// License, Version 2.0 (the "License"); you may not use this +// file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package pkg contains metadata about the sparrow. +package pkg + +// Version is the current version of sparrow. +// It is set at build time by using -ldflags "-X main.version=x.x.x". +var Version string diff --git a/scripts/run_e2e_tests.sh b/scripts/run_e2e_tests.sh index f8387baf..f9f667f1 100755 --- a/scripts/run_e2e_tests.sh +++ b/scripts/run_e2e_tests.sh @@ -5,11 +5,15 @@ EXIT_CODE=0 MAX_RETRY=3 -for i in $(ls e2e); do - for ATTEMPT in $(seq 1 $MAX_RETRY ); do - echo "[$ATTEMPT/$MAX_RETRY] Running test e2e/$i" - cd e2e/$i - ./test.sh +for i in $(ls -d test/e2e/*); do + if [ ! -d $i ] || [ ! -f $i/test.sh ]; then + continue + fi + + for ATTEMPT in $(seq 1 $MAX_RETRY); do + echo "[$ATTEMPT/$MAX_RETRY] Running test $i" + cd $i + ./test.sh TEST_EXIT_CODE=$? cd $root if [ $TEST_EXIT_CODE -eq 0 ]; then @@ -17,7 +21,7 @@ for i in $(ls e2e); do elif [ $ATTEMPT -eq $MAX_RETRY ]; then EXIT_CODE=1 fi - done + done done -exit $EXIT_CODE \ No newline at end of file +exit $EXIT_CODE diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore new file mode 100644 index 00000000..1cc9ae43 --- /dev/null +++ b/test/e2e/.gitignore @@ -0,0 +1 @@ +testdata/* diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 00000000..ec51f53d --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,516 @@ +package e2e + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/pkg/config" + "github.com/telekom/sparrow/test" + "github.com/telekom/sparrow/test/framework" + "github.com/telekom/sparrow/test/framework/builder" +) + +const ( + checkInterval = 10 * time.Second + checkTimeout = 10 * time.Second +) + +func TestE2E_Sparrow_WithChecks_ConfigureOnce(t *testing.T) { + test.MarkAsLong(t) + + fw := framework.New(t) + tests := []struct { + name string + startup builder.SparrowConfig + checks []builder.Check + wantEndpoints map[string]int + }{ + { + name: "no checks", + startup: *builder.NewSparrowConfig(), + checks: nil, + wantEndpoints: map[string]int{ + "http://localhost:8080/v1/metrics/health": http.StatusNotFound, + "http://localhost:8080/v1/metrics/latency": http.StatusNotFound, + "http://localhost:8080/v1/metrics/dns": http.StatusNotFound, + "http://localhost:8080/v1/metrics/traceroute": http.StatusNotFound, + }, + }, + { + name: "with health check", + startup: *builder.NewSparrowConfig(), + checks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/", "https://www.google.com/"), + }, + wantEndpoints: map[string]int{ + "http://localhost:8080/v1/metrics/health": http.StatusOK, + "http://localhost:8080/v1/metrics/latency": http.StatusNotFound, + "http://localhost:8080/v1/metrics/dns": http.StatusNotFound, + "http://localhost:8080/v1/metrics/traceroute": http.StatusNotFound, + }, + }, + { + name: "with health, latency and dns checks", + startup: *builder.NewSparrowConfig(), + checks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + builder.NewLatencyCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + builder.NewDNSCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("www.example.com"), + }, + wantEndpoints: map[string]int{ + "http://localhost:8080/v1/metrics/health": http.StatusOK, + "http://localhost:8080/v1/metrics/latency": http.StatusOK, + "http://localhost:8080/v1/metrics/dns": http.StatusOK, + "http://localhost:8080/v1/metrics/traceroute": http.StatusNotFound, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e2e := fw.E2E(t, tt.startup.Config(t)).WithChecks(tt.checks...) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + finish := make(chan error, 1) + go func() { + finish <- e2e.Run(ctx) + }() + e2e.AwaitStartup("http://localhost:8080", checkTimeout).AwaitChecks() + + for url, status := range tt.wantEndpoints { + e2e.HttpAssertion(url).WithSchema().Assert(status) + } + + cancel() + <-finish + }) + } +} + +const loaderInterval = 5 * time.Second + +type result struct { + status int + response checks.Result +} + +func TestE2E_Sparrow_WithChecks_Reconfigure(t *testing.T) { + test.MarkAsLong(t) + + fw := framework.New(t) + tests := []struct { + name string + startup builder.SparrowConfig + initialChecks []builder.Check + wantInitial map[string]result + secondChecks []builder.Check + wantSecond map[string]result + }{ + { + name: "with health check then latency check", + startup: *builder.NewSparrowConfig().WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/", "https://www.google.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + "https://www.google.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + secondChecks: []builder.Check{ + builder.NewLatencyCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantSecond: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + "https://www.google.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": map[string]any{ + "code": http.StatusOK, + "error": nil, + "total": time.Since(time.Now().Add(-100 * time.Millisecond)).Seconds(), + }, + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + { + name: "with health check then dns check", + startup: *builder.NewSparrowConfig().WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + secondChecks: []builder.Check{ + builder.NewDNSCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("www.example.com"), + }, + wantSecond: map[string]result{ //nolint:dupl // This is a test + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "www.example.com": map[string]any{ + "resolved": []string{"1.2.3.4"}, + "error": nil, + "total": time.Since(time.Now().Add(-100 * time.Millisecond)).Seconds(), + }, + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + { + name: "with health check then updated health check", + startup: *builder.NewSparrowConfig().WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + secondChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.google.com/"), + }, + wantSecond: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.google.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + { + name: "with health check then no checks", + startup: *builder.NewSparrowConfig().WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + secondChecks: nil, + wantSecond: map[string]result{ + "http://localhost:8080/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8080/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8080/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e2e := fw.E2E(t, tt.startup.Config(t)).WithChecks(tt.initialChecks...) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + finish := make(chan error, 1) + go func() { + finish <- e2e.Run(ctx) + }() + e2e.AwaitStartup("http://localhost:8080", checkTimeout).AwaitChecks() + + for url, result := range tt.wantInitial { + e2e.HttpAssertion(url). + WithSchema(). + WithCheckResult(result.response). + Assert(result.status) + } + + e2e.UpdateChecks(tt.secondChecks...).AwaitLoader().AwaitChecks() + for url, result := range tt.wantSecond { + e2e.HttpAssertion(url). + WithSchema(). + WithCheckResult(result.response). + Assert(result.status) + } + + cancel() + <-finish + }) + } +} + +func TestE2E_Sparrow_WithRemoteConfig(t *testing.T) { + test.MarkAsLong(t) + + fw := framework.New(t) + tests := []struct { + name string + startup builder.SparrowConfig + initialChecks []builder.Check + wantInitial map[string]result + secondChecks []builder.Check + wantSecond map[string]result + }{ + { + name: "with health check in remote config", + startup: *builder.NewSparrowConfig(). + WithAPI(builder.NewAPIConfig("localhost:8081")). + WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + FromHTTP(config.HttpLoaderConfig{Url: "http://localhost:50505/", Timeout: 5 * time.Second}). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8081/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8081/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8081/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8081/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + { + name: "with health check in remote config then dns check", + startup: *builder.NewSparrowConfig(). + WithAPI(builder.NewAPIConfig("localhost:8081")). + WithLoader( + builder.NewLoaderConfig(). + WithInterval(loaderInterval). + FromHTTP(config.HttpLoaderConfig{Url: "http://localhost:50505/", Timeout: 5 * time.Second}). + Build(), + ), + initialChecks: []builder.Check{ + builder.NewHealthCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("https://www.example.com/"), + }, + wantInitial: map[string]result{ + "http://localhost:8081/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8081/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8081/v1/metrics/dns": {status: http.StatusNotFound}, + "http://localhost:8081/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + secondChecks: []builder.Check{ + builder.NewDNSCheck(). + WithInterval(checkInterval). + WithTimeout(checkTimeout). + WithTargets("www.example.com"), + }, + wantSecond: map[string]result{ //nolint:dupl // This is a test + "http://localhost:8081/v1/metrics/health": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "https://www.example.com/": "healthy", + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8081/v1/metrics/latency": {status: http.StatusNotFound}, + "http://localhost:8081/v1/metrics/dns": { + status: http.StatusOK, + response: checks.Result{ + Data: map[string]any{ + "www.example.com": map[string]any{ + "resolved": []string{"1.2.3.4"}, + "error": nil, + "total": time.Since(time.Now().Add(-100 * time.Millisecond)).Seconds(), + }, + }, + Timestamp: time.Now(), + }, + }, + "http://localhost:8081/v1/metrics/traceroute": {status: http.StatusNotFound}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e2e := fw.E2E(t, tt.startup.Config(t)). + WithChecks(tt.initialChecks...). + WithRemote() + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + finish := make(chan error, 1) + go func() { + finish <- e2e.Run(ctx) + }() + e2e.AwaitStartup("http://localhost:8081", checkTimeout).AwaitChecks() + + for url, result := range tt.wantInitial { + e2e.HttpAssertion(url). + WithSchema(). + WithCheckResult(result.response). + Assert(result.status) + } + + if len(tt.secondChecks) > 0 { + e2e.UpdateChecks(tt.secondChecks...).AwaitLoader().AwaitChecks() + for url, result := range tt.wantSecond { + e2e.HttpAssertion(url). + WithSchema(). + WithCheckResult(result.response). + Assert(result.status) + } + } + cancel() + <-finish + }) + } +} diff --git a/e2e/traceroute/lab.conf b/test/e2e/traceroute/lab.conf similarity index 100% rename from e2e/traceroute/lab.conf rename to test/e2e/traceroute/lab.conf diff --git a/e2e/traceroute/pc1.startup b/test/e2e/traceroute/pc1.startup similarity index 100% rename from e2e/traceroute/pc1.startup rename to test/e2e/traceroute/pc1.startup diff --git a/e2e/traceroute/pc2.startup b/test/e2e/traceroute/pc2.startup similarity index 100% rename from e2e/traceroute/pc2.startup rename to test/e2e/traceroute/pc2.startup diff --git a/e2e/traceroute/r1.startup b/test/e2e/traceroute/r1.startup similarity index 100% rename from e2e/traceroute/r1.startup rename to test/e2e/traceroute/r1.startup diff --git a/e2e/traceroute/r2.startup b/test/e2e/traceroute/r2.startup similarity index 100% rename from e2e/traceroute/r2.startup rename to test/e2e/traceroute/r2.startup diff --git a/e2e/traceroute/shared/config.yaml b/test/e2e/traceroute/shared/config.yaml similarity index 100% rename from e2e/traceroute/shared/config.yaml rename to test/e2e/traceroute/shared/config.yaml diff --git a/e2e/traceroute/shared/get_api.sh b/test/e2e/traceroute/shared/get_api.sh similarity index 100% rename from e2e/traceroute/shared/get_api.sh rename to test/e2e/traceroute/shared/get_api.sh diff --git a/e2e/traceroute/test.sh b/test/e2e/traceroute/test.sh similarity index 100% rename from e2e/traceroute/test.sh rename to test/e2e/traceroute/test.sh diff --git a/test/flags.go b/test/flags.go new file mode 100644 index 00000000..0c1cc1a0 --- /dev/null +++ b/test/flags.go @@ -0,0 +1,19 @@ +package test + +import "testing" + +// MarkAsShort marks the test as short, so it will be skipped if the -test.short flag is not provided. +func MarkAsShort(t *testing.T) { + t.Helper() + if !testing.Short() { + t.Skip("skipping short tests, to run them use the -test.short flag") + } +} + +// MarkAsLong marks the test as long, so it will be skipped if the -test.short flag is provided. +func MarkAsLong(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("skipping long tests, to run them remove the -test.short flag") + } +} diff --git a/test/framework/builder/checks.go b/test/framework/builder/checks.go new file mode 100644 index 00000000..2e899acd --- /dev/null +++ b/test/framework/builder/checks.go @@ -0,0 +1,277 @@ +package builder + +import ( + "testing" + "time" + + "github.com/goccy/go-yaml" + "github.com/telekom/sparrow/internal/helper" + "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/pkg/checks/dns" + "github.com/telekom/sparrow/pkg/checks/health" + "github.com/telekom/sparrow/pkg/checks/latency" + "github.com/telekom/sparrow/pkg/checks/traceroute" +) + +type Check interface { + // For returns the name of the check. + For() string + // Check returns the check. + Check(t *testing.T) checks.Check + // YAML returns the yaml representation of the check. + YAML(t *testing.T) []byte + // ExpectedWaitTime returns the expected wait time for the check. + ExpectedWaitTime() time.Duration +} + +// newCheck creates a new check with the given config. +func newCheck(t *testing.T, c checks.Check, config checks.Runtime) checks.Check { + t.Helper() + if err := config.Validate(); err != nil { + t.Fatalf("[%T] is not a valid config: %v", config, err) + } + + if err := c.UpdateConfig(config); err != nil { + t.Fatalf("[%T] failed to update config: %v", c, err) + } + return c +} + +// checkConfig is a map of check names to their configuration. +type checkConfig map[string]checks.Runtime + +func newCheckAsYAML(t *testing.T, cfg checkConfig) []byte { + t.Helper() + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatalf("[%T] failed to marshal config: %v", cfg, err) + return []byte{} + } + return out +} + +var _ Check = (*healthCheckBuilder)(nil) + +type healthCheckBuilder struct{ cfg health.Config } + +// NewHealthCheck returns a new health check builder. +func NewHealthCheck() *healthCheckBuilder { + return &healthCheckBuilder{cfg: health.Config{Retry: checks.DefaultRetry}} +} + +// WithTargets sets the targets for the health check. +func (b *healthCheckBuilder) WithTargets(targets ...string) *healthCheckBuilder { + b.cfg.Targets = targets + return b +} + +// WithInterval sets the interval for the health check. +func (b *healthCheckBuilder) WithInterval(interval time.Duration) *healthCheckBuilder { + b.cfg.Interval = interval + return b +} + +// WithTimeout sets the timeout for the health check. +func (b *healthCheckBuilder) WithTimeout(timeout time.Duration) *healthCheckBuilder { + b.cfg.Timeout = timeout + return b +} + +// WithRetry sets the retry count and delay for the health check. +func (b *healthCheckBuilder) WithRetry(count int, delay time.Duration) *healthCheckBuilder { + b.cfg.Retry = helper.RetryConfig{Count: count, Delay: delay} + return b +} + +// Check returns the health check. +func (b *healthCheckBuilder) Check(t *testing.T) checks.Check { + t.Helper() + return newCheck(t, health.NewCheck(), &b.cfg) +} + +// YAML returns the yaml representation of the health check. +func (b *healthCheckBuilder) YAML(t *testing.T) []byte { + t.Helper() + return newCheckAsYAML(t, checkConfig{b.cfg.For(): &b.cfg}) +} + +// ExpectedWaitTime returns the expected wait time for the health check. +func (b *healthCheckBuilder) ExpectedWaitTime() time.Duration { + return b.cfg.Interval + b.cfg.Timeout + time.Duration(b.cfg.Retry.Count)*b.cfg.Retry.Delay +} + +// For returns the name of the check. +func (b *healthCheckBuilder) For() string { + return b.cfg.For() +} + +var _ Check = (*latencyConfigBuilder)(nil) + +type latencyConfigBuilder struct{ cfg latency.Config } + +// NewLatencyCheck returns a new latency check builder. +func NewLatencyCheck() *latencyConfigBuilder { + return &latencyConfigBuilder{cfg: latency.Config{Retry: checks.DefaultRetry}} +} + +// WithTargets sets the targets for the latency check. +func (b *latencyConfigBuilder) WithTargets(targets ...string) *latencyConfigBuilder { + b.cfg.Targets = targets + return b +} + +// WithInterval sets the interval for the latency check. +func (b *latencyConfigBuilder) WithInterval(interval time.Duration) *latencyConfigBuilder { + b.cfg.Interval = interval + return b +} + +// WithTimeout sets the timeout for the latency check. +func (b *latencyConfigBuilder) WithTimeout(timeout time.Duration) *latencyConfigBuilder { + b.cfg.Timeout = timeout + return b +} + +// WithRetry sets the retry count and delay for the latency check. +func (b *latencyConfigBuilder) WithRetry(count int, delay time.Duration) *latencyConfigBuilder { + b.cfg.Retry = helper.RetryConfig{Count: count, Delay: delay} + return b +} + +// Check returns the latency check. +func (b *latencyConfigBuilder) Check(t *testing.T) checks.Check { + t.Helper() + return newCheck(t, latency.NewCheck(), &b.cfg) +} + +// YAML returns the yaml representation of the latency check. +func (b *latencyConfigBuilder) YAML(t *testing.T) []byte { + t.Helper() + return newCheckAsYAML(t, checkConfig{b.cfg.For(): &b.cfg}) +} + +// For returns the name of the check. +func (b *latencyConfigBuilder) For() string { + return b.cfg.For() +} + +// ExpectedWaitTime returns the expected wait time for the health check. +func (b *latencyConfigBuilder) ExpectedWaitTime() time.Duration { + return b.cfg.Interval + b.cfg.Timeout + time.Duration(b.cfg.Retry.Count)*b.cfg.Retry.Delay +} + +var _ Check = (*dnsConfigBuilder)(nil) + +type dnsConfigBuilder struct{ cfg dns.Config } + +// NewDNSCheck returns a new dns check builder. +func NewDNSCheck() *dnsConfigBuilder { + return &dnsConfigBuilder{cfg: dns.Config{Retry: checks.DefaultRetry}} +} + +// WithTargets sets the targets for the dns check. +func (b *dnsConfigBuilder) WithTargets(targets ...string) *dnsConfigBuilder { + b.cfg.Targets = targets + return b +} + +// WithInterval sets the interval for the dns check. +func (b *dnsConfigBuilder) WithInterval(interval time.Duration) *dnsConfigBuilder { + b.cfg.Interval = interval + return b +} + +// WithTimeout sets the timeout for the dns check. +func (b *dnsConfigBuilder) WithTimeout(timeout time.Duration) *dnsConfigBuilder { + b.cfg.Timeout = timeout + return b +} + +// WithRetry sets the retry count and delay for the dns check. +func (b *dnsConfigBuilder) WithRetry(count int, delay time.Duration) *dnsConfigBuilder { + b.cfg.Retry = helper.RetryConfig{Count: count, Delay: delay} + return b +} + +// Check returns the dns check. +func (b *dnsConfigBuilder) Check(t *testing.T) checks.Check { + t.Helper() + return newCheck(t, dns.NewCheck(), &b.cfg) +} + +// YAML returns the yaml representation of the dns check. +func (b *dnsConfigBuilder) YAML(t *testing.T) []byte { + t.Helper() + return newCheckAsYAML(t, checkConfig{b.cfg.For(): &b.cfg}) +} + +// ExpectedWaitTime returns the expected wait time for the health check. +func (b *dnsConfigBuilder) ExpectedWaitTime() time.Duration { + return b.cfg.Interval + b.cfg.Timeout + time.Duration(b.cfg.Retry.Count)*b.cfg.Retry.Delay +} + +// For returns the name of the check. +func (b *dnsConfigBuilder) For() string { + return b.cfg.For() +} + +var _ Check = (*tracerouteConfigBuilder)(nil) + +type tracerouteConfigBuilder struct{ cfg traceroute.Config } + +// NewTracerouteCheck returns a new traceroute check builder. +func NewTracerouteCheck() *tracerouteConfigBuilder { + return &tracerouteConfigBuilder{cfg: traceroute.Config{Retry: checks.DefaultRetry}} +} + +// WithTargets sets the targets for the traceroute check. +func (b *tracerouteConfigBuilder) WithTargets(targets ...traceroute.Target) *tracerouteConfigBuilder { + b.cfg.Targets = targets + return b +} + +// WithMaxHops sets the maximum number of hops for the traceroute check. +func (b *tracerouteConfigBuilder) WithMaxHops(maxHops int) *tracerouteConfigBuilder { + b.cfg.MaxHops = maxHops + return b +} + +// WithInterval sets the interval for the traceroute check. +func (b *tracerouteConfigBuilder) WithInterval(interval time.Duration) *tracerouteConfigBuilder { + b.cfg.Interval = interval + return b +} + +// WithTimeout sets the timeout for the traceroute check. +func (b *tracerouteConfigBuilder) WithTimeout(timeout time.Duration) *tracerouteConfigBuilder { + b.cfg.Timeout = timeout + return b +} + +// WithRetry sets the retry count and delay for the traceroute check. +func (b *tracerouteConfigBuilder) WithRetry(count int, delay time.Duration) *tracerouteConfigBuilder { + b.cfg.Retry = helper.RetryConfig{Count: count, Delay: delay} + return b +} + +// Check returns the traceroute check. +func (b *tracerouteConfigBuilder) Check(t *testing.T) checks.Check { + t.Helper() + return newCheck(t, traceroute.NewCheck(), &b.cfg) +} + +// YAML returns the yaml representation of the traceroute check. +func (b *tracerouteConfigBuilder) YAML(t *testing.T) []byte { + t.Helper() + return newCheckAsYAML(t, checkConfig{b.cfg.For(): &b.cfg}) +} + +// ExpectedWaitTime returns the expected wait time for the health check. +func (b *tracerouteConfigBuilder) ExpectedWaitTime() time.Duration { + return b.cfg.Interval + b.cfg.Timeout + time.Duration(b.cfg.Retry.Count)*b.cfg.Retry.Delay +} + +// For returns the name of the check. +func (b *tracerouteConfigBuilder) For() string { + return b.cfg.For() +} diff --git a/test/framework/builder/startup.go b/test/framework/builder/startup.go new file mode 100644 index 00000000..3394df84 --- /dev/null +++ b/test/framework/builder/startup.go @@ -0,0 +1,172 @@ +package builder + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/goccy/go-yaml" + "github.com/telekom/sparrow/internal/helper" + "github.com/telekom/sparrow/pkg/api" + "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/pkg/config" + "github.com/telekom/sparrow/pkg/sparrow/metrics" + "github.com/telekom/sparrow/pkg/sparrow/targets" + "github.com/telekom/sparrow/pkg/sparrow/targets/interactor" + "github.com/telekom/sparrow/pkg/sparrow/targets/remote/gitlab" +) + +type SparrowConfig struct{ cfg config.Config } + +func NewSparrowConfig() *SparrowConfig { + return &SparrowConfig{ + cfg: config.Config{ + SparrowName: "sparrow.telekom.com", + Loader: NewLoaderConfig().Build(), + Api: NewAPIConfig("localhost:8080"), + }, + } +} + +func (b *SparrowConfig) WithName(n string) *SparrowConfig { + b.cfg.SparrowName = n + return b +} + +func (b *SparrowConfig) WithLoader(cfg config.LoaderConfig) *SparrowConfig { //nolint:gocritic // Performance is not a concern here + b.cfg.Loader = cfg + return b +} + +func (b *SparrowConfig) WithAPI(cfg api.Config) *SparrowConfig { + b.cfg.Api = cfg + return b +} + +func (b *SparrowConfig) WithTargetManager(cfg targets.TargetManagerConfig) *SparrowConfig { //nolint:gocritic // Performance is not a concern here + b.cfg.TargetManager = cfg + return b +} + +func (b *SparrowConfig) WithTelemetry(cfg metrics.Config) *SparrowConfig { //nolint:gocritic // Performance is not a concern here + b.cfg.Telemetry = cfg + return b +} + +func (b *SparrowConfig) Config(t *testing.T) *config.Config { + t.Helper() + if err := b.cfg.Validate(context.Background()); err != nil { + t.Fatalf("config is not valid: %v", err) + } + return &b.cfg +} + +func (b *SparrowConfig) YAML(t *testing.T) []byte { + t.Helper() + out, err := yaml.Marshal(b.cfg) + if err != nil { + t.Fatalf("[%T] failed to marshal config: %v", b.cfg, err) + return []byte{} + } + return out +} + +type LoaderConfigBuilder struct{ cfg config.LoaderConfig } + +func NewLoaderConfig() *LoaderConfigBuilder { + return &LoaderConfigBuilder{ + cfg: config.LoaderConfig{ + Type: "file", + Interval: 0, + File: config.FileLoaderConfig{ + Path: "testdata/checks.yaml", + }, + }, + } +} + +func (b *LoaderConfigBuilder) WithInterval(i time.Duration) *LoaderConfigBuilder { + b.cfg.Interval = i + return b +} + +func (b *LoaderConfigBuilder) FromFile(path string) *LoaderConfigBuilder { + b.cfg.Type = "file" + b.cfg.File.Path = path + return b +} + +func (b *LoaderConfigBuilder) FromHTTP(cfg config.HttpLoaderConfig) *LoaderConfigBuilder { + if cfg.RetryCfg == (helper.RetryConfig{}) { + cfg.RetryCfg = checks.DefaultRetry + } + + b.cfg.Type = "http" + b.cfg.Http = cfg + return b +} + +func (b *LoaderConfigBuilder) Build() config.LoaderConfig { + return b.cfg +} + +func NewAPIConfig(address string) api.Config { + return api.Config{ListeningAddress: address} +} + +type TargetManagerConfigBuilder struct{ cfg targets.TargetManagerConfig } + +func NewTargetManagerConfig() *TargetManagerConfigBuilder { + id, _ := strconv.Atoi(os.Getenv("SPARROW_TARGETMANAGER_GITLAB_PROJECTID")) + return &TargetManagerConfigBuilder{ + cfg: targets.TargetManagerConfig{ + Enabled: true, + Type: interactor.Gitlab, + General: targets.General{ + CheckInterval: 60 * time.Second, + RegistrationInterval: 0, + UpdateInterval: 0, + UnhealthyThreshold: 0, + Scheme: "http", + }, + Config: interactor.Config{ + Gitlab: gitlab.Config{ + BaseURL: os.Getenv("SPARROW_TARGETMANAGER_GITLAB_BASEURL"), + Token: os.Getenv("SPARROW_TARGETMANAGER_GITLAB_TOKEN"), + ProjectID: id, + }, + }, + }, + } +} + +func (b *TargetManagerConfigBuilder) WithScheme(s string) *TargetManagerConfigBuilder { + b.cfg.Scheme = s + return b +} + +func (b *TargetManagerConfigBuilder) WithCheckInterval(i time.Duration) *TargetManagerConfigBuilder { + b.cfg.CheckInterval = i + return b +} + +func (b *TargetManagerConfigBuilder) WithRegistrationInterval(i time.Duration) *TargetManagerConfigBuilder { + b.cfg.RegistrationInterval = i + return b +} + +func (b *TargetManagerConfigBuilder) WithUpdateInterval(i time.Duration) *TargetManagerConfigBuilder { + b.cfg.UpdateInterval = i + return b +} + +func (b *TargetManagerConfigBuilder) WithUnhealthyThreshold(t time.Duration) *TargetManagerConfigBuilder { + b.cfg.UnhealthyThreshold = t + return b +} + +func (b *TargetManagerConfigBuilder) Build() targets.TargetManagerConfig { + return b.cfg +} diff --git a/test/framework/e2e.go b/test/framework/e2e.go new file mode 100644 index 00000000..c020416a --- /dev/null +++ b/test/framework/e2e.go @@ -0,0 +1,491 @@ +package framework + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" + "github.com/telekom/sparrow/pkg/checks" + "github.com/telekom/sparrow/pkg/config" + "github.com/telekom/sparrow/pkg/sparrow" + "github.com/telekom/sparrow/test/framework/builder" +) + +var _ Runner = (*E2E)(nil) + +// E2E is an end-to-end test. +type E2E struct { + config config.Config + buf bytes.Buffer + sparrow *sparrow.Sparrow + t *testing.T + checks map[string]builder.Check + server *http.Server + mu sync.Mutex + running bool +} + +// WithChecks sets the checks in the test. +func (t *E2E) WithChecks(builders ...builder.Check) *E2E { + for _, b := range builders { + t.checks[b.For()] = b + t.buf.Write(b.YAML(t.t)) + } + return t +} + +// WithRemote sets up a remote server to serve the check config. +func (t *E2E) WithRemote() *E2E { + t.server = &http.Server{ + Addr: "localhost:50505", + Handler: http.HandlerFunc(t.serveConfig), + ReadHeaderTimeout: 3 * time.Second, + } + return t +} + +// UpdateChecks updates the checks of the test. +func (t *E2E) UpdateChecks(builders ...builder.Check) *E2E { + t.checks = map[string]builder.Check{} + t.buf.Reset() + for _, b := range builders { + t.checks[b.For()] = b + t.buf.Write(b.YAML(t.t)) + } + + // If the test is running with a remote server, we don't need to write the check config into a file. + if t.server == nil { + err := t.writeCheckConfig() + if err != nil { + t.t.Fatalf("Failed to write check config: %v", err) + } + } + + return t +} + +// Run runs the test. +// Runs indefinitely until the context is canceled. +func (t *E2E) Run(ctx context.Context) error { + if t.isRunning() { + t.t.Fatal("E2E.Run must be called once") + } + + if t.server != nil { + go func() { + err := t.server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.t.Errorf("Failed to start server: %v", err) + } + }() + defer func() { + err := t.server.Shutdown(ctx) + if err != nil { + t.t.Fatalf("Failed to shutdown server: %v", err) + } + }() + } else { + err := t.writeCheckConfig() + if err != nil { + t.t.Fatalf("Failed to write check config: %v", err) + } + } + + t.mu.Lock() + t.running = true + t.mu.Unlock() + return t.sparrow.Run(ctx) +} + +// AwaitStartup waits for the provided URL to be ready. +// +// Must be called after the e2e test started with [E2E.Run]. +func (t *E2E) AwaitStartup(u string, failureTimeout time.Duration) *E2E { + t.t.Helper() + // To ensure the goroutine is started before we are checking if the test is running. + const initialDelay = 100 * time.Millisecond + <-time.After(initialDelay) + if !t.isRunning() { + t.t.Fatal("E2E.AwaitStartup must be called after E2E.Run") + } + + const retryInterval = 100 * time.Millisecond + start := time.Now() + deadline := start.Add(failureTimeout) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, http.NoBody) + if err != nil { + t.t.Fatalf("Failed to create request: %v", err) + return t + } + + for { + resp, err := http.DefaultClient.Do(req) + if err == nil && resp.StatusCode == http.StatusOK { + t.t.Logf("%s is ready after %v", u, time.Since(start)) + _ = resp.Body.Close() + return t + } + if time.Now().After(deadline) { + t.t.Errorf("%s is not ready [%s (%d)] after %v: %v", u, http.StatusText(resp.StatusCode), resp.StatusCode, failureTimeout, err) + return t + } + <-time.After(retryInterval) + } +} + +// AwaitLoader waits for the loader to reload the configuration. +// +// Must be called after the e2e test started with [E2E.Run]. +func (t *E2E) AwaitLoader() *E2E { + t.t.Helper() + if !t.isRunning() { + t.t.Fatal("E2E.AwaitLoader must be called after E2E.Run") + } + + t.t.Logf("Waiting %s for loader to reload configuration", t.config.Loader.Interval.String()) + <-time.After(t.config.Loader.Interval) + return t +} + +// AwaitChecks waits for all checks to be executed before proceeding. +// +// Must be called after the e2e test started with [E2E.Run]. +func (t *E2E) AwaitChecks() *E2E { + t.t.Helper() + if !t.isRunning() { + t.t.Fatal("E2E.AwaitChecks must be called after E2E.Run") + } + + wait := 5 * time.Second + for _, check := range t.checks { + wait = max(wait, check.ExpectedWaitTime()) + } + t.t.Logf("Waiting %s for checks to be executed", wait.String()) + <-time.After(wait) + return t +} + +// writeCheckConfig writes the check config to a file at the provided path. +func (t *E2E) writeCheckConfig() error { + const fileMode = 0o755 + path := "testdata/checks.yaml" + err := os.MkdirAll(filepath.Dir(path), fileMode) + if err != nil { + return fmt.Errorf("failed to create %q: %w", filepath.Dir(path), err) + } + + err = os.WriteFile(path, t.buf.Bytes(), fileMode) + if err != nil { + return fmt.Errorf("failed to write %q: %w", path, err) + } + return nil +} + +// isRunning returns true if the test is running. +func (t *E2E) isRunning() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.running +} + +// serveConfig serves the check config over HTTP as text/yaml. +func (t *E2E) serveConfig(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/yaml") + w.WriteHeader(http.StatusOK) + _, err := w.Write(t.buf.Bytes()) + if err != nil { + t.t.Fatalf("Failed to write response: %v", err) + } +} + +// e2eHttpAsserter is an HTTP asserter for end-to-end tests. +type e2eHttpAsserter struct { + e2e *E2E + url string + response *e2eResponseAsserter + schema *openapi3.T + router routers.Router +} + +// e2eResponseAsserter is a response asserter for end-to-end tests. +type e2eResponseAsserter struct { + want any + asserter func(r *http.Response) error +} + +// HttpAssertion creates a new HTTP assertion for the given URL. +func (t *E2E) HttpAssertion(u string) *e2eHttpAsserter { + return &e2eHttpAsserter{e2e: t, url: u} +} + +// Assert asserts the status code and optional validations against the response. +// Optional validations must be set before calling this method. +// +// Must be called after the e2e test started with [E2E.Run]. +func (a *e2eHttpAsserter) Assert(status int) { + a.e2e.t.Helper() + if !a.e2e.isRunning() { + a.e2e.t.Fatal("e2eHttpAsserter.Assert must be called after E2E.Run") + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, a.url, http.NoBody) + if err != nil { + a.e2e.t.Fatalf("Failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + a.e2e.t.Errorf("Failed to get %s: %v", a.url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != status { + a.e2e.t.Errorf("Want status code %d for %s, got %d", status, a.url, resp.StatusCode) + } + a.e2e.t.Logf("Got status code %d for %s", resp.StatusCode, a.url) + + if status == http.StatusOK { + if a.schema != nil && a.router != nil { + if err = a.assertSchema(req, resp); err != nil { + a.e2e.t.Errorf("Response from %q does not match schema: %v", a.url, err) + } + } + + if a.response != nil { + err = a.response.asserter(resp) + if err != nil { + a.e2e.t.Errorf("Failed to assert response: %v", err) + } + } + } +} + +// WithSchema fetches the OpenAPI schema and validates the response against it. +func (a *e2eHttpAsserter) WithSchema() *e2eHttpAsserter { + a.e2e.t.Helper() + schema, err := a.fetchSchema() + if err != nil { + a.e2e.t.Fatalf("Failed to fetch OpenAPI schema: %v", err) + } + + router, err := gorillamux.NewRouter(schema) + if err != nil { + a.e2e.t.Fatalf("Failed to create router from OpenAPI schema: %v", err) + } + + a.schema = schema + a.router = router + return a +} + +// WithResult sets the expected result for the response. +// The result is validated against the response body. +func (a *e2eHttpAsserter) WithCheckResult(r checks.Result) *e2eHttpAsserter { + a.e2e.t.Helper() + a.response = &e2eResponseAsserter{ + want: r, + asserter: a.assertCheckResponse, + } + return a +} + +// fetchSchema fetches the OpenAPI schema from the server. +func (a *e2eHttpAsserter) fetchSchema() (*openapi3.T, error) { + ctx := context.Background() + u, err := url.Parse(a.url) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + u.Path = "/openapi" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to GET OpenAPI schema: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OpenAPI schema: %w", err) + } + + loader := openapi3.NewLoader() + schema, err := loader.LoadFromData(data) + if err != nil { + return nil, fmt.Errorf("failed to load OpenAPI schema: %w", err) + } + + if err = schema.Validate(ctx); err != nil { + return nil, fmt.Errorf("OpenAPI schema validation error: %w", err) + } + + return schema, nil +} + +// assertSchema asserts the response body against the OpenAPI schema. +func (a *e2eHttpAsserter) assertSchema(req *http.Request, resp *http.Response) error { + route, _, err := a.router.FindRoute(req) + if err != nil { + return fmt.Errorf("failed to find route: %w", err) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(data)) + + responseRef := route.Operation.Responses.Status(resp.StatusCode) + if responseRef == nil || responseRef.Value == nil { + return fmt.Errorf("no response defined in OpenAPI schema for status code %d", resp.StatusCode) + } + + mediaType := responseRef.Value.Content.Get("application/json") + if mediaType == nil { + return errors.New("no media type defined in OpenAPI schema for Content-Type 'application/json'") + } + + var body map[string]any + if err = json.Unmarshal(data, &body); err != nil { + return fmt.Errorf("failed to unmarshal response body: %w", err) + } + + // Validate the response body against the schema + err = mediaType.Schema.Value.VisitJSON(body) + if err != nil { + return fmt.Errorf("response body does not match schema: %w", err) + } + + return nil +} + +// assertCheckResponse asserts the response body against the expected check result. +func (a *e2eHttpAsserter) assertCheckResponse(resp *http.Response) error { + want, ok := a.response.want.(checks.Result) + if !ok { + a.e2e.t.Fatalf("Invalid response type: %T", a.response.want) + } + + var got checks.Result + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + a.e2e.t.Errorf("Failed to decode response body: %v", err) + } + + wantData := want.Data.(map[string]any) + gotData, ok := got.Data.(map[string]any) + if !ok { + a.e2e.t.Errorf("Result.Data = %T (%v), want %T (%v)", got.Data, got.Data, want.Data, want.Data) + } + assertMapEqual(a.e2e.t, wantData, gotData) + + const deltaTimeThreshold = 5 * time.Minute + if time.Since(got.Timestamp) > deltaTimeThreshold { + a.e2e.t.Errorf("Response timestamp is not recent: %v", got.Timestamp) + } + + return nil +} + +// assertMapEqual asserts the equality of the want and got maps. +// Fails the test if the maps are not equal. +func assertMapEqual(t *testing.T, want, got map[string]any) { + t.Helper() + if len(want) != len(got) { + t.Errorf("Want %d keys (%v), got %d keys (%v)", len(want), want, len(got), got) + } + + for k, w := range want { + g, ok := got[k] + if !ok { + t.Errorf("got[%q] not found (%v), want %v", k, got, w) + } + + if err := assertValueEqual(t, w, g); err != nil { + t.Errorf("got[%q]: %v", k, err) + } + } +} + +// assertValueEqual asserts the equality of the want and got values. +// For values that cannot be compared directly, it uses a type-specific comparison. +// e.g. IP addresses, timestamps, etc. +func assertValueEqual(t *testing.T, want, got any) error { + switch w := want.(type) { + case map[string]any: + gotMap, ok := got.(map[string]any) + if !ok { + return fmt.Errorf("%v (%T), want %v (%T)", got, got, w, w) + } + assertMapEqual(t, w, gotMap) + return nil + case time.Time, float32, float64: + // Timestamps and floating-point numbers are time-sensitive and are never equal. + return nil + case int: + // Unmarshaling JSON numbers as int will convert them to float64. + // We need to compare them as float64 to avoid type mismatch errors. + want = float64(w) + case []string: + // Unmarshaling JSON arrays as []string will convert them to []interface{}. + // We need to compare them as []interface{} and cast the elements to string + // to avoid type mismatch errors. + gs, ok := got.([]any) + if !ok { + return fmt.Errorf("%v (%T), want %v (%T)", got, got, w, w) + } + gotSlice := make([]string, len(gs)) + for i, g := range gs { + gotSlice[i] = g.(string) + } + for _, wantIPStr := range w { + wantIP := net.ParseIP(wantIPStr) + if wantIP == nil { + // This is a special case for string slices that might contain IP addresses. + // If the `want` value is not a valid IP address, we skip the IP validation + // and proceed to the default case for a generic equality check. + // + // Using `goto` here avoids introducing an additional boolean flag or + // nesting the logic further, which would make the code harder to read. + // In this case it simplifies the control flow by explicitly directing the + // execution to the default case. + goto defaultCase + } + + for _, gotIPStr := range gotSlice { + gotIP := net.ParseIP(gotIPStr) + if gotIP == nil { + return fmt.Errorf("%q, want an IP address (%s)", gotIPStr, wantIP) + } + } + } + return nil + } + +defaultCase: + if !reflect.DeepEqual(want, got) { + return fmt.Errorf("%v (%T), want %v (%T)", got, got, want, want) + } + return nil +} diff --git a/test/framework/framework.go b/test/framework/framework.go new file mode 100644 index 00000000..5d5e41a5 --- /dev/null +++ b/test/framework/framework.go @@ -0,0 +1,43 @@ +package framework + +import ( + "context" + "testing" + + "github.com/telekom/sparrow/pkg/config" + "github.com/telekom/sparrow/pkg/sparrow" + "github.com/telekom/sparrow/test/framework/builder" +) + +// Runner is a test runner. +type Runner interface { + // Run runs the test. + Run(ctx context.Context) error +} + +// Framework is a test framework. +// It provides a way to run various tests. +type Framework struct { + t *testing.T +} + +// NewFramework creates a new test framework. +func New(t *testing.T) *Framework { + t.Helper() + return &Framework{t: t} +} + +// E2E creates a new end-to-end test. +// If the test is run in short mode, it will be skipped. +func (f *Framework) E2E(t *testing.T, cfg *config.Config) *E2E { + if cfg == nil { + cfg = builder.NewSparrowConfig().Config(f.t) + } + + return &E2E{ + t: t, + config: *cfg, + sparrow: sparrow.New(cfg), + checks: map[string]builder.Check{}, + } +}