diff --git a/.github/workflows/deploy-lambda.yml b/.github/workflows/deploy-lambda.yml index b63b035..e69de29 100644 --- a/.github/workflows/deploy-lambda.yml +++ b/.github/workflows/deploy-lambda.yml @@ -1,279 +0,0 @@ -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') - - routes = [r.path for r in app.routes if hasattr(r, 'path')] - 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. - # If any are missing, run a direct mount_studio() diagnostic to surface the real error - # (e.g. a broken import inside the studio router chain that create_app swallowed). - required = ['/studio/api/health', '/studio/api/intelligence', '/studio/api/governance', '/studio/api/learning'] - missing = {r for r in required if r not in routes} - for r in required: - status = 'OK' if r not in missing else 'MISSING' - print(f' {r}: {status}') - - 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}) - print('Direct mount_studio() succeeded — route count discrepancy is in create_app() context') - 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 diff --git a/graqle/studio/app.py b/graqle/studio/app.py index ce9af91..195e812 100644 --- a/graqle/studio/app.py +++ b/graqle/studio/app.py @@ -41,14 +41,13 @@ def mount_studio(app: Any, state: dict) -> None: from graqle.studio.routes.traversal import router as traversal_router from graqle.studio.routes.auto import router as auto_router - # Mount static files - app.mount("/studio/static", StaticFiles(directory=str(STATIC_DIR)), name="studio-static") - # Store templates and state on app app.state.studio_templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.state.studio_state = state - # Include routers + # Include routers BEFORE static mount — FastAPI resolves Mounts before + # APIRouters in route lookup, so mounting /studio/static first shadows all + # /studio/api/* routes (they never match). Routers must be registered first. app.include_router(dashboard_router, prefix="/studio") app.include_router(api_router, prefix="/studio/api") app.include_router(intelligence_router, prefix="/studio/api/intelligence") @@ -60,6 +59,9 @@ def mount_studio(app: Any, state: dict) -> None: app.include_router(compliance_router, prefix="/studio/api/compliance") app.include_router(auto_router, prefix="/studio") + # Static mount goes LAST — after all API routers are registered. + app.mount("/studio/static", StaticFiles(directory=str(STATIC_DIR)), name="studio-static") + # Ring-fence guard: Studio routes are read-only on the knowledge graph. # No /learn or /reload endpoints are mounted in Studio — this is the # architectural Chinese wall. The guard below explicitly blocks any