Skip to content

Commit 5ff733e

Browse files
brianterryRJ Lohan
authored andcommitted
Add contract testing to plugin (#102)
* Add testEvent type so that contract test events can be unmarshalled * Create an entry point for contract test and return a ProgressEvent * Add testEventFunc type for contract testing * Add two entry points to allow the CLI to perform contract test * Update the docs * Update SAM template with testing environment variables
1 parent 09128bf commit 5ff733e

File tree

10 files changed

+295
-26
lines changed

10 files changed

+295
-26
lines changed

cfn/cfn.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"log"
8+
"os"
89
"time"
910

1011
"github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/callback"
@@ -67,8 +68,22 @@ type InvokeScheduler interface {
6768
}
6869

6970
// Start is the entry point called from a resource's main function
71+
//
72+
// We define two lambda entry points; MakeEventFunc is the entry point to all
73+
// invocations of a custom resource and MakeTestEventFunc is the entry point that
74+
// allows the CLI's contract testing framework to invoke the resource's CRUDL handlers.
7075
func Start(h Handler) {
71-
lambda.Start(makeEventFunc(h))
76+
77+
// MODE is an environment variable that is set ONLY
78+
// when contract test are performed.
79+
if mode, ok := os.LookupEnv("MODE"); ok == true {
80+
if mode == "Test" {
81+
lambda.Start(makeTestEventFunc(h))
82+
83+
} else {
84+
lambda.Start(makeEventFunc(h))
85+
}
86+
}
7287
}
7388

7489
// Tags are stored as key/value paired strings
@@ -77,6 +92,10 @@ type tags map[string]string
7792
// eventFunc is the function signature required to execute an event from the Lambda SDK
7893
type eventFunc func(ctx context.Context, event *event) (response, error)
7994

95+
// testEventFunc is the function signature required to execute an event from the Lambda SDK
96+
// and is only used in contract testing
97+
type testEventFunc func(ctx context.Context, event *testEvent) (handler.ProgressEvent, error)
98+
8099
// handlerFunc is the signature required for all actions
81100
type handlerFunc func(request handler.Request) handler.ProgressEvent
82101

@@ -330,3 +349,28 @@ func makeEventFunc(h Handler) eventFunc {
330349
}
331350
}
332351
}
352+
353+
// MakeTestEventFunc is the entry point that allows the CLI's
354+
// contract testing framework to invoke the resource's CRUDL handlers.
355+
func makeTestEventFunc(h Handler) testEventFunc {
356+
return func(ctx context.Context, event *testEvent) (handler.ProgressEvent, error) {
357+
358+
handlerFn, err := router(event.Action, h)
359+
360+
if err != nil {
361+
return handler.NewFailedEvent(err), err
362+
}
363+
364+
request := handler.NewRequest(
365+
event.Request.LogicalResourceIdentifier,
366+
event.CallbackContext,
367+
credentials.SessionFromCredentialsProvider(&event.Credentials),
368+
event.Request.PreviousResourceState,
369+
event.Request.DesiredResourceState,
370+
)
371+
372+
progEvt := handlerFn(request)
373+
374+
return progEvt, nil
375+
}
376+
}

cfn/cfn_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,88 @@ func loadEvent(path string, evt *event) *event {
233233
}
234234
return evt
235235
}
236+
237+
func TestMakeTestEventFunc(t *testing.T) {
238+
start := time.Now()
239+
future := start.Add(time.Minute * 15)
240+
241+
tc, cancel := context.WithDeadline(context.Background(), future)
242+
243+
defer cancel()
244+
245+
lc := lambdacontext.NewContext(tc, &lambdacontext.LambdaContext{})
246+
247+
f1 := func(callback map[string]interface{}, s *session.Session) handler.ProgressEvent {
248+
response := handler.ProgressEvent{
249+
OperationStatus: handler.Success,
250+
Message: "Create complete",
251+
}
252+
return response
253+
}
254+
255+
type args struct {
256+
h Handler
257+
ctx context.Context
258+
event *testEvent
259+
}
260+
tests := []struct {
261+
name string
262+
args args
263+
want handler.ProgressEvent
264+
wantErr bool
265+
}{
266+
{"Test simple CREATE", args{&MockHandler{f1}, lc, loadTestEvent("test.create.json", &testEvent{})}, handler.ProgressEvent{
267+
OperationStatus: handler.Success,
268+
Message: "Create complete",
269+
}, false},
270+
{"Test simple READ", args{&MockHandler{f1}, lc, loadTestEvent("test.read.json", &testEvent{})}, handler.ProgressEvent{
271+
OperationStatus: handler.Success,
272+
Message: "Create complete",
273+
}, false},
274+
{"Test simple DELETE", args{&MockHandler{f1}, lc, loadTestEvent("test.delete.json", &testEvent{})}, handler.ProgressEvent{
275+
OperationStatus: handler.Success,
276+
Message: "Create complete",
277+
}, false},
278+
{"Test simple INVALID", args{&MockHandler{f1}, lc, loadTestEvent("test.invalid.json", &testEvent{})}, handler.ProgressEvent{
279+
OperationStatus: handler.Failed,
280+
Message: "InvalidRequest: No action/invalid action specified",
281+
}, true},
282+
}
283+
for _, tt := range tests {
284+
t.Run(tt.name, func(t *testing.T) {
285+
f := makeTestEventFunc(tt.args.h)
286+
got, err := f(tt.args.ctx, tt.args.event)
287+
288+
if (err != nil) != tt.wantErr {
289+
t.Errorf("makeEventFunc() = %v, wantErr %v", err, tt.wantErr)
290+
return
291+
}
292+
293+
switch tt.wantErr {
294+
case true:
295+
if tt.want.OperationStatus != got.OperationStatus {
296+
t.Errorf("response = %v; want %v", got.OperationStatus, tt.want.OperationStatus)
297+
}
298+
299+
case false:
300+
if !reflect.DeepEqual(tt.want, got) {
301+
t.Errorf("response = %v; want %v", got, tt.want)
302+
}
303+
304+
}
305+
})
306+
}
307+
}
308+
309+
//loadEvent is a helper function that unmarshal the event from a file.
310+
func loadTestEvent(path string, evt *testEvent) *testEvent {
311+
validevent, err := openFixture(path)
312+
if err != nil {
313+
log.Fatalf("Unable to read fixture: %v", err)
314+
}
315+
316+
if err := json.Unmarshal(validevent, evt); err != nil {
317+
log.Fatalf("Marshaling error with event: %v", err)
318+
}
319+
return evt
320+
}

cfn/event.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,27 @@ func validateEvent(event *event) error {
6363

6464
return nil
6565
}
66+
67+
// testEvent base structure, it will be internal to the RPDK.
68+
type testEvent struct {
69+
Action string `json:"action"`
70+
Credentials credentials.CloudFormationCredentialsProvider `json:"credentials"`
71+
CallbackContext map[string]interface{} `json:"callbackContext"`
72+
73+
Request resourceHandlerRequest
74+
}
75+
76+
// resourceHandlerRequest is internal to the RPDK. It contains a number of fields that are for
77+
// internal contract testing use only.
78+
type resourceHandlerRequest struct {
79+
ClientRequestToken string `json:"clientRequestToken"`
80+
DesiredResourceState json.RawMessage `json:"desiredResourceState"`
81+
PreviousResourceState json.RawMessage `json:"previousResourceState"`
82+
DesiredResourceTags tags `json:"desiredResourceTags"`
83+
SystemTags tags `json:"systemTags"`
84+
AWSAccountID string `json:"awsAccountId"`
85+
AwsPartition string `json:"awsPartition"`
86+
LogicalResourceIdentifier string `json:"logicalResourceIdentifier"`
87+
NextToken string `json:"nextToken"`
88+
Region string `json:"region"`
89+
}

cfn/handler/event.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,38 @@ import "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr"
44

55
// ProgressEvent represent the progress of CRUD handlers.
66
type ProgressEvent struct {
7-
// The status indicates whether the handler has reached a terminal state or is
7+
// OperationStatus indicates whether the handler has reached a terminal state or is
88
// still computing and requires more time to complete.
9-
OperationStatus Status
9+
OperationStatus Status `json:"status,omitempty"`
1010

11-
// If OperationStatus is FAILED or IN_PROGRESS, an error code should be provided.
12-
HandlerErrorCode ErrorCode
11+
// HandlerErrorCode should be provided when OperationStatus is FAILED or IN_PROGRESS.
12+
HandlerErrorCode ErrorCode `json:"errorCode,omitempty"`
1313

14-
// The handler can (and should) specify a contextual information message which
15-
// can be shown to callers to indicate the nature of a progress transition or
16-
// callback delay; for example a message indicating "propagating to edge."
17-
Message string
14+
// Message which can be shown to callers to indicate the
15+
//nature of a progress transition or callback delay; for example a message
16+
//indicating "propagating to edge."
17+
Message string `json:"message,omitempty"`
1818

19-
// The callback context is an arbitrary datum which the handler can return in an
19+
// CallbackContext is an arbitrary datum which the handler can return in an
2020
// IN_PROGRESS event to allow the passing through of additional state or
2121
// metadata between subsequent retries; for example to pass through a Resource
2222
// identifier which can be used to continue polling for stabilization
23-
CallbackContext map[string]interface{}
23+
CallbackContext map[string]interface{} `json:"callbackContext,omitempty"`
2424

25-
// A callback will be scheduled with an initial delay of no less than the number
25+
// CallbackDelaySeconds will be scheduled with an initial delay of no less than the number
2626
// of seconds specified in the progress event. Set this value to <= 0 to
2727
// indicate no callback should be made.
28-
CallbackDelaySeconds int64
28+
CallbackDelaySeconds int64 `json:"callbackDelaySeconds,omitempty"`
2929

30-
// The output resource instance populated by a READ/LIST for synchronous results
30+
// ResourceModel is the output resource instance populated by a READ/LIST for synchronous results
3131
// and by CREATE/UPDATE/DELETE for final response validation/confirmation
32-
ResourceModel interface{}
32+
ResourceModel interface{} `json:"resourceModel,omitempty"`
33+
34+
// ResourceModels is the output resource instances populated by a LIST for synchronous results
35+
ResourceModels []interface{} `json:"resourceModels,omitempty"`
36+
37+
// NextToken is the token used to request additional pages of resources for a LIST operation
38+
NextToken string `json:"nextToken,omitempty"`
3339
}
3440

3541
// NewProgressEvent creates a new event with

cfn/response.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,32 @@ import (
88
// cloudformation service from a resource handler.
99
// The zero value is ready to use.
1010
type response struct {
11-
Message string `json:"message,omitempty"`
12-
OperationStatus handler.Status `json:"operationStatus,omitempty"`
13-
ResourceModel interface{} `json:"resourceModel,omitempty"`
14-
ErrorCode handler.ErrorCode `json:"errorCode,omitempty"`
15-
BearerToken string `json:"bearerToken,omitempty"`
11+
// Message which can be shown to callers to indicate the nature of a
12+
//progress transition or callback delay; for example a message
13+
//indicating "propagating to edge"
14+
Message string `json:"message,omitempty"`
15+
16+
//The operationStatus indicates whether the handler has reached a terminal
17+
//state or is still computing and requires more time to complete
18+
OperationStatus handler.Status `json:"operationStatus,omitempty"`
19+
20+
//ResourceModel it The output resource instance populated by a READ/LIST for
21+
//synchronous results and by CREATE/UPDATE/DELETE for final response
22+
//validation/confirmation
23+
ResourceModel interface{} `json:"resourceModel,omitempty"`
24+
25+
// ErrorCode is used to report granular failures back to CloudFormation
26+
ErrorCode handler.ErrorCode `json:"errorCode,omitempty"`
27+
28+
// BearerToken is used to report progress back to CloudFormation and is
29+
//passed back to CloudFormation
30+
BearerToken string `json:"bearerToken,omitempty"`
31+
32+
// ResourceModels is the output resource instances populated by a LIST for synchronous results
33+
ResourceModels []interface{} `json:"resourceModels,omitempty"`
34+
35+
// NextToken the token used to request additional pages of resources for a LIST operation
36+
NextToken string `json:"nextToken,omitempty"`
1637
}
1738

1839
// newFailedResponse returns a response pre-filled with the supplied error
@@ -33,6 +54,8 @@ func newResponse(pevt *handler.ProgressEvent, bearerToken string) (response, err
3354
Message: pevt.Message,
3455
OperationStatus: pevt.OperationStatus,
3556
ResourceModel: pevt.ResourceModel,
57+
ResourceModels: pevt.ResourceModels,
58+
NextToken: pevt.NextToken,
3659
}
3760

3861
if pevt.HandlerErrorCode != "" {

cfn/test/data/test.INVALID.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"action": "INVALID",
3+
"credentials": {
4+
"accessKeyId": "123456789",
5+
"secretAccessKey": "1234566",
6+
"sessionToken": "1234567"
7+
},
8+
"callbackContext": null,
9+
"Request": {
10+
"clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b",
11+
"desiredResourceState": {},
12+
"previousResourceState": {},
13+
"desiredResourceTags": null,
14+
"systemTags": null,
15+
"awsAccountId": "",
16+
"awsPartition": "",
17+
"logicalResourceIdentifier": "",
18+
"nextToken": "",
19+
"region": ""
20+
}
21+
}

cfn/test/data/test.create.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"action": "CREATE",
3+
"credentials": {
4+
"accessKeyId": "123456789",
5+
"secretAccessKey": "1234566",
6+
"sessionToken": "1234567"
7+
},
8+
"callbackContext": null,
9+
"Request": {
10+
"clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b",
11+
"desiredResourceState": {},
12+
"previousResourceState": {},
13+
"desiredResourceTags": null,
14+
"systemTags": null,
15+
"awsAccountId": "",
16+
"awsPartition": "",
17+
"logicalResourceIdentifier": "",
18+
"nextToken": "",
19+
"region": ""
20+
}
21+
}

cfn/test/data/test.delete.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"action": "DELETE",
3+
"credentials": {
4+
"accessKeyId": "123456789",
5+
"secretAccessKey": "1234566",
6+
"sessionToken": "1234567"
7+
},
8+
"callbackContext": null,
9+
"Request": {
10+
"clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b",
11+
"desiredResourceState": {},
12+
"previousResourceState": {},
13+
"desiredResourceTags": null,
14+
"systemTags": null,
15+
"awsAccountId": "",
16+
"awsPartition": "",
17+
"logicalResourceIdentifier": "",
18+
"nextToken": "",
19+
"region": ""
20+
}
21+
}

cfn/test/data/test.read.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"action": "READ",
3+
"credentials": {
4+
"accessKeyId": "123456789",
5+
"secretAccessKey": "1234566",
6+
"sessionToken": "1234567"
7+
},
8+
"callbackContext": null,
9+
"Request": {
10+
"clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b",
11+
"desiredResourceState": {},
12+
"previousResourceState": {},
13+
"desiredResourceTags": null,
14+
"systemTags": null,
15+
"awsAccountId": "",
16+
"awsPartition": "",
17+
"logicalResourceIdentifier": "",
18+
"nextToken": "",
19+
"region": ""
20+
}
21+
}

0 commit comments

Comments
 (0)