diff --git a/.github/workflows/deploy-lambda.yml b/.github/workflows/deploy-lambda.yml index e69de29..822ad08 100644 --- a/.github/workflows/deploy-lambda.yml +++ b/.github/workflows/deploy-lambda.yml @@ -0,0 +1,291 @@ +name: Deploy Lambda + +on: + push: + branches: [master] + paths: + - 'graqle/**' + - 'pyproject.toml' + - '.github/workflows/deploy-lambda.yml' + workflow_dispatch: + +env: + LAMBDA_FUNCTION: cognigraph-api + S3_BUCKET: graqle-graphs-eu + AWS_REGION: eu-central-1 + +jobs: + # ── Step 1: Full Graqle import scan ── + graqle-check: + name: Graqle Import & Quality Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install graqle with all Lambda extras + run: | + python -m pip install --upgrade pip + pip install -e ".[server,api,studio,dev]" + + - name: Full import scan — catch all ImportErrors + run: | + python -c " + import importlib, pkgutil, sys + + errors = [] + for importer, modname, ispkg in pkgutil.walk_packages( + path=['graqle'], + prefix='graqle.', + onerror=lambda name: errors.append(name), + ): + try: + importlib.import_module(modname) + except Exception as e: + errors.append(f'{modname}: {e}') + + if errors: + print('IMPORT ERRORS FOUND:') + for e in errors: + print(f' !! {e}') + sys.exit(1) + else: + print(f'All graqle modules import clean.') + " + + - name: Verify Lambda handler loads + run: | + python -c " + from graqle.server.lambda_handler import handler + print('Lambda handler: OK') + from graqle.server.app import create_app + print('FastAPI app factory: OK') + from graqle.studio.app import mount_studio + print('Studio mount: OK') + from graqle.metrics.engine import MetricsEngine + print('MetricsEngine: OK') + " + + - name: Verify Studio routes register + env: + AWS_LAMBDA_FUNCTION_NAME: test # scoped to this step — triggers /tmp path in create_app + run: | + python -c " + import os, sys, logging, traceback + logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(name)s: %(message)s') + + from graqle.server.app import create_app + app = create_app(graph_path='nonexistent.json', config_path='nonexistent.yaml') + + # Use OpenAPI schema to enumerate routes — handles FastAPI _IncludedRouter + # wrappers (introduced in FastAPI 0.100+) that don't expose .path at the + # top level of app.routes. The schema is built from the actual route tree. + try: + schema_paths = list(app.openapi().get('paths', {}).keys()) + except Exception as _e: + print(f'[warn] openapi() failed: {_e}') + schema_paths = [] + # Fallback: flat walk for any Route objects still at the top level. + flat_paths = [r.path for r in app.routes if hasattr(r, 'path')] + routes = list(dict.fromkeys(schema_paths + flat_paths)) # deduplicate, preserve order + studio_routes = [r for r in routes if '/studio' in r] + print(f'Total routes: {len(routes)}') + print(f'Studio routes: {len(studio_routes)}') + + # Validate required studio routes are registered. + required = ['/studio/api/health', '/studio/api/intelligence', '/studio/api/governance', '/studio/api/learning'] + missing = [r for r in required if not any(p.startswith(r) for p in routes)] + for r in required: + print(f' {r}: ' + ('OK' if r not in missing else 'MISSING')) + + if missing: + print('FAIL: Required studio routes missing — running direct mount_studio() diagnostic') + try: + from fastapi import FastAPI + from graqle.studio.app import mount_studio + _diag_app = FastAPI() + mount_studio(_diag_app, {'metrics': None, 'studio_available': False}) + diag_paths = list(_diag_app.openapi().get('paths', {}).keys()) + print(f'Direct mount_studio() paths: {diag_paths[:10]}') + if any('/studio/api' in p for p in diag_paths): + print('Direct mount_studio() succeeded — route count discrepancy is in create_app() context') + else: + print('Direct mount_studio() also missing routes — check router imports') + except Exception as _exc: + print(f'Direct mount_studio() FAILED with {type(_exc).__name__}: {_exc}') + traceback.print_exc() + print('Fix: add the missing dependency above to the pip install line in this workflow') + sys.exit(1) + " + + - name: Graqle governance gate (graq verify) + run: | + python -c " + from graqle.intelligence.verify import verify_changes + from pathlib import Path + result = verify_changes(Path('.')) + print(f'Governance gate: {\"PASS\" if result else \"CHECK\"}') + " + + - name: Run core tests + run: pytest tests/ -x -q --timeout=30 -k "not neo4j and not slow" 2>&1 | tail -20 + + # ── Step 2: Build & Deploy Lambda ── + deploy: + name: Build & Deploy to Lambda + needs: graqle-check + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Build Lambda package + run: | + mkdir -p build/lambda + + # Install deps for Linux Lambda runtime. + # NOTE (CR-013, 2026-05-17): neo4j + cryptography + jsonschema + + # pyshacl + rdflib added explicitly. Without neo4j, traversal/hubs + # returns 500 (Neptune speaks Bolt). Without the others, v0.57.0 + # compliance modules silently fail at import time, dropping their + # route registration. The graqle[api] extra in pyproject.toml + # already declares these, but this workflow hand-rolls a flat + # pip-install list and doesn't consult the [api] extra. + # TODO: switch to `pip install -e .[api] --target build/lambda` + # in a follow-on CR once the manylinux constraints are sorted. + # A1b (2026-06-07): pyjwt[crypto] is REQUIRED at runtime — + # graqle/studio/auth.py verifies the Cognito ID token (cross-tenant + # trust root). Without it the `import jwt` guard makes _verify_bearer + # return None for EVERY request → the fix would over-deny and break + # valid logins. cryptography (already below) is the RS256 backend. + # (Synced from public PR #191 for private/public parity.) + pip install --target build/lambda \ + --platform manylinux2014_x86_64 --only-binary=:all: \ + --python-version 3.10 --implementation cp \ + mangum fastapi uvicorn jinja2 pydantic pydantic-settings \ + pyyaml typer rich networkx numpy httpx anthropic openai \ + requests \ + neo4j cryptography jsonschema pyshacl rdflib \ + 'pyjwt[crypto]' + + # Copy graqle source (not pip-installed — use repo version) + cp -r graqle/ build/lambda/graqle/ + + # Clean up + rm -rf build/lambda/graqle/benchmarks/ build/lambda/graqle/examples/ + find build/lambda -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find build/lambda -name '*.pyc' -delete 2>/dev/null || true + + # Remove boto3/botocore (Lambda runtime has these) + rm -rf build/lambda/boto3* build/lambda/botocore* build/lambda/s3transfer* 2>/dev/null || true + + echo "Package size: $(du -sh build/lambda/ | cut -f1)" + + - name: Create deployment zip + run: | + cd build/lambda + zip -r9 ../../lambda-deploy.zip . -x "*.pyc" "*__pycache__*" + cd ../.. + echo "Zip size: $(du -sh lambda-deploy.zip | cut -f1)" + + - name: Deploy to Lambda + run: | + aws lambda update-function-code \ + --function-name ${{ env.LAMBDA_FUNCTION }} \ + --zip-file fileb://lambda-deploy.zip \ + --region ${{ env.AWS_REGION }} + + - name: Wait for code deploy + run: | + aws lambda wait function-updated \ + --function-name ${{ env.LAMBDA_FUNCTION }} \ + --region ${{ env.AWS_REGION }} + + - name: Update Lambda environment + run: | + aws lambda update-function-configuration \ + --function-name ${{ env.LAMBDA_FUNCTION }} \ + --environment "Variables={ + COGNIGRAPH_GRAPH_PATH=s3://graqle-graphs-eu/graqle.json, + COGNIGRAPH_CONFIG_PATH=s3://graqle-graphs-eu/graqle.yaml, + COGNIGRAPH_S3_BUCKET=graqle-graphs-eu, + ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}, + NEPTUNE_ENDPOINT=graqle-kg.cluster-cfb3tqihxeti.eu-central-1.neptune.amazonaws.com, + NEPTUNE_PORT=8182, + NEPTUNE_REGION=eu-central-1, + NEPTUNE_IAM_AUTH=true + }" \ + --timeout 300 \ + --memory-size 1024 \ + --region ${{ env.AWS_REGION }} + + - name: Wait for Lambda update + run: | + aws lambda wait function-updated \ + --function-name ${{ env.LAMBDA_FUNCTION }} \ + --region ${{ env.AWS_REGION }} + + - name: Smoke test Lambda + run: | + LAMBDA_URL=$(aws lambda get-function-url-config \ + --function-name ${{ env.LAMBDA_FUNCTION }} \ + --region ${{ env.AWS_REGION }} \ + --query 'FunctionUrl' --output text) + + echo "Lambda URL: $LAMBDA_URL" + + # Wait for cold start + sleep 10 + + # Test health endpoint + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "${LAMBDA_URL}health") + echo "GET /health: $HTTP_CODE" + if [ "$HTTP_CODE" != "200" ]; then + echo "FAIL: /health returned $HTTP_CODE" + exit 1 + fi + + # Test studio API + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "${LAMBDA_URL}studio/api/health") + echo "GET /studio/api/health: $HTTP_CODE" + + # Test graph loaded + HEALTH=$(curl -s --max-time 30 "${LAMBDA_URL}health") + echo "Health response: $HEALTH" + + # Test graph visualization returns nodes + GRAPH_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 60 "${LAMBDA_URL}studio/api/graph/visualization") + echo "GET /studio/api/graph/visualization: $GRAPH_CODE" + if [ "$GRAPH_CODE" != "200" ]; then + echo "FAIL: graph visualization returned $GRAPH_CODE" + exit 1 + fi + + # Test Neptune health (non-blocking — Neptune may not be in VPC from GH Actions) + NEPTUNE_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 60 "${LAMBDA_URL}studio/api/neptune/health") + echo "GET /studio/api/neptune/health: $NEPTUNE_CODE" + + # Verify CORS — single header only (ADR-056) + CORS_COUNT=$(curl -sv -H "Origin: https://graqle.com" --max-time 30 "${LAMBDA_URL}health" 2>&1 | grep -ci "access-control-allow-origin") + echo "CORS header count: $CORS_COUNT" + if [ "$CORS_COUNT" -gt 1 ]; then + echo "FAIL: Duplicate CORS headers detected ($CORS_COUNT)" + exit 1 + fi