From 075fadb78020e1c6675126369e6f30ea69a221b9 Mon Sep 17 00:00:00 2001 From: Brian Rodgers Date: Wed, 16 Nov 2016 17:09:24 -0600 Subject: [PATCH 1/3] Added support for cross-template references and Fn::Sub --- .../model/AmazonFunctionCall.scala | 17 ++++ .../arch/cloudformation/model/Output.scala | 17 ++-- .../model/IntrinsicFunctions_UT.scala | 86 +++++++++++++++++++ .../cloudformation/model/Template_UT.scala | 48 +++++++++++ 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 src/test/scala/com/monsanto/arch/cloudformation/model/IntrinsicFunctions_UT.scala diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/AmazonFunctionCall.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/AmazonFunctionCall.scala index 9e282343..3c6f1dc3 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/AmazonFunctionCall.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/AmazonFunctionCall.scala @@ -35,6 +35,8 @@ object AmazonFunctionCall extends DefaultJsonProtocol { case not: `Fn::Not` => implicitly[JsonWriter[`Fn::Not`#CFBackingType] ].write(not.arguments) case and: `Fn::And` => implicitly[JsonWriter[`Fn::And`#CFBackingType] ].write(and.arguments) case or: `Fn::Or` => implicitly[JsonWriter[`Fn::Or`#CFBackingType] ].write(or.arguments) + case sub: `Fn::Sub` => sub.serializeArguments + case imp: `Fn::ImportValue` => implicitly[JsonWriter[`Fn::ImportValue`#CFBackingType] ].write(imp.arguments) case cfr: ConditionFnRef => implicitly[JsonWriter[ConditionFnRef#CFBackingType] ].write(cfr.arguments) case s: `Fn::Select`[_] => s.serializeArguments case f: `Fn::If`[_] => f.serializeArguments @@ -131,6 +133,19 @@ case class `Fn::FindInMap`[R](mapName: Token[MappingRef[R]], outerKey: Token[Str case class `Fn::Base64`(toEncode: Token[String]) extends AmazonFunctionCall[String]("Fn::Base64"){type CFBackingType = Token[String] ; val arguments = toEncode} +case class `Fn::ImportValue`(importValue: Token[String]) + extends AmazonFunctionCall[String]("Fn::ImportValue"){type CFBackingType = Token[String] ; val arguments = importValue} + +case class `Fn::Sub`(template: Token[String], subs: Option[Map[Token[String], Token[String]]] = None) + extends AmazonFunctionCall[String]("Fn::Sub"){ + type CFBackingType = (Token[String], Option[Map[Token[String], Token[String]]]) ; + val arguments = (template, subs) + def serializeArguments = arguments._2 match { + case Some(x) => arguments.toJson + case None => arguments._1.toJson + } +} + case class `Fn::Equals`(a: Token[String], b: Token[String]) extends NestableAmazonFunctionCall[String]("Fn::Equals") {type CFBackingType = (Token[String], Token[String]) ; val arguments = (a, b)} @@ -183,10 +198,12 @@ object `Fn::Base64` extends DefaultJsonProtocol { // but you also want to be able to pass a literal ResourceRef[R] sealed trait Token[R] object Token extends DefaultJsonProtocol { + implicit def fromAnyTup[R1: JsonFormat, R2: JsonFormat](r: (R1, R2)): (AnyToken[R1], AnyToken[R2]) = (AnyToken(r._1), AnyToken(r._2)) implicit def fromAnySeq[R: JsonFormat](r: Seq[R]): AnyToken[Seq[R]] = AnyToken(r) implicit def fromAny[R: JsonFormat](r: R): AnyToken[R] = AnyToken(r) implicit def fromOptionAny[R: JsonFormat](or: Option[R]): Option[AnyToken[R]] = or.map(r => Token.fromAny(r)) implicit def fromString(s: String): StringToken = StringToken(s) + implicit def fromStrTup(r: (String, String)): (StringToken, StringToken) = (StringToken(r._1), StringToken(r._2)) implicit def fromBoolean(s: Boolean): BooleanToken = BooleanToken(s) implicit def fromInt(s: Int): IntToken = IntToken(s) implicit def fromFunction[R](f: AmazonFunctionCall[R]): FunctionCallToken[R] = FunctionCallToken[R](f) diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/Output.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/Output.scala index 66762a44..b3b3eac2 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/Output.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/Output.scala @@ -10,17 +10,24 @@ import scala.collection.immutable.ListMap * Created by Ryan Richt on 2/15/15 */ -case class Output[R](name: String, Description: String, Value: Token[R])(implicit format: JsonFormat[Token[R]]) { +case class Output[R](name: String, Description: String, Value: Token[R], Export: Option[Token[String]] = None)(implicit format: JsonFormat[Token[R]]) { def valueAsJson = Token.format[Token[R]].write(Value) } object Output extends DefaultJsonProtocol { implicit def format[A] : JsonWriter[Output[A]] = new JsonWriter[Output[A]] { - override def write(obj: Output[A]) = JsObject( - "Description" -> JsString(obj.Description), - "Value" -> obj.valueAsJson - ) + override def write(obj: Output[A]) = { + JsObject(Map( + "Description" -> JsString(obj.Description), + "Value" -> obj.valueAsJson) + ++ exportAsJsonTuple(obj)) + } + + def exportAsJsonTuple(obj: Output[A]): Option[(String, JsValue)] = obj.Export match { + case None => None + case Some(x) => "Export" -> JsObject("Name" -> x.toJson) + } } implicit val seqFormat: JsonWriter[Seq[Output[_]]] = new JsonWriter[Seq[Output[_]]] { diff --git a/src/test/scala/com/monsanto/arch/cloudformation/model/IntrinsicFunctions_UT.scala b/src/test/scala/com/monsanto/arch/cloudformation/model/IntrinsicFunctions_UT.scala new file mode 100644 index 00000000..755e6046 --- /dev/null +++ b/src/test/scala/com/monsanto/arch/cloudformation/model/IntrinsicFunctions_UT.scala @@ -0,0 +1,86 @@ +package com.monsanto.arch.cloudformation.model + +import com.monsanto.arch.cloudformation.model.AmazonFunctionCall._ +import com.monsanto.arch.cloudformation.model._ +import com.monsanto.arch.cloudformation.model.resource._ +import Token._ +import org.scalatest.{FunSpec, Matchers} +import spray.json._ +import DefaultJsonProtocol._ + +/** + * Created by Ryan Richt on 2/26/15 + */ +class IntrinsicFunctions_UT extends FunSpec with Matchers { + + describe("Fn::Sub"){ + + it("no args"){ + + val test: Token[String] = `Fn::Sub`(s"This is a $${test} template") + + val expected = JsObject( + "Fn::Sub"-> JsString(s"This is a $${test} template") + ) + + test.toJson should be(expected) + } + + it("one arg"){ + + val test: Token[String] = `Fn::Sub`( + s"This is a $${test} template", + Some(Map("test" -> "value")) + ) + + val expected = JsObject( + "Fn::Sub"-> JsArray( + JsString(s"This is a $${test} template"), + JsObject("test" -> JsString("value")) + ) + ) + + test.toJson should be(expected) + } + + it("two args"){ + + val test: Token[String] = `Fn::Sub`( + s"This is a $${test} template", + Some(Map("test" -> "value", "test2" -> "value2")) + ) + + val expected = JsObject( + "Fn::Sub"-> JsArray( + JsString(s"This is a $${test} template"), + JsObject("test" -> JsString("value"), "test2" -> JsString("value2")) + ) + ) + + test.toJson should be(expected) + } + } + + describe("Fn::ImportValue") { + it("should serialize with static string") { + val test: Token[String] = `Fn::ImportValue`("Test-Import-Value") + + val expected = JsObject( + "Fn::ImportValue" -> JsString("Test-Import-Value") + ) + test.toJson should be(expected) + } + + it("should serialize with an embedded function") { + val test: Token[String] = `Fn::ImportValue`(`Fn::Join`("", Seq("str1", "str2"))) + + val expected = JsObject( + "Fn::ImportValue" -> JsObject("Fn::Join" -> JsArray( + JsString(""), + JsArray(JsString("str1"), JsString("str2")) + ) + )) + test.toJson should be(expected) + } + } +} diff --git a/src/test/scala/com/monsanto/arch/cloudformation/model/Template_UT.scala b/src/test/scala/com/monsanto/arch/cloudformation/model/Template_UT.scala index c6239515..a8cf0442 100644 --- a/src/test/scala/com/monsanto/arch/cloudformation/model/Template_UT.scala +++ b/src/test/scala/com/monsanto/arch/cloudformation/model/Template_UT.scala @@ -135,6 +135,54 @@ class Template_UT extends FlatSpec with Matchers with VPC with Subnet with Avail """.stripMargin } + + it should "be able to add an output of type Token[String] with a static export" in new JsonWritingMatcher { + import spray.json._ + val token : Token[String] = ResourceRef(resource1) + val output = Output( + name = "out1", + Description = "test", + Value = token, + Export = Some("export-test") + ) + output.toJson shouldMatch + s""" + |{ + | "Description": "test", + | "Value": { + | "Ref": "${resource1.name}" + | }, + | "Export": { + | "Name": "export-test" + | } + |} + """.stripMargin + } + + + it should "be able to add an output of type Token[String] with a function export" in new JsonWritingMatcher { + import spray.json._ + val token : Token[String] = ResourceRef(resource1) + val output = Output( + name = "out1", + Description = "test", + Value = token, + Export = Some(`Fn::Sub`(s"$${AWS::StackName}-test-export")) + ) + output.toJson shouldMatch + s""" + |{ + | "Description": "test", + | "Value": { + | "Ref": "${resource1.name}" + | }, + | "Export": { + | "Name": {"Fn::Sub": "$${AWS::StackName}-test-export"} + | } + |} + """.stripMargin + } + it should "be able to add outputs" in { val outputs = Seq(output1, output2) val template = Template.EMPTY ++ outputs From d9df083573ecca439ef80a643c3cb6758d66c262 Mon Sep 17 00:00:00 2001 From: Brian Rodgers Date: Wed, 16 Nov 2016 17:40:31 -0600 Subject: [PATCH 2/3] Added output exports to builders --- .../monsanto/arch/cloudformation/model/simple/Builders.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala index 8d3735c8..4ace9541 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala @@ -27,6 +27,7 @@ trait Conditions { trait Outputs { implicit class RichResource[R <: Resource[R]](r: R) { def andOutput(name: String, description: String) = Template.fromResource(r) ++ Template.fromOutput( Output(name, description, ResourceRef(r)) ) + def andOutput(name: String, description: String, export: Token[String]) = Template.fromResource(r) ++ Template.fromOutput( Output(name, description, ResourceRef(r), Some(export)) ) } } @@ -185,7 +186,7 @@ trait Subnet extends AvailabilityZone with Outputs { Tags = AmazonTag.fromName(name) ) - f(sub) ++ sub.andOutput(name, name) + f(sub) ++ sub.andOutput(name, name, `Fn::Sub`(s"$${AWS::StackName}-${name}")) } def nat(routeTables: Seq[`AWS::EC2::RouteTable`], ga: `AWS::EC2::VPCGatewayAttachment`) @@ -536,7 +537,7 @@ trait VPC extends Outputs { EnableDnsHostnames = true ) - f(vpc) ++ vpc.andOutput("VPCID", "VPC Info") + f(vpc) ++ vpc.andOutput("VPCID", "VPC Info", `Fn::Sub`(s"$${AWS::StackName}-VPCID")) } } From ae1f2618c8c54ccf01d00146e0099de6cc1d1c0d Mon Sep 17 00:00:00 2001 From: Brian Rodgers Date: Thu, 17 Nov 2016 13:11:43 -0600 Subject: [PATCH 3/3] docs --- CHANGELOG.md | 6 ++++++ README.md | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc9e901..90e4ee1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [3.5.0] - 2016-11-17 + +- Support for AWS's new features supporting [cross template references](https://aws.amazon.com/blogs/aws/aws-cloudformation-update-yaml-cross-stack-references-simplified-substitution/). (see [#119](https://github.com/MonsantoCo/cloudformation-template-generator/pull/119)) + +- Support for [Fn::Sub](https://aws.amazon.com/blogs/aws/aws-cloudformation-update-yaml-cross-stack-references-simplified-substitution/), which provides a cleaner alternative to `Fn::Join` and `Fn::GetAtt`. (see [#119](https://github.com/MonsantoCo/cloudformation-template-generator/pull/119)) + ## [3.4.0] - 2016-11-07 ### Added diff --git a/README.md b/README.md index 7db1423d..a42bd566 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ This project packages certain useful custom CloudFormation types. These are Lam tasks that CloudFormation does not natively support. In order to use them, you must upload the Lambda function to your account and region. The code for these functions is found in this repo under assets/custom-types. -## Remote Route 53 entries +#### Remote Route 53 entries A given domain (or hosted zone, more specifically) must be managed out of a single AWS account. This poses problems if you want to create resources under that domain in templates that will run out of other accounts. A CloudFormation template can only work in one given account. However, with Cloud Formation's custom type functionality, we use custom code to assume a role in the account that owns the hosted zone. This requires some setup steps for each hosted zone and each account. For instructions, please see: https://github.com/MonsantoCo/cloudformation-template-generator/blob/master/assets/custom-types/remote-route53/README.md for more. ## Working with Cloudformation Concatenating @@ -181,6 +181,8 @@ In the CloudFormation DSL, there is support for concatenating strings, parameter This can get really ugly as they are chained together. There is a [string interpolator](http://monsantoco.github.io/cloudformation-template-generator/latest/api/#com.monsanto.arch.cloudformation.model.package$$AwsStringInterpolator) to make this easier. +**Update 11/17/2016:** While we are not deprecating this functionality at this time, CFTG now supports `Fn::Sub`, a native way to do something very similar. It can replace both `Fn::Join` and many uses of `Fn::GetAtt`. Read more [here](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html). + ## Releasing This project uses the sbt release plugin. After the changes you want to