Skip to content

Commit 73b7b81

Browse files
committed
Enable AWS S3 image storage.
1 parent 5ba5b16 commit 73b7b81

File tree

11 files changed

+3298
-126
lines changed

11 files changed

+3298
-126
lines changed

.env

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ POSTGRES_USER=postgres
1212
POSTGRES_PASSWORD=postgres
1313
POSTGRES_DB=vrt_db_dev
1414

15+
#If you store your images in AWS S3 bucket, then you can give this config and it will take that config. A small amout of data is needed to temporarily keep the downloaded images. You can configure the size of that below.
16+
USE_AWS_S3_BUCKET=false
17+
AWS_ACCESS_KEY_ID=
18+
AWS_SECRET_ACCESS_KEY=
19+
AWS_REGION=
20+
AWS_S3_BUCKET_NAME=
21+
# Below is the maximum allowed usage of local space to optimize/prevent multiple download from S3 bucket. Give the value in MB. E.g. enter 1024 for 1GB
22+
MAX_TEMP_STORAGE_FOR_S3_DOWNLOAD=
23+
1524
# optional
1625
#HTTPS_KEY_PATH='./secrets/ssl.key'
1726
#HTTPS_CERT_PATH='./secrets/ssl.cert'

package-lock.json

Lines changed: 3049 additions & 95 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"node": ">=18.12.0"
2727
},
2828
"dependencies": {
29+
"@aws-sdk/client-s3": "^3.715.0",
2930
"@nestjs/cache-manager": "^2.1.0",
3031
"@nestjs/common": "^10.2.5",
3132
"@nestjs/config": "^3.1.1",

src/compare/libs/looks-same/looks-same.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export class LookSameService implements ImageComparator {
3232
...NO_BASELINE_RESULT,
3333
};
3434

35-
const baseline = this.staticService.getImage(data.baseline);
36-
const image = this.staticService.getImage(data.image);
35+
const baseline = await this.staticService.getImage(data.baseline);
36+
const image = await this.staticService.getImage(data.image);
3737

3838
if (!baseline) {
3939
return NO_BASELINE_RESULT;

src/compare/libs/pixelmatch/pixelmatch.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export class PixelmatchService implements ImageComparator {
2727
...NO_BASELINE_RESULT,
2828
};
2929

30-
const baseline = this.staticService.getImage(data.baseline);
31-
const image = this.staticService.getImage(data.image);
30+
const baseline = await this.staticService.getImage(data.baseline);
31+
const image = await this.staticService.getImage(data.image);
3232

3333
if (!baseline) {
3434
return NO_BASELINE_RESULT;
@@ -68,7 +68,7 @@ export class PixelmatchService implements ImageComparator {
6868
if (result.diffPercent > data.diffTollerancePercent) {
6969
// save diff
7070
if (data.saveDiffAsFile) {
71-
result.diffName = this.staticService.saveImage('diff', PNG.sync.write(diff));
71+
result.diffName = await this.staticService.saveImage('diff', PNG.sync.write(diff));
7272
}
7373
result.status = TestStatus.unresolved;
7474
} else {

src/shared/shared.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { EventsGateway } from '../shared/events/events.gateway';
44
import { PrismaService } from '../prisma/prisma.service';
55
import { TasksService } from './tasks/tasks.service';
66
import { TestVariationsModule } from '../test-variations/test-variations.module';
7+
import { StaticController } from './static/static.controller';
78

89
@Global()
910
@Module({
1011
providers: [StaticService, EventsGateway, PrismaService, TasksService],
1112
exports: [StaticService, EventsGateway, PrismaService],
1213
imports: [forwardRef(() => TestVariationsModule)],
13-
controllers: [],
14+
controllers: [StaticController],
1415
})
1516
export class SharedModule {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import { StaticService } from './static.service';
4+
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
5+
6+
@ApiTags('images')
7+
@Controller('images')
8+
export class StaticController {
9+
private readonly logger: Logger = new Logger(StaticController.name);
10+
11+
constructor(private staticService: StaticService) {}
12+
13+
@Get('/:fileName')
14+
@ApiOkResponse()
15+
async downloadPngAndRedirect(@Param('fileName') fileName: string, @Res() res: Response) {
16+
try {
17+
if (!fileName.endsWith('.png')) {
18+
return res.status(400).send('Invalid file type. Only PNG files are allowed.');
19+
}
20+
if (this.staticService.doesFileExist(fileName)) {
21+
res.redirect('/' + fileName);
22+
} else {
23+
const localFileStream = await this.staticService.saveFileToServerFromS3(fileName);
24+
localFileStream.on('finish', () => {
25+
this.staticService.scheduleFileDeletion(fileName);
26+
this.staticService.checkDiskUsageAndClean();
27+
// After saving the file from S3, just redirect to the local file.
28+
res.redirect('/' + fileName);
29+
});
30+
localFileStream.on('error', (error) => {
31+
this.logger.error('Error writing file:', error);
32+
res.status(500).send('Error occurred while saving the file.');
33+
});
34+
}
35+
} catch (error) {
36+
this.logger.error('Error fetching file from S3:' + fileName, error);
37+
res.status(500).send('Error occurred while downloading the file.');
38+
}
39+
}
40+
}

src/shared/static/static.service.ts

Lines changed: 185 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,69 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import path from 'path';
3-
import { writeFileSync, readFileSync, unlink, mkdirSync, existsSync } from 'fs';
3+
import {
4+
writeFileSync,
5+
readFileSync,
6+
unlink,
7+
mkdirSync,
8+
existsSync,
9+
createWriteStream,
10+
unlinkSync,
11+
statSync,
12+
readdir,
13+
} from 'fs';
414
import { PNG, PNGWithMetadata } from 'pngjs';
515

16+
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
17+
import { Readable } from 'stream';
18+
619
export const IMAGE_PATH = 'imageUploads/';
720

821
@Injectable()
922
export class StaticService {
1023
private readonly logger: Logger = new Logger(StaticService.name);
1124

25+
private readonly USE_AWS_S3_BUCKET = process.env.USE_AWS_S3_BUCKET?.trim().toLowerCase();
26+
private readonly AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
27+
private readonly AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
28+
private readonly AWS_REGION = process.env.AWS_REGION;
29+
private readonly AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;
30+
private readonly MAX_DISK_USAGE: number = parseInt(process.env.MAX_TEMP_STORAGE_FOR_S3_DOWNLOAD) * 1 * 1024 * 1024;
31+
private s3Client: S3Client;
32+
33+
private readonly DELETE_INTERVAL = 60 * 60 * 1000; // Check for deletions every hour
34+
private deletionQueue: { filePath: string; size: number }[] = [];
35+
36+
constructor() {
37+
setInterval(() => this.cleanupQueuedFiles(), this.DELETE_INTERVAL);
38+
this.cleanUpAllFiles();
39+
}
40+
41+
getEnvBoolean(value: string, defaultValue: boolean = false): boolean {
42+
if (value === 'true' || value === '1') return true;
43+
if (value === 'false' || value === '0') return false;
44+
return defaultValue;
45+
}
46+
47+
isAWSDefined(): boolean {
48+
const areAWSVariablesValid =
49+
this.getEnvBoolean(this.USE_AWS_S3_BUCKET) &&
50+
this.AWS_ACCESS_KEY_ID?.trim().length > 1 &&
51+
this.AWS_SECRET_ACCESS_KEY?.trim().length > 1 &&
52+
this.AWS_S3_BUCKET_NAME?.trim().length > 1;
53+
54+
if (areAWSVariablesValid && !this.s3Client) {
55+
this.s3Client = new S3Client({
56+
credentials: {
57+
accessKeyId: this.AWS_ACCESS_KEY_ID,
58+
secretAccessKey: this.AWS_SECRET_ACCESS_KEY,
59+
},
60+
region: this.AWS_REGION,
61+
});
62+
this.logger.log('Using AWS S3 bucket.');
63+
}
64+
return areAWSVariablesValid;
65+
}
66+
1267
generateNewImage(type: 'screenshot' | 'diff' | 'baseline'): { imageName: string; imagePath: string } {
1368
const imageName = `${Date.now()}.${type}.png`;
1469
return {
@@ -22,37 +77,148 @@ export class StaticService {
2277
return path.resolve(IMAGE_PATH, imageName);
2378
}
2479

25-
saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): string {
80+
async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
81+
const { imageName, imagePath } = this.generateNewImage(type);
2682
try {
27-
new PNG().parse(imageBuffer);
83+
if (this.isAWSDefined()) {
84+
await this.s3Client.send(
85+
new PutObjectCommand({
86+
Bucket: this.AWS_S3_BUCKET_NAME,
87+
Key: imageName,
88+
ContentType: 'image/png',
89+
Body: imageBuffer,
90+
})
91+
);
92+
return imageName;
93+
} else {
94+
new PNG().parse(imageBuffer);
95+
writeFileSync(imagePath, imageBuffer);
96+
return imageName;
97+
}
2898
} catch (ex) {
29-
throw new Error('Cannot parse image as PNG file');
99+
throw new Error('Cannot parse image as PNG file: ' + ex);
30100
}
101+
}
31102

32-
const { imageName, imagePath } = this.generateNewImage(type);
33-
writeFileSync(imagePath, imageBuffer);
34-
return imageName;
103+
scheduleFileDeletion(fileName: string): void {
104+
const filePath = this.getImagePath(fileName);
105+
const fileSize = statSync(filePath).size;
106+
this.deletionQueue.push({ filePath, size: fileSize });
107+
this.logger.log(`Scheduled deletion for file: ${filePath} with size: ${fileSize} bytes`);
108+
}
109+
110+
checkDiskUsageAndClean(): void {
111+
const totalDiskUsage = this.getTotalDiskUsage();
112+
if (totalDiskUsage > this.MAX_DISK_USAGE) {
113+
this.logger.log(`Disk usage exceeded. Triggering cleanup.`);
114+
this.cleanupQueuedFiles();
115+
}
116+
}
117+
118+
private getTotalDiskUsage(): number {
119+
return this.deletionQueue.reduce((total, file) => total + file.size, 0);
35120
}
36121

37-
getImage(imageName: string): PNGWithMetadata {
38-
if (!imageName) return;
122+
private cleanUpAllFiles() {
123+
readdir(path.resolve(IMAGE_PATH), (err, files) => {
124+
if (err) throw err;
125+
for (const file of files) {
126+
unlink(path.join(path.resolve(IMAGE_PATH), file), (err) => {
127+
if (err) throw err;
128+
});
129+
}
130+
});
131+
}
132+
133+
private cleanupQueuedFiles(): void {
134+
const totalDiskUsage = this.getTotalDiskUsage();
135+
this.logger.log(`Cleaning up files. Total disk usage: ${totalDiskUsage} bytes`);
136+
137+
// Delete files until the total disk usage is within the limit
138+
let currentUsage = totalDiskUsage;
139+
140+
if (currentUsage > this.MAX_DISK_USAGE) {
141+
// Sort files by the earliest (oldest) first for deletion
142+
this.deletionQueue.sort((a, b) => statSync(a.filePath).birthtimeMs - statSync(b.filePath).birthtimeMs);
143+
144+
// Reduce the size by half
145+
while (currentUsage > this.MAX_DISK_USAGE / 2 && this.deletionQueue.length > 0) {
146+
const fileToDelete = this.deletionQueue.shift();
147+
if (fileToDelete) {
148+
try {
149+
unlinkSync(fileToDelete.filePath);
150+
currentUsage -= fileToDelete.size;
151+
this.logger.log(`Deleted file: ${fileToDelete.filePath}`);
152+
} catch (error) {
153+
this.logger.log(`Failed to delete file: ${fileToDelete.filePath}. Error: ${error.message}`);
154+
}
155+
}
156+
}
157+
}
158+
}
159+
160+
doesFileExist(fileName: string) {
161+
return existsSync(this.getImagePath(fileName));
162+
}
163+
164+
async getImage(fileName: string): Promise<PNGWithMetadata> {
165+
if (!fileName) return null;
39166
try {
40-
return PNG.sync.read(readFileSync(this.getImagePath(imageName)));
167+
if (!this.doesFileExist(fileName) && this.isAWSDefined()) {
168+
const localFileStream = await this.saveFileToServerFromS3(fileName);
169+
await new Promise<void>((resolve, reject) => {
170+
localFileStream.on('finish', () => {
171+
this.scheduleFileDeletion(fileName);
172+
this.checkDiskUsageAndClean();
173+
resolve();
174+
});
175+
localFileStream.on('error', (error) => {
176+
this.logger.error('Error writing file:', error);
177+
reject(error);
178+
});
179+
});
180+
}
181+
return PNG.sync.read(readFileSync(this.getImagePath(fileName)));
41182
} catch (ex) {
42-
this.logger.error(`Cannot get image: ${imageName}. ${ex}`);
183+
this.logger.error(`Error from read : Cannot get image: ${fileName}. ${ex}`);
184+
}
185+
}
186+
187+
async saveFileToServerFromS3(fileName: string) {
188+
if (this.isAWSDefined()) {
189+
const command = new GetObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: fileName });
190+
const s3Response = await this.s3Client.send(command);
191+
const fileStream = s3Response.Body as Readable;
192+
const localFileStream = createWriteStream(this.getImagePath(fileName));
193+
fileStream.pipe(localFileStream);
194+
this.logger.log(`File saved locally at ${this.getImagePath(fileName)}`);
195+
return localFileStream;
196+
} else {
197+
throw Error('Error connecting to AWS');
43198
}
44199
}
45200

46201
async deleteImage(imageName: string): Promise<boolean> {
47-
if (!imageName) return;
48-
return new Promise((resolvePromise) => {
49-
unlink(this.getImagePath(imageName), (err) => {
50-
if (err) {
51-
this.logger.error(err);
52-
}
53-
resolvePromise(true);
202+
if (!imageName) return false;
203+
if (this.isAWSDefined()) {
204+
try {
205+
await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: imageName }));
206+
return true;
207+
} catch (error) {
208+
this.logger.log(`Failed to delete image ${imageName}:`, error);
209+
return false; // Return `false` if an error occurs.
210+
}
211+
} else {
212+
return new Promise((resolvePromise) => {
213+
unlink(this.getImagePath(imageName), (err) => {
214+
if (err) {
215+
this.logger.error(err);
216+
resolvePromise(false);
217+
}
218+
resolvePromise(true);
219+
});
54220
});
55-
});
221+
}
56222
}
57223

58224
private ensureDirectoryExistence(dir: string) {

src/test-runs/test-runs.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { PrismaService } from '../prisma/prisma.service';
55
import { TestRunsController } from './test-runs.controller';
66
import { TestVariationsModule } from '../test-variations/test-variations.module';
77
import { CompareModule } from '../compare/compare.module';
8+
import { StaticController } from 'src/shared/static/static.controller';
89

910
@Module({
1011
imports: [SharedModule, forwardRef(() => TestVariationsModule), CompareModule],
1112
providers: [TestRunsService, PrismaService],
12-
controllers: [TestRunsController],
13+
controllers: [TestRunsController, StaticController],
1314
exports: [TestRunsService],
1415
})
1516
export class TestRunsModule {}

src/test-runs/test-runs.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ export class TestRunsService {
103103
}
104104

105105
// save new baseline
106-
const baseline = this.staticService.getImage(testRun.imageName);
107-
const baselineName = this.staticService.saveImage('baseline', PNG.sync.write(baseline));
106+
const baseline = await this.staticService.getImage(testRun.imageName);
107+
const baselineName = await this.staticService.saveImage('baseline', PNG.sync.write(baseline));
108108

109109
if (testRun.baselineBranchName !== testRun.branchName && !merge && !autoApprove) {
110110
// replace main branch with feature branch test variation
@@ -206,7 +206,7 @@ export class TestRunsService {
206206
imageBuffer: Buffer;
207207
}): Promise<TestRun> {
208208
// save image
209-
const imageName = this.staticService.saveImage('screenshot', imageBuffer);
209+
const imageName = await this.staticService.saveImage('screenshot', imageBuffer);
210210

211211
const testRun = await this.prismaService.testRun.create({
212212
data: {

0 commit comments

Comments
 (0)