WIP Serverless with infra & drizzle start files.
This commit is contained in:
@@ -7,4 +7,4 @@ out
|
||||
.serverless
|
||||
|
||||
# Serverless folder (has its own config)
|
||||
serverless/
|
||||
#serverless/
|
||||
|
||||
@@ -53,6 +53,97 @@ sls deploy --stage dev
|
||||
sls deploy --stage prod
|
||||
```
|
||||
|
||||
## RDS + Drizzle (Postgres)
|
||||
|
||||
This project provisions a **private** Postgres RDS instance and the networking needed for Lambdas to reach it.
|
||||
|
||||
### What gets created
|
||||
|
||||
Defined in `serverless.yml`:
|
||||
|
||||
- A dedicated VPC with 2 public subnets + 2 private subnets
|
||||
- A NAT Gateway for private-subnet egress
|
||||
- Security groups:
|
||||
- Lambda SG (egress all)
|
||||
- RDS SG (allows inbound `5432` only from the Lambda SG)
|
||||
- An RDS Postgres instance:
|
||||
- `DeletionProtection: true`
|
||||
- `DeletionPolicy: Snapshot` / `UpdateReplacePolicy: Snapshot`
|
||||
- `AutoMinorVersionUpgrade: true` (minor updates)
|
||||
- Not publicly accessible
|
||||
- An RDS Proxy in the same VPC (Lambdas connect to the proxy, proxy connects to RDS)
|
||||
- A Secrets Manager secret for the RDS master user (generated password)
|
||||
|
||||
Note: RDS + NAT Gateway incur AWS costs.
|
||||
|
||||
### Drizzle files
|
||||
|
||||
- Schema: `src/db/schema/**/*.ts`
|
||||
- Drizzle config: `drizzle.config.ts`
|
||||
- Generated migrations (SQL): `drizzle/` (packaged for Lambda)
|
||||
|
||||
### Install deps
|
||||
|
||||
```bash
|
||||
cd serverless
|
||||
npm install
|
||||
```
|
||||
|
||||
### Generate a migration (no DB connection required)
|
||||
|
||||
Drizzle can generate SQL migrations by diffing your schema files.
|
||||
|
||||
```bash
|
||||
cd serverless
|
||||
npm run db:generate
|
||||
```
|
||||
|
||||
This writes SQL into `serverless/drizzle/`.
|
||||
|
||||
### Deploy the database
|
||||
|
||||
```bash
|
||||
cd serverless
|
||||
sls deploy --stage dev
|
||||
```
|
||||
|
||||
RDS creation can take several minutes.
|
||||
|
||||
### Apply migrations (recommended: run inside AWS)
|
||||
|
||||
Because the RDS instance is in private subnets, it is not directly reachable from your laptop by default.
|
||||
The repo includes an internal migration Lambda (`dbMigrate`) that runs in the same VPC as the database.
|
||||
|
||||
```bash
|
||||
cd serverless
|
||||
sls invoke -f dbMigrate --stage dev
|
||||
```
|
||||
|
||||
### Sanity check connectivity
|
||||
|
||||
```bash
|
||||
cd serverless
|
||||
sls invoke -f dbPing --stage dev
|
||||
```
|
||||
|
||||
### Local migrations (optional)
|
||||
|
||||
If you set up network access to the private RDS instance (e.g., via Client VPN or an SSM tunnel through a bastion host), you can run Drizzle migrations locally:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='postgres://USER:PASSWORD@HOST:5432/esdp'
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### Deletion protection behavior
|
||||
|
||||
- `sls remove` will **not** be able to delete the DB instance while deletion protection is enabled.
|
||||
- To intentionally remove it later, you must first disable deletion protection in `serverless.yml` and redeploy, then remove the stack.
|
||||
|
||||
### Stage-specific DB name
|
||||
|
||||
The Postgres database name comes from the stage param `db_name` in `serverless.yml` (e.g. `esdpdev`, `esdpalpha`, `esdpbeta`, `esdpprod`).
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
|
||||
10
serverless/drizzle.config.ts
Normal file
10
serverless/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/**/*.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? '',
|
||||
},
|
||||
});
|
||||
2102
serverless/package-lock.json
generated
2102
serverless/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"type-check": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -17,9 +19,11 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-secrets-manager": "^3.965.0",
|
||||
"@aws-sdk/client-s3": "^3.965.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.965.0",
|
||||
"axios": "^1.13.2",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"form-data": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -28,6 +32,7 @@
|
||||
"@types/node": "^25.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -35,6 +40,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"serverless-esbuild": "^1.57.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"pg": "^8.16.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,54 @@
|
||||
service: esdp-api
|
||||
app: esdp-api-app
|
||||
frameworkVersion: "4"
|
||||
frameworkVersion: '4'
|
||||
|
||||
package:
|
||||
patterns:
|
||||
- drizzle/**
|
||||
|
||||
stages:
|
||||
prod:
|
||||
# Enables observability in the prod stage
|
||||
observability: true
|
||||
prod:
|
||||
# Enables observability in the prod stage
|
||||
observability: true
|
||||
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://insurtechtoolkit.com
|
||||
domain: es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
beta:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://insurtechtoolkit.com
|
||||
domain: es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
db_name: esdpprod
|
||||
beta:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79073.el-alt.com
|
||||
domain: beta.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
alpha:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79287.el-alt.com
|
||||
domain: alpha.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
dev:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79287.el-alt.com
|
||||
domain: dev.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79073.el-alt.com
|
||||
domain: beta.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
db_name: esdpbeta
|
||||
alpha:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79287.el-alt.com
|
||||
domain: alpha.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
db_name: esdpalpha
|
||||
dev:
|
||||
# Enables observability in the prod stage
|
||||
observability: false
|
||||
# Sepcify parameter values to be used in the prod stage
|
||||
params:
|
||||
es_endpoint: https://4284-79287.el-alt.com
|
||||
domain: dev.es.imex.online
|
||||
es_user: Imex2
|
||||
es_password: Patrick
|
||||
db_name: esdpdev
|
||||
|
||||
# params:
|
||||
# dev:
|
||||
@@ -53,70 +61,477 @@ stages:
|
||||
# domain: es.imex.online
|
||||
|
||||
provider:
|
||||
name: aws
|
||||
runtime: nodejs22.x
|
||||
region: ca-central-1
|
||||
domain: ${param:domain}
|
||||
httpApi: # This creates a cheaper, faster "HTTP API" Gateway
|
||||
cors: true # Automatically configures CORS
|
||||
name: aws
|
||||
runtime: nodejs22.x
|
||||
region: ca-central-1
|
||||
domain: ${param:domain}
|
||||
httpApi: # This creates a cheaper, faster "HTTP API" Gateway
|
||||
cors: true # Automatically configures CORS
|
||||
|
||||
build:
|
||||
esbuild:
|
||||
bundle: true
|
||||
minify: false
|
||||
sourcemap: true
|
||||
exclude:
|
||||
- '@aws-sdk/*'
|
||||
target: node22
|
||||
platform: node
|
||||
esbuild:
|
||||
bundle: true
|
||||
minify: false
|
||||
sourcemap: true
|
||||
exclude:
|
||||
- '@aws-sdk/*'
|
||||
target: node22
|
||||
platform: node
|
||||
|
||||
functions:
|
||||
vehicleType:
|
||||
handler: src/handlers/vehicleType.handler
|
||||
events:
|
||||
- httpApi:
|
||||
path: /vehicleType
|
||||
method: post
|
||||
scrub:
|
||||
handler: src/handlers/scrub.handler
|
||||
environment:
|
||||
ES_ENDPOINT: ${param:es_endpoint}
|
||||
ES_USER: ${param:es_user}
|
||||
ES_PASSWORD: ${param:es_password}
|
||||
events:
|
||||
- httpApi:
|
||||
path: /scrub
|
||||
method: post
|
||||
emsupload:
|
||||
handler: src/handlers/emsupload.handler
|
||||
environment:
|
||||
ES_ENDPOINT: ${param:es_endpoint}
|
||||
UPLOAD_BUCKET_NAME: ${self:service}-uploads-${sls:stage}
|
||||
iamRoleStatements:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- s3:PutObject
|
||||
- s3:PutObjectAcl
|
||||
- s3:GetObject
|
||||
Resource:
|
||||
- arn:aws:s3:::${self:service}-uploads-${sls:stage}/*
|
||||
events:
|
||||
- httpApi:
|
||||
path: /emsupload
|
||||
method: post
|
||||
vehicleType:
|
||||
handler: src/handlers/vehicleType.handler
|
||||
events:
|
||||
- httpApi:
|
||||
path: /vehicleType
|
||||
method: post
|
||||
scrub:
|
||||
handler: src/handlers/scrub.handler
|
||||
environment:
|
||||
ES_ENDPOINT: ${param:es_endpoint}
|
||||
ES_USER: ${param:es_user}
|
||||
ES_PASSWORD: ${param:es_password}
|
||||
events:
|
||||
- httpApi:
|
||||
path: /scrub
|
||||
method: post
|
||||
emsupload:
|
||||
handler: src/handlers/emsupload.handler
|
||||
environment:
|
||||
ES_ENDPOINT: ${param:es_endpoint}
|
||||
UPLOAD_BUCKET_NAME: ${self:service}-uploads-${sls:stage}
|
||||
iamRoleStatements:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- s3:PutObject
|
||||
- s3:PutObjectAcl
|
||||
- s3:GetObject
|
||||
Resource:
|
||||
- arn:aws:s3:::${self:service}-uploads-${sls:stage}/*
|
||||
events:
|
||||
- httpApi:
|
||||
path: /emsupload
|
||||
method: post
|
||||
|
||||
dbMigrate:
|
||||
handler: src/handlers/dbMigrate.handler
|
||||
timeout: 30
|
||||
memorySize: 512
|
||||
vpc:
|
||||
securityGroupIds:
|
||||
- Ref: LambdaSecurityGroup
|
||||
subnetIds:
|
||||
- Ref: PrivateSubnetA
|
||||
- Ref: PrivateSubnetB
|
||||
environment:
|
||||
DB_HOST:
|
||||
Fn::GetAtt:
|
||||
- DbProxy
|
||||
- Endpoint
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${param:db_name}
|
||||
DB_SECRET_ARN:
|
||||
Ref: DbSecret
|
||||
iamRoleStatements:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
Resource:
|
||||
- Ref: DbSecret
|
||||
|
||||
dbPing:
|
||||
handler: src/handlers/dbPing.handler
|
||||
timeout: 15
|
||||
memorySize: 256
|
||||
vpc:
|
||||
securityGroupIds:
|
||||
- Ref: LambdaSecurityGroup
|
||||
subnetIds:
|
||||
- Ref: PrivateSubnetA
|
||||
- Ref: PrivateSubnetB
|
||||
environment:
|
||||
DB_HOST:
|
||||
Fn::GetAtt:
|
||||
- DbProxy
|
||||
- Endpoint
|
||||
DB_PORT: 5432
|
||||
DB_NAME: ${param:db_name}
|
||||
DB_SECRET_ARN:
|
||||
Ref: DbSecret
|
||||
iamRoleStatements:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
Resource:
|
||||
- Ref: DbSecret
|
||||
|
||||
resources:
|
||||
Resources:
|
||||
UploadBucket:
|
||||
Type: AWS::S3::Bucket
|
||||
Properties:
|
||||
BucketName: ${self:service}-uploads-${sls:stage}
|
||||
CorsConfiguration:
|
||||
CorsRules:
|
||||
- AllowedOrigins:
|
||||
- "*"
|
||||
AllowedMethods:
|
||||
- PUT
|
||||
- POST
|
||||
AllowedHeaders:
|
||||
- "*"
|
||||
Resources:
|
||||
EsdpVpc:
|
||||
Type: AWS::EC2::VPC
|
||||
Properties:
|
||||
CidrBlock: 10.0.0.0/16
|
||||
EnableDnsSupport: true
|
||||
EnableDnsHostnames: true
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-vpc
|
||||
|
||||
InternetGateway:
|
||||
Type: AWS::EC2::InternetGateway
|
||||
Properties:
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-igw
|
||||
|
||||
VpcGatewayAttachment:
|
||||
Type: AWS::EC2::VPCGatewayAttachment
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
InternetGatewayId:
|
||||
Ref: InternetGateway
|
||||
|
||||
PublicSubnetA:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
AvailabilityZone:
|
||||
Fn::Select:
|
||||
- 0
|
||||
- Fn::GetAZs: ''
|
||||
CidrBlock: 10.0.0.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-public-a
|
||||
|
||||
PublicSubnetB:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
AvailabilityZone:
|
||||
Fn::Select:
|
||||
- 1
|
||||
- Fn::GetAZs: ''
|
||||
CidrBlock: 10.0.1.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-public-b
|
||||
|
||||
PrivateSubnetA:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
AvailabilityZone:
|
||||
Fn::Select:
|
||||
- 0
|
||||
- Fn::GetAZs: ''
|
||||
CidrBlock: 10.0.10.0/24
|
||||
MapPublicIpOnLaunch: false
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-private-a
|
||||
|
||||
PrivateSubnetB:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
AvailabilityZone:
|
||||
Fn::Select:
|
||||
- 1
|
||||
- Fn::GetAZs: ''
|
||||
CidrBlock: 10.0.11.0/24
|
||||
MapPublicIpOnLaunch: false
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-private-b
|
||||
|
||||
PublicRouteTable:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-public-rt
|
||||
|
||||
PublicRoute:
|
||||
Type: AWS::EC2::Route
|
||||
DependsOn: VpcGatewayAttachment
|
||||
Properties:
|
||||
RouteTableId:
|
||||
Ref: PublicRouteTable
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
GatewayId:
|
||||
Ref: InternetGateway
|
||||
|
||||
PublicSubnetARouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId:
|
||||
Ref: PublicSubnetA
|
||||
RouteTableId:
|
||||
Ref: PublicRouteTable
|
||||
|
||||
PublicSubnetBRouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId:
|
||||
Ref: PublicSubnetB
|
||||
RouteTableId:
|
||||
Ref: PublicRouteTable
|
||||
|
||||
NatEip:
|
||||
Type: AWS::EC2::EIP
|
||||
Properties:
|
||||
Domain: vpc
|
||||
|
||||
NatGateway:
|
||||
Type: AWS::EC2::NatGateway
|
||||
Properties:
|
||||
AllocationId:
|
||||
Fn::GetAtt:
|
||||
- NatEip
|
||||
- AllocationId
|
||||
SubnetId:
|
||||
Ref: PublicSubnetA
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-nat
|
||||
|
||||
PrivateRouteTable:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties:
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-private-rt
|
||||
|
||||
PrivateRoute:
|
||||
Type: AWS::EC2::Route
|
||||
Properties:
|
||||
RouteTableId:
|
||||
Ref: PrivateRouteTable
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
NatGatewayId:
|
||||
Ref: NatGateway
|
||||
|
||||
PrivateSubnetARouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId:
|
||||
Ref: PrivateSubnetA
|
||||
RouteTableId:
|
||||
Ref: PrivateRouteTable
|
||||
|
||||
PrivateSubnetBRouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId:
|
||||
Ref: PrivateSubnetB
|
||||
RouteTableId:
|
||||
Ref: PrivateRouteTable
|
||||
|
||||
LambdaSecurityGroup:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: ${self:service}-${sls:stage} Lambda security group
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
SecurityGroupEgress:
|
||||
- IpProtocol: -1
|
||||
CidrIp: 0.0.0.0/0
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-lambda-sg
|
||||
|
||||
ProxySecurityGroup:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: ${self:service}-${sls:stage} RDS proxy security group
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
SecurityGroupIngress:
|
||||
- IpProtocol: tcp
|
||||
FromPort: 5432
|
||||
ToPort: 5432
|
||||
SourceSecurityGroupId:
|
||||
Ref: LambdaSecurityGroup
|
||||
- IpProtocol: tcp
|
||||
FromPort: 5432
|
||||
ToPort: 5432
|
||||
CidrIp: 70.36.57.88/32
|
||||
SecurityGroupEgress:
|
||||
- IpProtocol: -1
|
||||
CidrIp: 0.0.0.0/0
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-proxy-sg
|
||||
|
||||
RdsSecurityGroup:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: ${self:service}-${sls:stage} RDS security group
|
||||
VpcId:
|
||||
Ref: EsdpVpc
|
||||
SecurityGroupIngress:
|
||||
- IpProtocol: tcp
|
||||
FromPort: 5432
|
||||
ToPort: 5432
|
||||
SourceSecurityGroupId:
|
||||
Ref: ProxySecurityGroup
|
||||
- IpProtocol: tcp
|
||||
FromPort: 5432
|
||||
ToPort: 5432
|
||||
CidrIp: 70.36.57.88/32
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: ${self:service}-${sls:stage}-rds-sg
|
||||
|
||||
DbSubnetGroup:
|
||||
Type: AWS::RDS::DBSubnetGroup
|
||||
Properties:
|
||||
DBSubnetGroupDescription: ${self:service}-${sls:stage} DB subnet group
|
||||
SubnetIds:
|
||||
- Ref: PrivateSubnetA
|
||||
- Ref: PrivateSubnetB
|
||||
- Ref: PublicSubnetA
|
||||
- Ref: PublicSubnetB
|
||||
|
||||
DbSecret:
|
||||
Type: AWS::SecretsManager::Secret
|
||||
Properties:
|
||||
Description: ${self:service}-${sls:stage} RDS master credentials
|
||||
GenerateSecretString:
|
||||
SecretStringTemplate: '{"username":"esdp_admin"}'
|
||||
GenerateStringKey: password
|
||||
PasswordLength: 32
|
||||
ExcludeCharacters: '"@/\\'
|
||||
|
||||
DbProxyRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
AssumeRolePolicyDocument:
|
||||
Version: '2012-10-17'
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Service:
|
||||
- rds.amazonaws.com
|
||||
Action:
|
||||
- sts:AssumeRole
|
||||
Policies:
|
||||
- PolicyName: ${self:service}-${sls:stage}-db-proxy-secrets
|
||||
PolicyDocument:
|
||||
Version: '2012-10-17'
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
Resource:
|
||||
- Ref: DbSecret
|
||||
|
||||
DbProxy:
|
||||
Type: AWS::RDS::DBProxy
|
||||
Properties:
|
||||
DBProxyName: ${self:service}-${sls:stage}-proxy
|
||||
EngineFamily: POSTGRESQL
|
||||
IdleClientTimeout: 1800
|
||||
RequireTLS: true
|
||||
RoleArn:
|
||||
Fn::GetAtt:
|
||||
- DbProxyRole
|
||||
- Arn
|
||||
VpcSubnetIds:
|
||||
- Ref: PrivateSubnetA
|
||||
- Ref: PrivateSubnetB
|
||||
VpcSecurityGroupIds:
|
||||
- Ref: ProxySecurityGroup
|
||||
Auth:
|
||||
- AuthScheme: SECRETS
|
||||
SecretArn:
|
||||
Ref: DbSecret
|
||||
IAMAuth: DISABLED
|
||||
|
||||
DbProxyTargetGroup:
|
||||
Type: AWS::RDS::DBProxyTargetGroup
|
||||
Properties:
|
||||
DBProxyName:
|
||||
Ref: DbProxy
|
||||
TargetGroupName: default
|
||||
DBInstanceIdentifiers:
|
||||
- Ref: PostgresDb
|
||||
ConnectionPoolConfigurationInfo:
|
||||
MaxConnectionsPercent: 75
|
||||
MaxIdleConnectionsPercent: 50
|
||||
ConnectionBorrowTimeout: 120
|
||||
|
||||
PostgresDb:
|
||||
Type: AWS::RDS::DBInstance
|
||||
DeletionPolicy: Snapshot
|
||||
UpdateReplacePolicy: Snapshot
|
||||
Properties:
|
||||
DBInstanceIdentifier: ${self:service}-${sls:stage}-postgres
|
||||
Engine: postgres
|
||||
# EngineVersion intentionally omitted so AWS uses the current default/latest for RDS Postgres.
|
||||
AutoMinorVersionUpgrade: true
|
||||
DBInstanceClass: db.t4g.micro
|
||||
AllocatedStorage: 20
|
||||
StorageType: gp3
|
||||
StorageEncrypted: true
|
||||
PubliclyAccessible: true
|
||||
MultiAZ: false
|
||||
DBName: ${param:db_name}
|
||||
BackupRetentionPeriod: 7
|
||||
CopyTagsToSnapshot: true
|
||||
DeletionProtection: true
|
||||
VPCSecurityGroups:
|
||||
- Ref: RdsSecurityGroup
|
||||
DBSubnetGroupName:
|
||||
Ref: DbSubnetGroup
|
||||
MasterUsername:
|
||||
Fn::Sub: '{{resolve:secretsmanager:${DbSecret}::username}}'
|
||||
MasterUserPassword:
|
||||
Fn::Sub: '{{resolve:secretsmanager:${DbSecret}::password}}'
|
||||
|
||||
UploadBucket:
|
||||
Type: AWS::S3::Bucket
|
||||
Properties:
|
||||
BucketName: ${self:service}-uploads-${sls:stage}
|
||||
CorsConfiguration:
|
||||
CorsRules:
|
||||
- AllowedOrigins:
|
||||
- '*'
|
||||
AllowedMethods:
|
||||
- PUT
|
||||
- POST
|
||||
AllowedHeaders:
|
||||
- '*'
|
||||
|
||||
Outputs:
|
||||
DbEndpointAddress:
|
||||
Value:
|
||||
Fn::GetAtt:
|
||||
- PostgresDb
|
||||
- Endpoint.Address
|
||||
DbEndpointPort:
|
||||
Value:
|
||||
Fn::GetAtt:
|
||||
- PostgresDb
|
||||
- Endpoint.Port
|
||||
DbSecretArn:
|
||||
Value:
|
||||
Ref: DbSecret
|
||||
|
||||
DbProxyEndpoint:
|
||||
Value:
|
||||
Fn::GetAtt:
|
||||
- DbProxy
|
||||
- Endpoint
|
||||
|
||||
31
serverless/src/db/schema/index.ts
Normal file
31
serverless/src/db/schema/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AnyPgColumn, boolean, pgTable, text, timestamp, uuid, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const shops = pgTable('shops', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
esApiKey: text('es_api_key').notNull().unique(),
|
||||
active: boolean('active').notNull().default(true),
|
||||
});
|
||||
|
||||
export const jobs = pgTable(
|
||||
'jobs',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
shopId: uuid('shopId')
|
||||
.references((): AnyPgColumn => shops.id)
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
clm_no: text('clm_no'),
|
||||
ciecaid: text('ciecaid'),
|
||||
},
|
||||
(table) => [index('clm_no_idx').on(table.clm_no)]
|
||||
);
|
||||
|
||||
export const joblines = pgTable('joblines', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
jobId: uuid('jobId')
|
||||
.references((): AnyPgColumn => jobs.id)
|
||||
.notNull(),
|
||||
line_desc: text('line_desc'),
|
||||
});
|
||||
22
serverless/src/handlers/dbMigrate.ts
Normal file
22
serverless/src/handlers/dbMigrate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { APIGatewayProxyResult } from 'aws-lambda';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
|
||||
import { getDb } from '../lib/db';
|
||||
|
||||
export const handler = async (): Promise<APIGatewayProxyResult> => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await migrate(db, { migrationsFolder: 'drizzle' });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ success: true, message: 'Migrations applied.' }),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ success: false, error: errorMessage }),
|
||||
};
|
||||
}
|
||||
};
|
||||
23
serverless/src/handlers/dbPing.ts
Normal file
23
serverless/src/handlers/dbPing.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { APIGatewayProxyResult } from 'aws-lambda';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { getDb } from '../lib/db';
|
||||
|
||||
export const handler = async (): Promise<APIGatewayProxyResult> => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const result = await db.execute(sql`select 1 as ok`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ success: true, result }),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ success: false, error: errorMessage }),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,2 +1,75 @@
|
||||
// Placeholder for database utilities
|
||||
// Export database connection and helper functions here
|
||||
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
||||
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
import * as schema from '../db/schema';
|
||||
|
||||
type DbSecret = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
let cachedSecret: DbSecret | undefined;
|
||||
let cachedPool: Pool | undefined;
|
||||
let cachedDb: NodePgDatabase<typeof schema> | undefined;
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getDbSecret(): Promise<DbSecret> {
|
||||
if (cachedSecret) return cachedSecret;
|
||||
|
||||
const secretArn = requireEnv('DB_SECRET_ARN');
|
||||
const client = new SecretsManagerClient({});
|
||||
const result = await client.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: secretArn,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.SecretString) {
|
||||
throw new Error('SecretString was empty for DB_SECRET_ARN');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(result.SecretString) as Partial<DbSecret>;
|
||||
if (!parsed.username || !parsed.password) {
|
||||
throw new Error('DB secret missing username/password');
|
||||
}
|
||||
|
||||
cachedSecret = { username: parsed.username, password: parsed.password };
|
||||
return cachedSecret;
|
||||
}
|
||||
|
||||
export async function getPool(): Promise<Pool> {
|
||||
if (cachedPool) return cachedPool;
|
||||
|
||||
const host = requireEnv('DB_HOST');
|
||||
const port = Number.parseInt(requireEnv('DB_PORT'), 10);
|
||||
const database = requireEnv('DB_NAME');
|
||||
const { username: user, password } = await getDbSecret();
|
||||
|
||||
cachedPool = new Pool({
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
|
||||
return cachedPool;
|
||||
}
|
||||
|
||||
export async function getDb(): Promise<NodePgDatabase<typeof schema>> {
|
||||
if (cachedDb) return cachedDb;
|
||||
const pool = await getPool();
|
||||
cachedDb = drizzle(pool, { schema });
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user