Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: Smoke Tests

on:
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize

jobs:
smoke:
name: Run Smoke Tests
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Download dependencies
run: go mod download

- name: Create kind cluster
uses: helm/kind-action@v1
with:
cluster_name: stackrox-mcp-smoke

- name: Clone StackRox repository
run: git clone --depth 1 https://github.com/stackrox/stackrox.git stackrox-repo

- name: Deploy StackRox Central
env:
MAIN_IMAGE_TAG: latest
SENSOR_HELM_DEPLOY: "true"
ROX_SCANNER_V4: "false"
run: |
cd stackrox-repo
./deploy/k8s/deploy-local.sh

- name: Deploy vulnerable workload
run: kubectl apply -f smoke/testdata/vulnerable-deployment.yaml

- name: Wait for vulnerable deployment
run: kubectl wait --for=condition=available --timeout=120s deployment/vulnerable-app -n vulnerable-apps

- name: Wait for Central pods ready
run: kubectl wait --for=condition=ready --timeout=180s pod -l app=central -n stackrox

- name: Wait for Sensor pods ready
run: kubectl wait --for=condition=ready --timeout=300s pod -l app=sensor -n stackrox || echo "Sensor pods not ready yet, will check cluster health"

- name: Extract Central password
id: extract-password
run: |
PASSWORD="$(cat stackrox-repo/deploy/k8s/central-deploy/password)"
echo "::add-mask::${PASSWORD}"
echo "password=${PASSWORD}" >> "$GITHUB_OUTPUT"

- name: Wait for cluster to be healthy
run: |
echo "Waiting for cluster to register and become healthy..."
PASSWORD="$(cat stackrox-repo/deploy/k8s/central-deploy/password)"

for i in {1..60}; do
CLUSTER_HEALTH=$(curl -k -s -u "admin:${PASSWORD}" \
https://localhost:8000/v1/clusters 2>/dev/null | \
jq -r '.clusters[0].healthStatus.overallHealthStatus // "NOT_FOUND"')

echo "Attempt $i/60: Cluster health status: $CLUSTER_HEALTH"

if [ "$CLUSTER_HEALTH" = "HEALTHY" ]; then
echo "Cluster is healthy and ready for testing"
break
fi

if [ $i -eq 60 ]; then
echo "ERROR: Cluster did not become healthy after 60 attempts (2 minutes)"
echo "Current status: $CLUSTER_HEALTH"
exit 1
fi

sleep 2
done

- name: Install go-junit-report
run: go install github.com/jstemmer/go-junit-report/v2@v2.1.0

- name: Run smoke tests with JUnit output
env:
ROX_ENDPOINT: localhost:8000
ROX_PASSWORD: ${{ steps.extract-password.outputs.password }}
run: |
go test -v -tags=smoke -cover -race -coverprofile=coverage-smoke.out -timeout=20m ./smoke 2>&1 | \
tee /dev/stderr | \
go-junit-report -set-exit-code -out junit-smoke.xml

- name: Upload JUnit test results
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-smoke-results
path: junit-smoke.xml
if-no-files-found: error

- name: Upload test results to Codecov
if: always()
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: junit-smoke.xml

- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
files: ./coverage-smoke.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: smoke
name: smoke-tests

- name: Collect logs
if: always()
run: |
mkdir -p logs
kubectl get pods -A > logs/pods.txt || true
kubectl get events -A --sort-by='.lastTimestamp' > logs/events.txt || true
kubectl logs -n vulnerable-apps deployment/vulnerable-app --all-containers=true > logs/vulnerable-app.log || true
kubectl logs -n stackrox deployment/central > logs/central.log || true
kubectl logs -n stackrox deployment/scanner > logs/scanner.log || true
kubectl describe pod -n vulnerable-apps > logs/vulnerable-app-describe.txt || true

- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-test-logs
path: logs/
if-no-files-found: ignore
36 changes: 0 additions & 36 deletions internal/testutil/integration_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import (
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stackrox/stackrox-mcp/internal/app"
"github.com/stackrox/stackrox-mcp/internal/config"
"github.com/stretchr/testify/require"
)

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

return NewMCPTestClient(t, runFunc)
}

// SetupInitializedClient creates an initialized MCP client for testing with automatic cleanup.
func SetupInitializedClient(t *testing.T, createClient func(*testing.T) (*MCPTestClient, error)) *MCPTestClient {
t.Helper()

client, err := createClient(t)
require.NoError(t, err, "Failed to create MCP client")
t.Cleanup(func() { client.Close() })

return client
}

// CallToolAndGetResult calls a tool and verifies it succeeds.
func CallToolAndGetResult(t *testing.T, client *MCPTestClient, toolName string, args map[string]any) *mcp.CallToolResult {
t.Helper()

ctx := context.Background()
result, err := client.CallTool(ctx, toolName, args)
require.NoError(t, err)
RequireNoError(t, result)

return result
}

// GetTextContent extracts text from the first content item.
func GetTextContent(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
require.NotEmpty(t, result.Content, "should have content in response")

textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected TextContent, got %T", result.Content[0])

return textContent.Text
}
48 changes: 48 additions & 0 deletions internal/testutil/test_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package testutil

import (
"context"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
)

// SetupInitializedClient creates an initialized MCP client with automatic cleanup.
func SetupInitializedClient(t *testing.T, createClient func(*testing.T) (*MCPTestClient, error)) *MCPTestClient {
t.Helper()

client, err := createClient(t)
require.NoError(t, err, "Failed to create MCP client")
t.Cleanup(func() { _ = client.Close() })

return client
}

// CallToolAndGetResult calls a tool and verifies it succeeds.
func CallToolAndGetResult(
t *testing.T,
client *MCPTestClient,
toolName string,
args map[string]any,
) *mcp.CallToolResult {
t.Helper()

ctx := context.Background()
result, err := client.CallTool(ctx, toolName, args)
require.NoError(t, err)
RequireNoError(t, result)

return result
}

// GetTextContent extracts text from the first content item.
func GetTextContent(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
require.NotEmpty(t, result.Content, "should have content in response")

textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected TextContent, got %T", result.Content[0])

return textContent.Text
}
Loading
Loading