Skip to content

Commit 9d02738

Browse files
committed
feat: implement S3 integration for persistent report storage and downloads
1 parent 95a6b4a commit 9d02738

6 files changed

Lines changed: 111 additions & 28 deletions

File tree

.github/workflows/aws-ec2-deploy.yml

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ jobs:
6464
cd infra-pulumi
6565
PUBLIC_IP=$(pulumi stack output publicIp --stack dev)
6666
echo "public_ip=${PUBLIC_IP}" >> $GITHUB_OUTPUT
67-
echo "EC2 Public IP: ${PUBLIC_IP}"
67+
68+
BUCKET=$(pulumi stack output reportsBucket --stack dev)
69+
echo "reports_bucket=${BUCKET}" >> $GITHUB_OUTPUT
6870
6971
echo "private_key<<EOF" >> $GITHUB_OUTPUT
7072
pulumi stack output privateKey --stack dev --show-secrets >> $GITHUB_OUTPUT
@@ -82,65 +84,50 @@ jobs:
8284
uses: appleboy/ssh-action@master
8385
env:
8486
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
85-
REPORTS_BUCKET_NAME: distributed-job-reports
87+
REPORTS_BUCKET_NAME: ${{ steps.ec2.outputs.reports_bucket }}
88+
AWS_REGION: ap-southeast-1
8689
with:
8790
host: ${{ steps.ec2.outputs.public_ip }}
8891
username: ec2-user
8992
key: ${{ steps.ec2.outputs.private_key }}
9093
timeout: 120s
91-
envs: DOCKERHUB_USERNAME,REPORTS_BUCKET_NAME
94+
envs: DOCKERHUB_USERNAME,REPORTS_BUCKET_NAME,AWS_REGION
9295
script: |
9396
set -e
94-
echo "Starting deployment..."
95-
9697
cd ~/distributed-report-queue
97-
98-
echo "Syncing code from GitHub..."
9998
git fetch origin
10099
git reset --hard origin/main
101100
102-
echo "Creating .env file..."
103101
cat > .env << EOF
104102
REDIS_HOST=redis
105103
REDIS_PORT=6379
106104
REDIS_URL=redis://redis:6379
107105
DOCKERHUB_USERNAME=$DOCKERHUB_USERNAME
108106
DOCKER_IMAGE_TAG=latest
109107
NODE_ENV=production
110-
REPORTS_BUCKET=$REPORTS_BUCKET_NAME
111-
AWS_REGION=ap-southeast-1
108+
REPORTS_BUCKET_NAME=$REPORTS_BUCKET_NAME
109+
AWS_REGION=$AWS_REGION
112110
EOF
113111
114-
echo "Verifying .env content (DOCKERHUB_USERNAME should be visible)..."
115-
grep DOCKERHUB_USERNAME .env
116-
117-
# Copy .env to infra for docker-compose interpolation
118112
cp .env infra/.env
119-
120113
cd infra
121-
echo "Stopping existing services..."
122114
docker-compose -f docker-compose.prod.yml down --remove-orphans
123-
124-
echo "Pulling latest images..."
125115
docker-compose -f docker-compose.prod.yml pull
126-
127-
echo "Starting services..."
128116
docker-compose -f docker-compose.prod.yml up -d --scale worker=2
129-
130-
echo "Cleaning up old images..."
131117
docker system prune -af --volumes=false
132-
echo "Deployment script finished successfully."
133118
134119
- name: Verify Deployment
135120
uses: appleboy/ssh-action@master
136121
env:
137122
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
123+
REPORTS_BUCKET_NAME: ${{ steps.ec2.outputs.reports_bucket }}
124+
AWS_REGION: ap-southeast-1
138125
with:
139126
host: ${{ steps.ec2.outputs.public_ip }}
140127
username: ec2-user
141128
key: ${{ steps.ec2.outputs.private_key }}
142129
timeout: 120s
143-
envs: DOCKERHUB_USERNAME
130+
envs: DOCKERHUB_USERNAME,REPORTS_BUCKET_NAME,AWS_REGION
144131
script: |
145132
set -e
146133
cd ~/distributed-report-queue/infra

infra-pulumi/index.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,58 @@ const keyPair = new aws.ec2.KeyPair("worker-key-pair", {
2626
publicKey: sshKey.publicKeyOpenssh,
2727
}, { provider });
2828

29+
// S3 Bucket for reports
30+
const bucket = new aws.s3.BucketV2("reports-bucket", {
31+
bucket: `distributed-job-reports-${githubUsername}`,
32+
forceDestroy: true, // Allow deletion if not empty during cleanup
33+
}, { provider });
34+
35+
// Enable Public Access Block (disable it to allow public read)
36+
const bucketPublicAccessBlock = new aws.s3.BucketPublicAccessBlock("reports-bucket-pab", {
37+
bucket: bucket.id,
38+
blockPublicAcls: false,
39+
blockPublicPolicy: false,
40+
ignorePublicAcls: false,
41+
restrictPublicBuckets: false,
42+
}, { provider });
43+
44+
// IAM Role for EC2
45+
const ec2Role = new aws.iam.Role("worker-ec2-role", {
46+
assumeRolePolicy: JSON.stringify({
47+
Version: "2012-10-17",
48+
Statement: [{
49+
Action: "sts:AssumeRole",
50+
Effect: "Allow",
51+
Principal: { Service: "ec2.amazonaws.com" },
52+
}],
53+
}),
54+
}, { provider });
55+
56+
// IAM Policy for S3 access
57+
const s3Policy = new aws.iam.RolePolicy("worker-s3-policy", {
58+
role: ec2Role.id,
59+
policy: pulumi.all([bucket.arn]).apply(([arn]) => JSON.stringify({
60+
Version: "2012-10-17",
61+
Statement: [
62+
{
63+
Action: ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject"],
64+
Effect: "Allow",
65+
Resource: [`${arn}/*`],
66+
},
67+
{
68+
Action: ["s3:ListBucket"],
69+
Effect: "Allow",
70+
Resource: [arn],
71+
},
72+
],
73+
})),
74+
}, { provider });
75+
76+
// Instance Profile
77+
const instanceProfile = new aws.iam.InstanceProfile("worker-instance-profile", {
78+
role: ec2Role.name,
79+
}, { provider });
80+
2981
// VPC
3082
const vpc = new aws.ec2.Vpc("job-queue-vpc", {
3183
cidrBlock: "10.0.0.0/16",
@@ -161,6 +213,7 @@ const ec2Instance = new aws.ec2.Instance("worker-instance", {
161213
vpcSecurityGroupIds: [sgWorkers.id],
162214
keyName: keyPair.keyName,
163215
userData: userDataScript,
216+
iamInstanceProfile: instanceProfile.name,
164217
associatePublicIpAddress: true,
165218
rootBlockDevice: {
166219
volumeSize: 20,
@@ -174,6 +227,6 @@ const ec2Instance = new aws.ec2.Instance("worker-instance", {
174227
export const publicIp = ec2Instance.publicIp;
175228
export const publicDns = ec2Instance.publicDns;
176229
export const ec2InstanceId = ec2Instance.id;
177-
export const reportsBucket = "distributed-job-reports";
230+
export const reportsBucket = bucket.id;
178231
export const dockerHubUsernameOut = dockerHubUsername;
179232
export const privateKey = sshKey.privateKeyPem;

infra/docker-compose.prod.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ services:
5555
- REDIS_PORT=6379
5656
- REDIS_URL=redis://redis:6379
5757
- NODE_ENV=production
58+
- REPORTS_BUCKET_NAME=${REPORTS_BUCKET_NAME}
59+
- AWS_REGION=${AWS_REGION:-ap-southeast-1}
5860
depends_on: [ redis ]
5961

6062
dashboard-frontend:

worker/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"express": "^5.2.1",
1818
"ioredis": "5.3.2",
1919
"pino": "^8.21.0",
20-
"puppeteer": "^24.37.5"
20+
"puppeteer": "^24.37.5",
21+
"@aws-sdk/client-s3": "^3.540.0"
2122
},
2223
"pnpm": {
2324
"overrides": {

worker/src/lib/s3.service.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
2+
import logger from "../utils/logger";
3+
4+
const region = process.env.AWS_REGION || "ap-southeast-1";
5+
const bucketName = process.env.REPORTS_BUCKET_NAME;
6+
7+
// Explicitly not providing credentials to use IAM Instance Profile
8+
const s3Client = new S3Client({ region });
9+
10+
export const uploadToS3 = async (
11+
buffer: Buffer,
12+
fileName: string,
13+
contentType: string = "application/pdf"
14+
): Promise<string> => {
15+
if (!bucketName) {
16+
throw new Error("REPORTS_BUCKET_NAME environment variable is not set");
17+
}
18+
19+
try {
20+
const command = new PutObjectCommand({
21+
Bucket: bucketName,
22+
Key: fileName,
23+
Body: buffer,
24+
ContentType: contentType,
25+
ACL: "public-read", // Make the report publicly downloadable
26+
});
27+
28+
await s3Client.send(command);
29+
30+
// Construct the public URL
31+
const url = `https://${bucketName}.s3.${region}.amazonaws.com/${fileName}`;
32+
logger.info({ fileName, bucketName, url }, "File uploaded to S3 successfully");
33+
34+
return url;
35+
} catch (error: any) {
36+
logger.error({ error: error.message, fileName }, "S3 Upload failed");
37+
throw error;
38+
}
39+
};

worker/src/processors/report.processor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import fs from "fs";
66
import path from "path";
77
import logger from "../utils/logger";
88
import redisConnection from "../lib/redis";
9+
import { uploadToS3 } from "../lib/s3.service";
910

1011
export interface IReportJobData {
1112
reportType: string;
@@ -104,10 +105,10 @@ export async function processReportJob(
104105
await browser.close();
105106
browser = null;
106107

107-
// 4. Save Locally
108+
// 4. Upload to S3
108109
await job.updateProgress(80);
109110
const fileName = `${reportType}-${jobId}-${Date.now()}.pdf`;
110-
const reportUrl = await saveToLocalDisk(pdfBuffer, fileName);
111+
const reportUrl = await uploadToS3(pdfBuffer, fileName);
111112

112113
// 5. Store Result in Redis (Metadata)
113114
await job.updateProgress(100);

0 commit comments

Comments
 (0)