diff --git a/.woodpecker/main.yml b/.woodpecker/main.yml new file mode 100644 index 0000000..d59b557 --- /dev/null +++ b/.woodpecker/main.yml @@ -0,0 +1,337 @@ +# Woodpecker CI Main Pipeline +# BreakPilot Lehrer - CI/CD Pipeline +# +# Plattform: ARM64 (Apple Silicon Mac Mini) +# +# Services: +# Python: voice-service, klausur-service, backend-lehrer +# Node.js: website, admin-lehrer, studio-v2 +# +# Strategie: +# - Lint bei PRs +# - Tests laufen bei JEDEM Push/PR +# - Test-Ergebnisse werden an Dashboard gesendet +# - Builds/Scans laufen nur bei Tags oder manuell +# - Deployment nur manuell (Sicherheit) + +when: + - event: [push, pull_request, manual, tag] + branch: [main, develop] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +variables: + - &python_image python:3.12-slim + - &nodejs_image node:20-alpine + - &docker_image docker:27-cli + +steps: + # ======================================== + # STAGE 1: Lint (nur bei PRs) + # ======================================== + + python-lint: + image: *python_image + commands: + - pip install --quiet ruff + - | + for svc in voice-service klausur-service backend-lehrer; do + if [ -d "$svc" ]; then + echo "=== Linting $svc ===" + ruff check "$svc/" --output-format=github || true + fi + done + when: + event: pull_request + + nodejs-lint: + image: *nodejs_image + commands: + - | + for svc in website admin-lehrer studio-v2; do + if [ -d "$svc" ]; then + echo "=== Linting $svc ===" + cd "$svc" + npm ci --silent 2>/dev/null || npm install --silent + npx next lint || true + cd .. + fi + done + when: + event: pull_request + + # ======================================== + # STAGE 2: Unit Tests mit JSON-Ausgabe + # Ergebnisse werden im Workspace gespeichert (.ci-results/) + # ======================================== + + test-python-voice: + image: *python_image + environment: + CI: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service" ]; then + echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json + echo "WARNUNG: voice-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report + + set +e + python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-voice.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-voice.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json + cat ../.ci-results/results-voice.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-nodejs-website: + image: *nodejs_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "website" ]; then + echo '{"service":"website","framework":"jest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-website.json + echo "WARNUNG: website Verzeichnis nicht gefunden" + exit 0 + fi + + cd website + npm ci --silent 2>/dev/null || npm install --silent + + set +e + npx jest --json --outputFile=../.ci-results/test-website.json 2>&1 + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-website.json ]; then + TOTAL=$(node -e "const d=require('../.ci-results/test-website.json'); console.log(d.numTotalTests || 0)" 2>/dev/null || echo "0") + PASSED=$(node -e "const d=require('../.ci-results/test-website.json'); console.log(d.numPassedTests || 0)" 2>/dev/null || echo "0") + FAILED=$(node -e "const d=require('../.ci-results/test-website.json'); console.log(d.numFailedTests || 0)" 2>/dev/null || echo "0") + SKIPPED=$(node -e "const d=require('../.ci-results/test-website.json'); console.log(d.numPendingTests || 0)" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + [ -z "$TOTAL" ] && TOTAL=0 + [ -z "$PASSED" ] && PASSED=0 + [ -z "$FAILED" ] && FAILED=0 + [ -z "$SKIPPED" ] && SKIPPED=0 + + echo "{\"service\":\"website\",\"framework\":\"jest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-website.json + cat ../.ci-results/results-website.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + # ======================================== + # STAGE 3: Test-Ergebnisse an Dashboard senden + # ======================================== + + report-test-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Test-Ergebnisse an Dashboard ===" + echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}" + ls -la .ci-results/ || echo "Verzeichnis nicht gefunden" + + PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}" + + for f in .ci-results/results-*.json; do + [ -f "$f" ] || continue + echo "Sending: $f" + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"repo\": \"breakpilot-lehrer\", + \"status\": \"${PIPELINE_STATUS}\", + \"test_results\": $(cat "$f") + }" || echo "WARNUNG: Konnte $f nicht senden" + done + + echo "=== Test-Ergebnisse gesendet ===" + when: + status: [success, failure] + depends_on: + - test-python-voice + - test-nodejs-website + + # ======================================== + # STAGE 4: Build & Security (nur Tags/manuell) + # ======================================== + + build-admin-lehrer: + image: *docker_image + commands: + - | + if [ -d ./admin-lehrer ]; then + docker build -t breakpilot/admin-lehrer:${CI_COMMIT_SHA:0:8} ./admin-lehrer + docker tag breakpilot/admin-lehrer:${CI_COMMIT_SHA:0:8} breakpilot/admin-lehrer:latest + echo "Built breakpilot/admin-lehrer:${CI_COMMIT_SHA:0:8}" + else + echo "admin-lehrer Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + build-studio-v2: + image: *docker_image + commands: + - | + if [ -d ./studio-v2 ]; then + docker build -t breakpilot/studio-v2:${CI_COMMIT_SHA:0:8} ./studio-v2 + docker tag breakpilot/studio-v2:${CI_COMMIT_SHA:0:8} breakpilot/studio-v2:latest + echo "Built breakpilot/studio-v2:${CI_COMMIT_SHA:0:8}" + else + echo "studio-v2 Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + build-website: + image: *docker_image + commands: + - | + if [ -d ./website ]; then + docker build -t breakpilot/website:${CI_COMMIT_SHA:0:8} ./website + docker tag breakpilot/website:${CI_COMMIT_SHA:0:8} breakpilot/website:latest + echo "Built breakpilot/website:${CI_COMMIT_SHA:0:8}" + else + echo "website Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + build-backend-lehrer: + image: *docker_image + commands: + - | + if [ -d ./backend-lehrer ]; then + docker build -t breakpilot/backend-lehrer:${CI_COMMIT_SHA:0:8} ./backend-lehrer + docker tag breakpilot/backend-lehrer:${CI_COMMIT_SHA:0:8} breakpilot/backend-lehrer:latest + echo "Built breakpilot/backend-lehrer:${CI_COMMIT_SHA:0:8}" + else + echo "backend-lehrer Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + build-klausur-service: + image: *docker_image + commands: + - | + if [ -d ./klausur-service ]; then + docker build -t breakpilot/klausur-service:${CI_COMMIT_SHA:0:8} ./klausur-service + docker tag breakpilot/klausur-service:${CI_COMMIT_SHA:0:8} breakpilot/klausur-service:latest + echo "Built breakpilot/klausur-service:${CI_COMMIT_SHA:0:8}" + else + echo "klausur-service Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + build-voice-service: + image: *docker_image + commands: + - | + if [ -d ./voice-service ]; then + docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service + docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest + echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}" + else + echo "voice-service Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + generate-sbom: + image: python:3.12-slim + commands: + - | + echo "Installing syft for ARM64..." + apt-get update -qq && apt-get install -y -qq wget > /dev/null + wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + for svc in voice-service klausur-service backend-lehrer website; do + if [ -d "./$svc" ]; then + syft dir:./$svc -o cyclonedx-json > sbom-$svc.json + echo "SBOM generated for $svc" + fi + done + when: + - event: tag + - event: manual + + vulnerability-scan: + image: python:3.12-slim + commands: + - | + echo "Installing grype for ARM64..." + apt-get update -qq && apt-get install -y -qq wget > /dev/null + wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin + for f in sbom-*.json; do + [ -f "$f" ] || continue + echo "=== Scanning $f ===" + grype sbom:"$f" -o table --fail-on critical || true + done + when: + - event: tag + - event: manual + depends_on: + - generate-sbom + + # ======================================== + # STAGE 5: Deploy (nur manuell) + # ======================================== + + deploy-production: + image: *docker_image + commands: + - echo "Deploying breakpilot-lehrer to production..." + - docker compose -f docker-compose.yml pull || true + - docker compose -f docker-compose.yml up -d --remove-orphans || true + when: + event: manual + depends_on: + - build-admin-lehrer + - build-studio-v2 + - build-website + - build-backend-lehrer + - build-klausur-service + - build-voice-service