Skip to content

Commit

Permalink
WIP: Remove S3 blobstore and attempt to use Http
Browse files Browse the repository at this point in the history
  • Loading branch information
GregBowyer committed Mar 28, 2018
1 parent 3d18a08 commit 021bdd2
Show file tree
Hide file tree
Showing 15 changed files with 348 additions and 258 deletions.
29 changes: 29 additions & 0 deletions site/docs/remote-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ include:
* [nginx](#nginx)
* [Bazel Remote Cache](#bazel-remote-cache)
* [Google Cloud Storage](#google-cloud-storage)
* [AWS S3 Buckets](#aws-s3-buckets)

### nginx

Expand Down Expand Up @@ -170,6 +171,34 @@ to/from your GCS bucket.
5. You can configure Cloud Storage to automatically delete old files. To do so, see
[Managing Object Lifecycles](https://cloud.google.com/storage/docs/managing-lifecycles).

### [AWS S3] Storage

[AWS S3] is a fully managed object store which provides an
HTTP API that is compatible with Bazel's remote caching protocol. It requires
that you have an AWS account with billing enabled.

To use S3 as the cache:

1. [Create a bucket](https://docs.aws.amazon.com/AmazonS3/latest/gsg/CreatingABucket.html).
Ensure that you select a bucket location that's closest to you, as network bandwidth
is important for the remote cache.

2. Generate relevant auth methods and then pass it to Bazel for authentication. Store
the key securely, as anyone with the key can read and write arbitrary data
to/from your S3 bucket.

3. Connect to S3 by adding the following flags to your Bazel command:
* Pass the following URL to Bazel by using the flag: `--remote_http_cache=https://bucket-name.s3-website-region.amazonaws.com`
where `bucket-name` is the name of your storage bucket, and `region` is the name of the chosen region.
* Pass the authentication key using the flags:
`--aws_access_key_id=$ACCESS_KEY`
`--aws_secret_access_key=$SECRET_KEY`
Alternatively, AWS credentials can configured using standard methods such as instance roles, environment variables
or similar.

4. You can configure S3 to automatically delete old files. To do so, see
[Object Lifecycle Management](https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lifecycle-mgmt.html)

### Other servers

You can set up any HTTP/1.1 server that supports PUT and GET as the cache's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.google.common.collect.ImmutableList;

import java.io.IOException;
import java.util.List;

/** Utility methods for using {@link AuthAndTLSOptions} with Amazon Web Services. */
public final class AwsAuthUtils {
Expand All @@ -32,7 +33,7 @@ public final class AwsAuthUtils {
* @throws IOException in case the credentials can't be constructed.
*/
public static AWSCredentialsProvider newCredentials(AuthAndTLSOptions options) throws IOException {
ImmutableList.Builder<AWSCredentialsProvider> creds = ImmutableList.builder();
final ImmutableList.Builder<AWSCredentialsProvider> creds = ImmutableList.builder();

if (options.awsAccessKeyId != null && options.awsSecretAccessKey != null) {
final BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(options.awsAccessKeyId, options.awsSecretAccessKey);
Expand All @@ -43,6 +44,7 @@ public static AWSCredentialsProvider newCredentials(AuthAndTLSOptions options) t
creds.add(DefaultAWSCredentialsProviderChain.getInstance());
}

return new AWSCredentialsProviderChain(creds.build());
final List<AWSCredentialsProvider> providers = creds.build();
return providers.isEmpty() ? null : new AWSCredentialsProviderChain(providers);
}
}
1 change: 0 additions & 1 deletion src/main/java/com/google/devtools/build/lib/remote/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ java_library(
"//src/main/java/com/google/devtools/build/lib/exec/local:options",
"//src/main/java/com/google/devtools/build/lib/remote/blobstore",
"//src/main/java/com/google/devtools/build/lib/remote/blobstore/http",
"//src/main/java/com/google/devtools/build/lib/remote/blobstore/s3",
"//src/main/java/com/google/devtools/build/lib/remote/util",
"//src/main/java/com/google/devtools/build/lib/vfs",
"//src/main/java/com/google/devtools/common/options",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class RemoteOptions extends OptionsBase {
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
help =
"A base URL of a HTTP caching service. Both http:// and https:// are supported. BLOBs are "
"A base URL of a HTTP caching service. http://, https:// and s3:// are supported. BLOBs are "
+ "stored with PUT and retrieved with GET. See remote/README.md for more information."
)
public String remoteHttpCache;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@

import static com.google.common.base.Preconditions.checkNotNull;

import com.amazonaws.auth.AWSCredentialsProvider;
import com.google.auth.Credentials;
import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
import com.google.devtools.build.lib.authandtls.AwsAuthUtils;
import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
import com.google.devtools.build.lib.remote.blobstore.OnDiskBlobStore;
import com.google.devtools.build.lib.remote.blobstore.SimpleBlobStore;
import com.google.devtools.build.lib.remote.blobstore.http.HttpBlobStore;
import com.google.devtools.build.lib.remote.blobstore.s3.S3BlobStore;
import com.google.devtools.build.lib.remote.blobstore.http.HttpCredentialsAdapter;
import com.google.devtools.build.lib.vfs.Path;
import java.io.IOException;
import java.net.URI;
Expand All @@ -39,7 +38,7 @@ public final class SimpleBlobStoreFactory {

private SimpleBlobStoreFactory() {}

public static SimpleBlobStore createRest(RemoteOptions options, Credentials creds)
public static SimpleBlobStore createRest(RemoteOptions options, HttpCredentialsAdapter creds)
throws IOException {
try {
return new HttpBlobStore(
Expand All @@ -51,18 +50,6 @@ public static SimpleBlobStore createRest(RemoteOptions options, Credentials cred
}
}

private static SimpleBlobStore createAws(RemoteOptions options, AWSCredentialsProvider creds)
throws IOException {
try {
return new S3BlobStore(
options.awsS3Cache,
(int) TimeUnit.SECONDS.toMillis(options.remoteTimeout),
creds);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public static SimpleBlobStore createLocalDisk(RemoteOptions options, Path workingDirectory)
throws IOException {
return new OnDiskBlobStore(
Expand All @@ -73,10 +60,7 @@ public static SimpleBlobStore create(
RemoteOptions options, @Nullable AuthAndTLSOptions authAndTLSOptions, @Nullable Path workingDirectory)
throws IOException {
if (isRestUrlOptions(options)) {
return createRest(options, GoogleAuthUtils.newCredentials(authAndTLSOptions));
}
if (isAwsS3Options(options)) {
return createAws(options, AwsAuthUtils.newCredentials(authAndTLSOptions));
return createRest(options, HttpCredentialsAdapter.fromOptions(authAndTLSOptions));
}
if (workingDirectory != null && isLocalDiskCache(options)) {
return createLocalDisk(options, workingDirectory);
Expand All @@ -86,12 +70,8 @@ public static SimpleBlobStore create(
+ "either Rest URL, or local cache options.");
}

private static boolean isAwsS3Options(RemoteOptions options) {
return options.awsS3Cache != null;
}

public static boolean isRemoteCacheOptions(RemoteOptions options) {
return isRestUrlOptions(options) || isAwsS3Options(options) || isLocalDiskCache(options);
return isRestUrlOptions(options) || isLocalDiskCache(options);
}

public static boolean isLocalDiskCache(RemoteOptions options) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
// limitations under the License.
package com.google.devtools.build.lib.remote.blobstore.http;

import com.google.auth.Credentials;
import com.google.common.base.Charsets;
import com.google.common.collect.Multimap;
import com.google.common.io.BaseEncoding;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelOutboundHandler;
Expand All @@ -27,16 +27,14 @@
import java.net.SocketAddress;
import java.net.URI;
import java.nio.channels.ClosedChannelException;
import java.util.List;
import java.util.Map;

/** Common functionality shared by concrete classes. */
abstract class AbstractHttpHandler<T extends HttpObject> extends SimpleChannelInboundHandler<T>
implements ChannelOutboundHandler {

private final Credentials credentials;
private final HttpCredentialsAdapter credentials;

public AbstractHttpHandler(Credentials credentials) {
public AbstractHttpHandler(HttpCredentialsAdapter credentials) {
this.credentials = credentials;
}

Expand All @@ -63,19 +61,11 @@ protected void addCredentialHeaders(HttpRequest request, URI uri) throws IOExcep
request.headers().set(HttpHeaderNames.AUTHORIZATION, "Basic " + value);
return;
}
if (credentials == null || !credentials.hasRequestMetadata()) {
if (credentials == null || !credentials.hasRequestHeaders()) {
return;
}
Map<String, List<String>> authHeaders = credentials.getRequestMetadata(uri);
if (authHeaders == null || authHeaders.isEmpty()) {
return;
}
for (Map.Entry<String, List<String>> entry : authHeaders.entrySet()) {
String name = entry.getKey();
for (String value : entry.getValue()) {
request.headers().add(name, value);
}
}
Multimap<String, String> authHeaders = credentials.getRequestHeaders(request, uri);
authHeaders.forEach(request.headers()::add);
}

protected String constructPath(URI uri, String hash, boolean isCas) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.google.devtools.build.lib.remote.blobstore.http;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSCredentialsProvider;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.collect.*;
import com.google.common.net.HttpHeaders;
import io.netty.handler.codec.http.HttpRequest;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.text.SimpleDateFormat;
import java.util.*;

/**
* Adapter for converting AWS credentials into Http Authentication headers
*
* This follows the specification found in
* https://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html#ConstructingTheAuthenticationHeader
*
* Which is principally the following grammar:
*
* <pre>{@code
* Authorization = "AWS" + " " + AWSAccessKeyId + ":" + Signature;
*
* Signature = Base64( HMAC-SHA1( YourSecretAccessKeyID, UTF-8-Encoding-Of( StringToSign ) ) );
*
* StringToSign = HTTP-Verb + "\n" +
* Content-MD5 + "\n" +
* Content-Type + "\n" +
* Date + "\n" +
* CanonicalizedAmzHeaders +
* CanonicalizedResource;
*
* CanonicalizedResource = [ "/" + Bucket ] +
* <HTTP-Request-URI, from the protocol name up to the query string> +
* [ subresource, if present. For example "?acl", "?location", "?logging", or "?torrent"];
*
* CanonicalizedAmzHeaders = <described elsewhere>
* }</pre>
*
*/
public class AwsHttpCredentialsAdapter extends HttpCredentialsAdapter {

private static final String SIGNING_ALGO = "HmacSHA256";
private static final String AWS_HDR_PREFIX = "x-amz-";

private static final ThreadLocal<SimpleDateFormat> headerDateFormatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z"));

private static final ImmutableSet<String> CANOC_PARAMETERS = ImmutableSet.of(
"acl", "torrent", "logging", "location", "policy", "requestPayment", "versioning",
"versions", "versionId", "notification", "uploadId", "uploads", "partNumber", "website",
"delete", "lifecycle", "tagging", "cors", "restore", "replication", "accelerate",
"inventory", "analytics", "metrics"
);

private static final Splitter URL_SPLITTER = Splitter.on('.');
private static final Splitter PARAM_SPLITTER = Splitter.on('&');
private static final Joiner PARAM_JOINER = Joiner.on('&');

private final AWSCredentialsProvider awsCredsProvider;

AwsHttpCredentialsAdapter(final AWSCredentialsProvider awsCredsProvider) {
this.awsCredsProvider = awsCredsProvider;
}

@Override
public void refresh() throws IOException {
awsCredsProvider.refresh();
}

@Override
public Multimap<String, String> getRequestHeaders(final HttpRequest request, final URI uri) throws IOException {
final AWSCredentials creds = awsCredsProvider.getCredentials();
final String accessKey = creds.getAWSAccessKeyId();
final String secretKey = creds.getAWSSecretKey();

final Date currentTime = new Date(System.currentTimeMillis());
final String date = headerDateFormatter.get().format(currentTime);

final String stringToSign = new StringBuilder()
.append(request.method().name())
.append('\n')
.append(canoicaliseHeaders(request, date))
.append(canoicaliseRequest(uri))
.toString();

final String signature = generateHMac(secretKey, stringToSign);
final String authHeader = String.format("AWS %s:%s", accessKey, signature);

return ImmutableMultimap.<String, String>builder()
.put(HttpHeaders.AUTHORIZATION, authHeader)
.put(HttpHeaders.DATE, date)
.build();
}

private String generateHMac(final String key, final String toSign) throws IOException {
try {
final Mac mac = Mac.getInstance(SIGNING_ALGO);
mac.init(new SecretKeySpec(key.getBytes(), SIGNING_ALGO));
final byte[] signed = mac.doFinal(toSign.getBytes());
return Base64.getEncoder().encodeToString(signed);
} catch (Exception e) {
throw new IOException("Unable to sign AWS auth header correctly", e);
}
}

private String canoicaliseHeaders(final HttpRequest request, final String date) {
final StringBuilder builder = new StringBuilder();
Map<String, List<String>> signHeaders = new TreeMap<>();

final io.netty.handler.codec.http.HttpHeaders headers = request.headers();
final Iterator<Map.Entry<String, String>> headersOfInterest = headers.iteratorAsString();
headersOfInterest.forEachRemaining(entry -> {
final String header = entry.getKey();
Preconditions.checkNotNull(header);
if (header.toLowerCase().startsWith(AWS_HDR_PREFIX)) {
signHeaders
.computeIfAbsent(header, (_x) -> new ArrayList<>())
.add(entry.getValue());
}
});

// Add the following positional headers as per spec, even if they are empty
builder
.append(headers.get(HttpHeaders.CONTENT_TYPE, ""))
.append('\n')
.append(headers.get(HttpHeaders.CONTENT_MD5, ""))
.append('\n')
.append(date)
.append('\n');

final Joiner COMMA_JOINER = Joiner.on(',');

for (Map.Entry<String, List<String>> entry : signHeaders.entrySet()) {
final String header = entry.getKey().toLowerCase();
final List<String> values = entry.getValue();
if (header.startsWith(AWS_HDR_PREFIX)) {
builder.append(header);
builder.append(":");
COMMA_JOINER.appendTo(builder, values);
builder.append('\n');
}
}

return builder.toString();
}

private String canoicaliseRequest(final URI uri) {
final StringBuilder builder = new StringBuilder();

if (!uri.getHost().startsWith("s3")) {
final String vhost = Iterables.getFirst(URL_SPLITTER.split(uri.getHost()), null);
Preconditions.checkNotNull(vhost);
builder.append("/")
.append(vhost);
}

builder.append(uri.getPath());

if (uri.getQuery() != null) {
final Iterator<String> queryParams = PARAM_SPLITTER
.trimResults()
.splitToList(uri.getQuery())
.stream()
.filter(CANOC_PARAMETERS::contains)
.sorted()
.iterator();

builder.append("?");
PARAM_JOINER.appendTo(builder, queryParams);
}

return builder.toString();
}

}
Loading

0 comments on commit 021bdd2

Please sign in to comment.