diff --git a/README.md b/README.md index 7b95de0..a9b4bc3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ How to verify the most important user journeys are not broken without writing a repo = "identity", personalAccessToken = config.Tip.personalAccessToken, // remove if you do not need GitHub label functionality label = "Verified in PROD", // remove if you do not need GitHub label functionality - boardSha = BuildInfo.GitHeadSha + boardSha = BuildInfo.GitHeadSha // remove if you do not only one board instead of board per sha ) if (config.App.stage == "PROD") diff --git a/cloud/deploy.sh b/cloud/deploy.sh index 2bfc119..ec69f22 100755 --- a/cloud/deploy.sh +++ b/cloud/deploy.sh @@ -20,5 +20,6 @@ aws lambda update-function-code --function-name tip-create-board --s3-bucket $S3 aws lambda update-function-code --function-name tip-get-board --s3-bucket $S3_BUCKET --s3-key $S3_KEY aws lambda update-function-code --function-name tip-get-head-board --s3-bucket $S3_BUCKET --s3-key $S3_KEY aws lambda update-function-code --function-name tip-verify-path --s3-bucket $S3_BUCKET --s3-key $S3_KEY +aws lambda update-function-code --function-name tip-verify-head-path --s3-bucket $S3_BUCKET --s3-key $S3_KEY echo "Done." \ No newline at end of file diff --git a/cloud/postman_collection.json b/cloud/postman_collection.json index a961791..ebd7a88 100644 --- a/cloud/postman_collection.json +++ b/cloud/postman_collection.json @@ -6,7 +6,6 @@ }, "item": [ { - "_postman_id": "2cf06285-c485-460a-9b73-c62572b71c21", "name": "create board", "request": { "method": "POST", @@ -18,20 +17,14 @@ ], "body": { "mode": "raw", - "raw": "{\n \"sha\": \"testsha2\",\n \"repo\": \"guardian/identity\",\n \"deployTime\": \"2018-07-31T13:28:40Z\",\n \"board\": [\n\n {\n \"name\": \"Register\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Register Guest\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Get User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Web Sign In\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"App Sign in\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Consents Set\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Validation Email\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Get User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Web Sign In\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"App Sign in\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Consents Set\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Validation Email\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Delete Account\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update Password\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Join Group\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Change Email\",\n \"verified\": false\n }\n\n ]\n }" + "raw": "{\n \"sha\": \"testsha6\",\n \"repo\": \"guardian/identity\",\n \"board\": [\n\n {\n \"name\": \"Register\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Register Guest\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Get User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Web Sign In\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"App Sign in\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Consents Set\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Validation Email\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Delete Account\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update Password\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Join Group\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Change Email\",\n \"verified\": false\n }\n\n ]\n }" }, "url": { - "raw": "https://howwf2kle3.execute-api.eu-west-1.amazonaws.com/PROD/board", - "protocol": "https", + "raw": "{{tipCloudApiUrl}}/board", "host": [ - "howwf2kle3", - "execute-api", - "eu-west-1", - "amazonaws", - "com" + "{{tipCloudApiUrl}}" ], "path": [ - "PROD", "board" ] } @@ -39,7 +32,6 @@ "response": [] }, { - "_postman_id": "c059d627-f1a9-4c64-a7f4-908d7b3fda78", "name": "get board", "request": { "method": "GET", @@ -52,26 +44,19 @@ ], "body": {}, "url": { - "raw": "https://howwf2kle3.execute-api.eu-west-1.amazonaws.com/PROD/board/fa315cb5ba207a9e945606903d8e1bf60003c5c1", - "protocol": "https", + "raw": "{{tipCloudApiUrl}}/board/testsha2", "host": [ - "howwf2kle3", - "execute-api", - "eu-west-1", - "amazonaws", - "com" + "{{tipCloudApiUrl}}" ], "path": [ - "PROD", "board", - "fa315cb5ba207a9e945606903d8e1bf60003c5c1" + "testsha2" ] } }, "response": [] }, { - "_postman_id": "d55c9d06-74c7-49f8-a285-de4cef56b681", "name": "verify path", "request": { "method": "POST", @@ -83,13 +68,32 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"sha\": \"fa315cb5ba207a9e945606903d8e1bf60003c5c1\",\n\t\"name\": \"Register\"\n}" + "raw": "{\n\t\"sha\": \"testsha2\",\n\t\"name\": \"Register Guest\"\n}" }, "url": { - "raw": "https://howwf2kle3.execute-api.eu-west-1.amazonaws.com/PROD/board/path", + "raw": "{{tipCloudApiUrl}}/board/path", + "host": [ + "{{tipCloudApiUrl}}" + ], + "path": [ + "board", + "path" + ] + } + }, + "response": [] + }, + { + "name": "head board", + "request": { + "method": "GET", + "header": [], + "body": {}, + "url": { + "raw": "https://i2i2l4x9kl.execute-api.eu-west-1.amazonaws.com/PROD/guardian/identity/boards/head", "protocol": "https", "host": [ - "howwf2kle3", + "i2i2l4x9kl", "execute-api", "eu-west-1", "amazonaws", @@ -97,8 +101,40 @@ ], "path": [ "PROD", - "board", - "path" + "guardian", + "identity", + "boards", + "head" + ] + } + }, + "response": [] + }, + { + "name": "verify head path", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"name\": \"Get User\"\n}" + }, + "url": { + "raw": "{{tipCloudApiUrl}}/guardian/identity/boards/head/paths", + "host": [ + "{{tipCloudApiUrl}}" + ], + "path": [ + "guardian", + "identity", + "boards", + "head", + "paths" ] } }, diff --git a/cloud/tip-cloud.yaml b/cloud/tip-cloud.yaml index e4bb4d4..730ba1b 100644 --- a/cloud/tip-cloud.yaml +++ b/cloud/tip-cloud.yaml @@ -160,6 +160,45 @@ Resources: - ApiHeadResource - tipGetHeadBoardLambda + ApiHeadPathsResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref Api + ParentId: !Ref ApiHeadResource + PathPart: paths + DependsOn: + - Api + - ApiOwnerResource + - ApiRepoResource + - ApiBoardsResource + - ApiHeadResource + + ApiHeadPathsMethod: + Type: AWS::ApiGateway::Method + Properties: + ApiKeyRequired: false + AuthorizationType: NONE + RestApiId: !Ref Api + ResourceId: !Ref ApiHeadPathsResource + HttpMethod: PATCH + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + IntegrationResponses: + - StatusCode: '200' + Uri: !Sub arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/${tipVerifyHeadPathLambda.Arn}/invocations + MethodResponses: + - StatusCode: '200' + ResponseModels: { 'text/html': 'Empty' } + DependsOn: + - Api + - ApiOwnerResource + - ApiRepoResource + - ApiBoardsResource + - ApiHeadResource + - ApiHeadPathsResource + - tipVerifyHeadPathLambda + AllowApiGatewayToInvokeCreateBoardLambdaPermission: Type: AWS::Lambda::Permission Properties: @@ -180,6 +219,16 @@ Resources: - tipVerifyPathLambda - ApiBoardPathMethod + AllowApiGatewayToInvokeVerifyHeadPathLambdaPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + FunctionName: !Sub arn:aws:lambda:eu-west-1:${AWS::AccountId}:function:tip-verify-head-path + DependsOn: + - tipVerifyHeadPathLambda + - ApiHeadPathsMethod + AllowApiGatewayToInvokeGetBoardLambdaPermission: Type: AWS::Lambda::Permission Properties: @@ -215,6 +264,7 @@ Resources: - AllowApiGatewayToInvokeVerifyPathLambdaPermission - AllowApiGatewayToInvokeGetBoardLambdaPermission - AllowApiGatewayToInvokeGetHeadBoardLambdaPermission + - AllowApiGatewayToInvokeVerifyHeadPathLambdaPermission # **************************************************************************** # Lambdas @@ -275,6 +325,20 @@ Resources: MemorySize: "128" Timeout: "10" + tipVerifyHeadPathLambda: + Type: "AWS::Lambda::Function" + Properties: + FunctionName: tip-verify-head-path + Description: Verify path on head (latest) board + Handler: "tip-verify-head-path.handler" + Role: !Sub arn:aws:iam::${AWS::AccountId}:role/lambda-dynamodb-full-access-role + Code: + S3Bucket: identity-lambda + S3Key: tip-cloud.zip + Runtime: nodejs8.10 + MemorySize: "128" + Timeout: "10" + # **************************************************************************** # Database # **************************************************************************** diff --git a/cloud/tip-get-head-board.js b/cloud/tip-get-head-board.js index c9ed0b9..2e2b043 100644 --- a/cloud/tip-get-head-board.js +++ b/cloud/tip-get-head-board.js @@ -17,6 +17,20 @@ const getLatestBoard = (boards) => .Items .sort((a,b) => new Date(b.deployTime) - new Date(a.deployTime))[0]; +const buildLinkToCommit = (repo, sha) => { + if (repo == sha) + return `https://github.com/${repo}/commit/master`; + else + return `https://github.com/${repo}/commit/${sha}`; +} + +const buildLinkToCommitHeader = (repo, sha) => { + if (repo == sha) + return `${repo}`; + else + return `${repo} ${sha}`; +} + const renderBoard = (item) => { return new Promise((resolve, reject) => { const pathsWithStatusColour = @@ -40,8 +54,6 @@ const renderBoard = (item) => { const deployTime = item.deployTime; const elapsedTimeSinceDeploy = Date.now() - Date.parse(deployTime); - const linkToCommit = `https://github.com/${repo}/commit/${sha}`; - const html = ` @@ -111,7 +123,7 @@ const renderBoard = (item) => {

- ${repo} ${sha} + ${buildLinkToCommitHeader(repo, sha)}


@@ -164,4 +176,4 @@ exports.handler = (event, context, callback) => { .then(boards => getLatestBoard(boards)) .then(latestBoard => renderBoard(latestBoard)) .then(boardAsHtml => callback(null, boardAsHtml)); -}; +}; \ No newline at end of file diff --git a/cloud/tip-verify-head-path.js b/cloud/tip-verify-head-path.js new file mode 100644 index 0000000..d4a94bf --- /dev/null +++ b/cloud/tip-verify-head-path.js @@ -0,0 +1,46 @@ +const AWS = require('aws-sdk'); +AWS.config.update({region: 'eu-west-1'}); +const ddb = new AWS.DynamoDB.DocumentClient(); + +function updateBoard(dbItem) { + ddb.put( + { + TableName: 'TipCloud-PROD', + Item: dbItem + } + ).promise(); +} + +const getBoards = (repo) => { + return ddb.scan( + { + TableName: 'TipCloud-PROD', + FilterExpression : 'repo = :repo', + ExpressionAttributeValues : {':repo' : `${repo}`} + } + ).promise(); +} + +function verifyPath(boards, path) { + return new Promise((resolve, reject) => { + const sortedBoards = boards.Items.sort((a,b) => new Date(b.deployTime) - new Date(a.deployTime)); + const headBoard = sortedBoards[0]; + const index = headBoard.board.findIndex(element => element.name === path); + headBoard.board[index] = { name: path, verified: true }; + resolve(headBoard); + }); +} + +exports.handler = (event, context, callback) => { + const owner = event.pathParameters.owner; + const repo = event.pathParameters.repo; + const slug = `${owner}/${repo}`; + + const body = JSON.parse(event.body); + const name = body.name; + + getBoards(slug) + .then(data => verifyPath(data, name)) + .then(dbItem => updateBoard(dbItem)) + .then(() => callback(null, {statusCode: 200, body: null})); +}; \ No newline at end of file diff --git a/src/main/scala/com/gu/tip/Configuration.scala b/src/main/scala/com/gu/tip/Configuration.scala index 52449e5..92a2082 100644 --- a/src/main/scala/com/gu/tip/Configuration.scala +++ b/src/main/scala/com/gu/tip/Configuration.scala @@ -14,9 +14,10 @@ import scala.io.Source case class TipConfig(owner: String, repo: String, + cloudEnabled: Boolean = true, + boardSha: String = "", personalAccessToken: String = "", - label: String = "", - boardSha: String = "") + label: String = "") class TipConfigurationException( msg: String = "Missing TiP config. Please refer to README.") @@ -64,8 +65,6 @@ class Configuration(config: TipConfig) { case e: FileNotFoundException => throw new MissingPathConfigurationFile case _ => throw new PathConfigurationSyntaxError }.get - - def cloudEnabled: Boolean = tipConfig.boardSha.nonEmpty } trait ConfigurationIf { diff --git a/src/main/scala/com/gu/tip/HttpClient.scala b/src/main/scala/com/gu/tip/HttpClient.scala index fd26202..38d29eb 100644 --- a/src/main/scala/com/gu/tip/HttpClient.scala +++ b/src/main/scala/com/gu/tip/HttpClient.scala @@ -13,6 +13,9 @@ trait HttpClientIf { def post(endpoint: String, authHeader: (String, String), jsonBody: String): WriterT[IO, List[Log], String] + def patch(endpoint: String, + authHeader: (String, String), + jsonBody: String): WriterT[IO, List[Log], String] } trait HttpClient extends HttpClientIf with Http4sClientDsl[IO] { @@ -55,5 +58,23 @@ trait HttpClient extends HttpClientIf with Http4sClientDsl[IO] { ) } + override def patch(endpoint: String, + authHeader: (String, String), + jsonBody: String): WriterT[IO, List[Log], String] = { + val request = PATCH(Uri.unsafeFromString(endpoint), jsonBody) + .putHeaders(Header(authHeader._1, authHeader._2)) + + WriterT( + client.expect[String](request).map { response => + ( + List( + Log("INFO", + s"Successfully executed HTTP PATCH request to $endpoint")), + response + ) + } + ) + } + private val client = Http1Client[IO]().unsafeRunSync } diff --git a/src/main/scala/com/gu/tip/Tip.scala b/src/main/scala/com/gu/tip/Tip.scala index b103cef..528eb71 100644 --- a/src/main/scala/com/gu/tip/Tip.scala +++ b/src/main/scala/com/gu/tip/Tip.scala @@ -89,8 +89,16 @@ trait Tip extends TipIf with LazyLogging { Future { inMemoryResult match { case PathsActorResponse(PathIsVerified(_)) | AllTestsInProductionPassed - if configuration.cloudEnabled => - verifyPath(configuration.tipConfig.boardSha, pathname).run.attempt + if configuration.tipConfig.cloudEnabled => + val action = if (configuration.tipConfig.boardSha.nonEmpty) { + verifyPath(configuration.tipConfig.boardSha, pathname) + } else { + verifyHeadPath(configuration.tipConfig.owner, + configuration.tipConfig.repo, + pathname) + } + + action.run.attempt .map({ case Left(error) => logger.error(s"Failed to cloud verify path $pathname", error) @@ -126,7 +134,7 @@ object Tip with HttpClient with ConfigFromTypesafe { - if (configuration.cloudEnabled) { + if (configuration.tipConfig.cloudEnabled) { val sha = configuration.tipConfig.boardSha val repo = s"${configuration.tipConfig.owner}/${configuration.tipConfig.repo}" @@ -141,7 +149,7 @@ object TipFactory { with ConfigurationIf { override val configuration: Configuration = new Configuration(tipConfig) - if (configuration.cloudEnabled) { + if (configuration.tipConfig.cloudEnabled) { val sha = configuration.tipConfig.boardSha val repo = s"${configuration.tipConfig.owner}/${configuration.tipConfig.repo}" @@ -156,7 +164,7 @@ object TipFactory { override val configuration: Configuration = new Configuration( typesafeConfig) - if (configuration.cloudEnabled) { + if (configuration.tipConfig.cloudEnabled) { val sha = configuration.tipConfig.boardSha val repo = s"${configuration.tipConfig.owner}/${configuration.tipConfig.repo}" diff --git a/src/main/scala/com/gu/tip/cloud/TipCloudApi.scala b/src/main/scala/com/gu/tip/cloud/TipCloudApi.scala index 85d18d4..a5c1857 100644 --- a/src/main/scala/com/gu/tip/cloud/TipCloudApi.scala +++ b/src/main/scala/com/gu/tip/cloud/TipCloudApi.scala @@ -10,6 +10,9 @@ import net.liftweb.json._ trait TipCloudApiIf { this: HttpClientIf with ConfigurationIf => def createBoard(sha: String, repo: String): WriterT[IO, List[Log], String] def verifyPath(sha: String, name: String): WriterT[IO, List[Log], String] + def verifyHeadPath(owner: String, + repo: String, + name: String): WriterT[IO, List[Log], String] def getBoard(sha: String): WriterT[IO, List[Log], String] } @@ -17,7 +20,7 @@ trait TipCloudApi extends TipCloudApiIf with LazyLogging { this: HttpClientIf with ConfigurationIf => val tipCloudApiRoot = - "https://i2i2l4x9kl.execute-api.eu-west-1.amazonaws.com/PROD" + "https://be9p0izsnc.execute-api.eu-west-1.amazonaws.com/PROD" override def createBoard(sha: String, repo: String): WriterT[IO, List[Log], String] = { @@ -35,7 +38,7 @@ trait TipCloudApi extends TipCloudApiIf with LazyLogging { val body = s""" |{ - | "sha": "$sha", + | "sha": "${if (sha.nonEmpty) sha else repo}", | "repo": "$repo", | "board": [ | ${board.mkString(",")} @@ -57,11 +60,29 @@ trait TipCloudApi extends TipCloudApiIf with LazyLogging { |} """.stripMargin - post(s"$tipCloudApiRoot/board/path", auth, compactRender(parse((body)))) + post(s"$tipCloudApiRoot/board/path", auth, compactRender(parse(body))) .tell( List(Log("INFO", s"Successfully verified path $name on board $sha"))) } + override def verifyHeadPath(owner: String, + repo: String, + name: String): WriterT[IO, List[Log], String] = { + val body = + s""" + |{ + | "name": "$name" + |} + """.stripMargin + + patch(s"$tipCloudApiRoot/$owner/$repo/boards/head/paths", + auth, + compactRender(parse(body))) + .tell( + List( + Log("INFO", s"Successfully verified path $name on head board $repo"))) + } + override def getBoard(sha: String): WriterT[IO, List[Log], String] = get(s"$tipCloudApiRoot/board/$sha", auth) .tell(List(Log("INFO", s"Successfully retrieved board $sha"))) diff --git a/src/test/resources/application.conf b/src/test/resources/application.conf index 4d4327a..602b7bc 100644 --- a/src/test/resources/application.conf +++ b/src/test/resources/application.conf @@ -1,6 +1,8 @@ tip { owner = "mario-galic" repo = "sandbox" + cloudEnabled = "false" personalAccessToken = "testtoken" label = "Verified in PROD" + boardSha = "testsha" } \ No newline at end of file