Skip to content

Commit 91fc5d1

Browse files
committed
Enable AWS S3 image storage.
1 parent 5ba5b16 commit 91fc5d1

File tree

10 files changed

+3308
-139
lines changed

10 files changed

+3308
-139
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const DEFAULT_CONFIG: LooksSameConfig = {
2121
export class LookSameService implements ImageComparator {
2222
private readonly logger: Logger = new Logger(LookSameService.name);
2323

24-
constructor(private staticService: StaticService) {}
24+
constructor(private staticService: StaticService) { }
2525

2626
parseConfig(configJson: string): LooksSameConfig {
2727
return parseConfig(configJson, DEFAULT_CONFIG, this.logger);
@@ -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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const DEFAULT_CONFIG: PixelmatchConfig = { threshold: 0.1, ignoreAntialia
1616
export class PixelmatchService implements ImageComparator {
1717
private readonly logger: Logger = new Logger(PixelmatchService.name);
1818

19-
constructor(private staticService: StaticService) {}
19+
constructor(private staticService: StaticService) { }
2020

2121
parseConfig(configJson: string): PixelmatchConfig {
2222
return parseConfig(configJson, DEFAULT_CONFIG, this.logger);
@@ -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 {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import { StaticService } from './static.service';
4+
5+
@Controller('images')
6+
export class StaticController {
7+
8+
private readonly logger: Logger = new Logger(StaticController.name);
9+
10+
constructor(private staticService: StaticService) { }
11+
12+
@Get('/:fileName')
13+
async downloadPngAndRedirect(@Param('fileName') fileName: string, @Res() res: Response) {
14+
try {
15+
if (!fileName.endsWith('.png')) {
16+
return res.status(400).send('Invalid file type. Only PNG files are allowed.');
17+
}
18+
if (this.staticService.doesFileExist(fileName)) {
19+
res.redirect("/" + fileName);
20+
} else {
21+
const localFileStream = await this.staticService.saveFileToServerFromS3(fileName);
22+
localFileStream.on('finish', () => {
23+
this.staticService.scheduleFileDeletion(fileName);
24+
this.staticService.checkDiskUsageAndClean();
25+
// After saving the file from S3, just redirect to the local file.
26+
res.redirect("/" + fileName);
27+
});
28+
localFileStream.on('error', (error) => {
29+
this.logger.error('Error writing file:', error);
30+
res.status(500).send('Error occurred while saving the file.');
31+
});
32+
}
33+
} catch (error) {
34+
this.logger.error('Error fetching file from S3:' + fileName, error);
35+
res.status(500).send('Error occurred while downloading the file.');
36+
}
37+
}
38+
}

src/shared/static/static.service.ts

Lines changed: 185 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,65 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import path from 'path';
3-
import { writeFileSync, readFileSync, unlink, mkdirSync, existsSync } from 'fs';
3+
import { writeFileSync, readFileSync, unlink, mkdirSync, existsSync, createWriteStream, unlinkSync, statSync, readdir } from 'fs';
44
import { PNG, PNGWithMetadata } from 'pngjs';
55

6+
import {
7+
S3Client,
8+
PutObjectCommand,
9+
DeleteObjectCommand,
10+
GetObjectCommand,
11+
} from "@aws-sdk/client-s3";
12+
import { Readable } from 'stream';
13+
614
export const IMAGE_PATH = 'imageUploads/';
715

816
@Injectable()
917
export class StaticService {
18+
1019
private readonly logger: Logger = new Logger(StaticService.name);
1120

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

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

32-
const { imageName, imagePath } = this.generateNewImage(type);
33-
writeFileSync(imagePath, imageBuffer);
34-
return imageName;
99+
scheduleFileDeletion(fileName: string): void {
100+
const filePath = this.getImagePath(fileName);
101+
const fileSize = statSync(filePath).size;
102+
this.deletionQueue.push({ filePath, size: fileSize });
103+
this.logger.log(`Scheduled deletion for file: ${filePath} with size: ${fileSize} bytes`);
35104
}
36105

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

46199
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);
200+
if (!imageName) return false;
201+
if (this.isAWSDefined()) {
202+
try {
203+
await this.s3Client.send(
204+
new DeleteObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: imageName }),
205+
);
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: 3 additions & 2 deletions
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
})
15-
export class TestRunsModule {}
16+
export class TestRunsModule { }

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class TestRunsService {
2525
private staticService: StaticService,
2626
private compareService: CompareService,
2727
private eventsGateway: EventsGateway
28-
) {}
28+
) { }
2929

3030
async findMany(buildId: string): Promise<TestRunDto[]> {
3131
const list = await this.prismaService.testRun.findMany({
@@ -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)