Skip to content
Merged
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
291 changes: 291 additions & 0 deletions .github/workflows/deploy-lambda.yml
Original file line number Diff line number Diff line change
@@ -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
Loading