Skip to content

Commit

Permalink
Merge pull request #1059 from amvanbaren/s3-storage
Browse files Browse the repository at this point in the history
Add support for AWS S3 external file storage
  • Loading branch information
amvanbaren authored Dec 7, 2024
2 parents 072bf3e + 53e29d4 commit 37a929d
Show file tree
Hide file tree
Showing 14 changed files with 429 additions and 31 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,42 @@ If you also would like to test download count via Azure Blob, follow these steps
* `AZURE_LOGS_SAS_TOKEN` with the shared access token for the `insights-logs-storageread` container.
* If you change the variables in a running workspace, run `scripts/generate-properties.sh` in the `server` directory to update the application properties.

### Amazon S3 Setup

If you would like to test file storage via Amazon S3, follow these steps:

* Login to the AWS Console and create an [S3 storage bucket](https://s3.console.aws.amazon.com/s3/home?refid=ft_card)
* Go to the bucket's `Permissions` tab.
* Disable the `Block all public access` setting.
* Add a `Cross-origin resource sharing (CORS)` configuration:
```json
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"GET",
"HEAD"
],
"AllowedOrigins": [
"*"
],
"ExposeHeaders": []
}
]
```
* Follow the steps for [programmatic access](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) to create your access key id and secret access key
* Configure the following environment variables on your server environment
* `AWS_ACCESS_KEY_ID` with your access key id
* `AWS_SECRET_ACCESS_KEY` with your secret access key
* `AWS_REGION` with your bucket region name
* `AWS_SERVICE_ENDPOINT` with the url of your S3 provider if not using AWS (for AWS do not set)
* `AWS_BUCKET` with your bucket name
* `AWS_PATH_STYLE_ACCESS` whether or not to use path style access, (defaults to `false`)
* Path-style access: `https://s3.<region>.amazonaws.com/<bucket-name>/<resource-key>`
* Virtual-style access: `https://<bucket-name>.s3.<region>.amazonaws.com/<resource-key>`

## License

[Eclipse Public License 2.0](https://www.eclipse.org/legal/epl-2.0/)
2 changes: 2 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def versions = [
springdoc: '2.1.0',
gcloud: '2.36.1',
azure: '12.23.0',
aws: '2.29.29',
junit: '5.9.2',
testcontainers: '1.15.2',
jackson: '2.15.2',
Expand Down Expand Up @@ -92,6 +93,7 @@ dependencies {
implementation "org.flywaydb:flyway-core:${versions.flyway}"
implementation "com.google.cloud:google-cloud-storage:${versions.gcloud}"
implementation "com.azure:azure-storage-blob:${versions.azure}"
implementation "software.amazon.awssdk:s3:${versions.aws}"
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${versions.springdoc}"
implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}"
implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
Expand Down
20 changes: 20 additions & 0 deletions server/scripts/generate-properties.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,23 @@ then
echo "ovsx.logs.azure.sas-token=$AZURE_LOGS_SAS_TOKEN" >> $OVSX_APP_PROFILE
echo "Using Azure Logs Storage: $AZURE_LOGS_SERVICE_ENDPOINT"
fi

# Set the AWS Storage service access key id, secret access key, region and endpoint
if [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ] && [ -n "$AWS_REGION" ] && [ -n "$AWS_BUCKET" ]
then
echo "ovsx.storage.aws.access-key-id=$AWS_ACCESS_KEY_ID" >> $OVSX_APP_PROFILE
echo "ovsx.storage.aws.secret-access-key=$AWS_SECRET_ACCESS_KEY" >> $OVSX_APP_PROFILE
echo "ovsx.storage.aws.region=$AWS_REGION" >> $OVSX_APP_PROFILE
echo "ovsx.storage.aws.bucket=$AWS_BUCKET" >> $OVSX_APP_PROFILE
if [ -n "$AWS_PATH_STYLE_ACCESS" ]
then
echo "ovsx.storage.aws.path-style-access=$AWS_PATH_STYLE_ACCESS" >> $OVSX_APP_PROFILE
fi
if [ -n "$AWS_SERVICE_ENDPOINT" ]
then
echo "ovsx.storage.aws.service-endpoint=$AWS_SERVICE_ENDPOINT" >> $OVSX_APP_PROFILE
echo "Using AWS S3 Storage: $AWS_SERVICE_ENDPOINT"
else
echo "Using AWS S3 Storage."
fi
fi
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class FileResource implements Serializable {
public static final String STORAGE_LOCAL = "local";
public static final String STORAGE_GOOGLE = "google-cloud";
public static final String STORAGE_AZURE = "azure-blob";
public static final String STORAGE_AWS = "aws";

@Id
@GeneratedValue(generator = "fileResourceSeq")
Expand Down Expand Up @@ -105,4 +106,4 @@ public String getStorageType() {
public void setStorageType(String storageType) {
this.storageType = storageType;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/********************************************************************************
* Copyright (c) 2022 Marshall Walker and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/

package org.eclipse.openvsx.storage;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.List;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.openvsx.entities.FileResource;
import org.eclipse.openvsx.entities.Namespace;
import org.eclipse.openvsx.util.TempFile;
import org.eclipse.openvsx.util.UrlUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.endpoints.S3EndpointParams;
import software.amazon.awssdk.services.s3.endpoints.S3EndpointProvider;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

import static org.eclipse.openvsx.cache.CacheService.CACHE_EXTENSION_FILES;
import static org.eclipse.openvsx.cache.CacheService.GENERATOR_FILES;

@Component
public class AwsStorageService implements IStorageService {

private final FileCacheDurationConfig fileCacheDurationConfig;

@Value("${ovsx.storage.aws.access-key-id:}")
String accessKeyId;

@Value("${ovsx.storage.aws.secret-access-key:}")
String secretAccessKey;

@Value("${ovsx.storage.aws.region:}")
String region;

@Value("${ovsx.storage.aws.service-endpoint:}")
String serviceEndpoint;

@Value("${ovsx.storage.aws.bucket:}")
String bucket;

@Value("${ovsx.storage.aws.path-style-access:false}")
boolean pathStyleAccess;

private S3Client s3Client;

public AwsStorageService(FileCacheDurationConfig fileCacheDurationConfig) {
this.fileCacheDurationConfig = fileCacheDurationConfig;
}

protected S3Client getS3Client() {
if (s3Client == null) {
var credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
var s3ClientBuilder = S3Client.builder()
.defaultsMode(DefaultsMode.STANDARD)
.forcePathStyle(pathStyleAccess)
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.of(region));

if(StringUtils.isNotEmpty(serviceEndpoint)) {
var endpointParams = S3EndpointParams.builder()
.endpoint(serviceEndpoint)
.region(Region.of(region))
.build();

var endpoint = S3EndpointProvider
.defaultProvider()
.resolveEndpoint(endpointParams).join();

s3ClientBuilder = s3ClientBuilder.endpointOverride(endpoint.url());
}

s3Client = s3ClientBuilder.build();
}
return s3Client;
}

private S3Presigner getS3Presigner() {
var credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
var builder = S3Presigner.builder()
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.of(region));

if(StringUtils.isNotEmpty(serviceEndpoint)) {
var endpointParams = S3EndpointParams.builder()
.endpoint(serviceEndpoint)
.region(Region.of(region))
.build();

var endpoint = S3EndpointProvider
.defaultProvider()
.resolveEndpoint(endpointParams).join();

builder = builder.endpointOverride(endpoint.url());
}

return builder.build();
}

@Override
public boolean isEnabled() {
return !StringUtils.isEmpty(accessKeyId);
}

@Override
public void uploadFile(TempFile tempFile) {
var resource = tempFile.getResource();
uploadFile(tempFile, resource.getName(), getObjectKey(resource));
}

@Override
public void uploadNamespaceLogo(TempFile logoFile) {
var namespace = logoFile.getNamespace();
uploadFile(logoFile, namespace.getLogoName(), getObjectKey(namespace));
}

protected void uploadFile(TempFile file, String fileName, String objectKey) {
var metadata = new HashMap<String, String>();
metadata.put("Content-Type", StorageUtil.getFileType(fileName).toString());
if (fileName.endsWith(".vsix")) {
metadata.put("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
} else {
metadata.put("Cache-Control", StorageUtil.getCacheControl(fileName).getHeaderValue());
}

var request = PutObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.metadata(metadata)
.build();

getS3Client().putObject(request, file.getPath());
}

@Override
public void removeFile(FileResource resource) {
removeFile(getObjectKey(resource));
}

@Override
public void removeNamespaceLogo(Namespace namespace) {
removeFile(getObjectKey(namespace));
}

private void removeFile(String objectKey) {
var request = DeleteObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build();

getS3Client().deleteObject(request);
}

@Override
public URI getLocation(FileResource resource) {
return getLocation(getObjectKey(resource));
}

@Override
public URI getNamespaceLogoLocation(Namespace namespace) {
return getLocation(getObjectKey(namespace));
}

private URI getLocation(String objectKey) {
var objectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build();

var presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(fileCacheDurationConfig.getCacheDuration())
.getObjectRequest(objectRequest)
.build();

try (var presigner = getS3Presigner()) {
var presignedRequest = presigner.presignGetObject(presignRequest);
return presignedRequest.url().toURI();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}

@Override
public TempFile downloadFile(FileResource resource) throws IOException {
var objectKey = getObjectKey(resource);
var request = GetObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build();

var tempFile = new TempFile("temp_file_", "");
try (var stream = getS3Client().getObject(request)) {
Files.copy(stream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
}
tempFile.setResource(resource);
return tempFile;
}

@Override
public void copyFiles(List<Pair<FileResource, FileResource>> pairs) {
for(var pair : pairs) {
var oldObjectKey = getObjectKey(pair.getFirst());
var newObjectKey = getObjectKey(pair.getSecond());
var request = CopyObjectRequest.builder()
.sourceBucket(bucket)
.sourceKey(oldObjectKey)
.destinationBucket(bucket)
.destinationKey(newObjectKey)
.build();

getS3Client().copyObject(request);
}
}

@Override
@Cacheable(value = CACHE_EXTENSION_FILES, keyGenerator = GENERATOR_FILES)
public Path getCachedFile(FileResource resource) throws IOException {
var objectKey = getObjectKey(resource);
var request = GetObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build();

var path = Files.createTempFile("cached_file", null);
try (var stream = getS3Client().getObject(request)) {
Files.copy(stream, path, StandardCopyOption.REPLACE_EXISTING);
}
return path;
}

protected String getObjectKey(FileResource resource) {
var extVersion = resource.getExtension();
var extension = extVersion.getExtension();
var namespace = extension.getNamespace();
var segments = new String[] {namespace.getName(), extension.getName()};
if (!extVersion.isUniversalTargetPlatform()) {
segments = ArrayUtils.add(segments, extVersion.getTargetPlatform());
}

segments = ArrayUtils.add(segments, extVersion.getVersion());
segments = ArrayUtils.addAll(segments, resource.getName().split("/"));
return UrlUtil.createApiUrl("", segments).substring(1); // remove first '/'
}

protected String getObjectKey(Namespace namespace) {
return UrlUtil.createApiUrl("", namespace.getName(), "logo", namespace.getLogoName()).substring(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private void removeFile(String blobName) {
}
}

@Override
@Override
public URI getLocation(FileResource resource) {
var blobName = getBlobName(resource);
if (StringUtils.isEmpty(serviceEndpoint)) {
Expand Down
Loading

0 comments on commit 37a929d

Please sign in to comment.