diff --git a/.travis.yml b/.travis.yml index 2f1bf192..11bc2992 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ # Travis auto-detects build.gradle, and configures an appropriate build step (`gradle check`): # (see https://docs.travis-ci.com/user/languages/java/#Projects-Using-Gradle) language: java +jdk: + - oraclejdk8 # Avoid updating the cache after every build: # (see https://docs.travis-ci.com/user/languages/java/#Caching) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1dc1e7c..2879e851 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,9 @@ If you would like to make a significant change, it's a good idea to first open a ### Making the request Development takes place against the dev branch of this repository and pull requests should be opened against that branch. +### Code Style +This project follows the [Google Java Styleguide](https://google.github.io/styleguide/javaguide.html), and this style is enforced as part of the `check` task. We recommend you install [the `google-java-format` plugin for your IDE](https://github.com/google/google-java-format), or use the `gradle spotlessApply` task to format code before checking in. + ### Testing Any contributions should pass all tests. You can run all tests by running `gradle check` from the project root. diff --git a/README.md b/README.md index 99370cd0..99402a2d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,66 @@ Blox is being delivered as a managed service via the Amazon ECS Console, API and Blox schedulers are built using AWS primitives, and the Blox designs and code are open source. If you are interested in learning more or collaborating on the designs, please read the [design](docs/daemon_design.md). If you are currently using Blox v0.3, please read the [FAQ](docs/faq.md). +### Project structure +For an overview of the components of Blox, run: + +``` +./gradlew projects +``` + +### Testing +To run the full unit test suite, run: + +``` +./gradlew check +``` + +This will run the same tests that we run in the Travis CI build. + +### Deploying +To deploy a personal stack: +- install the [official AWS CLI](https://aws.amazon.com/cli/) +- create an IAM user with the following permissions: + + ```json + { + "Version":"2012-10-17", + "Statement":[{ + "Effect":"Allow", + "Action":[ + "s3:*", + "lambda:*", + "apigateway:*", + "cloudformation:*", + "iam:*" + ], + "Resource":"*" + }] + } + + ``` + + These permissions are pretty broad, so we recommend you use a separate, test account. +- configure the `default` profile with the AWS credentials for the user you created above +- create an S3 bucket where all resources (code, cloudformation templates, etc) to be deployed will be stored: + + ``` + ./gradlew createBucket + ``` + +- deploy the Blox stack: + + ``` + ./gradlew deploy + ``` + +### End to end testing +Once you have a stack deployed, you can test it with: + +``` +./gradlew testEndToEnd +``` + ### Contact * [Gitter](https://gitter.im/blox) diff --git a/build.gradle b/build.gradle index 42953751..4b6f4c50 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,103 @@ +import groovy.json.JsonSlurper + group 'com.amazonaws.blox' version '0.1-SNAPSHOT' description "Blox: Open Source schedulers for Amazon ECS" + +buildscript { + repositories { + maven { url 'https://plugins.gradle.org/m2/' } + } + + dependencies { + classpath 'com.diffplug.gradle.spotless:spotless:2.4.1' + } +} + +def unformattedProjects = [ + 'frontend-infrastructure', + 'frontend-service-client' +] + +allprojects { + apply plugin: 'com.diffplug.gradle.spotless' +} + +configure(subprojects.findAll { !unformattedProjects.contains(it.name) }) { + spotless { + java { + googleJavaFormat() + licenseHeaderFile rootProject.file('licenses/license-header.java') + } + } +} + +ext { + // This can be overridden by specifying -PawsProfile=my-other-profile + // on the Gradle command line: + awsProfile = project.hasProperty("awsProfile") ? project.awsProfile : "default" + println("Profile: ${awsProfile}") + + awsCli = "/usr/local/bin/aws" + awsRegion = "us-west-2" + awsPrefix = System.getenv("USER") + + stackName = "${awsPrefix}-blox-frontend-${awsRegion}" + s3BucketName = "${stackName}-${awsProfile}" + stageName = "Beta" + + sdkZip = file("${buildDir}/java-sdk-${version}.zip") +} + +def aws(... args) { + return [awsCli, "--profile", awsProfile, "--region", awsRegion, *args] +} + +task downloadClient() { + group "codegen" + description "Download a new version of the SDK for the currently deployed stack." + + def deployTask = tasks.getByPath(":frontend-infrastructure:deploy") + + inputs.file deployTask + outputs.file sdkZip + + doLast { + sdkZip.parentFile.mkdirs() + + def stackOutputs = new JsonSlurper().parse(deployTask.outputs.files.singleFile) + + def parameters = [ + "service.name=Blox", + "java.package-name=com.amazonaws.blox", + "java.build-system=gradle", + "java.group-id=${project.group}", + "java.artifact-id=frontend-service-client", + "java.artifact-version=${project.version}", + ].join(",") + + exec { + commandLine aws("apigateway", "get-sdk", + "--rest-api-id", stackOutputs.ApiId, + "--stage-name", stageName, + "--sdk-type", "java", + "--parameters", parameters, + sdkZip) + } + } +} + +task updateClient(type: Copy, dependsOn: downloadClient) { + group "codegen" + description "Unpack the client for the currently deployed stack into the blox-client subproject." + + ext.tmpDir = file("${buildDir}/tmp/sdk") + + from zipTree(sdkZip) + into tmpDir + + doLast { + file("frontend-service-client").deleteDir() + file("${tmpDir}/generated-code").renameTo(file("frontend-service-client")) + } +} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 912a45d7..fa50d732 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,3 +1,20 @@ description "Custom build logic for building Blox" apply plugin: 'java' + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + compile gradleApi() + compile localGroovy() + + compile 'com.github.kongchen:swagger-maven-plugin:+' + + compileOnly 'org.projectlombok:lombok:1.16.16' + + testCompile group: 'junit', name: 'junit', version: '4.12' +} diff --git a/buildSrc/src/main/java/com/amazonaws/blox/swagger/ApiGatewayExtensionsFilter.java b/buildSrc/src/main/java/com/amazonaws/blox/swagger/ApiGatewayExtensionsFilter.java new file mode 100644 index 00000000..9b6c95f8 --- /dev/null +++ b/buildSrc/src/main/java/com/amazonaws/blox/swagger/ApiGatewayExtensionsFilter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.swagger; + +import io.swagger.models.Operation; +import io.swagger.models.Path; +import io.swagger.models.Swagger; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import org.gradle.api.tasks.Input; + +/** + * Add API Gateway extensions to all operations of a swagger spec. + * + *

This will add the necessary x-amazon-apigateway-integration section to all operations in a + * Swagger spec to correctly proxy all requests to a single lambda function. + * + *

TODO Move everything in com.amazonaws.blox.swagger to a separate project + */ +@AllArgsConstructor +public class ApiGatewayExtensionsFilter implements SwaggerFilter { + /** + * The template for the Lambda function name to proxy to, in Cloudformation's Fn::Sub syntax. + * + *

A typical format for this is: + * + *

arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MyLambdaFunction.Arn}/invocations + */ + @Input private final String lambdaFunctionArnTemplate; + + public Map defaultExtensions() { + Map extensions = new HashMap<>(); + + extensions.put("passthroughBehavior", "when_no_match"); + extensions.put("httpMethod", "POST"); + extensions.put("type", "aws_proxy"); + + extensions.put("uri", sub(lambdaFunctionArnTemplate)); + + return extensions; + } + + @Override + public void apply(Swagger swagger) { + Map extensions = defaultExtensions(); + + for (Path path : swagger.getPaths().values()) { + for (Operation operation : path.getOperations()) { + operation.setVendorExtension("x-amazon-apigateway-integration", extensions); + } + } + } + + /** + * Create a Fn::Sub node with the given template as contents. + * + *

This is to support using the CloudFormation Fn::Sub intrinsic function in the swagger + * definition. Using sub("foo${AWS::Region}") for example, would emit {"Fn::Sub": + * "foo${AWS::Region}"} in the template. + */ + Map sub(String template) { + Map map = new HashMap<>(); + map.put("Fn::Sub", template); + + return map; + } +} diff --git a/buildSrc/src/main/java/com/amazonaws/blox/swagger/GenerationTimestampFilter.java b/buildSrc/src/main/java/com/amazonaws/blox/swagger/GenerationTimestampFilter.java new file mode 100644 index 00000000..bacec7cc --- /dev/null +++ b/buildSrc/src/main/java/com/amazonaws/blox/swagger/GenerationTimestampFilter.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.swagger; + +import io.swagger.models.Swagger; +import java.time.Instant; + +/** + * Add a timestamp to a Swagger spec as an 'x-generated-at' extended property. + * + *

TODO Move everything in com.amazonaws.blox.swagger to a separate project + */ +public class GenerationTimestampFilter implements SwaggerFilter { + @Override + public void apply(Swagger swagger) { + swagger.setVendorExtension("x-generated-at", Instant.now().toString()); + } +} diff --git a/buildSrc/src/main/java/com/amazonaws/blox/swagger/SwaggerFilter.java b/buildSrc/src/main/java/com/amazonaws/blox/swagger/SwaggerFilter.java new file mode 100644 index 00000000..700ff0a8 --- /dev/null +++ b/buildSrc/src/main/java/com/amazonaws/blox/swagger/SwaggerFilter.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.swagger; + +import io.swagger.models.Swagger; + +/** + * A filter that can apply arbitrary changes to a Swagger model + * + *

Implementations of this interface are wired up into {@link + * com.amazonaws.blox.tasks.GenerateSwaggerModel} to post-process the Swagger model it generates + * from source code. + * + *

TODO Move everything in com.amazonaws.blox.swagger to a separate project + */ +public interface SwaggerFilter { + void apply(Swagger swagger); +} diff --git a/buildSrc/src/main/java/com/amazonaws/blox/tasks/GenerateSwaggerModel.java b/buildSrc/src/main/java/com/amazonaws/blox/tasks/GenerateSwaggerModel.java new file mode 100644 index 00000000..b4e52815 --- /dev/null +++ b/buildSrc/src/main/java/com/amazonaws/blox/tasks/GenerateSwaggerModel.java @@ -0,0 +1,112 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.tasks; + +import com.amazonaws.blox.swagger.GenerationTimestampFilter; +import com.amazonaws.blox.swagger.SwaggerFilter; +import com.github.kongchen.swagger.docgen.GenerateException; +import com.github.kongchen.swagger.docgen.reader.SpringMvcApiReader; +import io.swagger.models.Swagger; +import io.swagger.util.Yaml; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.gradle.api.DefaultTask; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +/** + * Generate a YAML-formatted Swagger specification from the given classes. + * + *

This does basically the same as the swagger task provided by + * https://github.com/gigaSproule/swagger-gradle-plugin, but gives us an extension point to wire in + * our own filters for modifying the resultant Swagger document. + */ +@Getter +@Setter +public class GenerateSwaggerModel extends DefaultTask { + private Log log = new SystemStreamLog(); + + public static final SwaggerFilter DEFAULT_FILTER = new GenerationTimestampFilter(); + + /** A list of class names to scan for Swagger annotations */ + @Input private List apiClasses = new ArrayList<>(); + + /** The file into which to write the swagger definition */ + @OutputFile private File swaggerFile; + + /** + * The classpath to use when loading classes to scan for swagger annotations + * + *

Typically, we need the Runtime classpath specifically so that we can load the classes being + * built in the parent project, as well as all its dependencies (in particular the Java + * annotations used to declare RESTful controllers). + */ + @Classpath @InputFiles private Iterable scanClasspath; + + /** + * An ordered list of SwaggerFilter instances to apply to the generated Swagger definition before + * writing it out. + */ + @Nested private List filters = new ArrayList<>(); + + public GenerateSwaggerModel() { + filters.add(DEFAULT_FILTER); + } + + @TaskAction + public void generateSpec() throws IOException, ClassNotFoundException, GenerateException { + ClassLoader loader = projectClassLoader(); + + Set> classes = new HashSet<>(); + + for (String name : apiClasses) { + classes.add(loader.loadClass(name)); + } + + SpringMvcApiReader reader = new SpringMvcApiReader(new Swagger(), log); + Swagger swagger = reader.read(classes); + + for (SwaggerFilter filter : filters) { + filter.apply(swagger); + } + + Yaml.pretty().writeValue(swaggerFile, swagger); + } + + private ClassLoader projectClassLoader() throws MalformedURLException { + Set urls = new HashSet<>(); + for (File file : getScanClasspath()) { + urls.add(file.toURI().toURL()); + } + + return new URLClassLoader( + urls.toArray(new URL[] {}), GenerateSwaggerModel.class.getClassLoader()); + } +} diff --git a/buildSrc/src/main/java/com/amazonaws/blox/tasks/PostProcessCloudformation.java b/buildSrc/src/main/java/com/amazonaws/blox/tasks/PostProcessCloudformation.java new file mode 100644 index 00000000..039b644f --- /dev/null +++ b/buildSrc/src/main/java/com/amazonaws/blox/tasks/PostProcessCloudformation.java @@ -0,0 +1,116 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.tasks; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.File; +import java.io.IOException; +import lombok.Getter; +import lombok.Setter; +import org.gradle.api.DefaultTask; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +/** + * Substitute dynamic values for Serverless::Function.CodeURI and Serverless::Api.DefinitionBody + * into a given Cloudformation/Serverless Application Model template. + * + *

Defining an API gateway API using Swagger using SAM has some limitations when using a separate + * swagger definition file (as declared through the DefinitionUri property): - We must hard-code the + * region and account ID of the arn of the lambda function it should invoke in the + * `x-amazon-apigateway-integration/uri` property. - If we don't hardcode the Lambda function name, + * we must provide it as a stage variable instead. + * + *

Instead, SAM supports specifying the Swagger definition inline using the DefinitionBody field. + * This allows us to use CloudFormation functions and references to derive the name of the Lambda + * function to invoke, but brings its own set of limitations: - Swagger YAML definitions almost + * always have at least one numeric field name (in the responses object), and CloudFormation + * incorrectly reads this as an integer instead of a string key. + * + *

The solution then is to embed the Swagger definition into the SAM Template from YAML source, + * but to produce the combined template in JSON format to avoid the non-string key issue. + * + *

In the same template, cloudformation only supports paths relative to the template file or full + * absolute paths for the CodeUri property of a Serverless::Function. This task will substitute in + * the given lambdaZipFile as an absolute path into the CodeUri property. + */ +@Getter +@Setter +public class PostProcessCloudformation extends DefaultTask { + /** Mapper used to read the source swagger/SAM YAML templates */ + private ObjectMapper inputMapper = new ObjectMapper(new YAMLFactory()); + /** Mapper used to produce combined JSON template */ + private ObjectMapper outputMapper = new ObjectMapper(new JsonFactory()); + + /** + * The name of the API GW resource in the SAM template into which the swagger template should be + * embedded. + */ + @Input private String apiName; + + /** + * The name of the Lambda Function resource in the SAM template into which the Lambda zip path + * should be embedded. + */ + @Input private String handlerName; + + /** The YAML file to read the swagger definition from. */ + @InputFile private File swaggerFile; + + /** The YAML file to read the SAM template from. */ + @InputFile private File templateFile; + + /** The JSON file to write the combined template into. */ + @OutputFile private File outputTemplateFile; + + /** The Zip file to substitute into the CodeUri property of the lambda function */ + @InputFile private File lambdaZipFile; + + @TaskAction + public void embedSwagger() throws IOException { + JsonNode template = inputMapper.readTree(templateFile); + + JsonNode resources = template.get("Resources"); + embedSwaggerBody(resources); + setCodeUri(resources); + + ObjectWriter writer = outputMapper.writer(new DefaultPrettyPrinter()); + outputTemplateFile.getParentFile().mkdirs(); + writer.writeValue(outputTemplateFile, template); + } + + private void setCodeUri(JsonNode resources) { + TextNode codeUri = new TextNode(lambdaZipFile.getAbsolutePath()); + + ObjectNode apiProperties = (ObjectNode) resources.get(handlerName).get("Properties"); + apiProperties.set("CodeUri", codeUri); + } + + private void embedSwaggerBody(JsonNode resources) throws IOException { + JsonNode swagger = inputMapper.readTree(swaggerFile); + + ObjectNode properties = (ObjectNode) resources.get(apiName).get("Properties"); + properties.set("DefinitionBody", swagger); + } +} diff --git a/buildSrc/src/test/java/com/amazonaws/blox/GenerateSwaggerModelTest.groovy b/buildSrc/src/test/java/com/amazonaws/blox/GenerateSwaggerModelTest.groovy new file mode 100644 index 00000000..9e649af2 --- /dev/null +++ b/buildSrc/src/test/java/com/amazonaws/blox/GenerateSwaggerModelTest.groovy @@ -0,0 +1,49 @@ +package com.amazonaws.blox + +import com.amazonaws.blox.tasks.GenerateSwaggerModel +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class GenerateSwaggerModelTest { + private File swaggerFile = new File("build/tmp/swagger.yml") + + private classpath = (TestController.class.classLoader as URLClassLoader).URLs.collect { new File(it.toString()) } + + @Before + void deleteSwaggerFile() { + if (swaggerFile.exists()) { + swaggerFile.delete() + } + } + + @Test + void generatesSwaggerModelFromGivenClasses() throws Exception { + GenerateSwaggerModel task = ProjectBuilder.builder().build().task("swagger", type: GenerateSwaggerModel) + + task.scanClasspath = this.classpath + task.apiClasses = ["com.amazonaws.blox.TestController"] + task.swaggerFile = this.swaggerFile + + task.execute() + + JsonNode swagger = readSwaggerFile() + + assertEquals("2.0", swagger.get("swagger").asText()) + assertEquals("test-summary", swagger + .get("paths") + .get("/test/{name}") + .get("get") + .get("summary") + .asText()) + } + + private JsonNode readSwaggerFile() { + new ObjectMapper(new YAMLFactory()).readTree(this.swaggerFile) + } +} diff --git a/buildSrc/src/test/java/com/amazonaws/blox/TestController.java b/buildSrc/src/test/java/com/amazonaws/blox/TestController.java new file mode 100644 index 00000000..0cec33b2 --- /dev/null +++ b/buildSrc/src/test/java/com/amazonaws/blox/TestController.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +@Api +@RequestMapping(path = "/test") +/** Only used by GenerateSwaggerModelTest */ +public class TestController { + @RequestMapping(path = "/{name}", method = RequestMethod.GET) + @ApiOperation(value = "test-summary") + public String doFoo(@PathVariable("name") String name) { + return name; + } +} diff --git a/docs/diagrams/api-lambda-proxy.png b/docs/diagrams/api-lambda-proxy.png new file mode 100644 index 00000000..5c2d344a Binary files /dev/null and b/docs/diagrams/api-lambda-proxy.png differ diff --git a/docs/diagrams/api-lambda-proxy.puml b/docs/diagrams/api-lambda-proxy.puml new file mode 100644 index 00000000..9b16e9ea --- /dev/null +++ b/docs/diagrams/api-lambda-proxy.puml @@ -0,0 +1,48 @@ +@startuml +skinparam monochrome true +skinparam defaultFontName Helvetica + +actor user as "User" +boundary api as "AWS API GW\n endpoint" +participant ars as "AuthRuntimeService" +participant lambda as "AWS Lambda" +participant handler as "LambdaHandler.java" +participant framework as "Java Servlet implementation\n(spring-mvc, JAX-RS, etc)" +control controller as "FrontendController.java" + +user -> api: GET /environment/fooenv + +api -> ars: authorize() +api <-- ars: allow +api -> api: throttle() +api -> lambda: invoke(arn:FrontendHandler) +note left of lambda +request: { + "path": "/environment/fooenv" + ... +} +end note + +lambda -> handler: handleRequest(\n <>\n) +handler -> framework: service(\n <>\n) +framework -> framework: routing and unmarshaling magic +framework -> controller: describeEnvironment("fooenv") + +note over controller + (actually service request) +end note +framework <-- controller: <> +handler <-- framework: <> +lambda <-- handler: <> +api <-- lambda: success +note right of api +response: { + "body": "{name:foo}" + "status": 200 +} +end note +user <-- api: HTTP 200 OK +note right of user +{name:foo} +end note +@enduml \ No newline at end of file diff --git a/docs/frontend_design.md b/docs/frontend_design.md new file mode 100644 index 00000000..132558f1 --- /dev/null +++ b/docs/frontend_design.md @@ -0,0 +1,33 @@ +# Blox frontend + +The purpose of the frontend is to provide a single HTTP endpoint to call Blox, where we can enforce authentication, authorization, throttling, logging, and other common concerns. + + +## Architecture +The frontend is structured as a single AWS API Gateway API ("API"), which dispatches all HTTP requests to it to a single handler lambda function using AWS_PROXY mode. + +In this mode, API Gateway serializes details about the HTTP request, caller identity, and other context information into a single JSON message that is provided as the input to the lambda function. The lambda function in turn serializes all information about the HTTP response it wants to send, and returns that to API Gateway, which turns it back into a raw HTTP response. + +The lambda function itself is implemented using `aws-serverless-java-container`, which implements the Java Servlet spec using these JSON messages, modeled as a `RequestHandler`. This lets us use any Servlet-compatible java web library supported by the container to define our HTTP API. + +Since API Gateway supports defining APIs using Swagger models, and some Java web frameworks support generating Swagger models from Java code that implements a RESTful controller, we can just write our RESTful controller, and use the generated Swagger models to simultaneously define our API. + +![overview](diagrams/api-lambda-proxy.png) + +## Why? +### Why a single lambda function? +The conventional way to use Lambda with API Gateway is to have a separate lambda function for each operation in the API. Each function has to be wired up to its corresponding API operation by ARN through custom Swagger extensions. + +Lambda also supports wiring up multiple API operations to the same lambda function, and then exposes the original HTTP request to the lambda function. Using this approach unlocks some big benefits: + +* Our Cloudformation template and Swagger model are simpler. Since all operations are wired up to a single lambda function, there's only one value to wire through to the Swagger model. +* It's easier to reason about deployments. Since we only have two AWS resources (the API and the Lambda function) we can easily version them for more deterministic deployment. +* It improves active container reuse. If low-volume and high-volume API calls share the same Lambda function, that function is more likely to be "warm" already. If the low-volume call uses its own lambda function, it's less likely to be dispatched to an active container. + +### Why use a Servlet implementation in the lambda function? +Instead of using `aws-serverless-java-container` and using a Servlet implementation, we could write some of our own glue code to correctly call the right code in response to incoming API calls. However, using a Servlet implementation that supports writing RESTful controllers has some compelling benefits: + +* We can use mature, third party web frameworks like SpringMVC or JAX-RS to do HTTP routing, so we have to write less code. +* It's easy to automatically generate Swagger models from the controllers and use those to define our API from CloudFormation (so there's nothing to manually keep in sync). +* For local development, we can run the web server locally like a normal Servlet using Tomcat/Jetty/etc. + diff --git a/end-to-end-tests/build.gradle b/end-to-end-tests/build.gradle new file mode 100644 index 00000000..a8a6097a --- /dev/null +++ b/end-to-end-tests/build.gradle @@ -0,0 +1,28 @@ +group 'com.amazonaws.blox' +version '0.1-SNAPSHOT' + +apply plugin: 'java' + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + testCompile "log4j:log4j:+" + testCompile project(":frontend-service-client") + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +test { onlyIf { false } } + +task testEndToEnd(type: Test) { + group "verification" + description "Run end to end integration tests" + + def deployTask = tasks.getByPath(":frontend-infrastructure:deploy") + + dependsOn deployTask + systemProperty 'blox.tests.stackoutputs', deployTask.outputs.files.singleFile +} diff --git a/end-to-end-tests/src/test/java/com/amazonaws/blox/EnvironmentTest.java b/end-to-end-tests/src/test/java/com/amazonaws/blox/EnvironmentTest.java new file mode 100644 index 00000000..9247d56e --- /dev/null +++ b/end-to-end-tests/src/test/java/com/amazonaws/blox/EnvironmentTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox; + +import static org.junit.Assert.assertEquals; + +import com.amazonaws.blox.model.DescribeEnvironmentRequest; +import com.amazonaws.blox.model.DescribeEnvironmentResult; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.net.URL; +import org.junit.Test; + +public class EnvironmentTest { + private Blox client = Blox.builder().endpoint(endpoint()).build(); + + @Test + public void describeEnvironmentReturnsFakeEnvironment() throws Exception { + DescribeEnvironmentResult result = + client.describeEnvironment(new DescribeEnvironmentRequest().name("foo")); + assertEquals("foo", result.getEnvironment().getName()); + } + + private static String endpoint() { + try { + JsonNode tree = + new ObjectMapper(new JsonFactory()) + .readTree(new File(System.getProperty("blox.tests.stackoutputs"))); + String urlString = tree.get("ApiUrl").asText(); + + // The generated client doesn't like the stage name in the endpoint, so strip it out: + URL url = new URL(urlString); + return new URL(url.getProtocol(), url.getHost(), url.getPort(), "/").toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/end-to-end-tests/src/test/resources/log4j.properties b/end-to-end-tests/src/test/resources/log4j.properties new file mode 100644 index 00000000..3b3caa4e --- /dev/null +++ b/end-to-end-tests/src/test/resources/log4j.properties @@ -0,0 +1,8 @@ +log4j.rootLogger=DEBUG A1 +log4j.appender.A1=org.apache.log4j.ConsoleAppender +log4j.appender.A1.layout=org.apache.log4j.PatternLayout +log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n +# Log all HTTP content (headers, parameters, content, etc) for +# all requests and responses. Use caution with this since it can +# be very expensive to log such verbose data! +# log4j.logger.org.apache.http.wire=DEBUG diff --git a/frontend-infrastructure/build.gradle b/frontend-infrastructure/build.gradle index 48351f31..0d0fa9bd 100644 --- a/frontend-infrastructure/build.gradle +++ b/frontend-infrastructure/build.gradle @@ -1 +1,134 @@ +import com.amazonaws.blox.tasks.PostProcessCloudformation +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + description "AWS Cloudformation templates and deployment scripts for the Blox Frontend API" + +buildscript { + repositories { + mavenCentral() + jcenter() + } + + dependencies { + classpath 'io.swagger:swagger-models:+' + } +} + +ext { + templateFile = file("cloudformation/template.yml") + processedTemplateFile = file("${buildDir}/template.json") + outputTemplateFile = file("${buildDir}/template.output.json") + + stackOutputsFile = file("${buildDir}/${stackName}-${awsProfile}.outputs.json") +} + +def outputOf(taskPath) { + tasks.getByPath(taskPath).outputs.files.singleFile +} + +task createBucket(type: Exec) { + group "deployment" + description "Create the S3 bucket used to store Cloudformation/Lambda resources for deployment" + + commandLine aws("s3", "mb", "s3://${s3BucketName}") +} + +task postprocessCloudformationTemplate(type: PostProcessCloudformation, dependsOn: [":frontend-service:swagger", ":frontend-service:packageLambda"]) { + group "deployment" + description "Postprocess the Cloudformation template to insert Swagger/Lambda references." + + apiName "FrontendApi" + handlerName "FrontendHandler" + + swaggerFile outputOf(":frontend-service:swagger") + lambdaZipFile outputOf(":frontend-service:packageLambda") + + templateFile project.templateFile + outputTemplateFile project.processedTemplateFile +} + +task packageCloudformationResources(type: Exec) { + group "deployment" + description "Use the Cloudformation package command to upload the deployment bundle to S3." + + inputs.files tasks.getByPath(":frontend-service:packageLambda"), postprocessCloudformationTemplate + outputs.file outputTemplateFile + + commandLine aws("cloudformation", "package", + "--template-file", processedTemplateFile, + "--output-template-file", outputTemplateFile, + "--s3-bucket", s3BucketName) +} + + +task deploy(dependsOn: packageCloudformationResources) { + group "deployment" + description "Deploy the Cloudformation package defined by an output template file" + + inputs.files tasks.getByPath(":frontend-service:packageLambda"), packageCloudformationResources + outputs.file stackOutputsFile + + doLast { + + def error = new ByteArrayOutputStream() + def result = exec { + commandLine aws("cloudformation", "deploy", + "--template-file", outputTemplateFile, + "--stack-name", stackName, + "--parameter-overrides", "StageName=${stageName}", + "--capabilities", "CAPABILITY_IAM") + + errorOutput error + ignoreExitValue true + } + + // HACK: The `deploy` command returns a nonzero status if the stack is + // up to date. We can remove this once + // https://github.com/awslabs/serverless-application-model/issues/71 is + // fixed. + if(!error.toString().contains("No changes to deploy")) { + result.assertNormalExitValue() + } + + // In order to make this task incremental, we store the stack outputs + // from deploying the stack as a file. That way tasks that depend on + // this one (such as downloadSdk) don't have to do a redeploy unless + // there's actual code changes. + def output = new ByteArrayOutputStream() + exec { + commandLine aws("cloudformation", "describe-stacks", + "--stack-name", stackName, + "--query", "Stacks[0].Outputs[*].{Key:OutputKey,Value:OutputValue}", + "--output", "json") + standardOutput output + } + + def stackOutputs = new JsonSlurper() + .parseText(output.toString()) + .collectEntries { [(it.Key): (it.Value)] } + + stackOutputsFile.write(JsonOutput.toJson(stackOutputs)) + } +} + +task deleteStack(type: Exec) { + group "debug" + description "Delete the entire cloudformation stack for the frontend" + + commandLine aws("cloudformation", "delete-stack", "--stack-name", stackName) + + doLast { + stackOutputsFile.delete() + } +} + +task describeStackEvents(type: Exec) { + group "debug" + description "Show a table of the events for the cloudformation stack for debugging" + + commandLine aws("cloudformation", "describe-stack-events", + "--stack-name", stackName, + "--query", "StackEvents[*].{Time:Timestamp,Type:ResourceType,Status:ResourceStatus,Reason:ResourceStatusReason}", + "--output", "table") +} diff --git a/frontend-infrastructure/cloudformation/template.yml b/frontend-infrastructure/cloudformation/template.yml new file mode 100644 index 00000000..a079d4a1 --- /dev/null +++ b/frontend-infrastructure/cloudformation/template.yml @@ -0,0 +1,55 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + StageName: + Description: Name of the API Gateway Stage to deploy to. + Type: String + Default: Beta + +Resources: + # Use a single lambda function to handle all requests to the Frontend API + FrontendHandler: + Type: AWS::Serverless::Function + Properties: + Runtime: java8 + CodeUri: "" # will be dynamically replaced + Handler: com.amazonaws.blox.frontend.LambdaHandler + MemorySize: 512 + Timeout: 15 + Events: + FrontendApi: + Type: Api + Properties: + RestApiId: + Ref: FrontendApi + Path: /{proxy+} + Method: ANY + + # This seems to be necessary because we're using AWS_PROXY integration, but not declaring only a + # single route in our swagger + FrontendHandlerPermission: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:invokeFunction' + Principal: apigateway.amazonaws.com + FunctionName: + Ref: FrontendHandler + SourceArn: + Fn::Sub: arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${FrontendApi}/*/*/* + + FrontendApi: + Type: AWS::Serverless::Api + Properties: + StageName: + Ref: StageName + DefinitionBody: {} # will be dynamically replaced + +Outputs: + ApiId: + Description: ID of Frontend API + Value: + Ref: FrontendApi + ApiUrl: + Description: URL of Frontend API endpoint + Value: + Fn::Sub: https://${FrontendApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName} diff --git a/frontend-service/README.md b/frontend-service/README.md new file mode 100644 index 00000000..f1ac1bfa --- /dev/null +++ b/frontend-service/README.md @@ -0,0 +1,9 @@ +# Blox: frontend-service +This is a Spring MVC/REST webservice, running as a single Lambda function, fronted by API Gateway. For more details on how this is structured, see the [documentation](../docs/frontend_design.md). + +### Testing +To run the unit tests for only this project, run the following from the repository root: + +``` +./gradlew frontend-service:check +``` diff --git a/frontend-service/api/swagger.yml b/frontend-service/api/swagger.yml new file mode 100644 index 00000000..cba0af79 --- /dev/null +++ b/frontend-service/api/swagger.yml @@ -0,0 +1,39 @@ +--- +swagger: "2.0" +info: + description: "Blox frontend" + version: "v2017-07-11" + title: "ecs-blox-frontend" +paths: + /environments/{name}: + get: + summary: "Describe Environment by name" + description: "" + operationId: "describeEnvironment" + consumes: + - "*/*" + produces: + - "application/json" + parameters: + - name: "name" + in: "path" + required: true + type: "string" + responses: + 200: + description: "successful operation" + schema: + $ref: "#/definitions/Environment" + x-amazon-apigateway-integration: + passthroughBehavior: "when_no_match" + httpMethod: "POST" + type: "aws_proxy" + uri: + Fn::Sub: "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${FrontendHandler.Arn}/invocations" +definitions: + Environment: + type: "object" + properties: + name: + type: "string" +x-generated-at: "2017-08-03T18:27:27.203Z" diff --git a/frontend-service/build.gradle b/frontend-service/build.gradle index 672e9755..90d68201 100644 --- a/frontend-service/build.gradle +++ b/frontend-service/build.gradle @@ -1,3 +1,75 @@ -description "Lambda-backed API Gateway API for the Blox Frontend API" +import com.amazonaws.blox.tasks.GenerateSwaggerModel +import io.swagger.models.Info + +import com.amazonaws.blox.swagger.ApiGatewayExtensionsFilter +import com.amazonaws.blox.swagger.SwaggerFilter + +description "Lambda-backed API Gateway API for the Blox Frontend Service" apply plugin: 'java' + +sourceCompatibility = 1.8 + +buildscript { + repositories { + mavenCentral() + jcenter() + } + + dependencies { + classpath 'io.swagger:swagger-models:+' + } +} + +repositories { + mavenCentral() + jcenter() +} + +dependencies { + // basic Lambda handler support + compile 'com.amazonaws:aws-lambda-java-core:1.+' + compile 'com.amazonaws:aws-lambda-java-log4j:1.+' + + // lambda+spring integration + compile 'com.amazonaws.serverless:aws-serverless-java-container-spring:0.4+' + + // extra swagger annotations for defining API properties in code + compile 'io.swagger:swagger-annotations:1.+' + + compileOnly 'org.projectlombok:lombok:1.16.16' + + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +task swagger(type: GenerateSwaggerModel, dependsOn: classes) { + group "build" + description "Generate a swagger.yml definition from the Resource classes in this application" + + scanClasspath = project.sourceSets.main.runtimeClasspath + + apiClasses.add 'com.amazonaws.blox.frontend.controllers.EnvironmentController' + + swaggerFile file("api/swagger.yml") + + filters.add({ swagger -> + swagger.info(new Info() + .title("ecs-blox-frontend") + .version("v2017-07-11") + .description("Blox frontend")) + } as SwaggerFilter) + + filters.add(new ApiGatewayExtensionsFilter("arn:aws:apigateway:\${AWS::Region}:lambda:path/2015-03-31/functions/\${FrontendHandler.Arn}/invocations")) +} + +task packageLambda(type: Zip, dependsOn: classes) { + group "build" + description "Create a Lambda deployment package with this package's code and all required libraries" + + from compileJava + from processResources + + into('lib') { + from configurations.runtime + } +} diff --git a/frontend-service/src/main/java/com/amazonaws/blox/frontend/Application.java b/frontend-service/src/main/java/com/amazonaws/blox/frontend/Application.java new file mode 100644 index 00000000..b9603367 --- /dev/null +++ b/frontend-service/src/main/java/com/amazonaws/blox/frontend/Application.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.frontend; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@Configuration +@EnableWebMvc +@ComponentScan("com.amazonaws.blox.frontend") +public class Application {} diff --git a/frontend-service/src/main/java/com/amazonaws/blox/frontend/LambdaHandler.java b/frontend-service/src/main/java/com/amazonaws/blox/frontend/LambdaHandler.java new file mode 100644 index 00000000..f48b8eb6 --- /dev/null +++ b/frontend-service/src/main/java/com/amazonaws/blox/frontend/LambdaHandler.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.frontend; + +import com.amazonaws.serverless.exceptions.ContainerInitializationException; +import com.amazonaws.serverless.proxy.internal.LambdaContainerHandler; +import com.amazonaws.serverless.proxy.internal.model.ApiGatewayRequestContext; +import com.amazonaws.serverless.proxy.internal.model.AwsProxyRequest; +import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsHttpServletResponse; +import com.amazonaws.serverless.proxy.internal.servlet.AwsProxyHttpServletRequest; +import com.amazonaws.serverless.proxy.spring.SpringLambdaContainerHandler; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import lombok.extern.log4j.Log4j; + +/** Entrypoint for mapping incoming API GW requests to Lambda into Spring controllers */ +@Log4j +public class LambdaHandler implements RequestHandler { + private SpringLambdaContainerHandler handler; + + public AwsProxyResponse handleRequest(AwsProxyRequest request, Context context) { + ApiGatewayRequestContext requestContext = request.getRequestContext(); + + log.info("Handling request: " + requestContext.getRequestId()); + log.debug("Caller identity: " + requestContext.getIdentity().getCaller()); + AwsProxyResponse response = getHandler().proxy(request, context); + + log.info( + "Completed request: " + + requestContext.getRequestId() + + ", with response: " + + response.getStatusCode()); + return response; + } + + private LambdaContainerHandler< + AwsProxyRequest, AwsProxyResponse, AwsProxyHttpServletRequest, AwsHttpServletResponse> + getHandler() { + // TODO: lazy initialization was copied from the example code, figure out why we can't + // statically initialize the handler instead. + if (handler == null) { + try { + log.info("Initializing handler (probably a cold start)"); + handler = SpringLambdaContainerHandler.getAwsProxyHandler(Application.class); + } catch (ContainerInitializationException e) { + throw new RuntimeException(e); + } + } + return handler; + } +} diff --git a/frontend-service/src/main/java/com/amazonaws/blox/frontend/controllers/EnvironmentController.java b/frontend-service/src/main/java/com/amazonaws/blox/frontend/controllers/EnvironmentController.java new file mode 100644 index 00000000..921ce02d --- /dev/null +++ b/frontend-service/src/main/java/com/amazonaws/blox/frontend/controllers/EnvironmentController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.frontend.controllers; + +import com.amazonaws.blox.frontend.models.Environment; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@Api +@RestController +@RequestMapping( + path = "/environments", + produces = "application/json", + consumes = "application/json" +) +public class EnvironmentController { + @RequestMapping(path = "/{name}", method = RequestMethod.GET, consumes = "*/*") + @ApiOperation(value = "Describe Environment by name") + public Environment describeEnvironment(@PathVariable("name") String name) { + return new Environment(name); + } +} diff --git a/frontend-service/src/main/java/com/amazonaws/blox/frontend/models/Environment.java b/frontend-service/src/main/java/com/amazonaws/blox/frontend/models/Environment.java new file mode 100644 index 00000000..01203701 --- /dev/null +++ b/frontend-service/src/main/java/com/amazonaws/blox/frontend/models/Environment.java @@ -0,0 +1,22 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.frontend.models; + +import lombok.Value; + +@Value +public final class Environment { + private final String name; +} diff --git a/frontend-service/src/main/resources/log4j.properties b/frontend-service/src/main/resources/log4j.properties new file mode 100644 index 00000000..26923c43 --- /dev/null +++ b/frontend-service/src/main/resources/log4j.properties @@ -0,0 +1,7 @@ +log = . +log4j.rootLogger = DEBUG, LAMBDA + +#Define the LAMBDA appender +log4j.appender.LAMBDA=com.amazonaws.services.lambda.runtime.log4j.LambdaAppender +log4j.appender.LAMBDA.layout=org.apache.log4j.PatternLayout +log4j.appender.LAMBDA.layout.conversionPattern=%d{yyyy-MM-dd HH:mm:ss} <%X{AWSRequestId}> %-5p %c{1}:%m%n \ No newline at end of file diff --git a/frontend-service/src/test/java/com/amazonaws/blox/frontend/ContainerStartupTest.java b/frontend-service/src/test/java/com/amazonaws/blox/frontend/ContainerStartupTest.java new file mode 100644 index 00000000..eb6bc4bb --- /dev/null +++ b/frontend-service/src/test/java/com/amazonaws/blox/frontend/ContainerStartupTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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.amazonaws.blox.frontend; + +import static org.junit.Assert.assertEquals; + +import com.amazonaws.serverless.proxy.internal.model.AwsProxyResponse; +import com.amazonaws.serverless.proxy.internal.testutils.AwsProxyRequestBuilder; +import com.amazonaws.serverless.proxy.internal.testutils.MockLambdaContext; +import org.junit.Test; + +public final class ContainerStartupTest { + private static final LambdaHandler handler = new LambdaHandler(); + + @Test + public final void handleRealisticRequestSuccessfully() { + AwsProxyResponse response = + handler.handleRequest( + new AwsProxyRequestBuilder().method("GET").path("/environments/test-env").build(), + new MockLambdaContext()); + + assertEquals(200, response.getStatusCode()); + assertEquals("{\"name\":\"test-env\"}", response.getBody()); + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f8409a9b Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..36f492bb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Aug 03 11:27:16 PDT 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.0.2-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..cccdd3d5 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/licenses/license-header.java b/licenses/license-header.java new file mode 100644 index 00000000..14014683 --- /dev/null +++ b/licenses/license-header.java @@ -0,0 +1,14 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. 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. A copy of the + * License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "LICENSE" file accompanying this file. This file 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. + */ diff --git a/settings.gradle b/settings.gradle index 3a8e9bbf..ab873be0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,3 +7,4 @@ include 'frontend-service' include 'frontend-infrastructure' include 'data-service-model' include 'data-service' +include 'end-to-end-tests'