Skip to content

Commit cde0dc3

Browse files
committed
Rewrite app as AWS SAM app
1 parent 7b5dade commit cde0dc3

11 files changed

Lines changed: 786 additions & 25 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
.env
2+
lambda-app
3+
webapp-binpackaged-template.yaml
4+
.aws-sam
5+
packaged-template.yaml
6+
bootstrap

Makefile

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
.PHONY: build clean deploy package test load-env check-bucket deploy-info redis-up redis-down test-local-full
2+
3+
# Configuration
4+
STACK_NAME := wine-pairing-suggestions-lambda
5+
DEFAULT_S3_BUCKET := wine-pairing-suggestions-sam-deployments
6+
AWS_REGION := us-west-2
7+
LAMBDA_BIN := bootstrap
8+
WEBAPP_BIN := webapp-bin
9+
10+
# Load environment variables from .env file if it exists
11+
load-env:
12+
@if [ -f .env ]; then \
13+
echo "Loading environment variables from .env file..."; \
14+
export $$(grep -v '^#' .env | grep -v '^$$' | xargs); \
15+
fi
16+
17+
# Build the Lambda function
18+
build:
19+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o $(LAMBDA_BIN) ./cmd/lambda
20+
21+
build-WinePairingFunction:
22+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o $(LAMBDA_BIN) ./cmd/lambda
23+
24+
# Build for local testing
25+
build-local:
26+
go build -o $(WEBAPP_BIN) ./cmd/webapp
27+
28+
# Clean build artifacts
29+
clean:
30+
rm -f $(LAMBDA_BIN) $(WEBAPP_BIN) packaged-template.yaml
31+
32+
# Clean everything including Docker
33+
clean-all: clean
34+
@echo "🧹 Cleaning Docker containers and volumes..."
35+
docker-compose down -v
36+
@echo "✅ All cleaned up"
37+
38+
# Check if S3 bucket exists, create if it doesn't
39+
check-bucket:
40+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
41+
BUCKET=$${S3_BUCKET:-$(DEFAULT_S3_BUCKET)}; \
42+
echo "📦 Checking deployment bucket: $$BUCKET"; \
43+
44+
@if aws s3 ls "s3://$$BUCKET" >/dev/null 2>&1; then \
45+
echo "✅ S3 bucket $$BUCKET exists."; \
46+
else \
47+
echo "📦 Creating S3 bucket: $$BUCKET"; \
48+
aws s3 mb "s3://$$BUCKET" --region "$(AWS_REGION)"; \
49+
fi
50+
51+
sam-build:
52+
echo "📦 Building application..."; \
53+
sam build
54+
55+
# Package for SAM deployment
56+
package: check-bucket sam-build
57+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
58+
BUCKET=$${S3_BUCKET:-$(DEFAULT_S3_BUCKET)}; \
59+
echo "📦 Packaging application..."; \
60+
sam package --template-file template.yaml --s3-bucket "$$BUCKET" --output-template-file packaged-template.yaml --region "$(AWS_REGION)"
61+
62+
# Deploy using SAM with parameter retrieval
63+
deploy: package
64+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
65+
\
66+
echo "🔍 Checking if stack $(STACK_NAME) exists and retrieving parameters..."; \
67+
if aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" >/dev/null 2>&1; then \
68+
echo "Stack exists, retrieving current parameters..."; \
69+
EXISTING_GOOGLE_CLIENT_ID=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`GoogleClientID`].ParameterValue' --output text 2>/dev/null || echo ""); \
70+
EXISTING_HOSTNAME=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`Hostname`].ParameterValue' --output text 2>/dev/null || echo ""); \
71+
EXISTING_VALKEY_ENDPOINT=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`ValkeyEndpoint`].ParameterValue' --output text 2>/dev/null || echo ""); \
72+
EXISTING_VPC_ID=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`VpcId`].ParameterValue' --output text 2>/dev/null || echo ""); \
73+
EXISTING_SECURITY_GROUP_ID=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`SecurityGroupId`].ParameterValue' --output text 2>/dev/null || echo ""); \
74+
EXISTING_SUBNET_IDS=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Parameters[?ParameterKey==`SubnetIds`].ParameterValue' --output text 2>/dev/null || echo ""); \
75+
fi; \
76+
\
77+
FINAL_GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID_OVERRIDE:-$${GOOGLE_CLIENT_ID:-$$EXISTING_GOOGLE_CLIENT_ID}}; \
78+
FINAL_HOSTNAME=$${HOSTNAME_OVERRIDE:-$${HOSTNAME:-$$EXISTING_HOSTNAME}}; \
79+
FINAL_VALKEY_ENDPOINT=$${VALKEY_ENDPOINT_OVERRIDE:-$${VALKEY_ENDPOINT:-$$EXISTING_VALKEY_ENDPOINT}}; \
80+
FINAL_VPC_ID=$${VPC_ID_OVERRIDE:-$${VPC_ID:-$$EXISTING_VPC_ID}}; \
81+
FINAL_SECURITY_GROUP_ID=$${SECURITY_GROUP_ID_OVERRIDE:-$${SECURITY_GROUP_ID:-$$EXISTING_SECURITY_GROUP_ID}}; \
82+
FINAL_SUBNET_IDS=$${SUBNET_IDS_OVERRIDE:-$${SUBNET_IDS:-$$EXISTING_SUBNET_IDS}}; \
83+
\
84+
if [ -z "$$FINAL_GOOGLE_CLIENT_ID" ] || [ -z "$$FINAL_HOSTNAME" ] || [ -z "$$FINAL_VALKEY_ENDPOINT" ] || [ -z "$$FINAL_VPC_ID" ] || [ -z "$$FINAL_SECURITY_GROUP_ID" ] || [ -z "$$FINAL_SUBNET_IDS" ]; then \
85+
echo "❌ Missing required parameters. Please set in .env file or as environment variables:"; \
86+
echo " - GOOGLE_CLIENT_ID"; \
87+
echo " - HOSTNAME"; \
88+
echo " - VALKEY_ENDPOINT"; \
89+
echo " - VPC_ID"; \
90+
echo " - SECURITY_GROUP_ID"; \
91+
echo " - SUBNET_IDS"; \
92+
exit 1; \
93+
fi; \
94+
\
95+
ANTHROPIC_API_KEY=$$(aws secretsmanager get-secret-value --secret-id prod/Anthropic/WineSuggestions --region $(AWS_REGION) --query SecretString --output text | jq -r .ANTHROPIC_WINESUGGESTIONS); \
96+
\
97+
echo "🚀 Deploying $(STACK_NAME) in bucket $(DEFAULT_S3_BUCKET) to AWS..."; \
98+
sam deploy \
99+
--config-file samconfig.toml \
100+
--template-file packaged-template.yaml \
101+
--no-resolve-s3 \
102+
--s3-bucket "$(DEFAULT_S3_BUCKET)" \
103+
--stack-name "$(STACK_NAME)" \
104+
--capabilities CAPABILITY_IAM \
105+
--region "$(AWS_REGION)" \
106+
--parameter-overrides \
107+
AnthropicApiKey="$$ANTHROPIC_API_KEY" \
108+
GoogleClientID="$$FINAL_GOOGLE_CLIENT_ID" \
109+
Hostname="$$FINAL_HOSTNAME" \
110+
ValkeyEndpoint="$$FINAL_VALKEY_ENDPOINT" \
111+
VpcId="$$FINAL_VPC_ID" \
112+
SecurityGroupId="$$FINAL_SECURITY_GROUP_ID" \
113+
SubnetIds="$$FINAL_SUBNET_IDS"
114+
\
115+
$(MAKE) deploy-info
116+
117+
# Get deployment info after successful deployment
118+
deploy-info:
119+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
120+
\
121+
echo ""; \
122+
echo "✅ Deployment successful!"; \
123+
echo ""; \
124+
API_URL=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Outputs[?OutputKey==`WinePairingAPI`].OutputValue' --output text 2>/dev/null || echo "N/A"); \
125+
CUSTOM_DOMAIN_TARGET=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Outputs[?OutputKey==`CustomDomainEndpoint`].OutputValue' --output text 2>/dev/null || echo "N/A"); \
126+
LAMBDA_ARN=$$(aws cloudformation describe-stacks --stack-name "$(STACK_NAME)" --region "$(AWS_REGION)" --query 'Stacks[0].Outputs[?OutputKey==`LambdaFunction`].OutputValue' --output text 2>/dev/null || echo "N/A"); \
127+
\
128+
echo "📡 API Gateway URL: $$API_URL"; \
129+
echo "🌐 Custom Domain Target: $$CUSTOM_DOMAIN_TARGET"; \
130+
echo "⚡ Lambda Function ARN: $$LAMBDA_ARN"; \
131+
echo ""; \
132+
echo "💡 Next steps:"; \
133+
echo " 1. In your DNS provider, create or update a CNAME record for your custom domain to point to the 'Custom Domain Target': $$CUSTOM_DOMAIN_TARGET"; \
134+
echo " 2. Ensure your OAuth redirect URIs use your custom domain: https://$$(HOSTNAME)"; \
135+
echo " 3. Test the custom domain: curl https://$$(HOSTNAME)/healthz"; \
136+
echo " 4. Monitor logs: aws logs tail /aws/lambda/$$(aws cloudformation describe-stack-resource --stack-name $(STACK_NAME) --logical-resource-id WinePairingFunction --query 'StackResourceDetail.PhysicalResourceId' --output text) --follow";
137+
138+
# Deploy using SAM with guided prompts
139+
deploy-guided:
140+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
141+
ANTHROPIC_API_KEY=$$(aws secretsmanager get-secret-value --secret-id prod/Anthropic/WineSuggestions --region $(AWS_REGION) --query SecretString --output text | jq -r .ANTHROPIC_WINESUGGESTIONS); \
142+
sam deploy \
143+
--stack-name $(STACK_NAME) \
144+
--s3-bucket $(DEFAULT_S3_BUCKET) \
145+
--no-resolve-s3 \
146+
--guided
147+
148+
# Start local Redis via docker-compose
149+
redis-up:
150+
@echo "🚀 Starting local Redis container..."
151+
docker-compose up -d cache
152+
@echo "✅ Redis is running at localhost:6379"
153+
154+
# Stop local Redis
155+
redis-down:
156+
@echo "🛑 Stopping local Redis container..."
157+
docker-compose down cache
158+
@echo "✅ Redis stopped"
159+
160+
# Test locally with SAM (using in-memory cache)
161+
test-local: sam-build
162+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
163+
echo "🚀 Starting SAM local API (connects to localhost:6379)..."; \
164+
VALKEY_ENDPOINT=localhost:6379 sam local start-api
165+
166+
# Test locally with SAM connected to Docker Redis network
167+
test-local-docker: build sam-build
168+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
169+
echo "🚀 Starting SAM local API connected to Docker network..."; \
170+
HOSTNAME=http://localhost:3000 \
171+
VALKEY_ENDPOINT=cache:6379 \
172+
sam local start-api --docker-network wine-pairing-suggestions_wine-net -t template.yaml
173+
174+
# Full local development setup (Redis + SAM)
175+
test-local-full: redis-up
176+
@echo "⏳ Waiting for Redis to be ready..."
177+
@sleep 2
178+
@$(MAKE) test-local-docker
179+
180+
# Run unit tests
181+
test:
182+
go test ./...
183+
184+
# Run linting
185+
lint:
186+
golangci-lint run
187+
188+
# Build and run the traditional web server locally (with host Redis)
189+
run-local: build-local
190+
@if [ -f .env ]; then export $$(grep -v '^#' .env | grep -v '^$$' | xargs); fi; \
191+
VALKEY_ENDPOINT=localhost:6379 ./webapp
192+
193+
# Run the full docker-compose stack
194+
run-docker:
195+
@echo "🚀 Starting full Docker Compose stack..."
196+
docker-compose up
197+
198+
# Run the full docker-compose stack in background
199+
run-docker-bg:
200+
@echo "🚀 Starting full Docker Compose stack in background..."
201+
docker-compose up -d
202+
203+
# Environment variable examples
204+
example-env:
205+
@echo "Example environment variables for deployment (.env file or environment):"
206+
@echo "S3_BUCKET=your-sam-deployment-bucket"
207+
@echo "GOOGLE_CLIENT_ID=your-google-client-id"
208+
@echo "HOSTNAME=your-custom-domain.com"
209+
@echo "VALKEY_ENDPOINT=your-valkey-endpoint:6379"
210+
@echo "VPC_ID=vpc-xxxxxxxx"
211+
@echo "SECURITY_GROUP_ID=sg-xxxxxxxx"
212+
@echo "SUBNET_IDS=subnet-xxx,subnet-yyy,subnet-zzz"

cmd/lambda/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package main
2+
3+
import (
4+
"log"
5+
6+
"github.com/aws/aws-lambda-go/lambda"
7+
lambdaHandler "github.com/thedahv/wine-pairing-suggestions/lambda"
8+
)
9+
10+
func main() {
11+
handler, err := lambdaHandler.NewHandler()
12+
if err != nil {
13+
log.Fatalf("Failed to initialize Lambda handler: %v", err)
14+
}
15+
16+
lambda.Start(handler.HandleRequest)
17+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.4
44

55
require (
66
github.com/JohannesKaufmann/html-to-markdown v1.6.0
7+
github.com/aws/aws-lambda-go v1.49.0
78
github.com/aws/aws-sdk-go-v2 v1.36.6
89
github.com/aws/aws-sdk-go-v2/config v1.27.12
910
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.1

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
44
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
55
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
66
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
7+
github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI=
8+
github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A=
9+
github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYLqIQ=
10+
github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A=
711
github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU=
812
github.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
913
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=

helpers/helpers.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"log"
910
"math/big"
1011
"net/http"
1112
"net/url"
13+
"time"
1214

1315
md "github.com/JohannesKaufmann/html-to-markdown"
1416
"github.com/golang-jwt/jwt/v5"
@@ -17,16 +19,16 @@ import (
1719
const googleCertsURL = "https://www.googleapis.com/oauth2/v3/certs"
1820

1921
// FetchRawFromURL fetches raw HTML encoding recipe content from the given URL.
20-
func FetchRawFromURL(url string) (io.ReadCloser, error) {
21-
httpClient := &http.Client{}
22-
req, err := http.NewRequest("GET", url, nil)
22+
func FetchRawFromURL(u string) (io.ReadCloser, error) {
23+
httpClient := &http.Client{Timeout: 15 * time.Second}
24+
req, err := http.NewRequest("GET", u, nil)
2325
if err != nil {
2426
return nil, fmt.Errorf("failed to create request: %w", err)
2527
}
26-
2728
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RecipeFetcher/1.0)")
2829
resp, err := httpClient.Do(req)
2930
if err != nil {
31+
fmt.Printf("error fetching raw for url (%s): %v\n", u, err)
3032
return nil, fmt.Errorf("failed to fetch URL: %w", err)
3133
}
3234

@@ -71,22 +73,28 @@ type Claims struct {
7173
}
7274

7375
func GetGoogleJWTToken(algorithm string) (*rsa.PublicKey, error) {
76+
l := log.New(log.Default().Writer(), "GetGoogleJWTToken", log.Default().Flags())
77+
l.Println("GetGoogleJWTToken")
7478
key := &rsa.PublicKey{}
7579

7680
c := http.Client{}
7781
resp, err := c.Get(googleCertsURL)
7882
if err != nil {
83+
log.Printf("Google fetch failed: %v\n", err)
7984
return key, fmt.Errorf("unable to fetch from Google: %v", err)
8085
}
8186
defer resp.Body.Close()
8287

8388
contents, err := io.ReadAll(resp.Body)
89+
8490
if err != nil {
91+
l.Printf("JWT read failed: %v\n", err)
8592
return key, fmt.Errorf("unable to read response body: %v", err)
8693
}
8794

8895
var response googleKeysResponse
8996
if err := json.Unmarshal(contents, &response); err != nil {
97+
l.Printf("failed parse: %v\n", err)
9098
return key, fmt.Errorf("unable to parse response JSON: %v", err)
9199
}
92100

@@ -107,6 +115,7 @@ func GetGoogleJWTToken(algorithm string) (*rsa.PublicKey, error) {
107115
}
108116
}
109117

118+
l.Println("didn't find algorithm match")
110119
return key, fmt.Errorf("algorithm '%s' was not in certificates response", algorithm)
111120
}
112121

0 commit comments

Comments
 (0)