From 08b4917c281a9e325f27126ce38acd3a773fb534 Mon Sep 17 00:00:00 2001 From: Nathan Probert <109328434+nathan-probert@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:55:31 -0400 Subject: [PATCH 1/2] Add local Floci AWS emulation support - Add docker-compose.yml for Floci (MIT-licensed, Docker-based AWS emulator) - Add PLAN-local-floci.md documenting the implementation plan - Modify smartscore/utility.py to support AWS_ENDPOINT_URL for local boto3 clients - Modify build_scripts/deploy.sh to add LOCAL_MODE flag with pattern - Modify build_scripts/compile.sh and rust_compile.sh for WSL compatibility - Modify Makefile with local development targets (local-up, local-down, local-deploy, etc.) - Update .gitignore to exclude data/ directory --- .gitignore | 3 +- Makefile | 36 +++++ PLAN-local-floci.md | 264 ++++++++++++++++++++++++++++++++++ build_scripts/compile.sh | 4 +- build_scripts/deploy.sh | 33 +++-- build_scripts/rust_compile.sh | 7 +- docker-compose.yml | 17 +++ smartscore/utility.py | 30 ++-- 8 files changed, 361 insertions(+), 33 deletions(-) create mode 100644 PLAN-local-floci.md create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 727a0bb..1b9f0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ output preview_email.html secret_readme.md .ocx/ -.opencode/ \ No newline at end of file +.opencode/ +data/ \ No newline at end of file diff --git a/Makefile b/Makefile index 468cea0..d631eb9 100644 --- a/Makefile +++ b/Makefile @@ -48,3 +48,39 @@ get_odds: watch_live: @echo "Running live" @uv run python smartscore/scripts/live_updates.py + +# --- Local Floci development --- +local-up: + @docker compose up -d + @echo "Waiting for Floci..." + @sleep 3 + @echo "Floci ready at http://localhost:4566" + +local-down: + @docker compose down + +local-deploy: + @LOCAL_MODE=1 ENV=local ./build_scripts/deploy.sh + +local-status: + @AWS_ENDPOINT_URL=http://localhost:4566 aws cloudformation describe-stacks \ + --stack-name "SmartScore-local" \ + --query "Stacks[0].StackStatus" \ + --output text 2>/dev/null || echo "Stack not deployed" + +local-stepfunctions: + @AWS_ENDPOINT_URL=http://localhost:4566 aws stepfunctions start-execution \ + --state-machine-arn "arn:aws:states:us-east-1:000000000000:stateMachine:$(SM)-local" \ + --input '$(INPUT)' + +local-invoke: + @AWS_ENDPOINT_URL=http://localhost:4566 aws lambda invoke \ + --function-name "$(FUNC)-local" \ + --payload '$(PAYLOAD)' \ + --cli-binary-format raw-in-base64-out \ + response.json + @cat response.json + +local-logs: + @AWS_ENDPOINT_URL=http://localhost:4566 aws logs tail \ + "/aws/lambda/$(FUNC)-local" --follow diff --git a/PLAN-local-floci.md b/PLAN-local-floci.md new file mode 100644 index 0000000..256e680 --- /dev/null +++ b/PLAN-local-floci.md @@ -0,0 +1,264 @@ +# Plan: Local AWS Emulation with Floci + +## Goal + +Locally emulate all AWS services (Lambda, Step Functions, EventBridge, IAM, SSM, STS, CloudWatch Logs) using Floci (free, MIT-licensed, Docker-based emulator). Reuse the same CloudFormation template and ASL definitions for both AWS and local execution. AWS remains a fallback for production. + +## Why Floci + +- **MIT license** - truly free forever +- **83 AWS services** supported including all services SmartScore uses +- **Real Lambda containers** - uses actual AWS runtime images, supports Python 3.12 +- **Full Step Functions** - ASL interpreter with nested `startExecution.sync`, Map, Retry/Catch +- **EventBridge scheduling** - PutRule, PutTargets, cron expressions +- **CloudFormation** - supports IAM, Lambda, Step Functions, Logs, SSM, EventBridge resource types +- **24ms startup, 13MB idle memory** - ideal for long-running server + +## Compatibility Matrix + +| SmartScore AWS Service | Floci Support | Notes | +|---|---|---| +| Lambda (Python 3.12, C/Rust `.so`) | Real Docker containers | Uses `public.ecr.aws/lambda/python3.12` | +| Step Functions (3 state machines) | Full ASL interpreter | Supports `startExecution.sync`, Map, Retry | +| EventBridge (cron rules + dynamic rules) | Full | PutRule, PutTargets, cron scheduling | +| IAM (3 roles) | Full | Users, roles, policies | +| SSM (parameter store) | Full | Parameter Store | +| STS (get caller identity) | Full | Returns account ID `000000000000` | +| CloudWatch Logs (11 log groups) | Full | Log groups, streams | + +## Hard Requirements + +- Docker must be running for Lambda execution (Floci uses real AWS runtime containers) +- Native extensions (C `.so`, Rust `.so`) must be compiled for `x86_64-unknown-linux-gnu` via existing Docker cross-compilation scripts +- CloudFormation template must work as-is with Floci's CloudFormation implementation. If any intrinsic function or resource type is unsupported, we fail and decide how to proceed. +- Supabase connects to existing hosted instance (no local database needed) +- Zip-upload approach for Lambda code (matches AWS deployment pattern) + +## Files to Create/Modify + +### 1. Create `docker-compose.yml` + +```yaml +services: + floci: + image: floci/floci:latest + ports: + - "4566:4566" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/floci:/app/data + environment: + FLOCI_STORAGE_MODE: hybrid + FLOCI_DEFAULT_REGION: us-east-1 + FLOCI_DEFAULT_ACCOUNT_ID: "000000000000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 5s + timeout: 3s + retries: 5 +``` + +### 2. Create `.env.local` + +```bash +# Floci / Local AWS +AWS_ENDPOINT_URL=http://localhost:4566 +AWS_DEFAULT_REGION=us-east-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +AWS_ACCOUNT_ID=000000000000 + +# Environment +ENV=local + +# Supabase (existing hosted instance) +SUPABASE_URL= +SUPABASE_API_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Feature flags +FEATURE_SEND_EMAILS=false +POSTHOG_API_KEY= + +# Brevo (optional) +BREVO_SMTP_LOGIN= +BREVO_SMTP_KEY= +BREVO_FROM_EMAIL=noreply@example.com +``` + +### 3. Modify `smartscore/utility.py` + +Add `endpoint_url` support to all boto3 client factories. When `AWS_ENDPOINT_URL` is set, point boto3 at Floci. + +Current pattern: +```python +_boto3_clients = {} + + +def get_lambda_client(): + if "lambda" not in _boto3_clients: + _boto3_clients["lambda"] = boto3.client("lambda") + return _boto3_clients["lambda"] +``` + +New pattern: +```python +import os + + +def _get_boto3_client(service_name): + cache_key = service_name + if cache_key not in _boto3_clients: + kwargs = {} + endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + if endpoint_url: + kwargs["endpoint_url"] = endpoint_url + _boto3_clients[cache_key] = boto3.client(service_name, **kwargs) + return _boto3_clients[cache_key] + + +def get_lambda_client(): + return _get_boto3_client("lambda") + + +def get_sts_client(): + return _get_boto3_client("sts") + + +def get_events_client(): + return _get_boto3_client("events") + + +def get_ssm_client(): + return _get_boto3_client("ssm") +``` + +The `invoke_lambda()` function already uses `get_lambda_client()` so no changes needed there. + +### 4. Modify `build_scripts/deploy.sh` + +Add `LOCAL_MODE` flag at the top of the script: + +```bash +# --- at the top, after ENV/REGION defaults --- +LOCAL_MODE=${LOCAL_MODE:-0} +AWSCLI="aws" +if [ "$LOCAL_MODE" = "1" ]; then + AWSCLI="aws --endpoint-url ${AWS_ENDPOINT_URL:-http://localhost:4566}" +fi +``` + +Then replace every `aws` call with `$AWSCLI` throughout the script. Key locations: +- Line 58: `if $AWSCLI cloudformation describe-stacks ...` +- Line 60: `$AWSCLI cloudformation update-stack ...` +- Line 78: `$AWSCLI cloudformation wait stack-update-complete ...` +- Line 82: `$AWSCLI cloudformation create-stack ...` +- Line 97: `$AWSCLI cloudformation wait stack-create-complete ...` +- Line 101: `$AWSCLI cloudformation describe-stacks ...` +- Line 111: `$AWSCLI cloudformation describe-stacks ...` +- Line 113: `$AWSCLI ssm put-parameter ...` +- Line 175: `$AWSCLI lambda update-function-code ...` +- Line 233: `$AWSCLI cloudformation describe-stacks ...` +- Line 244: `$AWSCLI stepfunctions describe-state-machine ...` +- Line 247: `$AWSCLI stepfunctions update-state-machine ...` +- Line 253: `$AWSCLI stepfunctions create-state-machine ...` + +### 5. Modify `Makefile` + +Add local development targets after the existing targets: + +```makefile +# --- Local Floci development --- +local-up: + @docker compose up -d + @echo "Waiting for Floci..." + @sleep 3 + @echo "Floci ready at http://localhost:4566" + +local-down: + @docker compose down + +local-deploy: + @LOCAL_MODE=1 ./build_scripts/deploy.sh + +local-status: + @AWS_ENDPOINT_URL=http://localhost:4566 aws cloudformation describe-stacks \ + --stack-name "SmartScore-local" \ + --query "Stacks[0].StackStatus" \ + --output text 2>/dev/null || echo "Stack not deployed" + +local-stepfunctions: + @AWS_ENDPOINT_URL=http://localhost:4566 aws stepfunctions start-execution \ + --state-machine-arn "arn:aws:states:us-east-1:000000000000:stateMachine:$(SM)-local" \ + --input '$(INPUT)' + +local-invoke: + @AWS_ENDPOINT_URL=http://localhost:4566 aws lambda invoke \ + --function-name "$(FUNC)-local" \ + --payload '$(PAYLOAD)' \ + --cli-binary-format raw-in-base64-out \ + response.json + @cat response.json + +local-logs: + @AWS_ENDPOINT_URL=http://localhost:4566 aws logs tail \ + "/aws/lambda/$(FUNC)-local" --follow +``` + +### 6. Modify `.gitignore` + +Add: +``` +data/ +``` + +## What Stays the Same + +- `templates/template.yaml` - unchanged, deployed as-is via CloudFormation +- `templates/player_processing_pipeline.asl.json` - unchanged, `envsubst` patching works the same +- `templates/get_players.asl.json` - unchanged +- `templates/notify_users.asl.json` - unchanged +- Lambda handler code (`smartscore/event_handler.py`, `smartscore/service.py`) - unchanged +- Deploy script logic - only adds `LOCAL_MODE` flag +- ASL linting via `statelint` - unchanged +- Unit tests - unchanged (they mock boto3 already) + +## Deployment Flow + +```bash +# 1. Start Floci +make local-up + +# 2. Deploy infrastructure + code +make local-deploy + +# 3. Check status +make local-status + +# 4. Trigger pipeline +make local-stepfunctions SM=PlayerProcessingPipeline INPUT='{"source":"test"}' + +# 5. Tail logs +make local-logs FUNC=CheckCompleted + +# 6. Stop +make local-down +``` + +## Known Limitations / Risks + +1. **CloudFormation compatibility**: Floci supports the resource types SmartScore uses (IAM::Role, Lambda::Function, StepFunctions::StateMachine, Events::Rule, Logs::LogGroup), but if any intrinsic function or template construct is unsupported, deployment will fail with a clear error. We fix forward from there. + +2. **Native extensions**: C and Rust `.so` files must be cross-compiled for `x86_64-unknown-linux-gnu` (Amazon Linux 2). Use existing `build_scripts/compile.sh` and `build_scripts/rust_compile.sh` which already handle this via Docker. + +3. **Docker required**: Lambda functions execute in real AWS runtime containers. Docker Desktop must be running. + +4. **Supabase**: Connects to hosted instance, not emulated locally. Network access to Supabase is required. + +5. **EventBridge dynamic rules**: The `schedule_run()` function in `utility.py` creates EventBridge rules at runtime. These work in Floci but the scheduler dispatcher fires on a 10-second tick interval, not real-time. + +## Follow-Up (Not in Scope) + +- CI workflow integration (`.github/workflows/deploy.yml` with Floci) +- Hot-reload bind-mount setup for faster iteration +- Integration test harness against Floci diff --git a/build_scripts/compile.sh b/build_scripts/compile.sh index c09860e..28b795a 100755 --- a/build_scripts/compile.sh +++ b/build_scripts/compile.sh @@ -3,8 +3,8 @@ SCRIPT_DIR=$(dirname "$(realpath "$0")") PROJECT_PATH=$(dirname "$SCRIPT_DIR") -# Convert to Windows path only when cygpath is available (Git Bash on Windows). -if command -v cygpath.exe >/dev/null 2>&1; then +# Convert to Windows path only when cygpath is available AND we're in Git Bash (not WSL). +if command -v cygpath.exe >/dev/null 2>&1 && [ -z "$WSL_DISTRO_NAME" ]; then PROJECT_PATH="$(cygpath.exe -C ANSI -w -p "${PROJECT_PATH}")" fi diff --git a/build_scripts/deploy.sh b/build_scripts/deploy.sh index abb39ea..6ee0580 100755 --- a/build_scripts/deploy.sh +++ b/build_scripts/deploy.sh @@ -5,6 +5,13 @@ ENV=${ENV:-dev} # If ENV is not set, default to "dev" AWS_REGION=${AWS_REGION:-us-east-1} +# --- Local mode support (Floci) --- +LOCAL_MODE=${LOCAL_MODE:-0} +AWSCLI="aws" +if [ "$LOCAL_MODE" = "1" ]; then + AWSCLI="aws --endpoint-url ${AWS_ENDPOINT_URL:-http://localhost:4566}" +fi + MAX_ZIP_SIZE_MB=50 SOURCE_DIR="smartscore" @@ -55,9 +62,9 @@ generate_smartscore_stack() { exit 1 fi - if aws cloudformation describe-stacks --stack-name "$STACK_NAME" &>/dev/null; then + if $AWSCLI cloudformation describe-stacks --stack-name "$STACK_NAME" &>/dev/null; then echo "Updating CloudFormation stack $STACK_NAME..." - UPDATE_OUTPUT=$(aws cloudformation update-stack \ + UPDATE_OUTPUT=$($AWSCLI cloudformation update-stack \ --stack-name "$STACK_NAME" \ --template-body file://"$TEMPLATE_FILE" \ --parameters ParameterKey=ENV,ParameterValue="$ENV" \ @@ -75,11 +82,11 @@ generate_smartscore_stack() { echo "No updates needed. Skipping wait." else echo "Waiting for CloudFormation stack update to complete..." - aws cloudformation wait stack-update-complete --stack-name "$STACK_NAME" + $AWSCLI cloudformation wait stack-update-complete --stack-name "$STACK_NAME" fi else echo "Creating CloudFormation stack $STACK_NAME..." - aws cloudformation create-stack \ + $AWSCLI cloudformation create-stack \ --stack-name "$STACK_NAME" \ --template-body file://"$TEMPLATE_FILE" \ --parameters ParameterKey=ENV,ParameterValue="$ENV" \ @@ -94,11 +101,11 @@ generate_smartscore_stack() { --capabilities CAPABILITY_NAMED_IAM echo "Waiting for CloudFormation stack creation to complete..." - aws cloudformation wait stack-create-complete --stack-name "$STACK_NAME" + $AWSCLI cloudformation wait stack-create-complete --stack-name "$STACK_NAME" fi # Check the final status of the stack - STACK_STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query "Stacks[0].StackStatus" --output text) + STACK_STATUS=$($AWSCLI cloudformation describe-stacks --stack-name "$STACK_NAME" --query "Stacks[0].StackStatus" --output text) if [[ "$STACK_STATUS" != "CREATE_COMPLETE" && "$STACK_STATUS" != "UPDATE_COMPLETE" ]]; then echo "CloudFormation stack operation failed with status: $STACK_STATUS." @@ -108,9 +115,9 @@ generate_smartscore_stack() { echo "CloudFormation stack $STACK_NAME completed successfully with status: $STACK_STATUS." # Get the EventBridge Invoke Role ARN from stack outputs and store in SSM - EVENTBRIDGE_ROLE_ARN=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query "Stacks[0].Outputs[?OutputKey=='EventBridgeInvokeRoleArn'].OutputValue" --output text) + EVENTBRIDGE_ROLE_ARN=$($AWSCLI cloudformation describe-stacks --stack-name "$STACK_NAME" --query "Stacks[0].Outputs[?OutputKey=='EventBridgeInvokeRoleArn'].OutputValue" --output text) if [ -n "$EVENTBRIDGE_ROLE_ARN" ] && [ "$EVENTBRIDGE_ROLE_ARN" != "None" ]; then - aws ssm put-parameter --name "/event_bridge_role/arn/$ENV" --value "$EVENTBRIDGE_ROLE_ARN" --type String --overwrite + $AWSCLI ssm put-parameter --name "/event_bridge_role/arn/$ENV" --value "$EVENTBRIDGE_ROLE_ARN" --type String --overwrite echo "Stored EventBridge Invoke Role ARN in SSM parameter /event_bridge_role/arn/$ENV" else echo "Warning: Could not retrieve EventBridgeInvokeRoleArn from stack outputs" @@ -172,7 +179,7 @@ update_lambda_code() { for FUNCTION in "${LAMBDA_FUNCTIONS[@]}"; do ( echo "Updating Lambda function code: $FUNCTION..." - if ERROR_OUTPUT=$(aws lambda update-function-code \ + if ERROR_OUTPUT=$($AWSCLI lambda update-function-code \ --function-name "$FUNCTION" \ --zip-file fileb://$OUTPUT_DIR/$KEY 2>&1); then echo "Lambda function code updated successfully: $FUNCTION." @@ -230,7 +237,7 @@ deploy_state_machine() { envsubst < "$DEFINITION_FILE" > "$PATCHED_FILE" # Get the role ARN from CloudFormation outputs - local ROLE_ARN=$(aws cloudformation describe-stacks \ + local ROLE_ARN=$($AWSCLI cloudformation describe-stacks \ --stack-name "$STACK_NAME" \ --query "Stacks[0].Outputs[?OutputKey=='StepFunctionExecutionRoleArn'].OutputValue" \ --output text) @@ -241,16 +248,16 @@ deploy_state_machine() { fi # Deploy the state machine - if aws stepfunctions describe-state-machine \ + if $AWSCLI stepfunctions describe-state-machine \ --state-machine-arn "$STATE_MACHINE_ARN" &>/dev/null; then echo "Updating Step Function: $STATE_MACHINE_NAME..." - aws stepfunctions update-state-machine \ + $AWSCLI stepfunctions update-state-machine \ --state-machine-arn "$STATE_MACHINE_ARN" \ --definition file://"$PATCHED_FILE" \ --role-arn "$ROLE_ARN" else echo "Creating Step Function: $STATE_MACHINE_NAME..." - aws stepfunctions create-state-machine \ + $AWSCLI stepfunctions create-state-machine \ --name "$STATE_MACHINE_NAME" \ --definition file://"$PATCHED_FILE" \ --role-arn "$ROLE_ARN" \ diff --git a/build_scripts/rust_compile.sh b/build_scripts/rust_compile.sh index 7337b6f..524323e 100644 --- a/build_scripts/rust_compile.sh +++ b/build_scripts/rust_compile.sh @@ -3,8 +3,8 @@ SCRIPT_DIR=$(dirname "$(realpath "$0")") PROJECT_PATH=$(dirname "$SCRIPT_DIR") -# Convert to Windows path only when cygpath is available (Git Bash on Windows). -if command -v cygpath.exe >/dev/null 2>&1; then +# Convert to Windows path only when cygpath is available AND we're in Git Bash (not WSL). +if command -v cygpath.exe >/dev/null 2>&1 && [ -z "$WSL_DISTRO_NAME" ]; then PROJECT_PATH="$(cygpath.exe -C ANSI -w -p "${PROJECT_PATH}")" fi @@ -16,8 +16,7 @@ if ! docker run --rm -v "$PROJECT_PATH:/project" quay.io/pypa/manylinux_2_28_x86 yum install -y rust-toolset # Navigate to the Rust project directory - cd /project - cd /smartscore/Rust/make_predictions + cd /project/smartscore/Rust/make_predictions # Compile the Rust code (assuming it's using Cargo) cargo build --release --target x86_64-unknown-linux-gnu diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fcb49a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + floci: + image: floci/floci:latest + ports: + - "4566:4566" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/floci:/app/data + environment: + FLOCI_STORAGE_MODE: hybrid + FLOCI_DEFAULT_REGION: us-east-1 + FLOCI_DEFAULT_ACCOUNT_ID: "000000000000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 5s + timeout: 3s + retries: 5 diff --git a/smartscore/utility.py b/smartscore/utility.py index 825dd30..df13b1c 100644 --- a/smartscore/utility.py +++ b/smartscore/utility.py @@ -1,4 +1,5 @@ import json +import os import time from datetime import timedelta @@ -17,28 +18,31 @@ _boto3_clients = {} +def _get_boto3_client(service_name): + cache_key = service_name + if cache_key not in _boto3_clients: + kwargs = {} + endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + if endpoint_url: + kwargs["endpoint_url"] = endpoint_url + _boto3_clients[cache_key] = boto3.client(service_name, **kwargs) + return _boto3_clients[cache_key] + + def get_lambda_client(): - if "lambda" not in _boto3_clients: - _boto3_clients["lambda"] = boto3.client("lambda") - return _boto3_clients["lambda"] + return _get_boto3_client("lambda") def get_sts_client(): - if "sts" not in _boto3_clients: - _boto3_clients["sts"] = boto3.client("sts") - return _boto3_clients["sts"] + return _get_boto3_client("sts") def get_events_client(): - if "events" not in _boto3_clients: - _boto3_clients["events"] = boto3.client("events") - return _boto3_clients["events"] + return _get_boto3_client("events") def get_ssm_client(): - if "ssm" not in _boto3_clients: - _boto3_clients["ssm"] = boto3.client("ssm") - return _boto3_clients["ssm"] + return _get_boto3_client("ssm") def invoke_lambda(function_name, payload, wait=True): @@ -111,7 +115,7 @@ def create_cron_schedule(date_string): def delete_expired_rules(): - client = boto3.client("events") + client = get_events_client() response = client.list_rules() for rule in response.get("Rules", []): From 3b9ac403f9b0761180625f8a9d9c3ab61a440ba5 Mon Sep 17 00:00:00 2001 From: Nathan Probert <109328434+nathan-probert@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:06:25 -0400 Subject: [PATCH 2/2] Changes for local aws --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1312090 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf \ No newline at end of file