Skip to content

Commit bd01ca2

Browse files
janiszclaude
andcommitted
Add smoke test infrastructure for end-to-end testing
Implements comprehensive smoke testing infrastructure that deploys StackRox Central in a Kind cluster and runs integration tests against real APIs. ## Key Components - Smoke test suite (`smoke/smoke_test.go`): - Tests cluster listing, CVE queries, and deployment detection - Runs against real StackRox deployment in CI - Uses build tag `smoke` to separate from unit tests - Authentication helpers (`smoke/token_helper.go`): - API token generation using HTTP basic auth - Health check polling with exponential backoff - Proper context handling and HTTP method constants - GitHub Actions workflow (`.github/workflows/smoke.yml`): - Deploys StackRox Central and vulnerable workload in Kind - Optimized for CI resources (reduced replicas, disabled features) - Waits for cluster health before running tests - Uploads test results and coverage to Codecov - Test utilities (`internal/testutil/test_helpers.go`): - Moved from integration_helpers.go for better organization - Port allocation for tests - Server readiness polling ## CI Optimizations - Kind cluster configured to maximize available CPU - Minimal StackRox deployment (no admission controller, no collector) - Resource constraints removed from sensor and scanner pods - Scanner image scanning skipped in CI to save resources - Port-forwarding to Central for API access ## Testing - Smoke tests run in dedicated workflow - Excluded from standard test target (uses build tags) - Test artifacts and logs collected on failure - Integration with Codecov for coverage tracking Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent cb8e0ab commit bd01ca2

File tree

8 files changed

+617
-37
lines changed

8 files changed

+617
-37
lines changed

.github/workflows/smoke.yml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
name: Smoke Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
types:
9+
- opened
10+
- reopened
11+
- synchronize
12+
13+
jobs:
14+
smoke:
15+
name: Run Smoke Tests
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 30
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Go
24+
uses: actions/setup-go@v5
25+
with:
26+
go-version-file: go.mod
27+
28+
- name: Download dependencies
29+
run: go mod download
30+
31+
- name: Create kind config
32+
run: |
33+
cat <<'EOF' > kind-config.yaml
34+
kind: Cluster
35+
apiVersion: kind.x-k8s.io/v1alpha4
36+
nodes:
37+
- role: control-plane
38+
kubeadmConfigPatches:
39+
- |
40+
kind: InitConfiguration
41+
nodeRegistration:
42+
kubeletExtraArgs:
43+
# Set reserved to 0 to maximize allocatable CPU for pods
44+
system-reserved: "cpu=0m,memory=256Mi"
45+
kube-reserved: "cpu=0m,memory=256Mi"
46+
EOF
47+
48+
- name: Create kind cluster
49+
uses: helm/kind-action@v1
50+
with:
51+
cluster_name: stackrox-mcp-smoke
52+
config: kind-config.yaml
53+
54+
- name: Show node resources
55+
run: kubectl describe node | grep -A 5 "Allocated resources"
56+
57+
- name: Clone StackRox repository
58+
run: git clone --depth 1 https://github.com/stackrox/stackrox.git stackrox-repo
59+
60+
- name: Deploy StackRox Central
61+
env:
62+
MAIN_IMAGE_TAG: latest
63+
SENSOR_HELM_DEPLOY: "true"
64+
ROX_SCANNER_V4: "false"
65+
ADMISSION_CONTROLLER: "false"
66+
SCANNER_REPLICAS: "1"
67+
COLLECTION_METHOD: "no_collection"
68+
run: |
69+
cd stackrox-repo
70+
./deploy/k8s/deploy-local.sh
71+
72+
- name: Remove resource requests to allow scheduling
73+
run: |
74+
# Remove resource constraints from sensor
75+
kubectl set resources deployment/sensor -n stackrox \
76+
--requests=cpu=0,memory=0 \
77+
--limits=cpu=0,memory=0
78+
# Remove resource constraints from scanner for faster image scanning
79+
kubectl set resources deployment/scanner -n stackrox \
80+
--requests=cpu=0,memory=0 \
81+
--limits=cpu=0,memory=0
82+
# Delete pods to force recreation
83+
kubectl delete pods -n stackrox -l app=sensor
84+
kubectl delete pods -n stackrox -l app=scanner
85+
sleep 10
86+
87+
- name: Deploy vulnerable workload
88+
run: kubectl apply -f smoke/testdata/vulnerable-deployment.yaml
89+
90+
- name: Wait for vulnerable deployment
91+
run: kubectl wait --for=condition=available --timeout=120s deployment/vulnerable-app -n vulnerable-apps
92+
93+
- name: Wait for Central pods ready
94+
run: kubectl wait --for=condition=ready --timeout=180s pod -l app=central -n stackrox
95+
96+
- name: Wait for Sensor pods ready
97+
run: kubectl wait --for=condition=ready --timeout=300s pod -l app=sensor -n stackrox || echo "Sensor pods not ready yet, will check cluster health"
98+
99+
- name: Extract Central password
100+
id: extract-password
101+
run: |
102+
PASSWORD="$(cat stackrox-repo/deploy/k8s/central-deploy/password)"
103+
echo "::add-mask::${PASSWORD}"
104+
echo "password=${PASSWORD}" >> "$GITHUB_OUTPUT"
105+
106+
- name: Setup port-forward to Central
107+
run: |
108+
# Kill any existing port-forward on port 8000
109+
pkill -f "port-forward.*8000" || true
110+
sleep 2
111+
# Start port-forward in background
112+
kubectl port-forward -n stackrox svc/central 8000:443 > /tmp/port-forward.log 2>&1 &
113+
sleep 5
114+
# Verify port-forward is working
115+
if ! curl -k -s https://localhost:8000/v1/ping > /dev/null 2>&1; then
116+
echo "Port-forward failed to start. Log:"
117+
cat /tmp/port-forward.log || true
118+
exit 1
119+
fi
120+
echo "Port-forward established successfully"
121+
122+
- name: Wait for cluster to be healthy
123+
run: |
124+
echo "Waiting for cluster to register and become healthy..."
125+
PASSWORD="$(cat stackrox-repo/deploy/k8s/central-deploy/password)"
126+
127+
for i in {1..180}; do
128+
CLUSTER_HEALTH=$(curl -k -s -u "admin:${PASSWORD}" \
129+
https://localhost:8000/v1/clusters 2>/dev/null | \
130+
jq -r '.clusters[0].healthStatus.overallHealthStatus // "NOT_FOUND"')
131+
132+
echo "Attempt $i/180: Cluster health status: $CLUSTER_HEALTH"
133+
134+
if [ "$CLUSTER_HEALTH" = "HEALTHY" ]; then
135+
echo "Cluster is healthy and ready for testing"
136+
break
137+
fi
138+
139+
if [ "$i" -eq 180 ]; then
140+
echo "ERROR: Cluster did not become healthy after 180 attempts (6 minutes)"
141+
echo "Current status: $CLUSTER_HEALTH"
142+
exit 1
143+
fi
144+
145+
sleep 2
146+
done
147+
148+
- name: Install go-junit-report
149+
run: go install github.com/jstemmer/go-junit-report/v2@v2.1.0
150+
151+
- name: Run smoke tests with JUnit output
152+
env:
153+
ROX_ENDPOINT: localhost:8000
154+
ROX_PASSWORD: ${{ steps.extract-password.outputs.password }}
155+
run: |
156+
go test -v -tags=smoke -cover -race -coverprofile=coverage-smoke.out -timeout=20m ./smoke 2>&1 | \
157+
tee /dev/stderr | \
158+
go-junit-report -set-exit-code -out junit-smoke.xml
159+
160+
- name: Upload JUnit test results
161+
if: always()
162+
uses: actions/upload-artifact@v4
163+
with:
164+
name: junit-smoke-results
165+
path: junit-smoke.xml
166+
if-no-files-found: error
167+
168+
- name: Upload test results to Codecov
169+
if: always()
170+
uses: codecov/test-results-action@v1
171+
with:
172+
token: ${{ secrets.CODECOV_TOKEN }}
173+
files: junit-smoke.xml
174+
175+
- name: Upload coverage to Codecov
176+
if: always()
177+
uses: codecov/codecov-action@v5
178+
with:
179+
files: ./coverage-smoke.out
180+
token: ${{ secrets.CODECOV_TOKEN }}
181+
fail_ci_if_error: false
182+
flags: smoke
183+
name: smoke-tests
184+
185+
- name: Collect logs
186+
if: always()
187+
run: |
188+
mkdir -p logs
189+
kubectl get pods -A > logs/pods.txt || true
190+
kubectl get events -A --sort-by='.lastTimestamp' > logs/events.txt || true
191+
kubectl logs -n vulnerable-apps deployment/vulnerable-app --all-containers=true > logs/vulnerable-app.log || true
192+
kubectl logs -n stackrox deployment/central > logs/central.log || true
193+
kubectl logs -n stackrox deployment/scanner > logs/scanner.log || true
194+
kubectl describe pod -n vulnerable-apps > logs/vulnerable-app-describe.txt || true
195+
196+
- name: Upload logs
197+
if: always()
198+
uses: actions/upload-artifact@v4
199+
with:
200+
name: smoke-test-logs
201+
path: logs/
202+
if-no-files-found: ignore

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@
1010
# Test output
1111
/*.out
1212
/*junit.xml
13+
/coverage-report.html
1314

1415
# Build output
1516
/stackrox-mcp
17+
/bin/
18+
19+
# Virtual environments
20+
/ENV_DIR/
1621

1722
# Lint output
1823
/report.xml
@@ -24,6 +29,7 @@
2429
/e2e-tests/.env
2530
/e2e-tests/mcp-reports/
2631
/e2e-tests/bin/
32+
/e2e-tests/**/mcpchecker
2733
/e2e-tests/**/*-out.json
2834

2935
# WireMock

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ e2e-test: ## Run E2E tests (uses WireMock)
7878
.PHONY: test-coverage-and-junit
7979
test-coverage-and-junit: ## Run unit tests with coverage and junit output
8080
go install github.com/jstemmer/go-junit-report/v2@v2.1.0
81-
$(GOTEST) -v -cover -race -coverprofile=$(COVERAGE_OUT) ./... 2>&1 | go-junit-report -set-exit-code -iocopy -out $(JUNIT_OUT)
81+
$(GOTEST) -v -cover -race -coverprofile=$(COVERAGE_OUT) $(shell go list ./... | grep -v '/smoke$$') 2>&1 | go-junit-report -set-exit-code -iocopy -out $(JUNIT_OUT)
8282

8383
.PHONY: test-integration-coverage
8484
test-integration-coverage: ## Run integration tests with coverage

internal/testutil/integration_helpers.go

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@ import (
88
"testing"
99
"time"
1010

11-
"github.com/modelcontextprotocol/go-sdk/mcp"
1211
"github.com/stackrox/stackrox-mcp/internal/app"
1312
"github.com/stackrox/stackrox-mcp/internal/config"
14-
"github.com/stretchr/testify/require"
1513
)
1614

1715
// CreateIntegrationTestConfig creates a test configuration for integration tests.
@@ -55,37 +53,3 @@ func CreateIntegrationMCPClient(t *testing.T) (*MCPTestClient, error) {
5553

5654
return NewMCPTestClient(t, runFunc)
5755
}
58-
59-
// SetupInitializedClient creates an initialized MCP client for testing with automatic cleanup.
60-
func SetupInitializedClient(t *testing.T, createClient func(*testing.T) (*MCPTestClient, error)) *MCPTestClient {
61-
t.Helper()
62-
63-
client, err := createClient(t)
64-
require.NoError(t, err, "Failed to create MCP client")
65-
t.Cleanup(func() { client.Close() })
66-
67-
return client
68-
}
69-
70-
// CallToolAndGetResult calls a tool and verifies it succeeds.
71-
func CallToolAndGetResult(t *testing.T, client *MCPTestClient, toolName string, args map[string]any) *mcp.CallToolResult {
72-
t.Helper()
73-
74-
ctx := context.Background()
75-
result, err := client.CallTool(ctx, toolName, args)
76-
require.NoError(t, err)
77-
RequireNoError(t, result)
78-
79-
return result
80-
}
81-
82-
// GetTextContent extracts text from the first content item.
83-
func GetTextContent(t *testing.T, result *mcp.CallToolResult) string {
84-
t.Helper()
85-
require.NotEmpty(t, result.Content, "should have content in response")
86-
87-
textContent, ok := result.Content[0].(*mcp.TextContent)
88-
require.True(t, ok, "expected TextContent, got %T", result.Content[0])
89-
90-
return textContent.Text
91-
}

internal/testutil/test_helpers.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package testutil
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/modelcontextprotocol/go-sdk/mcp"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// SetupInitializedClient creates an initialized MCP client with automatic cleanup.
12+
func SetupInitializedClient(t *testing.T, createClient func(*testing.T) (*MCPTestClient, error)) *MCPTestClient {
13+
t.Helper()
14+
15+
client, err := createClient(t)
16+
require.NoError(t, err, "Failed to create MCP client")
17+
t.Cleanup(func() { _ = client.Close() })
18+
19+
return client
20+
}
21+
22+
// CallToolAndGetResult calls a tool and verifies it succeeds.
23+
func CallToolAndGetResult(
24+
t *testing.T,
25+
client *MCPTestClient,
26+
toolName string,
27+
args map[string]any,
28+
) *mcp.CallToolResult {
29+
t.Helper()
30+
31+
ctx := context.Background()
32+
result, err := client.CallTool(ctx, toolName, args)
33+
require.NoError(t, err)
34+
RequireNoError(t, result)
35+
36+
return result
37+
}
38+
39+
// GetTextContent extracts text from the first content item.
40+
func GetTextContent(t *testing.T, result *mcp.CallToolResult) string {
41+
t.Helper()
42+
require.NotEmpty(t, result.Content, "should have content in response")
43+
44+
textContent, ok := result.Content[0].(*mcp.TextContent)
45+
require.True(t, ok, "expected TextContent, got %T", result.Content[0])
46+
47+
return textContent.Text
48+
}

0 commit comments

Comments
 (0)