Skip to content

Commit

Permalink
Merge pull request #6 from Dwolla/cloudformation-custom-resource
Browse files Browse the repository at this point in the history
add CloudFormation Custom Resource
  • Loading branch information
armanbilge committed Oct 20, 2021
2 parents 5f84f68 + 602582e commit 5d1cc91
Show file tree
Hide file tree
Showing 3 changed files with 257 additions and 0 deletions.
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)

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
}

0 comments on commit 5d1cc91

Please sign in to comment.