diff --git a/examples/command/portals/vercel/example-2/.gitignore b/examples/command/portals/vercel/example-2/.gitignore new file mode 100644 index 00000000000..76fc8a5a055 --- /dev/null +++ b/examples/command/portals/vercel/example-2/.gitignore @@ -0,0 +1,2 @@ +*.ticket +.vercel diff --git a/examples/command/portals/vercel/example-2/README.md b/examples/command/portals/vercel/example-2/README.md new file mode 100644 index 00000000000..80c8ea55fe7 --- /dev/null +++ b/examples/command/portals/vercel/example-2/README.md @@ -0,0 +1,73 @@ +# Access private AWS RDS PostgreSQL Databasefrom Vercel + +![Architecture](./diagram.png) + +## AWS + +### Setup a private RDS PostgreSQL Database + +- [Follow AWS Guide](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.AuroraPostgreSQL.html) to setup + a private RDS PostgreSQL Database + +- Create a database, table and insert some data + +```sql +CREATE TABLE public.products +( + id integer primary key, + product_name text, + price numeric +); + +INSERT INTO products (id, product_name, price) +VALUES (1, "Ergonomic Keyboard", 38.30), + (2, "Wireless Mouse", 93.30), + (3, "USB Hub", 47.63) ' +``` + +### Setup an Ockam Outlet Node + +- [Follow Ockam Guide](https://docs.ockam.io/reference/command/guides/aws-marketplace/ockam-node-for-amazon-rds-postgrese) + to setup an Ockam Outlet Node in AWS. + +## Vercel: Setup Vercel Serverless functions to access the private database. + +### Download latest ockam binary + +- Download ockam binary `x86_64-unknown-linux-gnu` from the [Ockam](https://github.com/build-trust/ockam/releases) + github repository and place it in the `data/linux-x86_64` directory. Rename the binary to `ockam`. + +### Create an enrollment ticket for the Vercel function + +```sh +# Generate an inlet ticket for the Vercel function. +# Values for --usage-count and --expires-in are setup to allow to be used 100 counts for 24 hours. +ockam project ticket --expires-in 24h --usage-count 100 \ + --attribute amazon-rds-postgresql-inlet \ + > "vercel-inlet.ticket" +``` + +### Setup a Vercel project and add the inlet ticket as a secret + +- Select `Project Settings`, `Environments`, `Production +- Click `Add Environment Variable` +- Select `Sensitive` +- Add `OCKAM_RDS_INLET_ENROLLMENT_TICKET` as the name +- Add the value of `vercel-inlet.ticket` as the value +- Click `Add` + +- Follow the same steps to add `DB_PASSWORD` as the environment variable + +### Deploy the Vercel function + +- Setup vercel cli and select the project +- Deploy the function inside the `api` directory + +```sh +vercel --prod +``` + +### Test the Vercel function + +- Use the `/api` endpoint to get the products +- Use the `/api/update` endpoint to update the products diff --git a/examples/command/portals/vercel/example-2/api/index.py b/examples/command/portals/vercel/example-2/api/index.py new file mode 100644 index 00000000000..9fbb2602a42 --- /dev/null +++ b/examples/command/portals/vercel/example-2/api/index.py @@ -0,0 +1,226 @@ +""" +Simple API handler for accessing PostgreSQL via Ockam secure channel +""" + +import json +import os +import subprocess +import threading +import time +from decimal import Decimal +from http.server import BaseHTTPRequestHandler + +import psycopg2 +from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT + +# Constants +# TODO: Use ockam_inmemory testing. Cleanup after testing +OCKAM_BINARY_PATH = '/var/task/data/linux-x86_64/ockam' if os.environ.get('VERCEL_ENV') == 'production' else 'ockam' +MAX_RETRIES = 60 +RETRY_DELAY = 1 # seconds +DB_PORT = 15432 +DB_TIMEOUT = 3 # seconds + +# Track if Ockam node creation has been attempted +NODE_INITIALIZED = False + + +def is_production() -> bool: + """Check if the environment is production.""" + return os.environ.get('VERCEL_ENV') == 'production' + + +def get_ockam_version() -> str: + """Get the installed Ockam version.""" + try: + ockam_path = OCKAM_BINARY_PATH + if is_production() and not os.path.exists(ockam_path): + return f"Error: Ockam binary not found at {os.path.abspath(ockam_path)}" + + result = subprocess.run([ockam_path, '--version'], + capture_output=True, + text=True) + + return result.stdout.strip() if result.returncode == 0 else f"Error running ockam: {result.stderr}" + except Exception as e: + return f"Error: {str(e)} (CWD: {os.getcwd()})" + + +class handler(BaseHTTPRequestHandler): + """Handler for PostgreSQL database requests via Ockam secure channel.""" + + def create_ockam_node(self, enrollment_ticket: str) -> bool: + """Initialize Ockam node with the provided enrollment ticket.""" + if not is_production(): + return False + + global NODE_INITIALIZED + request_id = self.headers.get('x-vercel-id', os.urandom(8).hex()) + + print(f"[{request_id}] Starting with NODE_INITIALIZED = {NODE_INITIALIZED}") + + if not NODE_INITIALIZED: + NODE_INITIALIZED = True + + def run_ockam_node(): + try: + config = '''{ + "tcp-inlet": { + "from": "127.0.0.1:15432", + "via": "postgresql", + "allow": "amazon-rds-postgresql-outlet" + } + }''' + + print(f"[{request_id}] Starting ockam node with config: {config}", flush=True) + + subprocess.Popen([ + OCKAM_BINARY_PATH, + 'node', + 'create', + '--configuration', + config, + '--enrollment-ticket', + enrollment_ticket.strip() + ], env={ + **os.environ, + 'OCKAM_HOME': '/tmp', + 'OCKAM_OPENTELEMETRY_EXPORT': 'false', + 'OCKAM_DISABLE_UPGRADE_CHECK': 'true' + }) + + except Exception as e: + print(f"[{request_id}] Error in node creation: {str(e)}", flush=True) + + thread = threading.Thread(target=run_ockam_node) + thread.daemon = True + print(f"[{request_id}] Starting ockam node thread", flush=True) + thread.start() + + return True + + def get_db_connection(self, request_id: str): + """Establish database connection with retries.""" + db_password = os.environ.get('DB_PASSWORD') + if not db_password: + raise ValueError("DB_PASSWORD environment variable is not set") + + for retry in range(MAX_RETRIES): + try: + conn = psycopg2.connect( + host="127.0.0.1", + port=DB_PORT, + database="testdb", + user="postgresuser", + password=db_password, + connect_timeout=DB_TIMEOUT + ) + print(f"[{request_id}] Connected to PostgreSQL successfully after {retry} retries") + conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) + return conn, None + + except Exception as e: + print(f"[{request_id}] Attempt {retry + 1} failed: {str(e)}") + if retry < MAX_RETRIES - 1: + time.sleep(RETRY_DELAY) + + return None, f"Failed to connect after {MAX_RETRIES} attempts" + + def handle_select(self, request_id: str) -> dict: + """Handle GET requests for product data.""" + conn, error = self.get_db_connection(request_id) + if error: + return {"status": "error", "message": f"Database connection failed: {error}"} + + try: + cur = conn.cursor() + cur.execute("SELECT * FROM products;") + rows = cur.fetchall() + formatted_rows = [ + { + 'id': row[0], + 'product_name': row[1], + 'price': round(float(row[2]), 2) if isinstance(row[2], Decimal) else row[2] + } + for row in rows + ] + + cur.close() + conn.close() + + return {"status": "success", "data": formatted_rows} + except Exception as e: + return {"status": "error", "message": str(e)} + + def handle_update(self, request_id: str) -> dict: + """Handle requests to update product prices.""" + conn, error = self.get_db_connection(request_id) + if error: + return {"status": "error", "message": f"Database connection failed: {error}"} + + try: + cur = conn.cursor() + cur.execute("UPDATE products SET price = (random() * 90 + 10)::numeric(10,2);") + + # Get updated data + cur.execute("SELECT * FROM products;") + rows = cur.fetchall() + formatted_rows = [ + { + 'id': row[0], + 'product_name': row[1], + 'price': round(float(row[2]), 2) if isinstance(row[2], Decimal) else row[2] + } + for row in rows + ] + + cur.close() + conn.close() + + return { + "status": "success", + "message": "Prices updated successfully", + "data": formatted_rows + } + except Exception as e: + return {"status": "error", "message": str(e)} + + def do_GET(self): + """Handle incoming GET requests.""" + try: + # Verify Ockam installation + version_result = get_ockam_version() + if 'Error' in version_result: + self._send_error(500, version_result) + return + + # Verify enrollment ticket + enrollment_ticket = os.environ.get('OCKAM_RDS_INLET_ENROLLMENT_TICKET') + if not enrollment_ticket: + self._send_error(500, 'OCKAM_RDS_INLET_ENROLLMENT_TICKET not configured') + return + + # Initialize Ockam node + if not self.create_ockam_node(enrollment_ticket): + self._send_error(500, 'Failed to initialize Ockam node') + return + + # Handle the request + request_id = self.headers.get('x-vercel-id', os.urandom(8).hex()) + result = self.handle_update(request_id) if self.path == '/api/update' else self.handle_select(request_id) + + self._send_response(200, result) + + except Exception as e: + self._send_error(500, str(e)) + + def _send_response(self, status: int, data: dict): + """Helper method to send JSON responses.""" + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def _send_error(self, status: int, message: str): + """Helper method to send error responses.""" + self._send_response(status, {'error': message}) diff --git a/examples/command/portals/vercel/example-2/diagram.png b/examples/command/portals/vercel/example-2/diagram.png new file mode 100644 index 00000000000..394fe11b29a Binary files /dev/null and b/examples/command/portals/vercel/example-2/diagram.png differ diff --git a/examples/command/portals/vercel/example-2/requirements.txt b/examples/command/portals/vercel/example-2/requirements.txt new file mode 100644 index 00000000000..55a2a37dc2d --- /dev/null +++ b/examples/command/portals/vercel/example-2/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +psycopg2-binary==2.9.9 diff --git a/examples/command/portals/vercel/example-2/vercel.json b/examples/command/portals/vercel/example-2/vercel.json new file mode 100644 index 00000000000..11e1711f14d --- /dev/null +++ b/examples/command/portals/vercel/example-2/vercel.json @@ -0,0 +1,19 @@ +{ + "redirects": [ + { + "source": "/", + "destination": "/api" + } + ], + "rewrites": [ + { + "source": "/api/update", + "destination": "/api" + } + ], + "functions": { + "api/**/*": { + "maxDuration": 300 + } + } +}