diff --git a/.dwollaci.yml b/.dwollaci.yml new file mode 100644 index 0000000..079994b --- /dev/null +++ b/.dwollaci.yml @@ -0,0 +1,21 @@ +stages: + enableCD: true + build: + nodeLabel: sbt + steps: + - | + export SDKMAN_DIR="$HOME/.sdkman" + mkdir -p "${SDKMAN_DIR}/candidates/java/current/bin" + set +o xtrace + . "${SDKMAN_DIR}/bin/sdkman-init.sh" + sdk env install use + set -o xtrace + sbt test + sbt doc + sbt npmPackage + filesToStash: + - '**' + deployProd: + nodeLabel: nvm-sbt-deployer + steps: + - ./deploy.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b01e452 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Continuous Integration + +on: + pull_request: + branches: ['**'] + push: + branches: ['**'] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Test + strategy: + matrix: + os: [ubuntu-22.04] + scala: [3] + java: [temurin@11] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@11) + id: setup-java-temurin-11 + if: matrix.java == 'temurin@11' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 11 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update + + - name: Check that workflows are up to date + run: sbt githubWorkflowCheck + + - name: Build project + run: sbt '++ ${{ matrix.scala }}' test + + - name: Package + run: sbt '++ ${{ matrix.scala }}' npmPackage diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml new file mode 100644 index 0000000..547aaa4 --- /dev/null +++ b/.github/workflows/clean.yml @@ -0,0 +1,59 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Clean + +on: push + +jobs: + delete-artifacts: + name: Delete Artifacts + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Delete artifacts + run: | + # Customize those three lines with your repository and credentials: + REPO=${GITHUB_API_URL}/repos/${{ github.repository }} + + # A shortcut to call GitHub API. + ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } + + # A temporary file which receives HTTP response headers. + TMPFILE=/tmp/tmp.$$ + + # An associative array, key: artifact name, value: number of artifacts of that name. + declare -A ARTCOUNT + + # Process all artifacts on this repository, loop on returned "pages". + URL=$REPO/actions/artifacts + while [[ -n "$URL" ]]; do + + # Get current page, get response headers in a temporary file. + JSON=$(ghapi --dump-header $TMPFILE "$URL") + + # Get URL of next page. Will be empty if we are at the last page. + URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') + rm -f $TMPFILE + + # Number of artifacts on this page: + COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) + + # Loop on all artifacts on this page. + for ((i=0; $i < $COUNT; i++)); do + + # Get name of artifact and count instances of this name. + name=$(jq <<<$JSON -r ".artifacts[$i].name?") + ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) + + id=$(jq <<<$JSON -r ".artifacts[$i].id?") + size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) + printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size + ghapi -X DELETE $REPO/actions/artifacts/$id + done + done diff --git a/.gitignore b/.gitignore index 5ac0569..85c9788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .idea/ target/ project/project/ +node_modules/ +.bsp/ +package.json +package-lock.json diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..8393230 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,17 @@ +# This file was automatically generated by sbt-typelevel-mergify using the +# mergifyGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the mergify configuration +# to meet your needs, then regenerate this file. + +pull_request_rules: +- name: merge scala-steward's PRs + conditions: + - author=dwolla-oss-scala-steward[bot] + - or: + - body~=labels:.*early-semver-patch + - body~=labels:.*early-semver-minor + - status-success=Test (ubuntu-22.04, 3, temurin@11) + actions: + merge: {} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..deed13c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/jod diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..598592a --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=11.0.28-tem diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d391b0c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: scala - -scala: - - 2.12.7 - -env: - global: - - JDK=oraclejdk8 - - AWS_REGION=us-west-2 - -before_script: - - jdk_switcher use $JDK - -script: sbt ++$TRAVIS_SCALA_VERSION clean stack/clean 'testOnly -- timefactor 10' 'stack/testOnly -- timefactor 10' diff --git a/build.sbt b/build.sbt index 7c902de..191d384 100644 --- a/build.sbt +++ b/build.sbt @@ -1,77 +1,66 @@ -lazy val commonSettings = Seq( - organization := "Dwolla", - homepage := Option(url("https://stash.dwolla.net/projects/OPS/repos/cloudflare-public-hostname-lambda")), -) - -lazy val specs2Version = "4.3.0" -lazy val awsSdkVersion = "1.11.475" +import org.typelevel.sbt.gha.WorkflowStep -lazy val `cloudflare-public-hostname-lambda` = (project in file(".")) - .settings( - name := "cloudflare-public-hostname-lambda", - resolvers ++= Seq( - Resolver.bintrayRepo("dwolla", "maven") - ), - libraryDependencies ++= { - val fs2AwsVersion = "1.3.0" +evictionErrorLevel := Level.Warn - Seq( - "com.dwolla" %% "scala-cloudformation-custom-resource" % "2.1.0", - "com.dwolla" %% "fs2-aws" % fs2AwsVersion, - "io.circe" %% "circe-fs2" % "0.9.0", - "com.dwolla" %% "cloudflare-api-client" % "4.0.0-M4", - "org.http4s" %% "http4s-blaze-client" % "0.18.21", - "com.amazonaws" % "aws-java-sdk-kms" % awsSdkVersion, - "org.apache.httpcomponents" % "httpclient" % "4.5.2", - "org.specs2" %% "specs2-core" % specs2Version % Test, - "org.specs2" %% "specs2-mock" % specs2Version % Test, - "org.specs2" %% "specs2-matcher-extra" % specs2Version % Test, - "com.dwolla" %% "testutils-specs2" % "1.11.0" % Test exclude("ch.qos.logback", "logback-classic"), - "com.dwolla" %% "fs2-aws-testkit" % fs2AwsVersion % Test, - ) - }, - updateOptions := updateOptions.value.withCachedResolution(false), - ) - .settings(commonSettings: _*) - .configs(IntegrationTest) - .settings(Defaults.itSettings: _*) - .enablePlugins(PublishToS3) +ThisBuild / organization := "Dwolla" +ThisBuild / homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")) +ThisBuild / licenses += ("MIT", url("http://opensource.org/licenses/MIT")) +ThisBuild / scalaVersion := "3.7.3" +ThisBuild / developers := List( + Developer( + "bpholt", + "Brian Holt", + "bholt+github@dwolla.com", + url("https://dwolla.com") + ), +) +ThisBuild / resolvers += Resolver.sonatypeCentralSnapshots +ThisBuild / mergifyStewardConfig ~= { _.map { + _.withAuthor("dwolla-oss-scala-steward[bot]") + .withMergeMinors(true) +}} +ThisBuild / githubWorkflowPublishTargetBranches := Seq.empty +ThisBuild / githubWorkflowBuild += WorkflowStep.Sbt(List("npmPackage"), name = Some("Package")) -lazy val stack: Project = (project in file("stack")) - .settings(commonSettings: _*) +lazy val `cloudflare-public-hostname-lambda` = project + .in(file(".")) .settings( - resolvers ++= Seq(Resolver.jcenterRepo), + name := "cloudflare-public-hostname-lambda", + smithy4sAwsSpecs ++= Seq(AWS.kms), + scalacOptions += "-Wconf:src=src_managed/.*:s", + dependencyOverrides += "org.scala-lang" %% "scala3-library" % scalaVersion.value, libraryDependencies ++= { - val scalaAwsUtilsVersion = "1.6.1" - Seq( - "com.monsanto.arch" %% "cloud-formation-template-generator" % "3.5.4", - "org.specs2" %% "specs2-core" % specs2Version % "test,it", - "com.amazonaws" % "aws-java-sdk-cloudformation" % awsSdkVersion % IntegrationTest, - "com.dwolla" %% "scala-aws-utils" % scalaAwsUtilsVersion % IntegrationTest, + "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.3.1-68-4c217bd-20251016T232327Z-SNAPSHOT", + "org.typelevel" %%% "cats-tagless-core" % "0.16.3", + "com.dwolla" %%% "cloudflare-api-client" % "4.0-827c1e4-SNAPSHOT", + "com.dwolla" %%% "natchez-tagless" % "0.2.6", + "com.disneystreaming.smithy4s" %%% "smithy4s-cats" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %%% "smithy4s-http4s" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %%% "smithy4s-aws-http4s" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value, + "org.http4s" %%% "http4s-ember-client" % "0.23.30", + "org.typelevel" %%% "mouse" % "1.3.2", + "org.tpolecat" %%% "natchez-mtl" % "0.3.8", + "org.tpolecat" %%% "natchez-xray" % "0.3.8", + "org.tpolecat" %%% "natchez-http4s" % "0.6.1", + "org.tpolecat" %%% "natchez-http4s-mtl" % "0.6.1", + "org.typelevel" %%% "log4cats-core" % "2.7.1", + "org.typelevel" %%% "log4cats-js-console" % "2.7.1", + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-circe" % "2.38.0", + "org.typelevel" %%% "munit-cats-effect" % "2.1.0" % Test, + "org.scalameta" %%% "munit" % "1.2.0" % Test, + "org.scalameta" %%% "munit-scalacheck" % "1.2.0" % Test, + "org.typelevel" %%% "scalacheck-effect-munit" % "2.1.0-RC1" % Test, + "org.tpolecat" %%% "natchez-testkit" % "0.3.8" % Test, + "org.typelevel" %%% "log4cats-testing" % "2.7.1" % Test, ) }, - stackName := (name in `cloudflare-public-hostname-lambda`).value, - stackParameters := List( - "S3Bucket" → (s3Bucket in `cloudflare-public-hostname-lambda`).value, - "S3Key" → (s3Key in `cloudflare-public-hostname-lambda`).value - ), - awsAccountId := sys.props.get("AWS_ACCOUNT_ID"), - awsRoleName := Option("cloudformation/deployer/cloudformation-deployer"), - scalacOptions --= Seq( - "-Xlint:missing-interpolator", - "-Xlint:option-implicit", + buildInfoKeys := Seq[BuildInfoKey]( + name, + version, ), - ) - .configs(IntegrationTest) - .settings(Defaults.itSettings: _*) - .enablePlugins(CloudFormationStack) - .dependsOn(`cloudflare-public-hostname-lambda`) + buildInfoPackage := "com.dwolla.lambda.cloudflare.record", -assemblyMergeStrategy in assembly := { - case PathList(ps @ _*) if ps.last == "Log4j2Plugins.dat" => sbtassembly.Log4j2MergeStrategy.plugincache - case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("log4j2.xml") => MergeStrategy.singleOrError - case _ ⇒ MergeStrategy.first -} -test in assembly := {} + ) + .enablePlugins(BuildInfoPlugin, CdkDeployPlugin, LambdaJSPlugin, Smithy4sCodegenPlugin) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..85619a1 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -o errexit +IFS=$'\n\t' + +# the .git folder and .gitignore file are not stashed by Jenkins, so we need to fetch them +git init +git remote add origin "$GIT_URL" +git fetch origin + +set +o nounset +if [ -z ${TAG_NAME+x} ]; then + TARGET_COMMIT="${GIT_COMMIT}" +else + TARGET_COMMIT="${TAG_NAME}" +fi +readonly TARGET_COMMIT + +# use `git checkout --force` here because we expect the working directory not to be +# empty at this point. Jenkins unstashed everything from the previous stage into the +# working directory; we want to keep the build artifacts (i.e. everything in the +# various target/ directories) but update the files committed to git to the version +# currently being built. +git checkout --force "${TARGET_COMMIT}" + +# nvm is a bash function, so fake command echoing for nvm commands to reduce noise +echo "+ . ${NVM_DIR}/nvm.sh --no-use" +. "${NVM_DIR}/nvm.sh" --no-use + +echo "+ nvm install" +nvm install + +echo "+ export SDKMAN_DIR=$HOME/.sdkman" +export SDKMAN_DIR="$HOME/.sdkman" + +# it seems like a bug in sdkman that this is needed 🤷 +echo "+ mkdir -p ${SDKMAN_DIR}/candidates/java/current/bin" +mkdir -p "${SDKMAN_DIR}/candidates/java/current/bin" + +echo "+ . ${SDKMAN_DIR}/bin/sdkman-init.sh" +. "${SDKMAN_DIR}/bin/sdkman-init.sh" + +echo "+ sdk env install use" +sdk env install use + +set -o xtrace -o nounset -o pipefail +npm install -g npm +npm install -g serverless + +sbt "show deploy Admin" diff --git a/project/CdkDeployPlugin.scala b/project/CdkDeployPlugin.scala new file mode 100644 index 0000000..da0fc31 --- /dev/null +++ b/project/CdkDeployPlugin.scala @@ -0,0 +1,63 @@ +import io.chrisdavenport.npmpackage.sbtplugin.NpmPackagePlugin.autoImport.* +import com.github.sbt.git.SbtGit.git +import feral.lambda.sbt.LambdaJSPlugin +import sbt.Keys.* +import sbt.internal.util.complete.DefaultParsers.* +import sbt.internal.util.complete.Parser +import sbt.{Def, settingKey, IO as _, *} + +object CdkDeployPlugin extends AutoPlugin { + object autoImport { + val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") + val deploy = inputKey[DeployOutcome]("deploy to AWS") + } + + import autoImport.* + + override def trigger: PluginTrigger = NoTrigger + + override def requires: Plugins = LambdaJSPlugin + + override lazy val projectSettings: Seq[Setting[?]] = Seq( + cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, + deploy := Def.inputTask { + import scala.sys.process.* + + val baseCommand = cdkDeployCommand.value + val deployProcess = Process( + baseCommand ++ Seq("--stage", Stage.parser.parsed.name), + Option((ThisBuild / baseDirectory).value), + "ARTIFACT_PATH" -> (Compile / npmPackageOutputDirectory).value.toString, + "VERSION" -> version.value, + "VCS_URL" -> (ThisBuild / homepage).value.get.toString, + ) + + if (taggedVersion.value.exists(_.toString == version.value)) { + if (deployProcess.! == 0) Success + else throw new IllegalStateException("Serverless returned a non-zero exit code. Please check the logs for more information.") + } else SkippedBecauseVersionIsNotLatestTag(version.value, taggedVersion.value) + }.evaluated + ) + + sealed abstract class Stage(val name: String) { + val parser: Parser[this.type] = (Space ~> token(this.toString)).map(_ => this) + } + + object Stage { + val parser: Parser[Stage] = + token(Stage.Admin.parser) | + token(Stage.Sandbox.parser) + + case object Admin extends Stage("admin") + case object Sandbox extends Stage("sandbox") + } + + private def taggedVersion: Def.Initialize[Option[Version]] = Def.setting { + git.gitCurrentTags.value.collect { case Version.Tag(v) => v }.sorted.lastOption + } + + sealed trait DeployOutcome // no failed outcome because we just throw an exception in that case + case object Success extends DeployOutcome + case class SkippedBecauseVersionIsNotLatestTag(version: String, taggedVersion: Option[Version]) extends DeployOutcome + +} diff --git a/project/LambdaStack.scala b/project/LambdaStack.scala new file mode 100644 index 0000000..3203037 --- /dev/null +++ b/project/LambdaStack.scala @@ -0,0 +1,95 @@ +import sbt.File +import software.amazon.awscdk.services.iam.{ArnPrincipal, PolicyStatement, ServicePrincipal} +import software.amazon.awscdk.{App, CfnOutput, Duration, Environment, Fn, Stack, StackProps} +import software.amazon.awscdk.services.lambda.* +import software.amazon.awscdk.services.kms.* +import software.constructs.Construct + +import scala.jdk.CollectionConverters.* +import scala.util.chaining.* + +object LambdaStack { + def apply(name: String, + handler: String, + assets: File, + ): App = { + val environment = Environment + .builder() + .account( + sys.env.getOrElse( + "CDK_DEFAULT_ACCOUNT", + throw new IllegalArgumentException("No default account found") + ) + ) + .region( + sys.env.getOrElse( + "CDK_DEFAULT_REGION", + throw new IllegalArgumentException("No default region found") + ) + ) + .build() + + new App() + .tap { + new LambdaStack(_, "cloudflare-public-hostname-lambda", StackProps.builder().env(environment).build())(name, handler, assets) + } + } + +} + +class LambdaStack(scope: Construct, + id: String, + props: StackProps) + (name: String, + handler: String, + assets: File, + ) + extends Stack(scope, id, props) { + + val function: Function = Function.Builder + .create(this, name) + .runtime(Runtime.NODEJS_22_X) + .timeout(Duration.seconds(60)) + .memorySize(512) + .handler(s"index.$handler") + .code(Code.fromAsset(assets.getPath)) + .initialPolicy( + List( + PolicyStatement.Builder.create() + .actions(List("route53:GetHostedZone").asJava) + .resources(List("*").asJava) + .build() + ).asJava + ) + .build() + + val keyAlias = "alias/CloudflarePublicDnsRecordKey" + + val kmsKey: Key = Key.Builder.create(this, "Key") + .description("Encryption key protecting secrets for the Cloudflare public record lambda") + .enabled(true) + .enableKeyRotation(true) + .alias(keyAlias) + .build() + + kmsKey.grant(new ArnPrincipal(Fn.sub("arn:aws:iam::$${AWS::AccountId}:role/DataEncrypter")), + "kms:Encrypt", + "kms:ReEncrypt", + "kms:DescribeKey", + ) + + kmsKey.grantDecrypt(new ArnPrincipal(function.getRole.getRoleArn)) + + CfnOutput.Builder + .create(this, "CloudflarePublicHostnameLambda") + .description("ARN of the Lambda that interfaces with Cloudflare") + .value(function.getFunctionName) + .exportName("CloudflarePublicHostnameLambda") + .build() + + CfnOutput.Builder + .create(this, "CloudflarePublicHostnameLambdaKey") + .description("KMS Key Alias for Cloudflare public DNS record lambda") + .value(keyAlias) + .build() +} diff --git a/project/Version.scala b/project/Version.scala new file mode 100644 index 0000000..91f9e7d --- /dev/null +++ b/project/Version.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2022 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. + */ +/* + * Originally obtained from + * https://raw.githubusercontent.com/typelevel/sbt-typelevel/main/kernel/src/main/scala/org/typelevel/sbt/kernel/V.scala + */ + +import scala.util.Try +import scala.util.matching.Regex + +final case class Version(major: Int, + minor: Int, + patch: Option[Int], + prerelease: Option[String] + ) extends Ordered[Version] { + + override def toString: String = + s"$major.$minor${patch.fold("")(p => s".$p")}${prerelease.fold("")(p => s"-$p")}" + + def isPrerelease: Boolean = prerelease.nonEmpty + + def isSameSeries(that: Version): Boolean = + this.major == that.major && this.minor == that.minor + + def mustBeBinCompatWith(that: Version): Boolean = + this >= that && !that.isPrerelease && this.major == that.major && + (major > 0 || (this.minor == that.minor && minor > 0)) + + def compare(that: Version): Int = { + val x = this.major.compare(that.major) + if (x != 0) return x + val y = this.minor.compare(that.minor) + if (y != 0) return y + (this.patch, that.patch) match { + case (None, None) => 0 + case (None, Some(_)) => 1 + case (Some(_), None) => -1 + case (Some(thisPatch), Some(thatPatch)) => + val z = thisPatch.compare(thatPatch) + if (z != 0) return z + (this.prerelease, that.prerelease) match { + case (None, None) => 0 + case (Some(_), None) => 1 + case (None, Some(_)) => -1 + case (Some(thisPrerelease), Some(thatPrerelease)) => + // TODO not great, but not everyone uses Ms and RCs + thisPrerelease.compare(thatPrerelease) + } + } + } + +} + +object Version { + val version: Regex = """^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-(.+))?$""".r + + def apply(v: String): Option[Version] = Version.unapply(v) + + def unapply(v: String): Option[Version] = v match { + case version(major, minor, patch, prerelease) => + Try(Version(major.toInt, minor.toInt, Option(patch).map(_.toInt), Option(prerelease))).toOption + case _ => None + } + + object Tag { + def unapply(v: String): Option[Version] = + if (v.startsWith("v")) Version.unapply(v.substring(1)) else None + } +} diff --git a/project/build.properties b/project/build.properties index cabf73b..5e6884d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.2.7 +sbt.version=1.11.6 diff --git a/project/plugins.sbt b/project/plugins.sbt index c65751e..7ca2661 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,10 +1,11 @@ -addSbtPlugin("com.dwolla.sbt" %% "sbt-s3-publisher" % "1.2.0") -addSbtPlugin("com.dwolla.sbt" %% "sbt-cloudformation-stack" % "1.2.2") -addSbtPlugin("com.dwolla.sbt" %% "sbt-dwolla-base" % "1.2.0") -addSbtPlugin("com.dwijnand" % "sbt-travisci" % "1.1.1") -addSbtPlugin("com.dwolla" % "sbt-assembly-log4j2" % "1.0.0-0e5d5dd98c4c1e12ff7134536456679069c13e4d") +addSbtPlugin("org.typelevel" % "sbt-typelevel-github-actions" % "0.8.2") +addSbtPlugin("org.typelevel" % "sbt-typelevel-settings" % "0.8.2") +addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.2") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") +addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % "0.3.1") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.42") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") -resolvers ++= Seq( - Resolver.bintrayIvyRepo("dwolla", "sbt-plugins"), - Resolver.bintrayRepo("dwolla", "maven") +libraryDependencies ++= Seq( + "software.amazon.awscdk" % "aws-cdk-lib" % "2.220.0", ) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index af8991e..e051b0f 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -1,205 +1,318 @@ package com.dwolla.lambda.cloudflare.record -import _root_.fs2._ -import _root_.io.circe._ -import _root_.io.circe.fs2._ -import _root_.io.circe.generic.auto._ -import _root_.io.circe.syntax._ -import cats.data._ -import cats.effect._ -import cats.implicits._ -import com.dwolla.cloudflare._ +import _root_.io.circe.* +import _root_.io.circe.JsoniterScalaCodec.* +import _root_.io.circe.syntax.* +import cats.* +import cats.data.* +import cats.effect.std.Env +import cats.effect.{Trace as _, *} +import cats.mtl.Local +import cats.syntax.all.* +import cats.tagless.Derive +import cats.tagless.aop.* +import com.amazonaws.kms.{CiphertextType, KMS, PlaintextType} +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model +import com.dwolla.cloudflare.domain.model.* import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists -import com.dwolla.cloudflare.domain.model._ -import com.dwolla.fs2aws.kms.KmsDecrypter -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler.parseRecordFrom -import com.dwolla.lambda.cloudformation._ +import com.dwolla.lambda.cloudflare.record.NothingEncoder.* +import com.dwolla.tracing.LowPriorityTraceableValueInstances.* +import com.dwolla.tracing.syntax.* +import feral.lambda.cloudformation.{CloudFormationCustomResource, CloudFormationCustomResourceRequest, HandlerResponse} +import feral.lambda.* +import fs2.Stream +import fs2.io.compression.* +import fs2.io.net.Network +import mouse.all.* +import natchez.* +import natchez.http4s.* +import natchez.mtl.* +import natchez.xray.XRay import org.http4s.Headers -import org.http4s.client.Client -import org.http4s.client.blaze._ -import org.http4s.client.middleware.Logger -import org.http4s.syntax.string._ +import org.http4s.client.{Client, middleware} +import org.http4s.ember.client.EmberClientBuilder +import org.typelevel.ci.* +import org.typelevel.log4cats.console.* +import org.typelevel.log4cats.{Logger, LoggerFactory} +import smithy4s.aws.kernel.AwsRegion +import smithy4s.aws.{AwsClient, AwsEnvironment} +import smithy4s.json.Json.* -import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.control.NoStackTrace -class CloudflareDnsRecordHandler(httpClientStream: Stream[IO, Client[IO]], kmsClientStream: Stream[IO, KmsDecrypter[IO]]) extends CatsAbstractCustomResourceHandler[IO] { - def this() = this( - Http1Client.stream[IO]().map(Logger(logHeaders = true, logBody = true, (Headers.SensitiveHeaders + "X-Auth-Key".ci).contains)), - KmsDecrypter.stream[IO]() - ) +case class DnsRecordWithCredentials(dnsRecord: UnidentifiedDnsRecord, + cloudflareEmail: CiphertextType, + cloudflareKey: CiphertextType, + ) - private def constructCloudflareClient(resourceProperties: Map[String, Json]): Stream[IO, DnsRecordClient[IO]] = +object DnsRecordWithCredentials { + implicit val decoder: Decoder[DnsRecordWithCredentials] = Decoder[UnidentifiedDnsRecord].flatMap { dnsRecord => + (Decoder[CiphertextType].at("CloudflareEmail"), Decoder[CiphertextType].at("CloudflareKey")) + .mapN(DnsRecordWithCredentials(dnsRecord, _, _)) + } + + implicit val encoder: Encoder[DnsRecordWithCredentials] = Encoder.instance { recordWithCredentials => + recordWithCredentials.asJson.mapObject { + _ + .add("CloudflareEmail", recordWithCredentials.cloudflareEmail.asJson) + .add("CloudflareKey", recordWithCredentials.cloudflareKey.asJson) + } + } +} + +case class NoPlaintextForCiphertext(ciphertext: CiphertextType) + extends RuntimeException(s"KMS returned no plaintext for ciphertext input $ciphertext") + with NoStackTrace + +@annotation.experimental +class CloudflareDnsRecordHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel, Trace}](httpClient: Client[F], + kms: KMS[F], + dnsRecordClient: StreamingCloudflareApiExecutor[F] => DnsRecordClient[Stream[F, *]], + ) extends CloudFormationCustomResource[F, DnsRecordWithCredentials, JsonObject] { + private implicit val logger: Logger[F] = LoggerFactory[F].getLogger + + private def constructCloudflareClient(input: DnsRecordWithCredentials): F[DnsRecordClient[Stream[F, *]]] = for { - (email, key) ← decryptSensitiveProperties(resourceProperties) - httpClient ← httpClientStream - executor = new StreamingCloudflareApiExecutor[IO](httpClient, CloudflareAuthorization(email, key)) - } yield DnsRecordClient(executor) + (email, key) <- decryptSensitiveProperties(input) + executor = new StreamingCloudflareApiExecutor[F](httpClient, CloudflareAuthorization(email.value.toUTF8String, key.value.toUTF8String)) + } yield dnsRecordClient(executor) - private def decryptSensitiveProperties(resourceProperties: Map[String, Json]): Stream[IO, (String, String)] = + private def decrypt(ciphertext: CiphertextType): F[PlaintextType] = for { - kmsClient ← kmsClientStream - emailCryptoText ← Stream.emit(resourceProperties("CloudflareEmail")).covary[IO].through(decoder[IO, String]) - keyCryptoText ← Stream.emit(resourceProperties("CloudflareKey")).covary[IO].through(decoder[IO, String]) - plaintextMap ← kmsClient.decryptBase64("CloudflareEmail" → emailCryptoText, "CloudflareKey" → keyCryptoText).map(_.mapValues(new String(_, "UTF-8"))) - emailPlaintext = plaintextMap("CloudflareEmail") - keyPlaintext = plaintextMap("CloudflareKey") - } yield (emailPlaintext, keyPlaintext) - - override def handleRequest(input: CloudFormationCustomResourceRequest): IO[HandlerResponse] = - (for { - resourceProperties ← Stream.eval(IO.fromEither(input.ResourceProperties.toRight(MissingResourceProperties))) - dnsRecord ← parseRecordFrom(resourceProperties) - cloudflareClient ← constructCloudflareClient(resourceProperties) - res ← UpdateCloudflare(cloudflareClient)(input.RequestType, dnsRecord, input.PhysicalResourceId) - } yield res).compile.toList.map(_.head) + res <- kms.decrypt(ciphertext) + out <- res.plaintext.toRight(NoPlaintextForCiphertext(ciphertext)).liftTo[F] + } yield out + + private def decryptSensitiveProperties(input: DnsRecordWithCredentials): F[(PlaintextType, PlaintextType)] = + (decrypt(input.cloudflareEmail), decrypt(input.cloudflareKey)).parTupled + + override def createResource(input: DnsRecordWithCredentials): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(UpdateCloudflare(_)) + .flatMap(_.handleCreateOrUpdate(input.dnsRecord, None)) + + override def updateResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(UpdateCloudflare(_)) + .flatMap(_.handleCreateOrUpdate(input.dnsRecord, physicalResourceId.some)) + + override def deleteResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(UpdateCloudflare(_)) + .flatMap(_.handleDelete(physicalResourceId)) } -object CloudflareDnsRecordHandler { - implicit class ParseJsonAs(json: Json) { - def parseAs[A](implicit d: Decoder[A]): IO[A] = IO.fromEither(json.as[A]) - } +object NothingEncoder { + @annotation.nowarn("msg=dead code following this construct") + implicit val encoder: Encoder[Nothing] = Encoder.instance[Nothing](_ => Json.Null) +} + +object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceRequest[DnsRecordWithCredentials], Nothing] { + private def httpClient[F[_] : {Async, Network, Trace}]: Resource[F, Client[F]] = + EmberClientBuilder + .default[F] + .build + .map(middleware.Logger(logHeaders = true, logBody = true, (Headers.SensitiveHeaders + ci"X-Auth-Key").contains)) + .map(NatchezMiddleware.client(_)) + + override def handler: Resource[IO, Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => IO[Option[Nothing]]] = + for + xray <- XRay.entryPoint[IO]() + given LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]).toResource + client <- httpClient[IO] + region <- Env[IO].get("AWS_REGION").liftEitherT(new RuntimeException("missing AWS_REGION environment variable")).map(AwsRegion(_)).rethrowT.toResource + awsEnv <- AwsEnvironment.default(client, region) + kms <- AwsClient(KMS, awsEnv) + yield buildHandler(xray, client, kms)(DnsRecordClient(_)) + + def buildHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel}](entryPoint: EntryPoint[F], + client: Client[F], + kms: KMS[F]) + (dnsRecordClient: StreamingCloudflareApiExecutor[F] => DnsRecordClient[Stream[F, *]]) + (using Local[F, Span[F]]): Invocation[F, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => F[Option[Nothing]] = + implicit inv => + given KernelSource[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] = KernelSource.emptyKernelSource + + TracedHandler(entryPoint): + CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms, dnsRecordClient)) - def parseRecordFrom(resourceProperties: Map[String, Json]): Stream[IO, UnidentifiedDnsRecord] = - Stream.eval(Json.obj(resourceProperties.toSeq: _*).parseAs[UnidentifiedDnsRecord]) } +trait UpdateCloudflare[F[_]] { + def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] + + def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] +} + +@annotation.experimental object UpdateCloudflare { - private val logger = org.slf4j.LoggerFactory.getLogger("LambdaLogger") - - private implicit def optionToStream[F[_], A](o: Option[A]): Stream[F, A] = Stream.emits(o.toSeq) - - /* Emit the stream, or if it's empty, some alternate value - - ```scala - yourStream.pull.uncons { - case None => Pull.output1(alternateValue) - case Some((hd, tl)) => Pull.output(hd) >> tl.pull.echo - }.stream - ``` - */ - - def apply(cloudflareDnsRecordClient: DnsRecordClient[IO]) - (requestType: String, - unidentifiedDnsRecord: UnidentifiedDnsRecord, - physicalResourceId: Option[String]): Stream[IO, HandlerResponse] = - requestType.toUpperCase match { - case "CREATE" | "UPDATE" ⇒ - handleCreateOrUpdate(unidentifiedDnsRecord, physicalResourceId)(cloudflareDnsRecordClient) - case "DELETE" ⇒ handleDelete(physicalResourceId.get)(cloudflareDnsRecordClient) - } + implicit val physicalResourceIdTraceableValue: TraceableValue[cloudformation.PhysicalResourceId] = TraceableValue[String].contramap(_.value) + implicit val aspect: Aspect[UpdateCloudflare, TraceableValue, TraceableValue] = Derive.aspect - private def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = + def apply[F[_] : {Concurrent, Logger, Trace}](cloudflare: DnsRecordClient[Stream[F, *]]): UpdateCloudflare[F] = + (new UpdateCloudflareImpl(cloudflare): UpdateCloudflare[F]).traceWithInputsAndOutputs +} + +class UpdateCloudflareImpl[F[_] : {Concurrent, Logger, Trace}](cloudflare: DnsRecordClient[Stream[F, *]]) extends UpdateCloudflare[F] { + + def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = { unidentifiedDnsRecord.recordType.toUpperCase() match { - case "CNAME" ⇒ Stream.eval(handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId)) - case _ ⇒ handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) + case "CNAME" => handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) + case _ => handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) } + } - private def handleDelete(physicalResourceId: String) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { - for { - existingRecord ← cloudflare.getByUri(physicalResourceId) - deleted ← cloudflare.deleteDnsRecord(existingRecord.physicalResourceId) - } yield { - val data = Map( - "deletedRecordId" → Json.fromString(deleted) - ) - - HandlerResponse(physicalResourceId, data) - } - }.last.evalMap(_.fold(warnAboutMissingRecordDeletion(physicalResourceId))(_.pure[IO])) + def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + Trace[F].span("DnsRecordClient.getByUri >> DnsRecordClient.deleteDnsRecord") { + Trace[F].put("physicalResourceId.input" -> physicalResourceId) >> + cloudflare.getByUri(physicalResourceId.value) + .map(_.physicalResourceId) + .flatMap(id => Stream.eval(Trace[F].put("physicalResourceId.found" -> id)) >> cloudflare.deleteDnsRecord(id)) + .compile + .toList + } + .flatMap { + case Nil => warnAboutMissingRecordDeletion(physicalResourceId) + case deleted :: Nil => + val data = JsonObject( + "deletedRecordId" -> deleted.asJson + ) - private def warnAboutMissingRecordDeletion(physicalResourceId: String): IO[HandlerResponse] = - for { - _ ← IO(logger.warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!")) - } yield HandlerResponse(physicalResourceId, Map.empty[String, Json]) + HandlerResponse(physicalResourceId, data.some).pure[F] + + case multipleDeleted => + val data = JsonObject( + "deletedRecordIds" -> multipleDeleted.asJson + ) + + HandlerResponse(physicalResourceId, data.some).pure[F] + } - private def handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Stream[IO, String]) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { + private def warnAboutMissingRecordDeletion(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + Logger[F].warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!") + .as(HandlerResponse(physicalResourceId, None)) + + private def handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]) + : F[HandlerResponse[JsonObject]] = for { - maybeExistingRecord ← cloudformationProvidedPhysicalResourceId.flatMap(cloudflare.getByUri).last - createOrUpdate ← maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) + maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.map(_.value).flatTraverse(str => Trace[F].span("DnsRecordClient.getByUri") { + for { + _ <- Trace[F].put("physicalResourceId.input" -> str) + out <- cloudflare.getByUri(str).compile.last + _ <- Trace[F].put("output" -> out) + } yield out + }) + createOrUpdate <- maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeExistingRecord) - } - private def findAtMostOneExistingCNAME(name: String) - (implicit cloudflare: DnsRecordClient[IO]): IO[Option[IdentifiedDnsRecord]] = + private def findAtMostOneExistingCNAME(name: String): F[Option[IdentifiedDnsRecord]] = cloudflare.getExistingDnsRecords(name, recordType = Option("CNAME")).compile.toList - .flatMap { identifiedDnsRecords ⇒ - if (identifiedDnsRecords.size < 2) IO.pure(identifiedDnsRecords.headOption) - else IO.raiseError(MultipleCloudflareRecordsExistForDomainNameException(name, identifiedDnsRecords.map { - import com.dwolla.cloudflare.domain.model.Implicits._ + .flatMap { identifiedDnsRecords => + if (identifiedDnsRecords.size < 2) identifiedDnsRecords.headOption.pure[F] + else MultipleCloudflareRecordsExistForDomainNameException(name, identifiedDnsRecords.map { + import com.dwolla.cloudflare.domain.model.Implicits.* _.toDto - }.toSet)) + }.toSet).raiseError } - private def handleCreateOrUpdateCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) - (implicit cloudflare: DnsRecordClient[IO]): IO[HandlerResponse] = + private def handleCreateOrUpdateCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = for { - maybeIdentifiedDnsRecord ← findAtMostOneExistingCNAME(unidentifiedDnsRecord.name) - createOrUpdate ← maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord).compile.toList.map(_.head) - _ ← warnIfProvidedIdDoesNotMatchDiscoveredId(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord, unidentifiedDnsRecord.name) - _ ← warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) + maybeIdentifiedDnsRecord <- findAtMostOneExistingCNAME(unidentifiedDnsRecord.name) + createOrUpdate <- maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) + _ <- warnIfProvidedIdDoesNotMatchDiscoveredId(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord, unidentifiedDnsRecord.name) + _ <- warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeIdentifiedDnsRecord) - /*_*/ - private def createRecord(implicit cloudflare: DnsRecordClient[IO]): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = + // TODO add tracing + private def createRecord: Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = Kleisli { unidentifiedDnsRecord => - for { - identifiedRecord <- cloudflare.createDnsRecord(unidentifiedDnsRecord).recoverWith { - case RecordAlreadyExists => - cloudflare.getExistingDnsRecords(unidentifiedDnsRecord.name, Option(unidentifiedDnsRecord.content), Option(unidentifiedDnsRecord.recordType)) - } - } yield Create(identifiedRecord) + Trace[F].span("createRecord") { + cloudflare + .createDnsRecord(unidentifiedDnsRecord) + .compile + .last + .recoverWith { + case RecordAlreadyExists => + Trace[F].span("createRecord.RecordAlreadyExists") { + cloudflare + .getExistingDnsRecords(unidentifiedDnsRecord.name, Option(unidentifiedDnsRecord.content), Option(unidentifiedDnsRecord.recordType)) + .compile + .last + } + } + .liftOptionT + .getOrRaise(new NoSuchElementException(s"No DNS record was created for ${unidentifiedDnsRecord.name}")) + .map(CreateOrUpdate.create) + } } - /*_*/ - private def updateRecord(existingRecord: IdentifiedDnsRecord)(implicit cloudflare: DnsRecordClient[IO]): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = - for { - update ← assertRecordTypeWillNotChange(existingRecord.recordType).andThen { unidentifiedDnsRecord ⇒ - cloudflare.updateDnsRecord(unidentifiedDnsRecord.identifyAs(existingRecord.physicalResourceId)).map(Update(_)) + // TODO add tracing + private def updateRecord(existingRecord: IdentifiedDnsRecord): Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = + assertRecordTypeWillNotChange(existingRecord.recordType) + .map(_.identifyAs(existingRecord.physicalResourceId.value)) + .andThen { + Stream.emit(_) + .unNone + .flatMap { + cloudflare + .updateDnsRecord(_) + .map(CreateOrUpdate.update) + } + .compile + .lastOrError } - } yield update - private def warnIfProvidedIdDoesNotMatchDiscoveredId(physicalResourceId: Option[String], updateableRecord: Option[IdentifiedDnsRecord], hostname: String): IO[Unit] = IO { - for { - providedId ← physicalResourceId - discoveredId ← updateableRecord.map(_.physicalResourceId) - if providedId != discoveredId - } logger.warn(s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""") + private def warnIfProvidedIdDoesNotMatchDiscoveredId(physicalResourceId: Option[cloudformation.PhysicalResourceId], + updateableRecord: Option[IdentifiedDnsRecord], + hostname: String): F[Unit] = { + val warning = + for { + providedId <- physicalResourceId + discoveredId <- updateableRecord.map(_.physicalResourceId) + if providedId.value != discoveredId.value + } yield s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""" + + warning.traverse_(Logger[F].warn(_)) } - private def warnIfNoIdWasProvidedButDnsRecordExisted(physicalResourceId: Option[String], existingRecord: Option[IdentifiedDnsRecord]): IO[Unit] = IO { - if (physicalResourceId.isEmpty) + private def warnIfNoIdWasProvidedButDnsRecordExisted(physicalResourceId: Option[cloudformation.PhysicalResourceId], existingRecord: Option[IdentifiedDnsRecord]): F[Unit] = { + val warning = for { - dnsRecord ← existingRecord - discoveredId ← dnsRecord.physicalResourceId - } logger.warn(s"""Discovered DNS record ID "$discoveredId" for hostname "${dnsRecord.name}", with existing content "${dnsRecord.content}". This record will be updated instead of creating a new record.""") + dnsRecord <- existingRecord + discoveredId = dnsRecord.physicalResourceId + if (physicalResourceId.isEmpty) + } yield s"""Discovered DNS record ID "$discoveredId" for hostname "${dnsRecord.name}", with existing content "${dnsRecord.content}". This record will be updated instead of creating a new record.""" + + warning.traverse_(Logger[F].warn(_)) } - private def createOrUpdateToHandlerResponse(createOrUpdate: CreateOrUpdate[IdentifiedDnsRecord], existingRecord: Option[IdentifiedDnsRecord]): HandlerResponse = { + private def createOrUpdateToHandlerResponse(createOrUpdate: CreateOrUpdate[IdentifiedDnsRecord], existingRecord: Option[IdentifiedDnsRecord]): HandlerResponse[JsonObject] = { val dnsRecord = createOrUpdate.value - val data = Map( - "dnsRecord" → dnsRecord.asJson, - "created" → createOrUpdate.create.asJson, - "updated" → createOrUpdate.update.asJson, - "oldDnsRecord" → existingRecord.asJson, + val data = JsonObject( + "dnsRecord" -> dnsRecord.asJson, + "created" -> createOrUpdate.create.asJson, + "updated" -> createOrUpdate.update.asJson, + "oldDnsRecord" -> existingRecord.asJson, ) - HandlerResponse(dnsRecord.physicalResourceId, data) + HandlerResponse(physicalResourceIdBijection.to(dnsRecord.physicalResourceId), data.some) } - /*_*/ - private def assertRecordTypeWillNotChange(existingRecordType: String): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, UnidentifiedDnsRecord] = + private def assertRecordTypeWillNotChange(existingRecordType: String): Kleisli[F, UnidentifiedDnsRecord, UnidentifiedDnsRecord] = Kleisli { unidentifiedDnsRecord => if (unidentifiedDnsRecord.recordType == existingRecordType) - Stream.emit(unidentifiedDnsRecord) + unidentifiedDnsRecord.pure else - Stream.raiseError(DnsRecordTypeChange(existingRecordType, unidentifiedDnsRecord.recordType)) + DnsRecordTypeChange(existingRecordType, unidentifiedDnsRecord.recordType).raiseError } - /*_*/ - - case class DnsRecordTypeChange(existingRecordType: String, newRecordType: String) - extends RuntimeException(s"""Refusing to change DNS record from "$existingRecordType" to "$newRecordType".""") } + +case class DnsRecordTypeChange(existingRecordType: String, newRecordType: String) + extends RuntimeException(s"""Refusing to change DNS record from "$existingRecordType" to "$newRecordType".""") diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala new file mode 100644 index 0000000..12762f1 --- /dev/null +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala @@ -0,0 +1,44 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.* +import cats.syntax.all.* + +sealed trait CreateOrUpdate[+A] extends Product with Serializable { + val value: A + def create: Option[A] = None + def update: Option[A] = None +} + +final case class Create[A](value: A) extends CreateOrUpdate[A] { + override def create: Option[A] = Some(value) +} + +final case class Update[A](value: A) extends CreateOrUpdate[A] { + override def update: Option[A] = Some(value) +} + +object CreateOrUpdate { + + def create[A](a: A): CreateOrUpdate[A] = Create(a) + def update[A](a: A): CreateOrUpdate[A] = Update(a) + + implicit val traverseInstance: Traverse[CreateOrUpdate] = new Traverse[CreateOrUpdate] { + override def traverse[G[_]: Applicative, A, B](fa: CreateOrUpdate[A]) + (f: A => G[B]): G[CreateOrUpdate[B]] = + fa match { + case Create(a) => f(a).map(Create(_)) + case Update(a) => f(a).map(Update(_)) + } + + override def foldLeft[A, B](fa: CreateOrUpdate[A], b: B)(f: (B, A) => B): B = f(b, fa.value) + override def foldRight[A, B](fa: CreateOrUpdate[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = f(fa.value, lb) + } + + implicit def createOrUpdateEq[A: Eq]: Eq[CreateOrUpdate[A]] = + (x: CreateOrUpdate[A], y: CreateOrUpdate[A]) => (x, y) match { + case (Create(a), Create(b)) => Eq[A].eqv(a, b) + case (Update(a), Update(b)) => Eq[A].eqv(a, b) + case _ => false + } + +} diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala new file mode 100644 index 0000000..476c623 --- /dev/null +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala @@ -0,0 +1,245 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.syntax.all.* +import io.circe.* +import io.circe.syntax.* +import smithy.api.TimestampFormat +import smithy4s.* +import smithy4s.Document.{DArray, DBoolean, DNull, DNumber, DObject, DString} +import smithy4s.schema.{Schema, *} + +import java.util.Base64 +import scala.util.Try + +object SchemaVisitorCirceCodec extends CachedSchemaCompiler.Impl[Codec] { + override protected type Aux[A] = Codec[A] + + override def fromSchema[A](schema: Schema[A], cache: CompilationCache[Codec]): Codec[A] = + schema.compile(new SchemaVisitorCirceCodec(cache)) +} + +class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Codec]) extends SchemaVisitor.Cached[Codec] { + self => + + private implicit val blobEncoder: Encoder[Blob] = Encoder.encodeString.contramap(_.toBase64String) + private implicit val blobDecoder: Decoder[Blob] = Decoder[String].emapTry { base64String => + Try(Base64.getDecoder.decode(base64String)).map(Blob(_)) + } + + private implicit def documentEncoder: Encoder[Document] = Encoder.instance { + case DNumber(value) => value.asJson + case DString(value) => value.asJson + case DBoolean(value) => value.asJson + case DNull => io.circe.Json.Null + case DArray(value) => io.circe.Json.fromValues(value.map(_.asJson(using documentEncoder))) + case DObject(value) => io.circe.Json.fromFields(value.map { case (k, v) => k -> v.asJson(using documentEncoder) }) + } + private implicit def documentDecoder: Decoder[Document] = Decoder.instance { c => + c.value.foldWith(DocumentFolder).toRight(DecodingFailure("Could not decode document", c.history)) + } + + private implicit val timestampEncoder: Encoder[Timestamp] = Encoder[String].contramap(_.format(TimestampFormat.DATE_TIME)) + private implicit val timestampDecoder: Decoder[Timestamp] = Decoder[String].emap(Timestamp.parse(_, TimestampFormat.DATE_TIME).toRight(s"Could not parse timestamp; expected ${Timestamp.showFormat(TimestampFormat.DATE_TIME)}")) + + override def primitive[P](shapeId: ShapeId, + hints: Hints, + tag: Primitive[P]): Codec[P] = { + val enc: Encoder[P] = Primitive.deriving[Encoder].apply(tag) + val dec: Decoder[P] = Primitive.deriving[Decoder].apply(tag) + Codec.from(dec, enc) + } + + override def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Codec[C[A]] = { + implicit val schemaA: Schema[A] = member + + val encoder: Encoder[C[A]] = Encoder.instance { (ca: C[A]) => + Document.array(tag.iterator(ca).map(Document.encode(_)).to(Iterable)).asJson +// Document.array(ca.map(Document.encode(_)).toIterable).asJson + } + + val decoder: Decoder[C[A]] = Decoder.instance { cursor => + cursor + .values + .toRight(DecodingFailure("Could not decode document", cursor.history)) + .flatMap(_.toList.traverse { + _.foldWith(DocumentFolder) + .toRight(DecodingFailure("Could not decode document", cursor.history)) + .flatMap(_.decode[A].leftMap(DecodingFailure.fromThrowable(_, cursor.history))) + }) + .map(_.iterator) + .map(tag.fromIterator) + } + + Codec.from(decoder, encoder) + } + + override def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): Codec[Map[K, V]] = { + implicit val keySchema: Schema[K] = key + val keyEncoder: KeyEncoder[K] = KeyEncoder.instance { (k: K) => + Document.encode(k) match { + case DString(s) => s + case other => other.toString + } + } + val keyDecoder: KeyDecoder[K] = KeyDecoder.instance { (s: String) => + DString(s).decode[K].toOption + } + implicit val valueCodec: Codec[V] = SchemaVisitorCirceCodec.fromSchema(value, cache) + + val encoder: Encoder[Map[K, V]] = Encoder.instance { (map: Map[K, V]) => + io.circe.Json.fromFields(map.toList.map { case (k, v) => + keyEncoder(k) -> v.asJson + }) + } + + val decoder: Decoder[Map[K, V]] = Decoder.instance { cursor => + cursor.keys + .toRight(DecodingFailure("not an object", cursor.history)) + .map(_.toList) + .flatMap { + _.traverse { (key: String) => + for { + k <- keyDecoder(key).toRight(DecodingFailure(s"Could not decode key $key", cursor.history)) + v <- cursor.downField(key).as[V] + } yield k -> v + } + } + .map(_.toMap) + } + + Codec.from(decoder, encoder) + } + + override def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): Codec[E] = { + val encoder: Encoder[E] = Encoder.instance(e => io.circe.Json.fromString(total(e).stringValue)) + val decoder: Decoder[E] = Decoder.instance { cursor => + cursor.as[String].flatMap { str => + values + .find(_.stringValue == str) + .map(_.value) + .toRight(DecodingFailure(s"Invalid enumeration value: $str", cursor.history)) + } + } + Codec.from(decoder, encoder) + } + + override def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, ?]], make: IndexedSeq[Any] => S): Codec[S] = { + final case class EncF[A](field: Field[S, A], enc: Encoder[A], get: S => A) + final case class DecF[A](field: Field[S, A], dec: Decoder[A]) + + val encFields: Vector[EncF[?]] = fields.map { f0 => + val f = f0.asInstanceOf[Field[S, Any]] + val codec = SchemaVisitorCirceCodec.fromSchema(f.schema, cache) + EncF(f, codec.asInstanceOf[Encoder[Any]], f.get(_)) + } + + val decFields: Vector[DecF[?]] = fields.map { f0 => + val schAny = f0.schema + val decAny = SchemaVisitorCirceCodec.fromSchema(schAny, cache).asInstanceOf[Decoder[Any]] + DecF(f0.asInstanceOf[Field[S, Any]], decAny) + } + + val encoder: Encoder[S] = Encoder.instance { (s: S) => + val kvs: Iterable[(String, Json)] = encFields.iterator.flatMap { ef => + val label = ef.field.label + val v: Any = ef.get(s) + v match { + case opt: Option[_] => opt.map(o => label -> ef.enc.asInstanceOf[Encoder[Any]].apply(o)) + case other => Some(label -> ef.enc.asInstanceOf[Encoder[Any]].apply(other)) + } + }.toList + io.circe.Json.obj(kvs.toSeq *) + } + + val decoder: Decoder[S] = Decoder.instance { cursor => + decFields.zipWithIndex.toList + .traverse { case (df, _) => + val label = df.field.label + val sub = cursor.downField(label) + sub.as(using df.dec) + } + .map(vec => make(vec.toIndexedSeq)) + } + + Codec.from(decoder, encoder) + } + + override def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, ?]], dispatch: Alt.Dispatcher[U]): Codec[U] = { + final case class AltInfo[A](alt: Alt[U, A], enc: Encoder[A], dec: Decoder[A]) + + val alts: Vector[AltInfo[?]] = alternatives.map { a0 => + val a = a0.asInstanceOf[Alt[U, Any]] + val codec = SchemaVisitorCirceCodec.fromSchema(a.schema, cache) + AltInfo(a0.asInstanceOf[Alt[U, Any]], codec.asInstanceOf[Encoder[Any]], codec.asInstanceOf[Decoder[Any]]) + } + + val encoder: Encoder[U] = Encoder.instance { (u: U) => + val fieldOpt: Option[(String, Json)] = alts.iterator.flatMap { ai => + val a = ai.alt.asInstanceOf[Alt[U, Any]] + a.project.lift(u).map { v => a.label -> ai.enc.asInstanceOf[Encoder[Any]].apply(v) } + }.toSeq.headOption + fieldOpt match { + case Some((label, json)) => Json.obj(label -> json) + case None => Json.obj() + } + } + + val decoder: Decoder[U] = Decoder.instance { c => + c.value.asObject.toRight(DecodingFailure("not an object", c.history)).flatMap { obj => + obj.toMap.toList match { + case (label, _) :: Nil => + alternatives.find(_.label == label).toRight(DecodingFailure(s"Unknown union alternative: $label", c.history)).flatMap { a0 => + val a = a0.asInstanceOf[Alt[U, Any]] + val decAny = SchemaVisitorCirceCodec.fromSchema(a.schema, cache).asInstanceOf[Decoder[Any]] + c.downField(label).as(using decAny).map(v => a.inject(v)) + } + case Nil => Left(DecodingFailure("empty object for union", c.history)) + case _ => Left(DecodingFailure("expected single-field object for union", c.history)) + } + } + } + + Codec.from(decoder, encoder) + } + + override def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): Codec[B] = { + val ca: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + val encB: Encoder[B] = ca.contramap[B](bijection.from) + val decB: Decoder[B] = ca.map(bijection.to) + Codec.from(decB, encB) + } + + override def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): Codec[B] = { + val ca: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + val encB: Encoder[B] = ca.contramap(refinement.from) + val decB: Decoder[B] = ca.emap(refinement.apply) + Codec.from(decB, encB) + } + + override def lazily[A](suspend: Lazy[Schema[A]]): Codec[A] = { + lazy val compiled: Codec[A] = SchemaVisitorCirceCodec.fromSchema(suspend.value, cache) + compiled + } + + override def option[A](schema: Schema[A]): Codec[Option[A]] = { + implicit val c: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + Codec.from(Decoder.decodeOption[A], Encoder.encodeOption[A]) + } + +} + +object DocumentFolder extends io.circe.Json.Folder[Option[Document]] { + override def onNull: Option[Document] = DNull.some + override def onBoolean(value: Boolean): Option[Document] = DBoolean(value).some + override def onNumber(value: JsonNumber): Option[Document] = value.toBigDecimal.map(DNumber(_)) + override def onString(value: String): Option[Document] = DString(value).some + override def onArray(value: Vector[Json]): Option[Document] = value.traverse(_.foldWith(this)).map(DArray(_)) + override def onObject(value: JsonObject): Option[Document] = + value + .toList + .traverse { case (k, v) => + v.foldWith(this).map(k -> _) + } + .map(_.toMap) + .map(DObject(_)) +} diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala index 6cd4931..161de6d 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala @@ -1,20 +1,26 @@ package com.dwolla.lambda.cloudflare +import cats.syntax.all.* +import com.dwolla.cloudflare.domain.model import com.dwolla.cloudflare.domain.model.UnidentifiedDnsRecord -import io.circe._ -import shapeless.tag.@@ -import cats.syntax.contravariant._ +import feral.lambda.cloudformation +import io.circe.* +import smithy4s.Bijection package object record { - implicit def TaggedStringEncoder[B]: Encoder[String @@ B] = Encoder[String].narrow + given physicalResourceIdBijection: Bijection[model.PhysicalResourceId, cloudformation.PhysicalResourceId] = + Bijection[model.PhysicalResourceId, cloudformation.PhysicalResourceId]( + model.PhysicalResourceId.codec.extract.map(cloudformation.PhysicalResourceId.unsafeApply), + cloudformation.PhysicalResourceId.codec.extract.map(model.PhysicalResourceId(_)), + ) - implicit val decodeUnidentifiedDnsRecord: Decoder[UnidentifiedDnsRecord] = (c: HCursor) ⇒ + given Decoder[UnidentifiedDnsRecord] = (c: HCursor) => for { - name ← c.downField("Name").as[String] - content ← c.downField("Content").as[String] - recordType ← c.downField("Type").as[String] - ttl ← c.downField("TTL").as[Option[Int]] - proxied ← c.downField("Proxied").as[Option[String]].map(_.flatMap(str ⇒ try { Some(str.toBoolean) } catch { case _: IllegalArgumentException ⇒ None })) - priority ← c.downField("Priority").as[Option[Int]] + name <- c.downField("Name").as[String] + content <- c.downField("Content").as[String] + recordType <- c.downField("Type").as[String] + ttl <- c.downField("TTL").as[Option[Int]] + proxied <- c.downField("Proxied").as[Option[String]].map(_.flatMap(str => try { Some(str.toBoolean) } catch { case _: IllegalArgumentException => None })) + priority <- c.downField("Priority").as[Option[Int]] } yield UnidentifiedDnsRecord(name, content, recordType, ttl, proxied, priority) } diff --git a/src/main/scala/io/circe/JsoniterScalaCodec.scala b/src/main/scala/io/circe/JsoniterScalaCodec.scala new file mode 100644 index 0000000..24732a0 --- /dev/null +++ b/src/main/scala/io/circe/JsoniterScalaCodec.scala @@ -0,0 +1,44 @@ +package io.circe + +import com.github.plokhotnyuk.jsoniter_scala.core._ + +/** + * Bridge utilities for using a Jsoniter JsonValueCodec[A] as a Circe Codec[A]. + * + * This is a minimal adapter that: + * - Encodes by serializing with Jsoniter to a compact JSON string and parsing it into io.circe.Json + * - Decodes by printing a compact JSON string from io.circe.Json and reading it with Jsoniter + * + * It relies on the jsoniter-scala-core and circe-parser modules transitively (parser is part of circe-core in 0.14.x via jawn). + */ +object JsoniterScalaCodec { + /** Build a Circe Codec[A] from a Jsoniter JsonValueCodec[A]. */ + def fromJsoniter[A](implicit jc: JsonValueCodec[A]): Codec[A] = { + val enc: Encoder[A] = Encoder.instance { a => + // Use Jsoniter to serialize, then parse into Circe Json + val s = writeToString(a)(using jc) + io.circe.parser.parse(s) match { + case Right(json) => json + case Left(err) => + // Encoder can't fail in its type, so throw if something goes very wrong + throw err + } + } + + val dec: Decoder[A] = Decoder.instance { c => + // Render the incoming Circe Json to a compact string, then read with Jsoniter + val jsonStr = Printer.noSpaces.print(c.value) + try { + Right(readFromString[A](jsonStr)(using jc)) + } catch { + case e: JsonReaderException => Left(DecodingFailure(e.getMessage, c.history)) + case e: Throwable => Left(DecodingFailure(e.getMessage, c.history)) + } + } + + Codec.from(dec, enc) + } + + /** Implicitly provide a Circe Codec[A] wherever a Jsoniter JsonValueCodec[A] is in scope. */ + implicit def circeCodecFromJsoniter[A](implicit jc: JsonValueCodec[A]): Codec[A] = fromJsoniter[A] +} diff --git a/src/main/smithy/KMS.smithy b/src/main/smithy/KMS.smithy new file mode 100644 index 0000000..dfb80ff --- /dev/null +++ b/src/main/smithy/KMS.smithy @@ -0,0 +1,7 @@ +$version: "1.0" + +namespace com.dwolla.aws.kms + +use smithy4s.meta#only + +apply com.amazonaws.kms#Decrypt @only diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala deleted file mode 100644 index 578d1dd..0000000 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ /dev/null @@ -1,636 +0,0 @@ -package com.dwolla.lambda.cloudflare.record - -import _root_.fs2._ -import cats.effect._ -import com.amazonaws.services.kms.model.AWSKMSException -import com.dwolla.cloudflare._ -import com.dwolla.cloudflare.domain.model._ -import com.dwolla.lambda.cloudformation._ -import com.dwolla.testutils.exceptions.NoStackTraceException -import _root_.io.circe._ -import _root_.io.circe.generic.auto._ -import _root_.io.circe.syntax._ -import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists -import org.specs2.concurrent.ExecutionEnv -import org.specs2.mock.Mockito -import org.specs2.mutable.Specification -import com.dwolla.fs2aws.kms._ -import com.dwolla.lambda.cloudflare.record.UpdateCloudflare.DnsRecordTypeChange - -class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with Mockito { - - private val tagPhysicalResourceId = shapeless.tag[PhysicalResourceIdTag][String] _ - private val tagZoneId = shapeless.tag[ZoneIdTag][String] _ - private val tagResourceId = shapeless.tag[ResourceIdTag][String] _ - - "CloudflareDnsRecordHandler" should { - "propagate exceptions thrown by the KMS decrypter" >> { - val kmsExceptionMessage = "The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" - - val mockKms = new ExceptionRaisingDecrypter[IO](new AWSKMSException(kmsExceptionMessage)) - val handler = new CloudflareDnsRecordHandler(Stream.empty, Stream.emit(mockKms)) - - val request = buildRequest( - requestType = "update", - physicalResourceId = Option("different-physical-id"), - resourceProperties = Option(Map( - "Name" → Json.fromString("example.dwolla.com"), - "Content" → Json.fromString("new-example.dwollalabs.com"), - "Type" → Json.fromString("CNAME"), - "TTL" → Json.fromString("42"), - "Proxied" → Json.fromString("true"), - "CloudflareEmail" → Json.fromString("cloudflare-account-email@dwollalabs.com"), - "CloudflareKey" → Json.fromString("fake-key") - )) - ) - - val output = handler.handleRequest(request).unsafeToFuture() - - output must throwA[AWSKMSException].like { case ex ⇒ ex.getMessage must startWith(kmsExceptionMessage) }.await - } - } - - "UpdateCloudflare create" should { - - "create specified CNAME record" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("created" → expectedRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) - }.await - } - - "log failure and close the clients if creation fails" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.raiseError(NoStackTraceException) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await - } - - "propagate exception if fetching existing records fails" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = - Stream.raiseError(NoStackTraceException) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await - } - - "create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation" >> { - val providedPhysicalId = Option("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, providedPhysicalId) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) - }.await - } - - "create a DNS record that isn't an CNAME even if record(s) with the same name already exist" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("created" → expectedRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) - }.await - } - - "pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val existingRecord = expectedRecord.copy( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), - resourceId = tagResourceId("different-record"), - content = "different-content", - priority = Option(0), - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== existingRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → existingRecord.asJson) - handlerResponse.data must havePair("created" → existingRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) - }.await - } - } - - "CloudflareDnsRecordHandler update" should { - "update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = None, - priority = Option(10), - ) - - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getByUri(uri: String) = - if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) - }.await - - // TODO deal with logging -// there were noCallsTo(mockLogger) - } - - "update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true), - ) - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) - }.await - -// there was one(mockLogger).warn(startsWith("""Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com"""")) - } - - "update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - val inputRecord = existingRecord.unidentify - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) - }.await - - // TODO deal with logging -// there was one(mockLogger).warn(startsWith( -// """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com".""")) - } - - "refuse to change the record type if the input type is CNAME" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "A", - ttl = Option(42), - proxied = Option(true) - ) - val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) ⇒ - existingRecordType must_== "A" - newRecordType must_== "CNAME" - } - } - - "refuse to change the record type if the input type is not CNAME" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true) - ) - val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(existingRecord) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) ⇒ - existingRecordType must_== "MX" - newRecordType must_== "TXT" - } - } - - "propagate the failure exception if update fails" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = Stream.raiseError(NoStackTraceException) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) - } - } - - "CloudflareDnsRecordHandler delete" should { - "delete a DNS record if requested" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val existingRecord = inputRecord.identifyAs(physicalResourceId) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(existingRecord) - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.emit(physicalResourceId).map(tagPhysicalResourceId) - } - - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must havePair("deletedRecordId" → physicalResourceId.asJson) - }.await - } - - "delete is successful even if the physical ID passed by CloudFormation doesn't exist" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.empty - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) - } - - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ - handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must not(havePair("deletedRecordId" → physicalResourceId)) - }.await - - // TODO deal with logging -// there was one(mockLogger).error("The record could not be deleted because it did not exist; nonetheless, responding with Success!", -// DnsRecordIdDoesNotExistException("fake-url")) - } - - "log failure and close the clients if delete fails" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(inputRecord.identifyAs(physicalResourceId)) - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.raiseError(NoStackTraceException) - } - - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) - } - } - - "Exceptions" >> { - "DnsRecordTypeChange" should { - "mention the existing and new record types" >> { - DnsRecordTypeChange("existing", "new") must beLikeA[RuntimeException] { - case ex ⇒ ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" - } - } - } - } - - private def buildRequest(requestType: String, - physicalResourceId: Option[String], - resourceProperties: Option[Map[String, Json]]) = - CloudFormationCustomResourceRequest( - RequestType = requestType, - ResponseURL = "", - StackId = "", - RequestId = "", - ResourceType = "", - LogicalResourceId = "", - PhysicalResourceId = physicalResourceId, - ResourceProperties = resourceProperties, - OldResourceProperties = None - ) - -} - -case class CustomNoStackTraceException(msg: String, ex: Throwable = null) extends RuntimeException(msg, ex, true, false) - -abstract class FakeDnsRecordClient extends DnsRecordClient[IO] { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def getById(zoneId: ZoneId, resourceId: ResourceId): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = Stream.raiseError(new NotImplementedError()) -} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala new file mode 100644 index 0000000..ccebfbd --- /dev/null +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala @@ -0,0 +1,16 @@ +package com.dwolla.lambda.cloudflare.record + +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model.* +import org.typelevel.scalaccompat.annotation.targetName3 + +class DnsRecordClientStub[F[+_]](const: F[Nothing]) extends DnsRecordClient[F] { + override def getById(zoneId: ZoneId, resourceId: ResourceId): F[IdentifiedDnsRecord] = const + override def createDnsRecord(record: UnidentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def updateDnsRecord(record: IdentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): F[IdentifiedDnsRecord] = const + override def deleteDnsRecord(physicalResourceId: String): F[PhysicalResourceId] = const + @targetName3("deleteDnsRecordNewtype") + final override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): F[PhysicalResourceId] = deleteDnsRecord(physicalResourceId.value) + override def getByUri(uri: String): F[IdentifiedDnsRecord] = const +} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala new file mode 100644 index 0000000..443b72c --- /dev/null +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala @@ -0,0 +1,724 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.* +import cats.effect.* +import cats.syntax.all.* +import cats.tagless.aop.* +import cats.tagless.syntax.all.* +import com.amazonaws.kms.* +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model +import com.dwolla.cloudflare.domain.model.* +import com.dwolla.cloudflare.domain.model.Exceptions.* +import feral.lambda.cloudformation +import fs2.Stream +import io.circe.* +import io.circe.syntax.* +import munit.{CatsEffectSuite, Compare} +import natchez.Trace +import org.http4s.client.Client +import org.typelevel.log4cats.testing.* +import org.typelevel.log4cats.{Logger, LoggerFactory} +import smithy4s.{Bijection, Blob} + +import scala.util.control.NoStackTrace + +@annotation.experimental +class UpdateCloudflareSuite extends CatsEffectSuite { + given [A, B](using Bijection[B, A], Compare[A, A]): Compare[A, B] = + (obtained: A, expected: B) => + summon[Compare[A, A]].isEqual(obtained, summon[Bijection[B, A]].to(expected)) + + private def stub[Alg[_[_]] : Instrument, F[_] : ApplicativeThrow](alg: Alg[F]): Alg[F] = + alg.instrument.mapK(new EnhancedStubWithInstrumentation) + + test("CloudflareDnsRecordHandler propagates exceptions thrown by the KMS client (smithy4s)") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Trace[IO] = natchez.Trace.Implicits.noop + + val kmsErrorMessage = "The ciphertext refers to a KMS key you cannot access" + + val failingKms: KMS[IO] = new KMS[IO] { + def decrypt(ciphertextBlob: CiphertextType, + encryptionContext: Option[Map[EncryptionContextKey, EncryptionContextValue]], + grantTokens: Option[List[GrantTokenType]], + keyId: Option[KeyIdType], + encryptionAlgorithm: Option[EncryptionAlgorithmSpec], + recipient: Option[RecipientInfo], + dryRun: Option[NullableBooleanType] + ): IO[DecryptResponse] = + IO.raiseError(InvalidCiphertextException(Option(ErrorMessageType(kmsErrorMessage)))) + } + + // minimal Client that should never be used in this test (decrypt fails first) + val dummyClient: Client[IO] = Client[IO](_ => Resource.eval(IO.raiseError(new RuntimeException("HTTP should not be called")))) + + val handler = new CloudflareDnsRecordHandler[IO](dummyClient, failingKms, DnsRecordClient(_)) + + val input = DnsRecordWithCredentials( + dnsRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "new-example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ), + cloudflareEmail = CiphertextType(Blob("cloudflare-account-email@dwollalabs.com".getBytes("UTF-8"))), + cloudflareKey = CiphertextType(Blob("fake-key".getBytes("UTF-8"))) + ) + + interceptMessageIO[InvalidCiphertextException](kmsErrorMessage) { + handler.updateResource(input, cloudformation.PhysicalResourceId.unsafeApply("different-physical-id")) + } + } + + test("create specified CNAME record") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty + else Stream.raiseError[IO](new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + val output = UpdateCloudflare(fakeCloudflareClient).handleCreateOrUpdate(inputRecord, None) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId.value, "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + assertEquals(handlerResponse.data.get.apply("dnsRecord"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("created"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("updated"), Some(None.asJson)) + assertEquals(handlerResponse.data.get.apply("oldDnsRecord"), Some(None.asJson)) + } + } + } + + test("update a non-CNAME DNS record if it already exists, with physical ID from CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = None, + priority = Option(10), + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) + + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) + } + + val output = UpdateCloudflare(fakeCloudflareClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId, expectedRecord.physicalResourceId) + assertEquals(handlerResponse.data.get.apply("dnsRecord"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("oldDnsRecord"), Some(existingRecord.asJson)) + } + } + } + + test("delete a DNS record if requested") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val existingRecord = inputRecord.identifyAs(physicalResourceId) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(existingRecord).unNone + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.emit(PhysicalResourceId(physicalResourceId)) + } + + val output = UpdateCloudflare(fakeDnsRecordClient).handleDelete(cloudformation.PhysicalResourceId.unsafeApply(physicalResourceId)) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId.value, physicalResourceId) + assertEquals(handlerResponse.data.get.apply("deletedRecordId"), Some(physicalResourceId.asJson)) + } + } + } + + test("UpdateCloudflare create should propagate exceptions thrown by the Cloudflare client") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val expectedInputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.empty + + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == expectedInputRecord) Stream.raiseError(NoStackTraceException) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + } + + interceptMessageIO[NoStackTraceException.type](NoStackTraceException.getMessage) { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(expectedInputRecord, None) + } + } + + test("UpdateCloudflare create should propagate exception if fetching existing records fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + Stream.raiseError(NoStackTraceException) + } + + interceptMessageIO[NoStackTraceException.type](NoStackTraceException.getMessage) { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, None) + } + } + + test("UpdateCloudflare create should create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val providedPhysicalId = cloudformation.PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, providedPhysicalId) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare create should create a DNS record that isn't an CNAME even if record(s) with the same name already exist") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, None) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(resp.data.exists(_.apply("created").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("updated").isEmpty)) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare create should pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = expectedRecord.copy( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), + resourceId = ResourceId("different-record"), + content = "different-content", + priority = Option(0), + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, None) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, existingRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(existingRecord.asJson))) + assert(resp.data.exists(_.apply("created").contains(existingRecord.asJson))) + assert(!resp.data.exists(_.apply("updated").isEmpty)) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare update should update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = None, + priority = Option(10), + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: expected ${inputRecord.identifyAs(physicalResourceId)} but received $record")) + + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) + }) + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("created").isEmpty)) + assert(resp.data.exists(_.apply("updated").contains(expectedRecord.asJson))) + assert(resp.data.exists(_.apply("oldDnsRecord").contains(existingRecord.asJson))) + } + } + } + + test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, None) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assertEquals(resp.data, JsonObject( + "dnsRecord" -> expectedRecord.asJson, + "created" -> None.asJson, + "updated" -> expectedRecord.asJson, + "oldDnsRecord" -> existingRecord.asJson, + ).some) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com", with existing content "example.dwollalabs.com". This record will be updated instead of creating a new record.""", None))) + } + + test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + val inputRecord = existingRecord.unidentify + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, cloudformation.PhysicalResourceId("different-physical-id")) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assertEquals(resp.data, JsonObject( + "dnsRecord" -> expectedRecord.asJson, + "created" -> None.asJson, + "updated" -> expectedRecord.asJson, + "oldDnsRecord" -> existingRecord.asJson, + ).some) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""", None))) + } + + test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is CNAME") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = (physicalResourceId), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "A", + ttl = Option(42), + proxied = Option(true) + ) + val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + + interceptIO[DnsRecordTypeChange] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + }.map(assertEquals(_, DnsRecordTypeChange("A", "CNAME"))) + } + + test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is not CNAME") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = (physicalResourceId), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true) + ) + val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(existingRecord) + }) + + interceptIO[DnsRecordTypeChange] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + }.map(assertEquals(_, DnsRecordTypeChange("MX", "TXT"))) + } + + test("CloudflareDnsRecordHandler update should propagate the failure exception if update fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(NoStackTraceException) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + interceptIO[NoStackTraceException.type] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + } + } + + test("CloudflareDnsRecordHandler delete should delete is successful even if the physical ID passed by CloudFormation doesn't exist") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.empty + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleDelete(physicalResourceIdBijection.to(physicalResourceId)) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, physicalResourceId) + assertEquals(resp.data, None) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """The record could not be deleted because it did not exist; nonetheless, responding with Success!""", None))) + } + + test("CloudflareDnsRecordHandler delete should propagate exceptions thrown by the Cloudflare client when a delete fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId: model.PhysicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(inputRecord.identifyAs(physicalResourceId)).unNone + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.raiseError(NoStackTraceException) + }) + + interceptIO[NoStackTraceException.type] { + UpdateCloudflare(fakeDnsRecordClient).handleDelete(physicalResourceIdBijection.to(physicalResourceId)) + } + } + + test("DnsRecordTypeChange should mention the existing and new record types") { + interceptMessage("""Refusing to change DNS record from "existing" to "new".""") { + throw DnsRecordTypeChange("existing", "new") + } + } + +} + +case object NoStackTraceException extends NoStackTrace { + override def getMessage: String = "NoStackTraceException" + override def toString: String = getMessage +} + +case object StubException extends NoStackTrace +class EnhancedStubWithInstrumentation[F[_] : ApplicativeThrow] extends (Instrumentation[F, *] ~> F) { + override def apply[A](fa: Instrumentation[F, A]): F[A] = + fa.value.recoverWith { + case StubException => new NotImplementedError(s"An implementation is missing for ${fa.algebraName}.${fa.methodName}").raiseError[F, A] + } +} diff --git a/stack/src/it/resources/logback-test.xml b/stack/src/it/resources/logback-test.xml deleted file mode 100644 index 684660e..0000000 --- a/stack/src/it/resources/logback-test.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - %date | log_thread=%thread | log_level=%-5level | log_logger=%logger | log_location=%class.%method | log_line=%line | log_message='%msg'%n - - - - - - - - - - diff --git a/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala b/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala deleted file mode 100644 index 3d59bd2..0000000 --- a/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala +++ /dev/null @@ -1,31 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.amazonaws.regions.Regions._ -import com.amazonaws.services.cloudformation.AmazonCloudFormationAsyncClientBuilder -import com.amazonaws.services.cloudformation.model.ValidateTemplateRequest -import com.dwolla.awssdk.utils.ScalaAsyncHandler.Implicits._ -import org.specs2.concurrent.ExecutionEnv -import org.specs2.mutable.{After, Specification} -import spray.json._ - -import scala.concurrent.duration._ - -class StackIntegrationSpec(implicit val ee: ExecutionEnv) extends Specification { - - trait Setup extends After { - val client = AmazonCloudFormationAsyncClientBuilder.standard().withRegion(US_WEST_2).build() - - override def after = client.shutdown() - } - - "Stack Template" should { - "validate using Amazon's online validation service" in new Setup { - - val request = new ValidateTemplateRequest().withTemplateBody(Stack.template().toJson.prettyPrint) - - val output = request.via(client.validateTemplateAsync) - - output.map(_.getDescription) must be_==("cloudflare-public-hostname-lambda lambda function and supporting resources").await(0, 10.seconds) - } - } -} diff --git a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala b/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala deleted file mode 100644 index 5b477dd..0000000 --- a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala +++ /dev/null @@ -1,13 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} - -import spray.json._ - -object CreateTemplate extends App { - val template = Stack.template() - - private val outputFilename = args(0) - Files.write(Paths.get(outputFilename), template.toJson.prettyPrint.getBytes(StandardCharsets.UTF_8)) -} diff --git a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala b/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala deleted file mode 100644 index 9143050..0000000 --- a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala +++ /dev/null @@ -1,137 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler -import com.monsanto.arch.cloudformation.model._ -import com.monsanto.arch.cloudformation.model.resource._ - -object Stack { - def template(): Template = { - val role = `AWS::IAM::Role`("Role", - AssumeRolePolicyDocument = PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("Service" → Seq("lambda.amazonaws.com")))), - Action = Seq("sts:AssumeRole") - ) - )), - Policies = Option(Seq( - Policy("Policy", - PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Action = Seq( - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ), - Resource = Option("arn:aws:logs:*:*:*") - ), - PolicyStatement( - Effect = "Allow", - Action = Seq( - "route53:GetHostedZone" - ), - Resource = Option("*") - ) - )) - ) - )) - ) - - val s3Bucket = StringParameter("S3Bucket", "bucket where Lambda code can be found") - val s3Key = StringParameter("S3Key", "key where Lambda code can be found") - - val key = `AWS::KMS::Key`("Key", - Option("Encryption key protecting secrets for the Cloudflare public record lambda"), - Enabled = Option(true), - EnableKeyRotation = Option(true), - KeyPolicy = PolicyDocument( - Seq( - PolicyStatement( - Sid = Option("AllowDataEncrypterToEncrypt"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::Sub`("arn:aws:iam::${AWS::AccountId}:role/DataEncrypter"))))), - Action = Seq( - "kms:Encrypt", - "kms:ReEncrypt", - "kms:DescribeKey" - ), - Resource = Option("*") - ), - PolicyStatement( - Sid = Option("AllowLambdaToDecrypt"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::GetAtt`(Seq(role.name, "Arn")))))), - Action = Seq( - "kms:Decrypt", - "kms:DescribeKey" - ), - Resource = Option("*") - ), - PolicyStatement( - Sid = Option("CloudFormationDeploymentRoleOwnsKey"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::Sub`("arn:aws:iam::${AWS::AccountId}:role/cloudformation/deployer/cloudformation-deployer"))))), - Action = Seq( - "kms:Create*", - "kms:Describe*", - "kms:Enable*", - "kms:List*", - "kms:Put*", - "kms:Update*", - "kms:Revoke*", - "kms:Disable*", - "kms:Get*", - "kms:Delete*", - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion" - ), - Resource = Option("*") - ) - ) - ) - ) - - val alias = `AWS::KMS::Alias`("KeyAlias", AliasName = "alias/CloudflarePublicDnsRecordKey", TargetKeyId = ResourceRef(key)) - - val lambda = `AWS::Lambda::Function`("Function", - Code = Code( - S3Bucket = Option(s3Bucket), - S3Key = Option(s3Key), - S3ObjectVersion = None, - ZipFile = None - ), - Description = Option("Creates or updates a public hostname at Cloudflare zone"), - Handler = classOf[CloudflareDnsRecordHandler].getName, - Runtime = Java8, - MemorySize = Some(512), - Role = `Fn::GetAtt`(Seq(role.name, "Arn")), - Timeout = Option(60) - ) - - Template( - Description = "cloudflare-public-hostname-lambda lambda function and supporting resources", - Parameters = Option(Seq( - s3Bucket, - s3Key - )), - Resources = Option(Seq(role, lambda, key, alias)), - Conditions = None, - Mappings = None, - Routables = None, - Outputs = Some(Seq( - Output( - "CloudflarePublicHostnameLambda", - "ARN of the Lambda that interfaces with Cloudflare", - `Fn::GetAtt`(Seq(lambda.name, "Arn")), - Some("CloudflarePublicHostnameLambda") - ), - Output( - "CloudflarePublicHostnameKey", - "KMS Key Alias for Cloudflare public DNS record lambda", - ResourceRef(alias) - ) - )) - ) - } -} diff --git a/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java b/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java deleted file mode 100644 index ade187f..0000000 --- a/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.dwolla.cloudformation.cloudflare; - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler; - -public class ConstructorTest { - - // This needs to compile for the Lambda to be constructable at AWS - final CloudflareDnsRecordHandler handler = new CloudflareDnsRecordHandler(); - -} diff --git a/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala b/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala deleted file mode 100644 index 83d0172..0000000 --- a/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala +++ /dev/null @@ -1,96 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler -import com.monsanto.arch.cloudformation.model._ -import com.monsanto.arch.cloudformation.model.resource._ -import org.specs2.matcher.ContainWithResult -import org.specs2.mutable.Specification -import org.specs2.specification.Scope - -class StackSpec extends Specification { - - trait Setup extends Scope { - val template = Stack.template() - - val s3Bucket = StringParameter("S3Bucket", "bucket where Lambda code can be found") - val s3Key = StringParameter("S3Key", "key where Lambda code can be found") - - val role = `AWS::IAM::Role`("Role", - AssumeRolePolicyDocument = PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("Service" → Seq("lambda.amazonaws.com")))), - Action = Seq("sts:AssumeRole") - ) - )), - Policies = Option(Seq( - Policy("Policy", - PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Action = Seq( - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ), - Resource = Option("arn:aws:logs:*:*:*") - ), - PolicyStatement( - Effect = "Allow", - Action = Seq( - "route53:GetHostedZone" - ), - Resource = Option("*") - ) - )) - ) - )) - ) - - val function = `AWS::Lambda::Function`("Function", - Code = Code( - S3Bucket = Option(s3Bucket), - S3Key = Option(s3Key), - S3ObjectVersion = None, - ZipFile = None - ), - Description = Option("Creates or updates a public hostname at Cloudflare zone"), - Handler = classOf[CloudflareDnsRecordHandler].getName, - Runtime = Java8, - MemorySize = Some(512), - Role = `Fn::GetAtt`(Seq(role.name, "Arn")), - Timeout = Option(60) - ) - } - - "Template" should { - - "define lambda function for correct handler class" in new Setup { - template.lookupResource[`AWS::Lambda::Function`]("Function") must_== function - } - - "define IAM role for the function" in new Setup { - template.lookupResource[`AWS::IAM::Role`]("Role") must_== role - } - - "define input parameters for S3 location so that info can come from sbt" in new Setup { - template.Parameters must beSome(contain(s3Bucket.asInstanceOf[Parameter])) - template.Parameters must beSome(contain(s3Key.asInstanceOf[Parameter])) - } - - "have an appropriate description" in new Setup { - template.Description must_== "cloudflare-public-hostname-lambda lambda function and supporting resources" - } - - "export the lambda function" in new Setup { - template.Outputs must beSome(thingThatContains(Output( - "CloudflarePublicHostnameLambda", - "ARN of the Lambda that interfaces with Cloudflare", - `Fn::GetAtt`(Seq("Function", "Arn")), - Some("CloudflarePublicHostnameLambda") - ))) - } - } - - def thingThatContains[R](output: Output[R]): ContainWithResult[Output[_]] = contain(output) -}