Skip to content

Commit 006df1d

Browse files
committed
Add salmon.cloudformation/template component
1 parent 4667223 commit 006df1d

File tree

3 files changed

+209
-11
lines changed

3 files changed

+209
-11
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
was not checked for failure.
1010
- (breaking) When a region is specified, pass it to cfn-lint. This may
1111
cause some templates to fail linting that previously passed.
12+
- Add `salmon.cloudformation/template` component. This is useful for
13+
validating a template that is used by more than one stack or change-set.
1214

1315
## v0.23.2 (2025-02-20)
1416

src/salmon/cloudformation.clj

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,19 @@
3838
(or (str/includes? s "_IN_PROGRESS state")
3939
(boolean (re-find re-in-progress-error-message s))))
4040

41-
(defn- cfn-lint! [{:keys [region]} template]
41+
(defn- cfn-lint! [{:keys [region regions]} template]
4242
(fs/with-temp-dir [dir {:prefix "salmon-cloudformation"}]
4343
(let [f (fs/create-file (fs/path dir "cloudformation.template"))]
4444
(spit (fs/file f) template)
45-
(let [{:keys [err exit out]}
45+
(let [regions (or regions (when region [region]))
46+
{:keys [err exit out]}
4647
#__ (apply sh/sh
4748
"cfn-lint"
4849
(str f)
49-
(when (some-> region ds/ref? not)
50-
["-r" (name region)]))]
50+
(when (and (seq regions)
51+
(not (some ds/ref? regions)))
52+
(cons "-r"
53+
(map name regions))))]
5154
(cond
5255
(zero? exit) nil
5356
(empty? err) out
@@ -58,7 +61,11 @@
5861
[config
5962
& {:keys [template validate?]
6063
:or {validate? true}}]
61-
(let [template-json (json/write-str template)]
64+
; unwrap {:template {}} from the template component
65+
; while allowing direct template data specification when not using
66+
; a template component
67+
(let [template (:template template template)
68+
template-json (json/write-str template)]
6269
(if-let [errors (and validate? (cfn-lint! config template-json))]
6370
(if (str/blank? errors)
6471
{:json template-json}
@@ -101,6 +108,18 @@
101108
[:string {:min 1 :max 128}]
102109
[:re re-stack-name]]]])
103110

111+
(def ^{:private true}
112+
template-config-schema
113+
[:map
114+
[:lint?
115+
{:optional true}]
116+
[:regions
117+
{:optional true}
118+
[:sequential [:or :keyword :string]]]
119+
[:template
120+
[:or ds/DonutRef
121+
[:map]]]])
122+
104123
(defn- validate!
105124
[{:as signal ::ds/keys [component-id system]}
106125
& {:keys [pre?]}]
@@ -129,6 +148,41 @@
129148
{:message message
130149
:template resolved-template})))))))
131150

151+
(defn- start-template!
152+
[{::ds/keys [config instance]
153+
:as signal}]
154+
(or instance
155+
(let [{:keys [template]} config]
156+
(validate! signal)
157+
{:template template})))
158+
159+
(defn template
160+
"Returns a component that defines and validates a CloudFormation template.
161+
162+
Supported signals: ::ds/start, ::ds/stop, :salmon/early-validate
163+
164+
config options:
165+
166+
:lint?
167+
Validate the template using cfn-lint.
168+
Default: false.
169+
170+
:regions
171+
The AWS regions to consider when linting the template.
172+
Default: nil.
173+
174+
:template
175+
A map representing a CloudFormation template. The map
176+
may contain donut.system refs."
177+
[& {:as config}]
178+
{::ds/config config
179+
::ds/config-schema template-config-schema
180+
::ds/start start-template!
181+
::ds/stop (constantly nil)
182+
:salmon/early-validate
183+
(fn [signal]
184+
(validate! signal :pre? true))})
185+
132186
(defn- response-error [message response]
133187
(ex-info (str message
134188
(some->> response u/aws-error-message (str ": ")))

test/salmon/cloudformation_test.clj

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,29 @@
6666
:name (or name (test/rand-stack-name))
6767
:template template})}}))
6868

69+
(defn template-system [& {:as opts}]
70+
(assoc
71+
test/system-base
72+
::ds/defs
73+
{:t
74+
{:template
75+
(cfn/template opts)}}))
76+
6977
(deftest test-blank-template-early-validation
7078
(testing "Blank templates should fail validation"
79+
(testing "using template component"
80+
(is (thrown-with-msg?
81+
ExceptionInfo
82+
#"Template must be a map"
83+
(-> (template-system :lint? true :template nil)
84+
(ds/signal :salmon/early-validate)
85+
cause)))
86+
(is (thrown-with-msg?
87+
ExceptionInfo
88+
#"Template must not be empty"
89+
(-> (template-system :lint? true :template {})
90+
(ds/signal :salmon/early-validate)
91+
cause))))
7192
(is (thrown-with-msg?
7293
ExceptionInfo
7394
#"Template must be a map"
@@ -110,12 +131,20 @@
110131

111132
(deftest test-early-validation-linting
112133
(testing "cfn-lint works in :early-validate when there are no refs in the template"
134+
(is (thrown-with-msg?
135+
ExceptionInfo
136+
#"'Resources' is a required property"
137+
(-> (template-system :lint? true :template {:a 1})
138+
(ds/signal :salmon/early-validate)
139+
cause)))
113140
(is (thrown-with-msg?
114141
ExceptionInfo
115142
#"'Resources' is a required property"
116143
(cause (ds/signal (system-a (stack-a :lint? true :template {:a 1}))
117144
:salmon/early-validate)))))
118145
(testing "cfn-lint doesn't run unless :lint? is true"
146+
(is (ds/signal (template-system :template {:a 1})
147+
:salmon/early-validate))
119148
(is (ds/signal (system-a (stack-a :template {:a 1}))
120149
:salmon/early-validate)))
121150
(testing "cfn-lint works in :early-validate when all refs have been resolved"
@@ -170,6 +199,71 @@
170199

171200
(deftest test-validation-linting
172201
(testing "ref templates are validated during :start"
202+
(testing "in template components"
203+
(is (thrown-with-msg?
204+
ExceptionInfo
205+
#"Template must not be empty"
206+
(-> (template-system :lint? true :template (ds/local-ref [:empty]))
207+
(assoc-in [::ds/defs :t :empty] {})
208+
ds/start
209+
cause)))
210+
(testing "regions are considered during linting"
211+
(is (thrown-with-msg?
212+
ExceptionInfo
213+
#"Template must not be empty"
214+
(-> (template-system
215+
:lint? true
216+
:regions [:us-east-1]
217+
:template (ds/local-ref [:empty]))
218+
(assoc-in [::ds/defs :t :empty] {})
219+
ds/start
220+
cause))
221+
"one region")
222+
(is (thrown-with-msg?
223+
ExceptionInfo
224+
#"Template must not be empty"
225+
(-> (template-system
226+
:lint? true
227+
:regions [:us-east-1 :us-east-2 :us-west-2]
228+
:template (ds/local-ref [:empty]))
229+
(assoc-in [::ds/defs :t :empty] {})
230+
ds/start
231+
cause))
232+
"several regions")
233+
(let [sm-template
234+
#__ {:AWSTemplateFormatVersion "2010-09-09"
235+
:Resources
236+
{:Instance
237+
{:Type "AWS::SageMaker::NotebookInstance"
238+
:Properties
239+
{:InstanceType "ml.t2.medium"
240+
:RoleArn {"Fn::Sub" "arn:aws:iam::${AWS::AccountId}:role/SageMakerExecutionRole"}}}}}]
241+
(is (-> (template-system
242+
:lint? true
243+
:regions [:us-east-1]
244+
:template sm-template)
245+
ds/start))
246+
(testing "Linting fails for a valid resource that is not supported in a region"
247+
(is (thrown-with-msg?
248+
ExceptionInfo
249+
#"Resource type.*AWS::SageMaker::NotebookInstance.*does not exist in"
250+
(-> (template-system
251+
:lint? true
252+
:regions [:ca-west-1]
253+
:template sm-template)
254+
ds/start
255+
cause))
256+
"with one region")
257+
(is (thrown-with-msg?
258+
ExceptionInfo
259+
#"Resource type.*AWS::SageMaker::NotebookInstance.*does not exist in"
260+
(-> (template-system
261+
:lint? true
262+
:regions [:us-east-1 :ca-west-1 :us-east-2]
263+
:template sm-template)
264+
ds/start
265+
cause))
266+
"with several regions, most of which are supported")))))
173267
(is (thrown-with-msg?
174268
ExceptionInfo
175269
#"Template must not be empty"
@@ -203,6 +297,23 @@
203297
(def template-b
204298
(assoc-in template-a [:Resources :OAI2] (oai "OAI2")))
205299

300+
(deftest test-stack-from-template
301+
(let [{:keys [regions]} (test/get-config)
302+
stack-name (test/rand-stack-name)]
303+
(doseq [region regions
304+
:let [system-def (-> (stack-a
305+
:name stack-name
306+
:region region
307+
:template (ds/local-ref [:template]))
308+
system-a
309+
(assoc-in [::ds/defs :services :template]
310+
(cfn/template :template template-a)))]]
311+
(test/with-system-delete [sys system-def]
312+
(let [{:keys [stack-a]} (-> @sys ::ds/instances :services)]
313+
(is (= "CREATE_COMPLETE"
314+
(-> stack-a :resources :OAI1 :ResourceStatus))
315+
"A stack can sucessfully be created from a template component"))))))
316+
206317
(deftest test-lifecycle
207318
(let [{:keys [regions]} (test/get-config)
208319
stack-name (test/rand-stack-name)]
@@ -706,7 +817,7 @@
706817
(let [{:keys [regions]} (test/get-config)
707818
stack-name (test/rand-stack-name)]
708819
(doseq [region regions
709-
:let [system-def
820+
:let [system-def-no-template
710821
#__ (assoc test/system-base
711822
::ds/defs
712823
{:service
@@ -720,11 +831,42 @@
720831
(cfn/stack
721832
{:change-set (ds/local-ref [:change-set])
722833
:name stack-name
723-
:region region})}})]]
724-
(is (ds/signal system-def :salmon/early-validate)
725-
"early-validate succeeds")
726-
(test/with-system-delete [sys system-def]
727-
(testing "A stack can be created from a change set"
834+
:region region})}})
835+
system-def-template
836+
#__ (assoc test/system-base
837+
::ds/defs
838+
{:service
839+
{:change-set
840+
(cfn/change-set
841+
{:name (test/rand-stack-name)
842+
:region region
843+
:stack-name stack-name
844+
:template (ds/local-ref [:template])})
845+
:stack
846+
(cfn/stack
847+
{:change-set (ds/local-ref [:change-set])
848+
:name stack-name
849+
:region region})
850+
:template
851+
(cfn/template
852+
{:template template-a})}})]]
853+
(test/with-system-delete [sys system-def-no-template]
854+
(testing "A stack can be created from a change-set"
855+
(is (ds/signal system-def-no-template :salmon/early-validate)
856+
"early-validate succeeds")
857+
(let [{:keys [change-set stack]} (-> @sys ::ds/instances :service)]
858+
(is (->> (select-keys change-set [:id :name :stack-id :stack-name])
859+
vals
860+
(every? string?))
861+
"change-set returns expected names and IDs")
862+
(is (= (:stack-id change-set)
863+
(:stack-id stack)))
864+
(is (= "CREATE_COMPLETE"
865+
(-> stack :resources :OAI1 :ResourceStatus))))))
866+
(test/with-system-delete [sys system-def-template]
867+
(testing "A change-set can be created from a template and applied to a stack"
868+
(is (ds/signal system-def-template :salmon/early-validate)
869+
"early-validate succeeds")
728870
(let [{:keys [change-set stack]} (-> @sys ::ds/instances :service)]
729871
(is (->> (select-keys change-set [:id :name :stack-id :stack-name])
730872
vals

0 commit comments

Comments
 (0)