-
Notifications
You must be signed in to change notification settings - Fork 7
feat: Add support for S3 #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
bcf864a to
1b65234
Compare
nytamin
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!
I added a few comments regarding docs and style that would be good to address before merging.
shared/packages/api/src/inputApi.ts
Outdated
| /** S3 endpoint (obligatory for non-AWS S3 deployments) */ | ||
| endpoint?: string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add docs explaining what does undefined imply
shared/packages/api/src/inputApi.ts
Outdated
| /** If true, forces path-style URLs (required for some S3-compatible storage solutions) */ | ||
| forcePathStyle?: boolean |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you know any solutions that require this? if so, maybe name them in the doc?
| constructor( | ||
| private readonly bucketId: string, | ||
| private readonly region: string, | ||
| private readonly accessKey: string, | ||
| private readonly secretAccessKey: string, | ||
| private readonly endpoint?: string, | ||
| private readonly forcePathStyle: boolean = false | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor note: To me, passing the S3Options object would be cleaner
| constructor( | |
| private readonly bucketId: string, | |
| private readonly region: string, | |
| private readonly accessKey: string, | |
| private readonly secretAccessKey: string, | |
| private readonly endpoint?: string, | |
| private readonly forcePathStyle: boolean = false | |
| ) { | |
| constructor( | |
| private readonly options: S3Options | |
| ) { |
| user: 'The requested file does not exist in the S3 storage bucket', | ||
| } | ||
| : { | ||
| tech: `S3 Error: ${reason} err: ${err?.message || JSON.stringify(err)}`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use the function stringifyerror(error) to stringify errors (it ensures that stack traces etc are included)
(this also applies to other places in the code)
| ? fullPath.replace(/^\//, '').endsWith('/') | ||
| ? fullPath.replace(/^\//, '') | ||
| : `${fullPath.replace(/^\//, '')}/` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
readability: can fullPath.replace(/^\//, '') be DRY'ed up here?
| async putPackageStream(sourceStream: NodeJS.ReadableStream): Promise<PutPackageHandler> { | ||
| const s3 = await this.getS3Client() | ||
|
|
||
| const fullPath = this.workOptions.useTemporaryFilePath ? this.temporaryFilePath : this.fullPath |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just realized that useTemporaryFilePath might not be needed for S3 storage.
It is used when writing to files on disk to ensure atomic operations, but since S3 operations are atomic by definition, I think we can omit any uses of useTemporaryFilePath completely.
| private async getS3Client(): Promise<S3BucketClient> { | ||
| let client = this.getS3ClientFromCache( | ||
| this.s3Options.region, | ||
| this.s3Options.accessKey, | ||
| this.s3Options.secretAccessKey, | ||
| this.s3Options.s3PublicBaseUrl, | ||
| this.s3Options.endpoint, | ||
| this.s3Options.forcePathStyle | ||
| ) | ||
|
|
||
| if (!client) { | ||
| client = this.createNewS3Client() | ||
|
|
||
| this.cacheS3Client( | ||
| client, | ||
| this.s3Options.region, | ||
| this.s3Options.accessKey, | ||
| this.s3Options.secretAccessKey, | ||
| this.s3Options.s3PublicBaseUrl, | ||
| this.s3Options.endpoint, | ||
| this.s3Options.forcePathStyle | ||
| ) | ||
| } | ||
|
|
||
| return client | ||
| } | ||
|
|
||
| private getS3ClientFromCache( | ||
| region: string, | ||
| accessKey: string, | ||
| secretAccessKey: string, | ||
| publicBaseUrl: string, | ||
| endpoint?: string, | ||
| forcePathStyle?: boolean | ||
| ): S3BucketClient | undefined { | ||
| const accessorCache = this.worker.accessorCache as { | ||
| [accessorType: string]: S3BucketClient | undefined | ||
| } | ||
|
|
||
| return accessorCache[ | ||
| S3AccessorHandle.getCacheKeyForS3Client( | ||
| accessKey, | ||
| secretAccessKey, | ||
| region, | ||
| publicBaseUrl, | ||
| endpoint, | ||
| forcePathStyle | ||
| ) | ||
| ] | ||
| } | ||
|
|
||
| private cacheS3Client( | ||
| client: S3BucketClient, | ||
| region: string, | ||
| accessKey: string, | ||
| secretAccessKey: string, | ||
| publicBaseUrl: string, | ||
| endpoint?: string, | ||
| forcePathStyle?: boolean | ||
| ): void { | ||
| const accessorCache = this.worker.accessorCache as { | ||
| [accessorType: string]: S3BucketClient | undefined | ||
| } | ||
|
|
||
| accessorCache[ | ||
| S3AccessorHandle.getCacheKeyForS3Client( | ||
| accessKey, | ||
| secretAccessKey, | ||
| region, | ||
| publicBaseUrl, | ||
| endpoint, | ||
| forcePathStyle | ||
| ) | ||
| ] = client | ||
| } | ||
|
|
||
| static getCacheKeyForS3Client( | ||
| accessKey: string, | ||
| secretAccessKey: string, | ||
| region: string, | ||
| publicBaseUrl: string, | ||
| endpoint?: string, | ||
| forcePathStyle?: boolean | ||
| ): string { | ||
| return JSON.stringify([accessKey, secretAccessKey, region, publicBaseUrl, endpoint, forcePathStyle]) | ||
| } | ||
|
|
||
| private createNewS3Client(): S3BucketClient { | ||
| return new S3BucketClient( | ||
| this.s3Options.bucketId, | ||
| this.s3Options.region, | ||
| this.s3Options.accessKey, | ||
| this.s3Options.secretAccessKey, | ||
| this.s3Options.endpoint, | ||
| this.s3Options.forcePathStyle | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be simplified using worker.cacheData().
| private async getS3Client(): Promise<S3BucketClient> { | |
| let client = this.getS3ClientFromCache( | |
| this.s3Options.region, | |
| this.s3Options.accessKey, | |
| this.s3Options.secretAccessKey, | |
| this.s3Options.s3PublicBaseUrl, | |
| this.s3Options.endpoint, | |
| this.s3Options.forcePathStyle | |
| ) | |
| if (!client) { | |
| client = this.createNewS3Client() | |
| this.cacheS3Client( | |
| client, | |
| this.s3Options.region, | |
| this.s3Options.accessKey, | |
| this.s3Options.secretAccessKey, | |
| this.s3Options.s3PublicBaseUrl, | |
| this.s3Options.endpoint, | |
| this.s3Options.forcePathStyle | |
| ) | |
| } | |
| return client | |
| } | |
| private getS3ClientFromCache( | |
| region: string, | |
| accessKey: string, | |
| secretAccessKey: string, | |
| publicBaseUrl: string, | |
| endpoint?: string, | |
| forcePathStyle?: boolean | |
| ): S3BucketClient | undefined { | |
| const accessorCache = this.worker.accessorCache as { | |
| [accessorType: string]: S3BucketClient | undefined | |
| } | |
| return accessorCache[ | |
| S3AccessorHandle.getCacheKeyForS3Client( | |
| accessKey, | |
| secretAccessKey, | |
| region, | |
| publicBaseUrl, | |
| endpoint, | |
| forcePathStyle | |
| ) | |
| ] | |
| } | |
| private cacheS3Client( | |
| client: S3BucketClient, | |
| region: string, | |
| accessKey: string, | |
| secretAccessKey: string, | |
| publicBaseUrl: string, | |
| endpoint?: string, | |
| forcePathStyle?: boolean | |
| ): void { | |
| const accessorCache = this.worker.accessorCache as { | |
| [accessorType: string]: S3BucketClient | undefined | |
| } | |
| accessorCache[ | |
| S3AccessorHandle.getCacheKeyForS3Client( | |
| accessKey, | |
| secretAccessKey, | |
| region, | |
| publicBaseUrl, | |
| endpoint, | |
| forcePathStyle | |
| ) | |
| ] = client | |
| } | |
| static getCacheKeyForS3Client( | |
| accessKey: string, | |
| secretAccessKey: string, | |
| region: string, | |
| publicBaseUrl: string, | |
| endpoint?: string, | |
| forcePathStyle?: boolean | |
| ): string { | |
| return JSON.stringify([accessKey, secretAccessKey, region, publicBaseUrl, endpoint, forcePathStyle]) | |
| } | |
| private createNewS3Client(): S3BucketClient { | |
| return new S3BucketClient( | |
| this.s3Options.bucketId, | |
| this.s3Options.region, | |
| this.s3Options.accessKey, | |
| this.s3Options.secretAccessKey, | |
| this.s3Options.endpoint, | |
| this.s3Options.forcePathStyle | |
| ) | |
| } | |
| private async getS3Client(): Promise<S3BucketClient> { | |
| const cacheKey = JSON.stringify(this.s3Options) | |
| return this.worker.cacheData( | |
| this.type, | |
| `s3Client${cacheKey}`, | |
| () => { | |
| return this.createNewS3Client() | |
| }, | |
| 10 * 60 * 1000 | |
| ) // 10 min cache time | |
| } | |
| private createNewS3Client(): S3BucketClient { | |
| return new S3BucketClient(this.s3Options) | |
| } |
|
|
As a side comment, I was hoping that it would be possible to do this without propagating the credentials through to each worker (to minimise the spread and risk of leaking them). At least any keys which can write to the bucket, instead having the workforce or something provide presigned urls for uploading. Is there an opensource s3 server we should recommend for use during development? (either as a docker image, or cross platform binary) |
|
@Julusian after investigation - it is of course possible to pass it through presigned URLS and it's feasible for this use case, but architecturally it seems to be a big intervention in terms of data flow direction |
hmm Im hesistant to put minio as a recommendation as it is a little dead these days https://github.com/minio/minio#maintenance-mode. That said for my personal usage I havent figured out what to move to, so havent migrated away. |


About Me
superfly.tv
Type of Contribution
This is a:
Feature
Current Behavior
no S3 support
New Behavior
Added AWS S3 support
Testing Instructions
Add working S3 key credentials to expectedPackages.json, then everything
Other Information
Status