// Copyright 2016 Twitter. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.twitter.heron.uploader.s3;
import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.amazonaws.ClientConfiguration;
import com.amazonaws.Protocol;
import com.amazonaws.SdkClientException;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.auth.profile.ProfileCredentialsProvider;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.google.common.base.Strings;
import com.twitter.heron.spi.common.Config;
import com.twitter.heron.spi.common.Context;
import com.twitter.heron.spi.uploader.IUploader;
import com.twitter.heron.spi.uploader.UploaderException;
/**
* Provides a basic uploader class for uploading topology packages to s3.
* <p>
* By default this uploader will write topology packages to s3://<bucket>/<topologyName>/topology.tar.gz
* trying to obtain credentials using the default credential provider chain. The package destination serves as known
* location which can be used to download the topology package in order to run the topology.
* <p>
* This class also handles the undo action by copying any existing topology.tar.gz package found in the folder to
* previous_topology.tar.gz. In the event that the deploy fails and the undo action is triggered the previous_topology.tar.gz
* file will be renamed to topology.tar.gz effectively rolling back the live code. In the event that the deploy is successful
* the previous_topology.tar.gz package will be deleted as it is no longer needed.
* <p>
* The config values for this uploader are:
* heron.class.uploader (required) com.twitter.heron.uploader.s3.S3Uploader
* heron.uploader.s3.bucket (required) The bucket that you have write access to where you want the topology packages to be stored
* heron.uploader.s3.path_prefix (optional) Optional prefix for the path to the topology packages
* heron.uploader.s3.access_key (optional) S3 access key that can be used to write to the bucket provided
* heron.uploader.s3.secret_key (optional) S3 access secret that can be used to write to the bucket provided
* heron.uploader.s3.aws_profile (optional) AWS profile to use
*/
public class S3Uploader implements IUploader {
private static final Logger LOG = Logger.getLogger(S3Uploader.class.getName());
private String bucket;
protected AmazonS3 s3Client;
private String remoteFilePath;
// The path prefix will be prepended to the path inside the provided bucket.
// For example if you set bucket=foo and path_prefix=some/sub/folder then you
// can expect the final path in s3 to look like:
// s3://foo/some/sub/folder/<topologyName>/topology.tar.gz
private String pathPrefix;
// Stores the path to the backup version of the topology in case the deploy fails and
// it needs to be undone. This is the same as the existing path but prepended with `previous_`.
// This serves as a simple backup incase we need to revert.
private String previousVersionFilePath;
private File packageFileHandler;
@Override
public void initialize(Config config) {
bucket = S3Context.bucket(config);
String accessKey = S3Context.accessKey(config);
String accessSecret = S3Context.secretKey(config);
String awsProfile = S3Context.awsProfile(config);
String proxy = S3Context.proxyUri(config);
String endpoint = S3Context.uri(config);
String customRegion = S3Context.region(config);
AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard();
if (Strings.isNullOrEmpty(bucket)) {
throw new RuntimeException("Missing heron.uploader.s3.bucket config value");
}
// If an accessKey is specified, use it. Otherwise check if an aws profile
// is specified. If neither was set just use the DefaultAWSCredentialsProviderChain
// by not specifying a CredentialsProvider.
if (!Strings.isNullOrEmpty(accessKey) || !Strings.isNullOrEmpty(accessSecret)) {
if (!Strings.isNullOrEmpty(awsProfile)) {
throw new RuntimeException("Please provide access_key/secret_key "
+ "or aws_profile, not both.");
}
if (Strings.isNullOrEmpty(accessKey)) {
throw new RuntimeException("Missing heron.uploader.s3.access_key config value");
}
if (Strings.isNullOrEmpty(accessSecret)) {
throw new RuntimeException("Missing heron.uploader.s3.secret_key config value");
}
builder.setCredentials(
new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, accessSecret))
);
} else if (!Strings.isNullOrEmpty(awsProfile)) {
builder.setCredentials(new ProfileCredentialsProvider(awsProfile));
}
if (!Strings.isNullOrEmpty(proxy)) {
URI proxyUri;
try {
proxyUri = new URI(proxy);
} catch (URISyntaxException e) {
throw new RuntimeException("Invalid heron.uploader.s3.proxy_uri config value: " + proxy, e);
}
ClientConfiguration clientCfg = new ClientConfiguration();
clientCfg.withProtocol(Protocol.HTTPS)
.withProxyHost(proxyUri.getHost())
.withProxyPort(proxyUri.getPort());
if (!Strings.isNullOrEmpty(proxyUri.getUserInfo())) {
String[] info = proxyUri.getUserInfo().split(":", 2);
clientCfg.setProxyUsername(info[0]);
if (info.length > 1) {
clientCfg.setProxyPassword(info[1]);
}
}
builder.setClientConfiguration(clientCfg);
}
s3Client = builder.withRegion(customRegion)
.withPathStyleAccessEnabled(true)
.withChunkedEncodingDisabled(true)
.withPayloadSigningEnabled(true)
.build();
if (!Strings.isNullOrEmpty(endpoint)) {
s3Client.setEndpoint(endpoint);
}
final String topologyName = Context.topologyName(config);
final String topologyPackageLocation = Context.topologyPackageFile(config);
pathPrefix = S3Context.pathPrefix(config);
packageFileHandler = new File(topologyPackageLocation);
// The path the packaged topology will be uploaded to
remoteFilePath = generateS3Path(pathPrefix, topologyName, packageFileHandler.getName());
// Generate the location of the backup file incase we need to revert the deploy
previousVersionFilePath = generateS3Path(pathPrefix, topologyName,
"previous_" + packageFileHandler.getName());
}
@Override
public URI uploadPackage() throws UploaderException {
// Backup any existing files incase we need to undo this action
if (s3Client.doesObjectExist(bucket, remoteFilePath)) {
s3Client.copyObject(bucket, remoteFilePath, bucket, previousVersionFilePath);
}
// Attempt to write the topology package to s3
try {
s3Client.putObject(bucket, remoteFilePath, packageFileHandler);
} catch (SdkClientException e) {
throw new UploaderException(
String.format("Error writing topology package to %s %s", bucket, remoteFilePath), e);
}
// Ask s3 for the url to the topology package we just uploaded
final URL resourceUrl = s3Client.getUrl(bucket, remoteFilePath);
LOG.log(Level.INFO, "Package URL: {0}", resourceUrl);
// This will happen if the package does not actually exist in the place where we uploaded it to.
if (resourceUrl == null) {
throw new UploaderException(
String.format("Resource not found for bucket %s and path %s", bucket, remoteFilePath));
}
try {
return resourceUrl.toURI();
} catch (URISyntaxException e) {
throw new UploaderException(
String.format("Could not convert URL %s to URI", resourceUrl), e);
}
}
/**
* Generate the path to a file in s3 given a prefix, topologyName, and filename
*
* @param pathPrefixParent designates any parent folders that should be prefixed to the resulting path
* @param topologyName the name of the topology that we are uploaded
* @param filename the name of the resulting file that is going to be uploaded
* @return the full path of the package under the bucket. The bucket is not included in this path as it is a separate
* argument that is passed to the putObject call in the s3 sdk.
*/
private String generateS3Path(String pathPrefixParent, String topologyName, String filename) {
List<String> pathParts = new ArrayList<>(Arrays.asList(pathPrefixParent.split("/")));
pathParts.add(topologyName);
pathParts.add(filename);
return String.join("/", pathParts);
}
@Override
public boolean undo() {
// Check if there is a previous version. This will not be true on the first deploy.
if (s3Client.doesObjectExist(bucket, previousVersionFilePath)) {
try {
// Restore the previous version of the topology
s3Client.copyObject(bucket, previousVersionFilePath, bucket, remoteFilePath);
} catch (SdkClientException e) {
LOG.log(Level.SEVERE, "Reverting to previous topology version failed", e);
return false;
}
}
return true;
}
@Override
public void close() {
// Cleanup the backup file if it exists as its not needed anymore.
// This will succeed whether the file exists or not.
if (!Strings.isNullOrEmpty(previousVersionFilePath)) {
s3Client.deleteObject(bucket, previousVersionFilePath);
}
}
}