diff --git a/handle-resolution-service/.gitignore b/lambdas/.gitignore similarity index 100% rename from handle-resolution-service/.gitignore rename to lambdas/.gitignore diff --git a/handle-resolution-service/README.md b/lambdas/README.md similarity index 98% rename from handle-resolution-service/README.md rename to lambdas/README.md index 5234c4b..e4652f9 100644 --- a/handle-resolution-service/README.md +++ b/lambdas/README.md @@ -52,6 +52,11 @@ chmod +x build.sh sam deploy --guided ``` +3. Plan +```bash +sam build && sam deploy --no-execute-changese +``` + 3. Subsequent deployments: ```bash sam build && sam deploy --no-confirm-changeset diff --git a/handle-resolution-service/build.sh b/lambdas/build.sh similarity index 100% rename from handle-resolution-service/build.sh rename to lambdas/build.sh diff --git a/handle-resolution-service/env.json b/lambdas/env.json similarity index 100% rename from handle-resolution-service/env.json rename to lambdas/env.json diff --git a/handle-resolution-service/events/get-request.json b/lambdas/events/get-request.json similarity index 100% rename from handle-resolution-service/events/get-request.json rename to lambdas/events/get-request.json diff --git a/handle-resolution-service/events/head-request.json b/lambdas/events/head-request.json similarity index 100% rename from handle-resolution-service/events/head-request.json rename to lambdas/events/head-request.json diff --git a/lambdas/profile/README.md b/lambdas/profile/README.md new file mode 100644 index 0000000..7cfd2f8 --- /dev/null +++ b/lambdas/profile/README.md @@ -0,0 +1,179 @@ +# Arkavo Profile Lambda + +ATProtocol-compliant Lambda function for retrieving user profile information. This service provides a scalable endpoint for accessing both standard ATProtocol profile fields and Arkavo-specific extensions. + +## Overview + +The Profile Lambda function serves as an endpoint for retrieving user profile information through the ATProtocol specification. It interfaces with DynamoDB to store and retrieve profile data, handling both standard ATProtocol fields and Arkavo-specific extensions. + +### Endpoint Details + +- **URL**: `https://xrpc.arkavo.net/xrpc/app.arkavo.actor.getProfile` +- **Method**: GET +- **Query Parameters**: + - `actor` (required): String representing either the DID or handle of the user + +## Getting Started + +### Prerequisites + +- Python 3.9+ +- AWS CLI configured with appropriate credentials +- AWS SAM CLI (for local development) + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/arkavo/devsecops.git +cd lambdas/profile +``` + +2. Create a virtual environment and install dependencies: +```bash +python3.12 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +3. Set up local environment variables: +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +### Local Development + +Run the function locally using SAM CLI: + +```bash +sam local invoke -e events/test_event.json +``` + +Or start a local API endpoint: + +```bash +sam local start-api +``` + +## Deployment + +### Using AWS SAM + +1. Build the application: +```bash +sam build +``` + +2. Deploy to AWS: +```bash +sam deploy --guided +``` + +### Manual Deployment + +1. Package the Lambda: +```bash +zip -r function.zip . -x "*.git*" "*.env*" "*.venv*" +``` + +2. Deploy using AWS CLI: +```bash +aws lambda update-function-code --function-name profile-lambda --zip-file fileb://function.zip +``` + +## Configuration + +### Environment Variables + +- `PROFILES_TABLE_NAME`: DynamoDB table name for storing profiles + +### DynamoDB Schema + +The DynamoDB table requires the following schema: + +```json +{ + "actor": "string" (Primary Key), + "handle": "string", + "did": "string", + "displayName": "string", + "avatarUrl": "string" (optional), + "description": "string" (optional), + "profileType": "string", + "isVerified": "boolean", + "followersCount": "number", + "followingCount": "number", + "creationDate": "string", + "publicID": "string" +} +``` + +## API Response Format + +### Successful Response + +```json +{ + "standard": { + "handle": "string", + "did": "string", + "displayName": "string", + "avatarUrl": "string", + "description": "string" + }, + "extended": { + "profileType": "string", + "isVerified": boolean, + "followersCount": number, + "followingCount": number, + "creationDate": "string", + "publicID": "string" + } +} +``` + +### Error Response + +```json +{ + "error": "Error message" +} +``` + +## Testing + +Run the test suite: + +```bash +pytest tests/ +``` + +Run with coverage: + +```bash +pytest --cov=src tests/ +``` + +## Monitoring and Logging + +The function uses AWS Lambda Powertools for structured logging and tracing. Logs are available in CloudWatch Logs, and traces can be viewed in AWS X-Ray. + +### Log Groups + +- `/aws/lambda/profile-lambda`: Main function logs +- `/aws/lambda/profile-lambda-dev`: Development environment logs + +### Metrics + +Key metrics are available in CloudWatch Metrics under the custom namespace `Arkavo/ProfileLambda`: + +- `GetProfileLatency`: Response time for profile retrieval +- `ProfileNotFound`: Count of 404 errors +- `DatabaseErrors`: Count of DynamoDB-related errors + +## Related Documentation + +- [ATProtocol Specification](https://atproto.com/specs/atp) +- [AWS Lambda Powertools](https://awslabs.github.io/aws-lambda-powertools-python/) +- \ No newline at end of file diff --git a/lambdas/profile/app.py b/lambdas/profile/app.py new file mode 100644 index 0000000..1992e7d --- /dev/null +++ b/lambdas/profile/app.py @@ -0,0 +1,137 @@ +import os +from typing import Dict, Any, Optional + +import boto3 +from aws_lambda_powertools import Logger, Metrics +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError + +# Initialize utilities +logger = Logger() +metrics = Metrics() +app = APIGatewayRestResolver(strip_prefixes=["/xrpc"]) + +# Constants +DYNAMO_TABLE_NAME = os.environ["PROFILES_TABLE_NAME"] + + +class ProfileError(Exception): + """Custom exception for profile-related errors""" + + def __init__(self, message: str, status_code: int = 400): + self.message = message + self.status_code = status_code + super().__init__(self.message) + + +def get_profile_from_dynamo(actor: str) -> Optional[Dict[str, Any]]: + """ + Retrieve profile from DynamoDB + """ + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(DYNAMO_TABLE_NAME) + + try: + # First try as handle (primary key) + response = table.get_item(Key={"handle": actor}) + if "Item" in response: + return response["Item"] + + # If not found, scan for DID or publicID + response = table.scan( + FilterExpression="did = :did OR publicID = :pid", + ExpressionAttributeValues={ + ":did": actor, + ":pid": actor + } + ) + items = response.get("Items", []) + return items[0] if items else None + except ClientError as e: + logger.error(f"DynamoDB error: {str(e)}") + raise ProfileError("Database error", 500) + + +def validate_profile_data(profile: Dict[str, Any]) -> None: + """ + Validate profile data structure and required fields + """ + required_fields = { + "handle", "did", "profileName", "creationDate", "publicID" + } + + missing_fields = required_fields - set(profile.keys()) + if missing_fields: + raise ProfileError(f"Missing required fields: {', '.join(missing_fields)}") + + +def format_profile_response(profile: Dict[str, Any]) -> Dict[str, Any]: + """ + Format profile data as a flat structure + """ + return { + "handle": profile["handle"], + "did": profile["did"], + "displayName": profile["profileName"], + "avatarUrl": profile.get("avatarUrl"), + "description": profile.get("description"), + "creationDate": profile["creationDate"], + "publicID": profile["publicID"] + } + + +@app.get("/app.arkavo.actor.getProfile") +def get_profile() -> Dict[str, Any]: + """ + Main handler for profile retrieval + """ + try: + # Get and validate query parameters + actor = app.current_event.get_query_string_value(name="actor") + if not actor: + app.response.status_code = 400 + return {"error": "Missing 'actor' parameter"} + + # Get profile data + profile = get_profile_from_dynamo(actor) + if not profile: + app.response.status_code = 404 + return {"error": "Profile not found"} + + # Validate profile data + validate_profile_data(profile) + + # Record metric for successful lookup + metrics.add_metric(name="ProfileLookupSuccess", unit="Count", value=1) + + # Return formatted response + return format_profile_response(profile) + + except ProfileError as error: + logger.error(f"Profile error: {error.message}") + metrics.add_metric(name="ProfileLookupError", unit="Count", value=1) + app.response.status_code = error.status_code + return {"error": error.message} + except Exception as e: + logger.exception("Unhandled exception") + metrics.add_metric(name="UnhandledError", unit="Count", value=1) + app.response.status_code = 500 + return {"error": "Internal server error"} + + +@logger.inject_lambda_context +@metrics.log_metrics +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: + """ + Lambda handler entry point + """ + return app.resolve(event, context) + + +if __name__ == "__main__": + # For local testing + test_event = { + "queryStringParameters": {"actor": "test_user"} + } + print(lambda_handler(test_event, None)) diff --git a/lambdas/profile/requirements.txt b/lambdas/profile/requirements.txt new file mode 100644 index 0000000..d70aacd --- /dev/null +++ b/lambdas/profile/requirements.txt @@ -0,0 +1,70 @@ +aiohappyeyeballs==2.4.4 +aiohttp==3.11.9 +aiosignal==1.3.1 +annotated-types==0.7.0 +anthropic==0.40.0 +anyio==4.6.2.post1 +async-timeout==4.0.3 +attrs==24.2.0 +aws-lambda-powertools==3.4.0 +boto3==1.34.64 +botocore==1.34.162 +certifi==2024.8.30 +charset-normalizer==3.4.0 +click==8.1.7 +dataclasses-json==0.6.7 +defusedxml==0.7.1 +distro==1.9.0 +docker==7.1.0 +duckduckgo_search==6.3.7 +env==0.1.0 +exceptiongroup==1.2.2 +frozenlist==1.5.0 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.27.2 +httpx-sse==0.4.0 +idna==3.10 +jiter==0.8.0 +jmespath==1.0.1 +jsonpatch==1.33 +jsonpointer==3.0.0 +langchain==0.3.9 +langchain-anthropic==0.3.0 +langchain-community==0.3.9 +langchain-core==0.3.21 +langchain-ollama==0.2.1 +langchain-text-splitters==0.3.2 +langgraph==0.2.56 +langgraph-checkpoint==2.0.8 +langgraph-sdk==0.1.43 +langsmith==0.1.147 +marshmallow==3.23.1 +msgpack==1.1.0 +multidict==6.1.0 +mypy-extensions==1.0.0 +numpy==1.26.4 +ollama==0.4.2 +orjson==3.10.12 +packaging==24.2 +primp==0.8.1 +propcache==0.2.1 +pydantic==2.10.3 +pydantic-settings==2.6.1 +pydantic_core==2.27.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-gitlab==5.1.0 +PyYAML==6.0.2 +redis==5.0.1 +requests==2.32.3 +requests-toolbelt==1.0.0 +s3transfer==0.10.4 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.36 +tenacity==9.0.0 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +urllib3==1.26.20 +yarl==1.18.3 diff --git a/handle-resolution-service/requirements.txt b/lambdas/requirements.txt similarity index 100% rename from handle-resolution-service/requirements.txt rename to lambdas/requirements.txt diff --git a/handle-resolution-service/samconfig.toml b/lambdas/samconfig.toml similarity index 100% rename from handle-resolution-service/samconfig.toml rename to lambdas/samconfig.toml diff --git a/handle-resolution-service/src/app.py b/lambdas/src/app.py similarity index 100% rename from handle-resolution-service/src/app.py rename to lambdas/src/app.py diff --git a/handle-resolution-service/template.yaml b/lambdas/template.yaml similarity index 89% rename from handle-resolution-service/template.yaml rename to lambdas/template.yaml index 3ec5047..f97ce14 100644 --- a/handle-resolution-service/template.yaml +++ b/lambdas/template.yaml @@ -176,7 +176,9 @@ Resources: - "dynamodb:DeleteItem" - "dynamodb:Query" - "dynamodb:Scan" - Resource: !GetAtt HandleTable.Arn + Resource: + - !GetAtt HandleTable.Arn + - !GetAtt ProfilesTable.Arn RouteTableIds: - !Ref PrivateRouteTable1 - !Ref PrivateRouteTable2 @@ -478,6 +480,59 @@ Resources: ResourceRecords: - !GetAtt Authnz.PublicIp + # ProfilesTable definition + ProfilesTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub ${Environment}-profiles + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: handle + AttributeType: S + KeySchema: + - AttributeName: handle + KeyType: HASH + SSESpecification: + SSEEnabled: true + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + Tags: + - Key: Environment + Value: !Ref Environment + + # Profile Lambda Function + ProfileFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${Environment}-profile + CodeUri: profile/ + Handler: app.lambda_handler + Runtime: python3.12 + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Environment: + Variables: + PROFILES_TABLE_NAME: !Ref ProfilesTable + POWERTOOLS_SERVICE_NAME: profile-service + POWERTOOLS_METRICS_NAMESPACE: ProfileService + Events: + GetProfile: + Type: Api + Properties: + Path: /xrpc/app.arkavo.actor.getProfile + Method: get + RestApiId: !Ref ServerlessRestApi + AutoPublishAlias: live + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ProfilesTable + - VPCAccessPolicy: {} + # XrpcDnsRecord: # Type: AWS::Route53::RecordSet # Properties: @@ -528,3 +583,11 @@ Outputs: EC2PublicDNS: Description: Public DNS of EC2 instance Value: !GetAtt Authnz.PublicDnsName + + ProfileFunction: + Description: Profile Lambda Function ARN + Value: !GetAtt ProfileFunction.Arn + + ProfilesTable: + Description: Profiles DynamoDB table name + Value: !Ref ProfilesTable