Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add CloudFormation Custom Resource #6

Merged
merged 5 commits into from
Oct 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ lazy val root =
.aggregate(
core.js,
core.jvm,
lambdaCloudFormationCustomResource.js,
lambdaCloudFormationCustomResource.jvm,
lambda.js,
lambda.jvm,
lambdaEvents.js,
Expand Down Expand Up @@ -97,3 +99,21 @@ lazy val lambdaApiGatewayProxyHttp4s = crossProject(JSPlatform, JVMPlatform)
)
)
.dependsOn(lambda, lambdaEvents)

lazy val lambdaCloudFormationCustomResource = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Pure)
.in(file("lambda-cloudformation-custom-resource"))
.settings(
name := "feral-lambda-cloudformation-custom-resource",
scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, 13)) => Seq("-Ywarn-macros:after")
case _ => Nil
}),
libraryDependencies ++= Seq(
"io.monix" %%% "newtypes-core" % "0.0.1",
"io.circe" %%% "circe-generic" % circeVersion,
"org.http4s" %%% "http4s-ember-client" % http4sVersion,
"org.http4s" %%% "http4s-circe" % http4sVersion,
)
)
.dependsOn(lambda)
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2021 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package feral.lambda.cloudformation

import cats.effect._
import cats.effect.kernel.Resource
import cats.syntax.all._
import feral.lambda.cloudformation.CloudFormationCustomResourceHandler.stackTraceLines
import feral.lambda.cloudformation.CloudFormationRequestType._
import feral.lambda.{Context, IOLambda}
import io.circe._
import io.circe.syntax._
import org.http4s.Method.POST
import org.http4s.client.Client
import org.http4s.circe._
import org.http4s.client.dsl.io._
import org.http4s.ember.client.EmberClientBuilder

import java.io.{PrintWriter, StringWriter}

trait CloudFormationCustomResource[F[_], Input, Output] {
def createResource(event: CloudFormationCustomResourceRequest[Input], context: Context): F[HandlerResponse[Output]]
def updateResource(event: CloudFormationCustomResourceRequest[Input], context: Context): F[HandlerResponse[Output]]
def deleteResource(event: CloudFormationCustomResourceRequest[Input], context: Context): F[HandlerResponse[Output]]
}

abstract class CloudFormationCustomResourceHandler[Input : Decoder, Output: Encoder]
extends IOLambda[CloudFormationCustomResourceRequest[Input], Unit] {
type Setup = (Client[IO], CloudFormationCustomResource[IO, Input, Output])

override final def setup: Resource[IO, Setup] =
client.mproduct(handler)
bpholt marked this conversation as resolved.
Show resolved Hide resolved

protected def client: Resource[IO, Client[IO]] =
EmberClientBuilder.default[IO].build

def handler(client: Client[IO]): Resource[IO, CloudFormationCustomResource[IO, Input, Output]]

override def apply(event: CloudFormationCustomResourceRequest[Input],
context: Context,
setup: Setup): IO[Option[Unit]] =
(event.RequestType match {
case CreateRequest => setup._2.createResource(event, context)
case UpdateRequest => setup._2.updateResource(event, context)
case DeleteRequest => setup._2.deleteResource(event, context)
case OtherRequestType(other) => illegalRequestType(other)
})
.attempt
.map(_.fold(exceptionResponse(event)(_), successResponse(event)(_)))
.flatMap { resp =>
setup._1.successful(POST(resp.asJson, event.ResponseURL))
}
.as(None)

private def illegalRequestType[A](other: String): IO[A] =
(new IllegalArgumentException(s"unexpected CloudFormation request type `$other``"): Throwable).raiseError[IO, A]

private def exceptionResponse(req: CloudFormationCustomResourceRequest[Input])
(ex: Throwable): CloudFormationCustomResourceResponse =
CloudFormationCustomResourceResponse(
Status = RequestResponseStatus.Failed,
Reason = Option(ex.getMessage),
PhysicalResourceId = req.PhysicalResourceId,
StackId = req.StackId,
RequestId = req.RequestId,
LogicalResourceId = req.LogicalResourceId,
Data = JsonObject("StackTrace" -> Json.arr(stackTraceLines(ex).map(Json.fromString): _*)).asJson
)

private def successResponse(req: CloudFormationCustomResourceRequest[Input])
(res: HandlerResponse[Output]): CloudFormationCustomResourceResponse =
CloudFormationCustomResourceResponse(
Status = RequestResponseStatus.Success,
Reason = None,
PhysicalResourceId = Option(res.physicalId),
StackId = req.StackId,
RequestId = req.RequestId,
LogicalResourceId = req.LogicalResourceId,
Data = res.data.asJson
)

}

object CloudFormationCustomResourceHandler {
def stackTraceLines(throwable: Throwable): List[String] = {
val writer = new StringWriter()
throwable.printStackTrace(new PrintWriter(writer))
writer.toString.linesIterator.toList
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2021 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package feral.lambda

import cats.syntax.all._
import io.circe._
import io.circe.syntax._
import io.circe.generic.semiauto._
import monix.newtypes._
import org.http4s.Uri
import org.http4s.circe.CirceInstances

package object cloudformation {
type PhysicalResourceId = PhysicalResourceId.Type
type StackId = StackId.Type
type RequestId = RequestId.Type
type LogicalResourceId = LogicalResourceId.Type
type ResourceType = ResourceType.Type
}

package cloudformation {

object PhysicalResourceId extends NewtypeWrapped[String] {
implicit val PhysicalResourceIdDecoder: Decoder[PhysicalResourceId] = derive[Decoder]
implicit val PhysicalResourceIdEncoder: Encoder[PhysicalResourceId] = derive[Encoder]
}
object StackId extends NewtypeWrapped[String] {
implicit val StackIdDecoder: Decoder[StackId] = derive[Decoder]
implicit val StackIdEncoder: Encoder[StackId] = derive[Encoder]
}
object RequestId extends NewtypeWrapped[String] {
implicit val RequestIdDecoder: Decoder[RequestId] = derive[Decoder]
implicit val RequestIdEncoder: Encoder[RequestId] = derive[Encoder]
}
object LogicalResourceId extends NewtypeWrapped[String] {
implicit val LogicalResourceIdDecoder: Decoder[LogicalResourceId] = derive[Decoder]
implicit val LogicalResourceIdEncoder: Encoder[LogicalResourceId] = derive[Encoder]
}
object ResourceType extends NewtypeWrapped[String] {
implicit val ResourceTypeDecoder: Decoder[ResourceType] = derive[Decoder]
implicit val ResourceTypeEncoder: Encoder[ResourceType] = derive[Encoder]
}

sealed trait CloudFormationRequestType
object CloudFormationRequestType {
case object CreateRequest extends CloudFormationRequestType
case object UpdateRequest extends CloudFormationRequestType
case object DeleteRequest extends CloudFormationRequestType
case class OtherRequestType(requestType: String) extends CloudFormationRequestType

implicit val encoder: Encoder[CloudFormationRequestType] = {
case CreateRequest => "Create".asJson
case UpdateRequest => "Update".asJson
case DeleteRequest => "Delete".asJson
case OtherRequestType(req) => req.asJson
}

implicit val decoder: Decoder[CloudFormationRequestType] = Decoder[String].map {
case "Create" => CreateRequest
case "Update" => UpdateRequest
case "Delete" => DeleteRequest
case other => OtherRequestType(other)
}
}

sealed trait RequestResponseStatus
object RequestResponseStatus {
case object Success extends RequestResponseStatus
case object Failed extends RequestResponseStatus

implicit val encoder: Encoder[RequestResponseStatus] = {
case Success => "SUCCESS".asJson
case Failed => "FAILED".asJson
}

implicit val decoder: Decoder[RequestResponseStatus] = Decoder[String].emap {
case "SUCCESS" => Success.asRight
case "FAILED" => Failed.asRight
case other => s"Invalid response status: $other".asLeft
}
}

case class CloudFormationCustomResourceRequest[A](RequestType: CloudFormationRequestType,
ResponseURL: Uri,
StackId: StackId,
RequestId: RequestId,
ResourceType: ResourceType,
LogicalResourceId: LogicalResourceId,
PhysicalResourceId: Option[PhysicalResourceId],
ResourceProperties: A,
OldResourceProperties: Option[JsonObject])

object CloudFormationCustomResourceRequest extends CirceInstances {
implicit def CloudFormationCustomResourceRequestDecoder[A: Decoder]: Decoder[CloudFormationCustomResourceRequest[A]] = deriveDecoder[CloudFormationCustomResourceRequest[A]]
implicit def CloudFormationCustomResourceRequestEncoder[A: Encoder]: Encoder[CloudFormationCustomResourceRequest[A]] = deriveEncoder[CloudFormationCustomResourceRequest[A]]
}

case class CloudFormationCustomResourceResponse(Status: RequestResponseStatus,
Reason: Option[String],
PhysicalResourceId: Option[PhysicalResourceId],
StackId: StackId,
RequestId: RequestId,
LogicalResourceId: LogicalResourceId,
Data: Json)

object CloudFormationCustomResourceResponse {
implicit val CloudFormationCustomResourceResponseDecoder: Decoder[CloudFormationCustomResourceResponse] = deriveDecoder[CloudFormationCustomResourceResponse]
implicit val CloudFormationCustomResourceResponseEncoder: Encoder[CloudFormationCustomResourceResponse] = deriveEncoder[CloudFormationCustomResourceResponse]
}

case class HandlerResponse[A](physicalId: PhysicalResourceId,
data: Option[A])

object HandlerResponse {
implicit def HandlerResponseCodec[A: Encoder : Decoder]: Codec[HandlerResponse[A]] = deriveCodec[HandlerResponse[A]]
}

object MissingResourceProperties extends RuntimeException
}