From ad9a4fb1baf60832d1cc2574531f1f3e29634e42 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Fri, 4 Sep 2026 11:55:20 +0300 Subject: [PATCH] feat: let the S3 client address a bucket path-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3-compatible storage — MinIO, Ceph, Garage — is normally reached path-style (host/bucket/key). AWS uses virtual-host style (bucket.host/key) and the SDK assumes that, with no environment variable for the switch, so a deployment pointing VRT at its own storage got requests for a hostname that does not resolve. The endpoint itself the SDK already reads from AWS_ENDPOINT_URL; only the addressing style was unreachable. AWS_S3_FORCE_PATH_STYLE=true turns it on. Unset, the SDK keeps deciding for itself, so nothing changes for an AWS deployment. --- src/static/aws/s3.service.spec.ts | 33 ++++++++++++++++++++++++++++++- src/static/aws/s3.service.ts | 14 +++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/static/aws/s3.service.spec.ts b/src/static/aws/s3.service.spec.ts index af53cf65..d7bdc4df 100644 --- a/src/static/aws/s3.service.spec.ts +++ b/src/static/aws/s3.service.spec.ts @@ -8,8 +8,9 @@ import { generateNewImageName } from '../utils'; const mockSend = jest.fn(); jest.mock('@aws-sdk/client-s3', () => ({ - S3Client: jest.fn().mockImplementation(() => ({ + S3Client: jest.fn().mockImplementation((config) => ({ send: mockSend, + config, })), PutObjectCommand: jest.fn().mockImplementation((input) => ({ input, type: 'put' })), GetObjectCommand: jest.fn().mockImplementation((input) => ({ input, type: 'get' })), @@ -232,4 +233,34 @@ describe('AWSS3Service', () => { expect(loggerSpy).toHaveBeenCalledWith('Failed to delete file at AWS S3 for image image.png:', error); }); }); + + // S3-compatible storage — MinIO, Ceph, Garage — is normally reached + // path-style (host/bucket/key); AWS itself uses virtual-host style + // (bucket.host/key) and the SDK assumes that. Without a way to switch, a + // self-hosted deployment cannot point VRT at its own storage, and neither + // can anyone trying to reproduce a production setup locally. + describe('S3-compatible storage', () => { + const originalPathStyle = process.env.AWS_S3_FORCE_PATH_STYLE; + + afterEach(() => { + process.env.AWS_S3_FORCE_PATH_STYLE = originalPathStyle; + if (originalPathStyle === undefined) delete process.env.AWS_S3_FORCE_PATH_STYLE; + }); + + it('addresses the bucket path-style when asked to', () => { + process.env.AWS_S3_FORCE_PATH_STYLE = 'true'; + + const service = new AWSS3Service(); + + expect((service as any).s3Client.config).toMatchObject({ forcePathStyle: true }); + }); + + it('leaves the SDK to its own addressing by default', () => { + delete process.env.AWS_S3_FORCE_PATH_STYLE; + + const service = new AWSS3Service(); + + expect((service as any).s3Client.config?.forcePathStyle).toBeFalsy(); + }); + }); }); diff --git a/src/static/aws/s3.service.ts b/src/static/aws/s3.service.ts index db6fed5a..f622ce92 100644 --- a/src/static/aws/s3.service.ts +++ b/src/static/aws/s3.service.ts @@ -23,8 +23,18 @@ export class AWSS3Service implements Static { private s3Client: S3Client; constructor() { - this.s3Client = new S3Client(); - this.logger.log('AWS S3 service is being used for file storage.'); + // S3-compatible storage — MinIO, Ceph, Garage — is normally addressed + // path-style (host/bucket/key), while AWS uses virtual-host style + // (bucket.host/key) and the SDK assumes that. It has no environment + // variable for the switch, so without this a self-hosted deployment cannot + // point VRT at its own storage, and neither can anyone standing up a + // production-shaped stack locally. The endpoint itself the SDK does read + // from AWS_ENDPOINT_URL. + const forcePathStyle = process.env.AWS_S3_FORCE_PATH_STYLE === 'true'; + this.s3Client = new S3Client(forcePathStyle ? { forcePathStyle } : {}); + this.logger.log( + `AWS S3 service is being used for file storage${forcePathStyle ? ' (path-style addressing)' : ''}.` + ); } async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise {