diff --git a/aws-aps-rulegroupsnamespace/.rpdk-config b/aws-aps-rulegroupsnamespace/.rpdk-config index a33c1be..1e91a7a 100644 --- a/aws-aps-rulegroupsnamespace/.rpdk-config +++ b/aws-aps-rulegroupsnamespace/.rpdk-config @@ -14,6 +14,6 @@ "artifact_type": null, "importpath": "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/aws-aps-rulegroupsnamespace", "protocolVersion": "2.0.0", - "pluginVersion": "2.0.0" + "pluginVersion": "2.0.4" } } diff --git a/aws-aps-rulegroupsnamespace/aws-aps-rulegroupsnamespace.json b/aws-aps-rulegroupsnamespace/aws-aps-rulegroupsnamespace.json index 3c8d276..89c9b76 100644 --- a/aws-aps-rulegroupsnamespace/aws-aps-rulegroupsnamespace.json +++ b/aws-aps-rulegroupsnamespace/aws-aps-rulegroupsnamespace.json @@ -70,7 +70,13 @@ "readOnlyProperties": [ "/properties/Arn" ], - "taggable": true, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, "primaryIdentifier": [ "/properties/Arn" ], diff --git a/aws-aps-rulegroupsnamespace/cmd/resource/resource.go b/aws-aps-rulegroupsnamespace/cmd/resource/resource.go index 96ac2bb..68e5dec 100644 --- a/aws-aps-rulegroupsnamespace/cmd/resource/resource.go +++ b/aws-aps-rulegroupsnamespace/cmd/resource/resource.go @@ -13,7 +13,7 @@ import ( "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" ) -const defaultCallbackSeconds = 2 +const defaultCallbackSeconds = 10 // Create handles the Create event from the Cloudformation service. func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { @@ -41,11 +41,15 @@ func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler if err != nil { return internal.NewFailedEvent(err) } + systemTags := internal.ToAWSStringMap(req.RequestContext.SystemTags) + tags := tagsToStringMap(currentModel.Tags) + internal.MergeMaps(systemTags, tags) + resp, err := client.CreateRuleGroupsNamespace(&prometheusservice.CreateRuleGroupsNamespaceInput{ WorkspaceId: aws.String(workspaceID), Name: currentModel.Name, Data: []byte(*currentModel.Data), - Tags: tagsToStringMap(currentModel.Tags), + Tags: tags, }) if err != nil { return internal.NewFailedEvent(err) @@ -117,7 +121,12 @@ func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler }, nil } - toAdd, toRemove := internal.StringMapDifference(tagsToStringMap(currentModel.Tags), tagsToStringMap(prevModel.Tags)) + currentModelTags := tagsToStringMap(currentModel.Tags) + systemTags := internal.ToAWSStringMap(req.RequestContext.SystemTags) + + internal.MergeMaps(systemTags, currentModelTags) + + toAdd, toRemove := internal.StringMapDifference(currentModelTags, tagsToStringMap(prevModel.Tags)) if len(toRemove) > 0 { _, err = client.UntagResource(&prometheusservice.UntagResourceInput{ ResourceArn: currentModel.Arn, @@ -291,7 +300,7 @@ func buildCallbackContext(model *Model) map[string]interface{} { } func stringMapToTags(m map[string]*string) []Tag { - res := []Tag{} + res := make([]Tag, 0) for key, val := range m { res = append(res, Tag{ Key: aws.String(key), diff --git a/aws-aps-rulegroupsnamespace/inputs/inputs_1_create.json b/aws-aps-rulegroupsnamespace/inputs/inputs_1_create.json index eb583ed..5b67995 100644 --- a/aws-aps-rulegroupsnamespace/inputs/inputs_1_create.json +++ b/aws-aps-rulegroupsnamespace/inputs/inputs_1_create.json @@ -1,5 +1,12 @@ { - "Workspace": "{{AlertManagerDefinitionTestWorkspaceArn}}", - "Name": "CustomerObsession", - "Data": "groups:\n - name: example\n interval: 5m\n rules:\n - alert: foo\n expr: job:request_latency_seconds:mean5m{job=\"myjob\"} > 0.5\n for: 10m\n labels:\n severity: page\n annotations:\n summary: High request latency\n" + "Workspace": "{{AlertManagerDefinitionTestWorkspaceArn}}", + "Name": "CustomerObsession", + "Data": "groups:\n - name: example\n interval: 5m\n rules:\n - alert: foo\n expr: job:request_latency_seconds:mean5m{job=\"myjob\"} > 0.5\n for: 10m\n labels:\n severity: page\n annotations:\n summary: High request latency\n", + "Tags": [ + { + "Key": "FavoriteDrink", + "Value": "Latte" + } + ] } + diff --git a/aws-aps-workspace/aws-aps-workspace.json b/aws-aps-workspace/aws-aps-workspace.json index 449bc42..85d9329 100644 --- a/aws-aps-workspace/aws-aps-workspace.json +++ b/aws-aps-workspace/aws-aps-workspace.json @@ -1,117 +1,144 @@ { - "typeName": "AWS::APS::Workspace", - "description": "Resource Type definition for AWS::APS::Workspace", - "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", - "definitions": { - "Tag": { - "description": "A key-value pair to associate with a resource.", - "type": "object", - "properties": { - "Key": { - "type": "string", - "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", - "minLength": 1, - "maxLength": 128 + "typeName": "AWS::APS::Workspace", + "description": "Resource Type definition for AWS::APS::Workspace", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false }, - "Value": { - "type": "string", - "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", - "minLength": 0, - "maxLength": 256 + "LoggingConfiguration": { + "description": "Logging configuration", + "type": "object", + "properties": { + "LogGroupArn": { + "description": "CloudWatch log group ARN", + "type": "string", + "minLength": 0, + "maxLength": 512 + } + }, + "additionalProperties": false } - }, - "required": [ - "Key", - "Value" - ], - "additionalProperties": false - } - }, - "properties": { - "WorkspaceId": { - "description": "Required to identify a specific APS Workspace.", - "type": "string", - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}\\Z", - "minLength": 1, - "maxLength": 100 - }, - "Alias": { - "description": "AMP Workspace alias.", - "type": "string", - "minLength": 0, - "maxLength": 100 - }, - "Arn": { - "description": "Workspace arn.", - "type": "string", - "pattern": "^arn:\b(aws|aws-us-gov|aws-cn)\b:aps:[a-z0-9-]+:[0-9]+:workspace/[a-zA-Z0-9-]+$", - "minLength": 1, - "maxLength": 128 - }, - "AlertManagerDefinition": { - "description": "The AMP Workspace alert manager definition data", - "type": "string" - }, - "PrometheusEndpoint": { - "description": "AMP Workspace prometheus endpoint", - "type": "string" }, - "Tags": { - "description": "An array of key-value pairs to apply to this resource.", - "type": "array", - "uniqueItems": true, - "insertionOrder": false, - "items": { - "$ref": "#/definitions/Tag" - } - } - }, - "additionalProperties": false, - "required": [], - "readOnlyProperties": [ - "/properties/WorkspaceId", - "/properties/Arn", - "/properties/PrometheusEndpoint" - ], - "taggable": true, - "primaryIdentifier": [ - "/properties/Arn" - ], - "handlers": { - "create": { - "permissions": [ - "aps:CreateWorkspace", - "aps:TagResource", - "aps:CreateAlertManagerDefinition" - ] - }, - "read": { - "permissions": [ - "aps:DescribeWorkspace", - "aps:ListTagsForResource", - "aps:DescribeAlertManagerDefinition" - ] - }, - "update": { - "permissions": [ - "aps:UpdateWorkspaceAlias", - "aps:TagResource", - "aps:UntagResource", - "aps:ListTagsForResource", - "aps:PutAlertManagerDefinition" - ] + "properties": { + "WorkspaceId": { + "description": "Required to identify a specific APS Workspace.", + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}\\Z", + "minLength": 1, + "maxLength": 100 + }, + "Alias": { + "description": "AMP Workspace alias.", + "type": "string", + "minLength": 0, + "maxLength": 100 + }, + "Arn": { + "description": "Workspace arn.", + "type": "string", + "pattern": "^arn:\b(aws|aws-us-gov|aws-cn)\b:aps:[a-z0-9-]+:[0-9]+:workspace/[a-zA-Z0-9-]+$", + "minLength": 1, + "maxLength": 128 + }, + "AlertManagerDefinition": { + "description": "The AMP Workspace alert manager definition data", + "type": "string" + }, + "PrometheusEndpoint": { + "description": "AMP Workspace prometheus endpoint", + "type": "string" + }, + "LoggingConfiguration": { + "$ref": "#/definitions/LoggingConfiguration" + }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } }, - "delete": { - "permissions": [ - "aps:DeleteWorkspace", - "aps:DeleteAlertManagerDefinition" - ] + "additionalProperties": false, + "required": [], + "readOnlyProperties": [ + "/properties/WorkspaceId", + "/properties/Arn", + "/properties/PrometheusEndpoint" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" }, - "list": { - "permissions": [ - "aps:ListWorkspaces", - "aps:ListTagsForResource" - ] + "primaryIdentifier": [ + "/properties/Arn" + ], + "handlers": { + "create": { + "permissions": [ + "aps:CreateWorkspace", + "aps:TagResource", + "aps:CreateAlertManagerDefinition", + "aps:DescribeAlertManagerDefinition", + "aps:CreateLoggingConfiguration" + ] + }, + "read": { + "permissions": [ + "aps:DescribeWorkspace", + "aps:ListTagsForResource", + "aps:DescribeAlertManagerDefinition", + "aps:DescribeLoggingConfiguration" + ] + }, + "update": { + "permissions": [ + "aps:UpdateWorkspaceAlias", + "aps:TagResource", + "aps:UntagResource", + "aps:ListTagsForResource", + "aps:PutAlertManagerDefinition", + "aps:UpdateLoggingConfiguration" + ] + }, + "delete": { + "permissions": [ + "aps:DeleteWorkspace", + "aps:DeleteAlertManagerDefinition", + "aps:DeleteLoggingConfiguration" + ] + }, + "list": { + "permissions": [ + "aps:ListWorkspaces", + "aps:ListTagsForResource" + ] + } } - } } diff --git a/aws-aps-workspace/cmd/resource/alert_manager_handler.go b/aws-aps-workspace/cmd/resource/alert_manager_handler.go new file mode 100644 index 0000000..c415ac0 --- /dev/null +++ b/aws-aps-workspace/cmd/resource/alert_manager_handler.go @@ -0,0 +1,222 @@ +package resource + +import ( + "fmt" + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/prometheusservice" + "strings" +) + +var ( + alertManagerFailedStates = map[string]struct{}{ + prometheusservice.AlertManagerDefinitionStatusCodeCreationFailed: {}, + prometheusservice.AlertManagerDefinitionStatusCodeUpdateFailed: {}, + } + + waitForAlertManagerStatusActiveKey = "waitForAlertManagerActive" + waitForAlertManagerStatusDeleteKey = "waitForAlertManagerDeleted" +) + +func CreateAlertManager(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForAlertManagerStatusActiveKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateAlertManagerState(client, currentModel, prometheusservice.AlertManagerDefinitionStatusCodeActive, messageCreateComplete) + return proceedOnSuccess(evt, err) + } + if currentModel.AlertManagerDefinition != nil { + evt, err := createAlertManagerDefinition(client, currentModel) + return proceedOnSuccess(evt, err) + } + return proceed(currentModel) +} + +func createAlertManagerDefinition(client internal.APSService, currentModel *Model) (handler.ProgressEvent, error) { + _, err := client.CreateAlertManagerDefinition(&prometheusservice.CreateAlertManagerDefinitionInput{ + Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), + WorkspaceId: currentModel.WorkspaceId, + }) + + if err != nil { + return internal.NewFailedEvent(err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusActiveKey), + }, nil +} + +func alertManagerChanged(prevModel, currentModel *Model) bool { + return internal.StringDiffers(currentModel.AlertManagerDefinition, prevModel.AlertManagerDefinition) +} + +func UpdateAlertManager(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForAlertManagerStatusActiveKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateAlertManagerState(client, + currentModel, + prometheusservice.AlertManagerDefinitionStatusCodeActive, + messageUpdateComplete) + return proceedOnSuccess(evt, err) + } + + if arn, ok := req.CallbackContext[waitForAlertManagerStatusDeleteKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateAlertManagerDeleted(client, + currentModel, + messageUpdateComplete) + return proceedOnSuccess(evt, err) + } + if alertManagerChanged(prevModel, currentModel) { + evt, err := manageAlertManagerDefinition(currentModel, prevModel, client) + return proceedOnSuccess(evt, err) + } + return proceed(currentModel) +} + +func manageAlertManagerDefinition(currentModel *Model, prevModel *Model, client internal.APSService) (handler.ProgressEvent, error) { + var err error + + shouldCreateAlertManagerDefinition := currentModel.AlertManagerDefinition != nil && + prevModel.AlertManagerDefinition == nil && + strings.TrimSpace(aws.StringValue(currentModel.AlertManagerDefinition)) != "" + + shouldDeleteAlertManagerDefinition := currentModel.AlertManagerDefinition == nil + key := waitForAlertManagerStatusActiveKey + + if shouldCreateAlertManagerDefinition { + _, err = client.CreateAlertManagerDefinition(&prometheusservice.CreateAlertManagerDefinitionInput{ + Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), + WorkspaceId: currentModel.WorkspaceId, + }) + } else if shouldDeleteAlertManagerDefinition { + _, err = client.DeleteAlertManagerDefinition(&prometheusservice.DeleteAlertManagerDefinitionInput{ + WorkspaceId: currentModel.WorkspaceId, + }) + if err != nil { + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { + err = nil + } + } + } + key = waitForAlertManagerStatusDeleteKey + } else { + _, err = client.PutAlertManagerDefinition(&prometheusservice.PutAlertManagerDefinitionInput{ + Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), + WorkspaceId: currentModel.WorkspaceId, + }) + } + + if err != nil { + return internal.NewFailedEvent(err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, key), + }, nil +} + +func readAlertManagerDefinition(client internal.APSService, currentModel *Model) (*prometheusservice.AlertManagerDefinitionStatus, error) { + _, workspaceID, err := internal.ParseARN(*currentModel.Arn) + if err != nil { + return nil, err + } + + data, err := client.DescribeAlertManagerDefinition(&prometheusservice.DescribeAlertManagerDefinitionInput{ + WorkspaceId: aws.String(workspaceID), + }) + if err != nil { + return nil, err + } + + currentModel.AlertManagerDefinition = aws.String(string(data.AlertManagerDefinition.Data)) + return data.AlertManagerDefinition.Status, nil +} + +func validateAlertManagerState(client internal.APSService, currentModel *Model, targetState string, successMessage string) (handler.ProgressEvent, error) { + _, err := readWorkspace(client, currentModel) + if err != nil { + return handler.ProgressEvent{}, awserr.New(ErrCodeWorkspaceNotFoundException, "Workspace not found", err) + } + + state, err := readAlertManagerDefinition(client, currentModel) + if err != nil { + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.Failed, + Message: "AlertManagerDefinition was deleted out-of-band", + }, err + } + + if _, ok := alertManagerFailedStates[aws.StringValue(state.StatusCode)]; ok { + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.Failed, + Message: fmt.Sprintf("AlertManagerDefinition status: %s. Reason: %s", aws.StringValue(state.StatusCode), aws.StringValue(state.StatusReason)), + }, err + } + + if aws.StringValue(state.StatusCode) != targetState { + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.InProgress, + Message: messageInProgress, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusActiveKey), + }, nil + } + + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.Success, + Message: successMessage, + }, nil +} + +func validateAlertManagerDeleted(client internal.APSService, currentModel *Model, successMessage string) (handler.ProgressEvent, error) { + _, err := readWorkspace(client, currentModel) + if err != nil { + return handler.ProgressEvent{}, err + } + + _, err = readAlertManagerDefinition(client, currentModel) + + if err == nil { + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.InProgress, + Message: messageInProgress, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusDeleteKey), + }, nil + } + + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: successMessage, + ResourceModel: currentModel, + }, nil + } + } + + return handler.ProgressEvent{}, err +} + +func buildWaitForAlertManagerStatusCallbackContext(model *Model, key string) map[string]interface{} { + return map[string]interface{}{ + key: aws.StringValue(model.Arn), + } +} diff --git a/aws-aps-workspace/cmd/resource/alert_manager_handler_test.go b/aws-aps-workspace/cmd/resource/alert_manager_handler_test.go new file mode 100644 index 0000000..2a219ba --- /dev/null +++ b/aws-aps-workspace/cmd/resource/alert_manager_handler_test.go @@ -0,0 +1,89 @@ +package resource + +import ( + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/prometheusservice" + "testing" +) + +type MockPrometheusService struct { + internal.APSService + status *prometheusservice.AlertManagerDefinitionStatus +} + +func (c *MockPrometheusService) DescribeWorkspace(*prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { + return &prometheusservice.DescribeWorkspaceOutput{ + Workspace: &prometheusservice.WorkspaceDescription{ + Arn: aws.String("arn:aws:aps:us-west-2:933102010132:workspace/ws-5f41d54a-41fc-4783-984f-7facb35c928c"), + }, + }, nil +} + +func (c *MockPrometheusService) DescribeAlertManagerDefinition(*prometheusservice.DescribeAlertManagerDefinitionInput) (*prometheusservice.DescribeAlertManagerDefinitionOutput, error) { + return &prometheusservice.DescribeAlertManagerDefinitionOutput{ + AlertManagerDefinition: &prometheusservice.AlertManagerDefinitionDescription{ + Status: c.status, + }, + }, nil +} + +func Test_validateAlertManagerState(t *testing.T) { + testCases := map[string]struct { + client internal.APSService + status handler.Status + targetState string + targetMessage string + }{ + "Should return Fail when status is failed": { + client: &MockPrometheusService{ + status: &prometheusservice.AlertManagerDefinitionStatus{ + StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeCreationFailed), + StatusReason: aws.String("Failure Reason"), + }, + }, + status: handler.Failed, + targetMessage: "AlertManagerDefinition status: CREATION_FAILED. Reason: Failure Reason", + }, + "Should return in progress when state is not target state": { + client: &MockPrometheusService{ + status: &prometheusservice.AlertManagerDefinitionStatus{ + StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeCreating), + }, + }, + status: handler.InProgress, + targetState: prometheusservice.AlertManagerDefinitionStatusCodeActive, + targetMessage: "In Progress", + }, + "Should return Success when state is target state": { + client: &MockPrometheusService{ + status: &prometheusservice.AlertManagerDefinitionStatus{ + StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeActive), + }, + }, + status: handler.Success, + targetState: prometheusservice.AlertManagerDefinitionStatusCodeActive, + }, + } + + m := &Model{ + Arn: aws.String("arn:aws:aps:us-west-2:933102010132:workspace/ws-5f41d54a-41fc-4783-984f-7facb35c928c"), + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + evt, err := validateAlertManagerState(tc.client, m, tc.targetState, "") + if err != nil { + t.Fatalf("Failed: %v", err) + } + if evt.OperationStatus != tc.status { + t.Fatalf("Unexpected Status: %s and should be %s", evt.OperationStatus, tc.status) + } + + if evt.Message != tc.targetMessage { + t.Fatalf("Unexpected message: %s and should be %s", evt.Message, tc.targetMessage) + } + }) + } +} diff --git a/aws-aps-workspace/cmd/resource/logging_config_handler.go b/aws-aps-workspace/cmd/resource/logging_config_handler.go new file mode 100644 index 0000000..de37d21 --- /dev/null +++ b/aws-aps-workspace/cmd/resource/logging_config_handler.go @@ -0,0 +1,227 @@ +package resource + +import ( + "errors" + "fmt" + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/prometheusservice" + "strings" +) + +var ( + waitForLoggingConfigurationActiveKey = "waitForLoggingConfigurationActive" + waitForLoggingConfigurationDeleteKey = "waitForLoggingConfigurationDeleted" +) + +func CreateLoggingConfiguration(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForLoggingConfigurationActiveKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateLoggingConfigurationState(client, currentModel, prometheusservice.LoggingConfigurationStatusCodeActive, messageCreateComplete) + return proceedOnSuccess(evt, err) + } + + if currentModel.LoggingConfiguration != nil && currentModel.LoggingConfiguration.LogGroupArn != nil { + evt, err := createLoggingConfiguration(client, currentModel) + return proceedOnSuccess(evt, err) + } + return proceed(currentModel) +} + +func createLoggingConfiguration(client internal.APSService, currentModel *Model) (handler.ProgressEvent, error) { + loggingConfigurationOutput, err := client.CreateLoggingConfiguration(&prometheusservice.CreateLoggingConfigurationInput{ + WorkspaceId: currentModel.WorkspaceId, + LogGroupArn: currentModel.LoggingConfiguration.LogGroupArn, + }) + + if err != nil { + return internal.NewFailedEvent(err) + } + + if *loggingConfigurationOutput.Status.StatusCode == prometheusservice.LoggingConfigurationStatusCodeCreationFailed { + return internal.NewFailedEvent(errors.New(fmt.Sprintf("logging config creation failed due to %s", *loggingConfigurationOutput.Status.StatusReason))) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForLoggingConfigurationStatusCallbackContext(currentModel, waitForLoggingConfigurationActiveKey), + }, nil +} + +func updateLoggingConfiguration(client internal.APSService, currentModel *Model) (handler.ProgressEvent, error) { + loggingConfigurationOutput, err := client.UpdateLoggingConfiguration(&prometheusservice.UpdateLoggingConfigurationInput{ + LogGroupArn: currentModel.LoggingConfiguration.LogGroupArn, + WorkspaceId: currentModel.WorkspaceId, + }) + + if err != nil { + return internal.NewFailedEvent(err) + } + + if *loggingConfigurationOutput.Status.StatusCode == prometheusservice.LoggingConfigurationStatusCodeUpdateFailed { + return internal.NewFailedEvent(errors.New(fmt.Sprintf("logging config update failed due to %s", *loggingConfigurationOutput.Status.StatusReason))) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForLoggingConfigurationStatusCallbackContext(currentModel, waitForLoggingConfigurationActiveKey), + }, nil +} + +func deleteLoggingConfiguration(client internal.APSService, currentModel *Model) (handler.ProgressEvent, error) { + _, err := client.DeleteLoggingConfiguration(&prometheusservice.DeleteLoggingConfigurationInput{ + WorkspaceId: currentModel.WorkspaceId, + }) + + if err != nil { + return internal.NewFailedEvent(err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForLoggingConfigurationStatusCallbackContext(currentModel, waitForLoggingConfigurationDeleteKey), + }, nil +} + +func UpdateLoggingConfiguration(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForLoggingConfigurationActiveKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateLoggingConfigurationState(client, + currentModel, + prometheusservice.LoggingConfigurationStatusCodeActive, + messageUpdateComplete) + return proceedOnSuccess(evt, err) + } + if arn, ok := req.CallbackContext[waitForLoggingConfigurationDeleteKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateLoggingConfigurationState(client, currentModel, "", messageUpdateComplete) + return validateLoggingConfigurationDeleted(evt, err, currentModel) + } + if loggingConfigurationChanged(prevModel, currentModel) { + evt, err := manageLoggingConfiguration(currentModel, prevModel, client) + return proceedOnSuccess(evt, err) + } + return proceed(currentModel) +} + +func validateLoggingConfigurationDeleted(evt handler.ProgressEvent, err error, currentModel *Model) (bool, handler.ProgressEvent, error) { + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { + return proceedOnSuccess( + handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: messageUpdateComplete, + ResourceModel: currentModel, + }, nil) + } + } + return proceedOnSuccess(evt, err) +} + +func logGroupARN(model *Model) string { + if model.LoggingConfiguration != nil { + return strings.TrimSpace(aws.StringValue(model.LoggingConfiguration.LogGroupArn)) + } + return "" +} + +func manageLoggingConfiguration(currentModel *Model, prevModel *Model, client internal.APSService) (handler.ProgressEvent, error) { + currentLogGroup := logGroupARN(currentModel) + prevLogGroup := logGroupARN(prevModel) + + shouldCreateLoggingConfiguration := currentLogGroup != "" && prevLogGroup == "" + + shouldDeleteLoggingConfiguration := currentLogGroup == "" && prevLogGroup != "" + + if shouldCreateLoggingConfiguration { + return createLoggingConfiguration(client, currentModel) + } + if shouldDeleteLoggingConfiguration { + return deleteLoggingConfiguration(client, currentModel) + } + + return updateLoggingConfiguration(client, currentModel) +} + +func loggingConfigurationChanged(prevModel, currentModel *Model) bool { + + prevLogGroup := logGroupARN(prevModel) + currentLogGroup := logGroupARN(currentModel) + + return prevLogGroup != currentLogGroup +} + +func readLoggingConfiguration(client internal.APSService, currentModel *Model) (*prometheusservice.DescribeLoggingConfigurationOutput, error) { + _, workspaceID, err := internal.ParseARN(*currentModel.Arn) + if err != nil { + return nil, err + } + loggingConfigurationOutput, err := client.DescribeLoggingConfiguration(&prometheusservice.DescribeLoggingConfigurationInput{ + WorkspaceId: aws.String(workspaceID), + }) + + if err != nil { + return nil, err + } + currentModel.LoggingConfiguration = &LoggingConfiguration{ + LogGroupArn: loggingConfigurationOutput.LoggingConfiguration.LogGroupArn, + } + return loggingConfigurationOutput, nil +} + +func validateLoggingConfigurationState(client internal.APSService, currentModel *Model, targetState string, successMessage string) (handler.ProgressEvent, error) { + _, err := readWorkspace(client, currentModel) + if err != nil { + return handler.ProgressEvent{}, awserr.New(ErrCodeWorkspaceNotFoundException, "Workspace not found", err) + } + loggingConfigurationOutput, err := readLoggingConfiguration(client, currentModel) + if err != nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + ResourceModel: currentModel, + Message: err.Error(), + }, err + } + + status := aws.StringValue(loggingConfigurationOutput.LoggingConfiguration.Status.StatusCode) + switch status { + case targetState: + return handler.ProgressEvent{ + OperationStatus: handler.Success, + ResourceModel: currentModel, + Message: successMessage, + }, nil + case prometheusservice.LoggingConfigurationStatusCodeCreating, prometheusservice.LoggingConfigurationStatusCodeUpdating, prometheusservice.LoggingConfigurationStatusCodeDeleting: + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + ResourceModel: currentModel, + Message: messageInProgress, + CallbackDelaySeconds: longCallbackSeconds, + }, nil + case prometheusservice.LoggingConfigurationStatusCodeCreationFailed, prometheusservice.LoggingConfigurationStatusCodeUpdateFailed: + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + ResourceModel: currentModel, + Message: fmt.Sprintf("Logging configuration status %s", aws.StringValue(loggingConfigurationOutput.LoggingConfiguration.Status.StatusReason)), + }, nil + } + + return handler.ProgressEvent{}, nil +} + +func buildWaitForLoggingConfigurationStatusCallbackContext(model *Model, key string) map[string]interface{} { + return map[string]interface{}{ + key: aws.StringValue(model.Arn), + } +} diff --git a/aws-aps-workspace/cmd/resource/logging_config_handler_test.go b/aws-aps-workspace/cmd/resource/logging_config_handler_test.go new file mode 100644 index 0000000..8f19082 --- /dev/null +++ b/aws-aps-workspace/cmd/resource/logging_config_handler_test.go @@ -0,0 +1,147 @@ +package resource + +import ( + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/prometheusservice" + "testing" + "time" +) + +type FakePrometheusServiceLoggingConfig struct { + internal.APSService + createLoggingConfigFunc func(input *prometheusservice.CreateLoggingConfigurationInput) (*prometheusservice.CreateLoggingConfigurationOutput, error) + describeWorkspaceFunc func(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) + describeLoggingConfigFunc func(input *prometheusservice.DescribeLoggingConfigurationInput) (*prometheusservice.DescribeLoggingConfigurationOutput, error) +} + +func (f *FakePrometheusServiceLoggingConfig) CreateLoggingConfiguration(input *prometheusservice.CreateLoggingConfigurationInput) (*prometheusservice.CreateLoggingConfigurationOutput, error) { + return f.createLoggingConfigFunc(input) +} + +func (f *FakePrometheusServiceLoggingConfig) DescribeWorkspace(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { + return f.describeWorkspaceFunc(input) +} + +func (f *FakePrometheusServiceLoggingConfig) DescribeLoggingConfiguration(input *prometheusservice.DescribeLoggingConfigurationInput) (*prometheusservice.DescribeLoggingConfigurationOutput, error) { + return f.describeLoggingConfigFunc(input) +} + +func loggingConfigInProgress(input *prometheusservice.CreateLoggingConfigurationInput) (*prometheusservice.CreateLoggingConfigurationOutput, error) { + return &prometheusservice.CreateLoggingConfigurationOutput{ + Status: &prometheusservice.LoggingConfigurationStatus{ + StatusCode: aws.String(prometheusservice.LoggingConfigurationStatusCodeCreating), + }, + }, nil +} + +func loggingConfigWorkspaceDescriptionActive(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { + return &prometheusservice.DescribeWorkspaceOutput{ + Workspace: &prometheusservice.WorkspaceDescription{ + Alias: aws.String("Test"), + Arn: aws.String("arn:aws:aps:us-west-2:123456789:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + CreatedAt: aws.Time(time.Now()), + PrometheusEndpoint: aws.String("https://testendpoint"), + Status: &prometheusservice.WorkspaceStatus{ + StatusCode: aws.String(prometheusservice.WorkspaceStatusCodeActive), + }, + Tags: map[string]*string{ + "aws:cloudformation:stack-name": aws.String("Test"), + "aws:cloudformation:logical-id": aws.String("1234"), + "aws:cloudformation:stack-id": aws.String("arn:aws:cloudformation:us-west-2:123456789:stack/Test/fa4bdfd0-0498-11ed-a87d-06c24a5b766d"), + }, + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + }, + }, nil +} + +func loggingConfigActive(input *prometheusservice.DescribeLoggingConfigurationInput) (*prometheusservice.DescribeLoggingConfigurationOutput, error) { + return &prometheusservice.DescribeLoggingConfigurationOutput{ + LoggingConfiguration: &prometheusservice.LoggingConfigurationMetadata{ + LogGroupArn: aws.String("arn:aws:logs:us-west-2:123456789012:log-group:test-loggroup:*"), + Status: &prometheusservice.LoggingConfigurationStatus{ + StatusCode: aws.String(prometheusservice.LoggingConfigurationStatusCodeActive), + }, + Workspace: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + }, + }, nil +} + +func Test_CreateLoggingConfiguration(t *testing.T) { + testCases := map[string]struct { + client internal.APSService + status handler.Status + req handler.Request + prevModel *Model + currentModel *Model + callbackContext map[string]interface{} + }{ + "Should return in progress": { + client: &FakePrometheusServiceLoggingConfig{ + createLoggingConfigFunc: loggingConfigInProgress, + }, + status: handler.InProgress, + req: handler.Request{ + CallbackContext: map[string]interface{}{}, + }, + prevModel: &Model{}, + currentModel: &Model{ + Arn: aws.String("arn:aws:aps:us-west-2:123456789012:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + LoggingConfiguration: &LoggingConfiguration{ + LogGroupArn: aws.String("arn:aws:logs:us-west-2:123456789012:log-group:test-loggroup:*"), + }, + }, + callbackContext: map[string]interface{}{ + waitForLoggingConfigurationActiveKey: "arn:aws:aps:us-west-2:123456789012:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1", + }, + }, + "Should return success": { + client: &FakePrometheusServiceLoggingConfig{ + describeWorkspaceFunc: loggingConfigWorkspaceDescriptionActive, + describeLoggingConfigFunc: loggingConfigActive, + }, + status: handler.Success, + req: handler.Request{ + CallbackContext: map[string]interface{}{ + waitForLoggingConfigurationActiveKey: "arn:aws:aps:us-west-2:123456789:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1", + }, + }, + prevModel: &Model{ + Arn: aws.String("arn:aws:aps:us-west-2:123456789012:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + LoggingConfiguration: &LoggingConfiguration{ + LogGroupArn: aws.String("arn:aws:logs:us-west-2:123456789012:log-group:test-loggroup:*"), + }, + }, + currentModel: &Model{ + Arn: aws.String("arn:aws:aps:us-west-2:123456789012:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + LoggingConfiguration: &LoggingConfiguration{ + LogGroupArn: aws.String("arn:aws:logs:us-west-2:123456789012:log-group:test-loggroup:*"), + }, + }, + callbackContext: map[string]interface{}{}, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _, evt, err := CreateLoggingConfiguration(tc.req, tc.client, tc.prevModel, tc.currentModel) + if err != nil { + t.Fatalf("Failed %v", err) + } + if evt.OperationStatus != tc.status { + t.Fatalf("Unexpected status %s and should be %s", evt.OperationStatus, tc.status) + } + if (evt.CallbackContext == nil || len(evt.CallbackContext) == 0) && (tc.callbackContext != nil && len(tc.callbackContext) > 0) { + t.Fatalf("Missing callback context. Expected %+v", tc.callbackContext) + } + for k, v := range tc.callbackContext { + if evt.CallbackContext[k] != v { + t.Fatalf("Expected key, value = [%s,%s]. Got [%s]", k, v, evt.CallbackContext[k]) + } + } + }) + } +} diff --git a/aws-aps-workspace/cmd/resource/resource.go b/aws-aps-workspace/cmd/resource/resource.go index 3887325..f83c276 100644 --- a/aws-aps-workspace/cmd/resource/resource.go +++ b/aws-aps-workspace/cmd/resource/resource.go @@ -1,32 +1,38 @@ package resource import ( - "fmt" "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/cloudformation" - "strings" - "github.com/aws/aws-sdk-go/service/prometheusservice" ) const ( - defaultCallbackSeconds = 2 - waitForWorkspaceStatusKey = "Arn" // for backwards compatibility during release - waitForAlertManagerStatusActiveKey = "waitForAlertManagerActive" - waitForAlertManagerStatusDeleteKey = "waitForAlertManagerDeleted" + shortCallbackSeconds = 2 + longCallbackSeconds = 10 + ErrCodeWorkspaceNotFoundException = "WorkspaceNotFoundException" messageUpdateComplete = "Update Completed" messageCreateComplete = "Create Completed" messageInProgress = "In Progress" + stageKey = "stage" ) -var alertManagerFailedStates = map[string]struct{}{ - prometheusservice.AlertManagerDefinitionStatusCodeCreationFailed: {}, - prometheusservice.AlertManagerDefinitionStatusCodeUpdateFailed: {}, -} +var ( + createResourceHandlers = []AMPResourceHandler{ + CreateWorkspace, + CreateAlertManager, + CreateLoggingConfiguration, + } + + updateResourceHandlers = []AMPResourceHandler{ + UpdateWorkspace, + UpdateAlertManager, + UpdateLoggingConfiguration, + } +) // Create handles the Create event from the Cloudformation service. func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { @@ -39,67 +45,23 @@ func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler } client := internal.NewAPS(req.Session) - // wait for workspace to be ACTIVE before managing alert manager configuration - if arn, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { - currentModel.Arn = aws.String(arn.(string)) - evt, err := validateWorkspaceState( - client, - currentModel, - prometheusservice.WorkspaceStatusCodeActive, - messageCreateComplete) - if evt.OperationStatus == handler.InProgress || currentModel.AlertManagerDefinition == nil { + currentStage := currentStage(req) + for stage := currentStage; stage < len(createResourceHandlers); stage++ { + handler := createResourceHandlers[stage] + if proceed, evt, err := handler(req, client, prevModel, currentModel); !proceed { + addStageToCallbackContext(evt, stage) return evt, err } - - return createAlertManagerDefinition(req, client, currentModel) } - // AlertManagerDefinition is always created last. As such we have to continue waiting after the Workspace is created - if arn, ok := req.CallbackContext[waitForAlertManagerStatusActiveKey]; ok { - currentModel.Arn = aws.String(arn.(string)) - - return validateAlertManagerState(client, - currentModel, - prometheusservice.AlertManagerDefinitionStatusCodeActive, - messageCreateComplete) - } - - resp, err := client.CreateWorkspace(&prometheusservice.CreateWorkspaceInput{ - Alias: currentModel.Alias, - Tags: tagsToStringMap(currentModel.Tags), - }) - if err != nil { - return internal.NewFailedEvent(err) - } - currentModel.Arn = resp.Arn - + // all done return handler.ProgressEvent{ - OperationStatus: handler.InProgress, - Message: messageInProgress, - ResourceModel: currentModel, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + OperationStatus: handler.Success, + Message: messageCreateComplete, + ResourceModel: currentModel, }, nil -} - -func createAlertManagerDefinition(req handler.Request, client internal.APSService, currentModel *Model) (handler.ProgressEvent, error) { - _, err := client.CreateAlertManagerDefinition(&prometheusservice.CreateAlertManagerDefinitionInput{ - Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), - WorkspaceId: currentModel.WorkspaceId, - }) - - if err != nil { - return internal.NewFailedEvent(err) - } - return handler.ProgressEvent{ - OperationStatus: handler.InProgress, - Message: messageInProgress, - ResourceModel: currentModel, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusActiveKey), - }, nil } // Read handles the Read event from the Cloudformation service. @@ -118,6 +80,17 @@ func Read(req handler.Request, prevModel *Model, currentModel *Model) (handler.P return internal.NewFailedEvent(err) } if _, err := readAlertManagerDefinition(client, currentModel); err != nil { + + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() != prometheusservice.ErrCodeResourceNotFoundException { + return internal.NewFailedEvent(err) + } + } else { + return internal.NewFailedEvent(err) + } + } + + if _, err := readLoggingConfiguration(client, currentModel); err != nil { if awsErr, ok := err.(awserr.Error); ok { if awsErr.Code() != prometheusservice.ErrCodeResourceNotFoundException { return internal.NewFailedEvent(err) @@ -146,7 +119,6 @@ func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler } client := internal.NewAPS(req.Session) - _, workspaceID, err := internal.ParseARN(*currentModel.Arn) if err != nil { return handler.ProgressEvent{ @@ -155,210 +127,36 @@ func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler HandlerErrorCode: cloudformation.HandlerErrorCodeNotFound, }, nil } - currentModel.WorkspaceId = aws.String(workspaceID) - - if arn, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { - currentModel.Arn = aws.String(arn.(string)) - - evt, err := validateWorkspaceState( - client, - currentModel, - prometheusservice.WorkspaceStatusCodeActive, - messageUpdateComplete) - if err != nil { - return internal.NewFailedEvent(err) - } - - if evt.OperationStatus == handler.InProgress { - return evt, err - } - - if !internal.StringDiffers(currentModel.AlertManagerDefinition, prevModel.AlertManagerDefinition) { + currentStage := currentStage(req) + for stage := currentStage; stage < len(updateResourceHandlers); stage++ { + handler := updateResourceHandlers[stage] + if proceed, evt, err := handler(req, client, prevModel, currentModel); !proceed { + addStageToCallbackContext(evt, stage) return evt, err } - - return manageAlertManagerDefinition(currentModel, prevModel, client) - } - - // AlertManagerDefinition is always updated last. As such we have to continue waiting after the Workspace is in ACTIVE state again - if arn, ok := req.CallbackContext[waitForAlertManagerStatusActiveKey]; ok { - currentModel.Arn = aws.String(arn.(string)) - - return validateAlertManagerState(client, - currentModel, - prometheusservice.AlertManagerDefinitionStatusCodeActive, - messageUpdateComplete) - } - - if arn, ok := req.CallbackContext[waitForAlertManagerStatusDeleteKey]; ok { - currentModel.Arn = aws.String(arn.(string)) - - return validateAlertManagerDeleted(client, - currentModel, - messageUpdateComplete) - } - - if internal.StringDiffers(currentModel.Alias, prevModel.Alias) { - _, err = client. - UpdateWorkspaceAlias(&prometheusservice.UpdateWorkspaceAliasInput{ - WorkspaceId: aws.String(workspaceID), - Alias: currentModel.Alias, - }) - if err != nil { - return internal.NewFailedEvent(err) - } - } - - toAdd, toRemove := internal.StringMapDifference(tagsToStringMap(currentModel.Tags), tagsToStringMap(prevModel.Tags)) - if len(toRemove) > 0 { - _, err = client.UntagResource(&prometheusservice.UntagResourceInput{ - ResourceArn: currentModel.Arn, - TagKeys: toRemove, - }) - if err != nil { - return internal.NewFailedEvent(err) - } - } - - if len(toAdd) > 0 { - _, err = client.TagResource(&prometheusservice.TagResourceInput{ - ResourceArn: currentModel.Arn, - Tags: toAdd, - }) - if err != nil { - return internal.NewFailedEvent(err) - } } return handler.ProgressEvent{ - OperationStatus: handler.InProgress, - Message: messageInProgress, - ResourceModel: currentModel, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + ResourceModel: currentModel, + OperationStatus: handler.Success, + Message: messageUpdateComplete, }, nil -} - -// manageAlertManagerDefinition handles AlertManagerDefinition state changes for UPDATE calls -func manageAlertManagerDefinition( - currentModel *Model, - prevModel *Model, - client internal.APSService) (handler.ProgressEvent, error) { - var err error - - shouldCreateAlertManagerDefinition := currentModel.AlertManagerDefinition != nil && - prevModel.AlertManagerDefinition == nil && - strings.TrimSpace(aws.StringValue(currentModel.AlertManagerDefinition)) != "" - shouldDeleteAlertManagerDefinition := currentModel.AlertManagerDefinition == nil - key := waitForAlertManagerStatusActiveKey - - if shouldCreateAlertManagerDefinition { - _, err = client.CreateAlertManagerDefinition(&prometheusservice.CreateAlertManagerDefinitionInput{ - Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), - WorkspaceId: currentModel.WorkspaceId, - }) - } else if shouldDeleteAlertManagerDefinition { - _, err = client.DeleteAlertManagerDefinition(&prometheusservice.DeleteAlertManagerDefinitionInput{ - WorkspaceId: currentModel.WorkspaceId, - }) - if err != nil { - if awsErr, ok := err.(awserr.Error); ok { - if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { - err = nil - } - } - } - key = waitForAlertManagerStatusDeleteKey - } else { - _, err = client.PutAlertManagerDefinition(&prometheusservice.PutAlertManagerDefinitionInput{ - Data: []byte(aws.StringValue(currentModel.AlertManagerDefinition)), - WorkspaceId: currentModel.WorkspaceId, - }) - } - - if err != nil { - return internal.NewFailedEvent(err) - } - - return handler.ProgressEvent{ - OperationStatus: handler.InProgress, - Message: messageInProgress, - ResourceModel: currentModel, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, key), - }, nil } -// Delete handles the Delete event from the Cloudformation service. -func Delete(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { - if currentModel.Arn == nil { - return handler.ProgressEvent{ - OperationStatus: handler.Failed, - Message: "Invalid Delete: workspace ARN cannot be empty", - HandlerErrorCode: cloudformation.HandlerErrorCodeNotFound, - }, nil - } - - client := internal.NewAPS(req.Session) - if _, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { - currentModel.Arn = aws.String(req.CallbackContext[waitForWorkspaceStatusKey].(string)) - return validateWorkspaceDeleted( - client, - currentModel, - "Delete Complete") +func addStageToCallbackContext(evt handler.ProgressEvent, stage int) { + if evt.CallbackContext == nil { + evt.CallbackContext = make(map[string]interface{}) } - - _, workspaceID, err := internal.ParseARN(*currentModel.Arn) - if err != nil { - return handler.ProgressEvent{ - OperationStatus: handler.Failed, - Message: "Invalid Read: invalid workspace ARN format", - HandlerErrorCode: cloudformation.HandlerErrorCodeNotFound, - }, nil - } - - // no need to delete AlertManagerDefinition here, because APSService deletes this when the workspace is deleted - _, err = client. - DeleteWorkspace(&prometheusservice.DeleteWorkspaceInput{ - WorkspaceId: aws.String(workspaceID), - }) - if err != nil { - return internal.NewFailedEvent(err) - } - - return handler.ProgressEvent{ - OperationStatus: handler.InProgress, - Message: messageInProgress, - ResourceModel: currentModel, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), - }, nil + evt.CallbackContext[stageKey] = stage } -func validateWorkspaceDeleted(client internal.APSService, currentModel *Model, successMessage string) (handler.ProgressEvent, error) { - _, err := readWorkspace(client, currentModel) - if err == nil { - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.InProgress, - Message: messageInProgress, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), - }, nil - } - - if awsErr, ok := err.(awserr.Error); ok { - if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { - return handler.ProgressEvent{ - OperationStatus: handler.Success, - Message: successMessage, - }, nil - } +func currentStage(req handler.Request) int { + if stg := req.CallbackContext[stageKey]; stg != nil { + return int(stg.(float64)) } - - return handler.ProgressEvent{}, err + return 0 } // List handles the List event from the Cloudformation service. @@ -402,170 +200,3 @@ func List(req handler.Request, prevModel *Model, currentModel *Model) (handler.P NextToken: responseNextToken, }, nil } - -func readWorkspace(client internal.APSService, currentModel *Model) (*prometheusservice.WorkspaceStatus, error) { - _, workspaceID, err := internal.ParseARN(*currentModel.Arn) - if err != nil { - return nil, err - } - data, err := client.DescribeWorkspace(&prometheusservice.DescribeWorkspaceInput{ - WorkspaceId: aws.String(workspaceID), - }) - if err != nil { - return nil, err - } - - currentModel.WorkspaceId = &workspaceID - currentModel.Arn = data.Workspace.Arn - currentModel.PrometheusEndpoint = data.Workspace.PrometheusEndpoint - currentModel.Alias = data.Workspace.Alias - currentModel.Tags = stringMapToTags(data.Workspace.Tags) - - return data.Workspace.Status, nil -} - -func readAlertManagerDefinition( - client internal.APSService, - currentModel *Model, -) (*prometheusservice.AlertManagerDefinitionStatus, error) { - _, workspaceID, err := internal.ParseARN(*currentModel.Arn) - if err != nil { - return nil, err - } - - data, err := client.DescribeAlertManagerDefinition(&prometheusservice.DescribeAlertManagerDefinitionInput{ - WorkspaceId: aws.String(workspaceID), - }) - if err != nil { - return nil, err - } - - currentModel.AlertManagerDefinition = aws.String(string(data.AlertManagerDefinition.Data)) - return data.AlertManagerDefinition.Status, nil -} - -func validateAlertManagerState(client internal.APSService, currentModel *Model, targetState string, successMessage string) (handler.ProgressEvent, error) { - _, err := readWorkspace(client, currentModel) - if err != nil { - return handler.ProgressEvent{}, err - } - - state, err := readAlertManagerDefinition(client, currentModel) - if err != nil { - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.Failed, - Message: "AlertManagerDefinition was deleted out-of-band", - }, err - } - - if _, ok := alertManagerFailedStates[aws.StringValue(state.StatusCode)]; ok { - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.Failed, - Message: fmt.Sprintf("AlertManagerDefinition status: %s", aws.StringValue(state.StatusCode)), - }, err - } - - if aws.StringValue(state.StatusCode) != targetState { - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.InProgress, - Message: messageInProgress, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusActiveKey), - }, nil - } - - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.Success, - Message: successMessage, - }, nil -} - -func validateWorkspaceState(client internal.APSService, currentModel *Model, targetState string, successMessage string) (handler.ProgressEvent, error) { - state, err := readWorkspace(client, currentModel) - if err != nil { - return handler.ProgressEvent{}, err - } - - if aws.StringValue(state.StatusCode) != targetState { - return handler.ProgressEvent{ - ResourceModel: &Model{ - Arn: currentModel.Arn, - }, - OperationStatus: handler.InProgress, - Message: messageInProgress, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), - }, nil - } - - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.Success, - Message: successMessage, - }, nil -} - -func buildWaitForWorkspaceStatusCallbackContext(model *Model) map[string]interface{} { - return map[string]interface{}{ - waitForWorkspaceStatusKey: aws.StringValue(model.Arn), - } -} - -func buildWaitForAlertManagerStatusCallbackContext(model *Model, key string) map[string]interface{} { - return map[string]interface{}{ - key: aws.StringValue(model.Arn), - } -} - -func stringMapToTags(m map[string]*string) []Tag { - res := []Tag{} - for key, val := range m { - res = append(res, Tag{ - Key: aws.String(key), - Value: val, - }) - } - return res -} - -func tagsToStringMap(tags []Tag) map[string]*string { - result := map[string]*string{} - for _, tag := range tags { - result[aws.StringValue(tag.Key)] = tag.Value - } - return result -} - -func validateAlertManagerDeleted(client internal.APSService, currentModel *Model, successMessage string) (handler.ProgressEvent, error) { - _, err := readWorkspace(client, currentModel) - if err != nil { - return handler.ProgressEvent{}, err - } - - _, err = readAlertManagerDefinition(client, currentModel) - if err == nil { - return handler.ProgressEvent{ - ResourceModel: currentModel, - OperationStatus: handler.InProgress, - Message: messageInProgress, - CallbackDelaySeconds: defaultCallbackSeconds, - CallbackContext: buildWaitForAlertManagerStatusCallbackContext(currentModel, waitForAlertManagerStatusDeleteKey), - }, nil - } - - if awsErr, ok := err.(awserr.Error); ok { - if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { - return handler.ProgressEvent{ - OperationStatus: handler.Success, - Message: successMessage, - ResourceModel: currentModel, - }, nil - } - } - - return handler.ProgressEvent{}, err -} diff --git a/aws-aps-workspace/cmd/resource/resource_common.go b/aws-aps-workspace/cmd/resource/resource_common.go new file mode 100644 index 0000000..937a7be --- /dev/null +++ b/aws-aps-workspace/cmd/resource/resource_common.go @@ -0,0 +1,24 @@ +package resource + +import ( + "github.com/aws/aws-sdk-go/aws" +) + +func stringMapToTags(m map[string]*string) []Tag { + res := make([]Tag, 0) + for key, val := range m { + res = append(res, Tag{ + Key: aws.String(key), + Value: val, + }) + } + return res +} + +func tagsToStringMap(tags []Tag) map[string]*string { + result := map[string]*string{} + for _, tag := range tags { + result[aws.StringValue(tag.Key)] = tag.Value + } + return result +} diff --git a/aws-aps-workspace/cmd/resource/resource_handler.go b/aws-aps-workspace/cmd/resource/resource_handler.go new file mode 100644 index 0000000..30651f7 --- /dev/null +++ b/aws-aps-workspace/cmd/resource/resource_handler.go @@ -0,0 +1,23 @@ +package resource + +import ( + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" +) + +type AMPResourceHandler func(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) + +func proceed(currentModel *Model) (bool, handler.ProgressEvent, error) { + return proceedOnSuccess(handler.ProgressEvent{ + OperationStatus: handler.Success, + ResourceModel: currentModel, + }, nil) +} + +func proceedOnSuccess(evt handler.ProgressEvent, err error) (bool, handler.ProgressEvent, error) { + if err != nil { + return false, evt, err + } + proceedToNextStep := evt.OperationStatus == handler.Success + return proceedToNextStep, evt, err +} diff --git a/aws-aps-workspace/cmd/resource/resource_test.go b/aws-aps-workspace/cmd/resource/resource_test.go index 15af351..0861e05 100644 --- a/aws-aps-workspace/cmd/resource/resource_test.go +++ b/aws-aps-workspace/cmd/resource/resource_test.go @@ -6,82 +6,128 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/prometheusservice" "testing" + "time" ) -type MockPrometheusService struct { +type FakePrometheusService struct { internal.APSService - status *prometheusservice.AlertManagerDefinitionStatus + createWorkspaceFunc func(input *prometheusservice.CreateWorkspaceInput) (*prometheusservice.CreateWorkspaceOutput, error) + describeWorkspaceFunc func(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) } -func (c *MockPrometheusService) DescribeWorkspace(*prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { - return &prometheusservice.DescribeWorkspaceOutput{ - Workspace: &prometheusservice.WorkspaceDescription{ - Arn: aws.String("arn:aws:aps:us-west-2:111111111111:workspace/ws-11111111-1111-1111-1111-111111111111"), +func (f *FakePrometheusService) CreateWorkspace(input *prometheusservice.CreateWorkspaceInput) (*prometheusservice.CreateWorkspaceOutput, error) { + return f.createWorkspaceFunc(input) +} + +func (f *FakePrometheusService) DescribeWorkspace(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { + return f.describeWorkspaceFunc(input) +} + +func workspaceCreateInProgress(input *prometheusservice.CreateWorkspaceInput) (*prometheusservice.CreateWorkspaceOutput, error) { + return &prometheusservice.CreateWorkspaceOutput{ + Arn: aws.String("arn:aws:aps:us-west-2:123456789:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + Status: &prometheusservice.WorkspaceStatus{ + StatusCode: aws.String(prometheusservice.WorkspaceStatusCodeCreating), }, + Tags: input.Tags, + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), }, nil } -func (c *MockPrometheusService) DescribeAlertManagerDefinition(*prometheusservice.DescribeAlertManagerDefinitionInput) (*prometheusservice.DescribeAlertManagerDefinitionOutput, error) { - return &prometheusservice.DescribeAlertManagerDefinitionOutput{ - AlertManagerDefinition: &prometheusservice.AlertManagerDefinitionDescription{ - Status: c.status, +func workspaceDescriptionActive(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) { + return &prometheusservice.DescribeWorkspaceOutput{ + Workspace: &prometheusservice.WorkspaceDescription{ + Alias: aws.String("Test"), + Arn: aws.String("arn:aws:aps:us-west-2:123456789:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), + CreatedAt: aws.Time(time.Now()), + PrometheusEndpoint: aws.String("https://testendpoint"), + Status: &prometheusservice.WorkspaceStatus{ + StatusCode: aws.String(prometheusservice.WorkspaceStatusCodeActive), + }, + Tags: map[string]*string{ + "aws:cloudformation:stack-name": aws.String("Test"), + "aws:cloudformation:logical-id": aws.String("1234"), + "aws:cloudformation:stack-id": aws.String("arn:aws:cloudformation:us-west-2:123456789:stack/Test/fa4bdfd0-0498-11ed-a87d-06c24a5b766d"), + }, + WorkspaceId: aws.String("ws-55c7e22b-094a-4109-ab5e-7456421d30b1"), }, }, nil } -func Test_validateAlertManagerState(t *testing.T) { +func Test_SimpleWorkspaceCreate(t *testing.T) { testCases := map[string]struct { - client internal.APSService - status handler.Status - targetState string - targetMessage string + client internal.APSService + status handler.Status + req handler.Request + prevModel *Model + currentModel *Model + callbackContext map[string]interface{} }{ - "Should return Fail when status is failed": { - client: &MockPrometheusService{ - status: &prometheusservice.AlertManagerDefinitionStatus{ - StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeCreationFailed), - }, + "Should return in progress": { + client: &FakePrometheusService{ + createWorkspaceFunc: workspaceCreateInProgress, }, - status: handler.Failed, - targetMessage: "AlertManagerDefinition status: CREATION_FAILED", - }, - "Should return in progress when state is not target state": { - client: &MockPrometheusService{ - status: &prometheusservice.AlertManagerDefinitionStatus{ - StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeCreating), + status: handler.InProgress, + req: handler.Request{ + LogicalResourceID: "1234", + CallbackContext: nil, + RequestContext: handler.RequestContext{ + SystemTags: map[string]string{ + "aws:cloudformation:stack-name": "Test", + "aws:cloudformation:logical-id": "1234", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:us-west-2:123456789:stack/Test/fa4bdfd0-0498-11ed-a87d-06c24a5b766d", + }, }, }, - status: handler.InProgress, - targetState: prometheusservice.AlertManagerDefinitionStatusCodeActive, - targetMessage: "In Progress", + prevModel: &Model{}, + currentModel: &Model{ + Alias: aws.String("Test"), + }, + callbackContext: map[string]interface{}{ + waitForWorkspaceStatusKey: "arn:aws:aps:us-west-2:123456789:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1", + }, }, - "Should return Success when state is target state": { - client: &MockPrometheusService{ - status: &prometheusservice.AlertManagerDefinitionStatus{ - StatusCode: aws.String(prometheusservice.AlertManagerDefinitionStatusCodeActive), + "Should return success": { + client: &FakePrometheusService{ + describeWorkspaceFunc: workspaceDescriptionActive, + }, + status: handler.Success, + req: handler.Request{ + LogicalResourceID: "1234", + CallbackContext: map[string]interface{}{ + waitForWorkspaceStatusKey: "arn:aws:aps:us-west-2:123456789012:workspace/ws-55c7e22b-094a-4109-ab5e-7456421d30b1", + }, + RequestContext: handler.RequestContext{ + SystemTags: map[string]string{ + "aws:cloudformation:stack-name": "Test", + "aws:cloudformation:logical-id": "1234", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:us-west-2:123456789012:stack/Test/fa4bdfd0-0498-11ed-a87d-06c24a5b766d", + }, }, }, - status: handler.Success, - targetState: prometheusservice.AlertManagerDefinitionStatusCodeActive, + prevModel: &Model{}, + currentModel: &Model{ + Alias: aws.String("Test"), + }, + callbackContext: map[string]interface{}{}, }, } - - m := &Model{ - Arn: aws.String("arn:aws:aps:us-west-2:111111111111:workspace/ws-11111111-1111-1111-1111-111111111111"), - } - for name, tc := range testCases { t.Run(name, func(t *testing.T) { - evt, err := validateAlertManagerState(tc.client, m, tc.targetState, "") + _, evt, err := CreateWorkspace(tc.req, tc.client, tc.prevModel, tc.currentModel) if err != nil { - t.Fatalf("Failed: %v", err) + t.Fatalf("Failed %v", err) } if evt.OperationStatus != tc.status { - t.Fatalf("Unexpected Status: %s and should be %s", evt.OperationStatus, tc.status) + t.Fatalf("Unexpected status %s and should be %s", evt.OperationStatus, tc.status) } - - if evt.Message != tc.targetMessage { - t.Fatalf("Unexpected message: %s and should be %s", evt.Message, tc.targetMessage) + if (evt.CallbackContext == nil || len(evt.CallbackContext) == 0) && (tc.callbackContext != nil && len(tc.callbackContext) > 0) { + t.Fatalf("Missing callback context. Expected %+v", tc.callbackContext) + } + for k, v := range tc.callbackContext { + if evt.CallbackContext[k] != v { + t.Fatalf("Expected key, value = [%s,%s]. Got [%s]", k, v, evt.CallbackContext[k]) + } } }) } diff --git a/aws-aps-workspace/cmd/resource/workspace_handler.go b/aws-aps-workspace/cmd/resource/workspace_handler.go new file mode 100644 index 0000000..01cd4fb --- /dev/null +++ b/aws-aps-workspace/cmd/resource/workspace_handler.go @@ -0,0 +1,245 @@ +package resource + +import ( + "fmt" + "github.com/aws-cloudformation/aws-cloudformation-resource-providers-aps/internal" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/aws/aws-sdk-go/service/prometheusservice" +) + +var ( + waitForWorkspaceStatusKey = "Arn" // for backwards compatibility during release +) + +func CreateWorkspace(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateWorkspaceState( + client, + currentModel, + prometheusservice.WorkspaceStatusCodeActive, + messageCreateComplete) + return proceedOnSuccess(evt, err) + } + if len(req.CallbackContext) == 0 { + // create new workspace + systemTags := internal.ToAWSStringMap(req.RequestContext.SystemTags) + tags := tagsToStringMap(currentModel.Tags) + internal.MergeMaps(systemTags, tags) + resp, err := client.CreateWorkspace(&prometheusservice.CreateWorkspaceInput{ + Alias: currentModel.Alias, + Tags: tags, + }) + if err != nil { + return proceedOnSuccess(internal.NewFailedEvent(err)) + } + currentModel.Arn = resp.Arn + return proceedOnSuccess(handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: shortCallbackSeconds, + CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + }, nil) + } + return proceed(currentModel) +} + +func UpdateWorkspace(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + if arn, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { + currentModel.Arn = aws.String(arn.(string)) + evt, err := validateWorkspaceState( + client, + currentModel, + prometheusservice.WorkspaceStatusCodeActive, + messageUpdateComplete) + if err != nil { + return proceedOnSuccess(internal.NewFailedEvent(err)) + } + return proceedOnSuccess(evt, err) + } + if internal.StringDiffers(currentModel.Alias, prevModel.Alias) { + _, err := client.UpdateWorkspaceAlias(&prometheusservice.UpdateWorkspaceAliasInput{ + WorkspaceId: currentModel.WorkspaceId, + Alias: currentModel.Alias, + }) + + if err != nil { + return proceedOnSuccess(internal.NewFailedEvent(err)) + } + } + + return updateTags(req, client, prevModel, currentModel) +} + +func updateTags(req handler.Request, client internal.APSService, prevModel, currentModel *Model) (bool, handler.ProgressEvent, error) { + currentModelTags := tagsToStringMap(currentModel.Tags) + systemTags := internal.ToAWSStringMap(req.RequestContext.SystemTags) + internal.MergeMaps(systemTags, currentModelTags) + toAdd, toRemove := internal.StringMapDifference(currentModelTags, tagsToStringMap(prevModel.Tags)) + if len(toRemove) > 0 { + _, err := client.UntagResource(&prometheusservice.UntagResourceInput{ + ResourceArn: currentModel.Arn, + TagKeys: toRemove, + }) + if err != nil { + return proceedOnSuccess(internal.NewFailedEvent(err)) + } + } + + if len(toAdd) > 0 { + _, err := client.TagResource(&prometheusservice.TagResourceInput{ + ResourceArn: currentModel.Arn, + Tags: toAdd, + }) + if err != nil { + return proceedOnSuccess(internal.NewFailedEvent(err)) + } + } + + return proceedOnSuccess(handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: shortCallbackSeconds, + CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + }, nil) +} + +func readWorkspace(client internal.APSService, currentModel *Model) (*prometheusservice.WorkspaceStatus, error) { + _, workspaceID, err := internal.ParseARN(*currentModel.Arn) + if err != nil { + return nil, err + } + data, err := client.DescribeWorkspace(&prometheusservice.DescribeWorkspaceInput{ + WorkspaceId: aws.String(workspaceID), + }) + if err != nil { + return nil, err + } + + currentModel.WorkspaceId = &workspaceID + currentModel.Arn = data.Workspace.Arn + currentModel.PrometheusEndpoint = data.Workspace.PrometheusEndpoint + currentModel.Alias = data.Workspace.Alias + currentModel.Tags = stringMapToTags(data.Workspace.Tags) + + return data.Workspace.Status, nil +} + +// Delete handles the Delete event from the Cloudformation service. +func Delete(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + if currentModel.Arn == nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Invalid Delete: workspace ARN cannot be empty", + HandlerErrorCode: cloudformation.HandlerErrorCodeNotFound, + }, nil + } + + client := internal.NewAPS(req.Session) + if _, ok := req.CallbackContext[waitForWorkspaceStatusKey]; ok { + currentModel.Arn = aws.String(req.CallbackContext[waitForWorkspaceStatusKey].(string)) + return validateWorkspaceDeleted( + client, + currentModel, + "Delete Complete") + } + + _, workspaceID, err := internal.ParseARN(*currentModel.Arn) + if err != nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Invalid Read: invalid workspace ARN format", + HandlerErrorCode: cloudformation.HandlerErrorCodeNotFound, + }, nil + } + + // no need to delete AlertManagerDefinition or logging config here, because APSService deletes this when the workspace is deleted + _, err = client. + DeleteWorkspace(&prometheusservice.DeleteWorkspaceInput{ + WorkspaceId: aws.String(workspaceID), + }) + if err != nil { + return internal.NewFailedEvent(err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: messageInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + }, nil +} + +func validateWorkspaceDeleted(client internal.APSService, currentModel *Model, successMessage string) (handler.ProgressEvent, error) { + _, err := readWorkspace(client, currentModel) + if err == nil { + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.InProgress, + Message: messageInProgress, + CallbackDelaySeconds: longCallbackSeconds, + CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + }, nil + } + + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() == prometheusservice.ErrCodeResourceNotFoundException { + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: successMessage, + }, nil + } + } + return handler.ProgressEvent{}, err +} + +func validateWorkspaceState(client internal.APSService, currentModel *Model, targetState string, successMessage string) (handler.ProgressEvent, error) { + state, err := readWorkspace(client, currentModel) + if err != nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + ResourceModel: currentModel, + Message: err.Error(), + }, err + } + + statusCode := aws.StringValue(state.StatusCode) + switch statusCode { + case targetState: + return handler.ProgressEvent{ + ResourceModel: currentModel, + OperationStatus: handler.Success, + Message: successMessage, + }, nil + case prometheusservice.WorkspaceStatusCodeCreating, prometheusservice.WorkspaceStatusCodeUpdating: + return handler.ProgressEvent{ + ResourceModel: &Model{ + Arn: currentModel.Arn, + }, + OperationStatus: handler.InProgress, + Message: messageInProgress, + CallbackDelaySeconds: shortCallbackSeconds, + CallbackContext: buildWaitForWorkspaceStatusCallbackContext(currentModel), + }, nil + case prometheusservice.WorkspaceStatusCodeCreationFailed: + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + ResourceModel: currentModel, + Message: fmt.Sprintf("Workspace status: %s", aws.StringValue(state.StatusCode)), + }, nil + } + + return handler.ProgressEvent{}, nil +} + +func buildWaitForWorkspaceStatusCallbackContext(model *Model) map[string]interface{} { + return map[string]interface{}{ + waitForWorkspaceStatusKey: aws.StringValue(model.Arn), + } +} diff --git a/aws-aps-workspace/inputs/inputs_1_create.json b/aws-aps-workspace/inputs/inputs_1_create.json index 8653219..1c92290 100644 --- a/aws-aps-workspace/inputs/inputs_1_create.json +++ b/aws-aps-workspace/inputs/inputs_1_create.json @@ -1,3 +1,9 @@ { - "Alias": "Monkey" -} + "Alias": "Monkey", + "Tags": [ + { + "Key": "FavoriteDrink", + "Value": "Latte" + } + ] +} \ No newline at end of file diff --git a/aws-aps-workspace/inputs/inputs_1_invalid.json b/aws-aps-workspace/inputs/inputs_1_invalid.json index f6f49fe..97efcd0 100644 --- a/aws-aps-workspace/inputs/inputs_1_invalid.json +++ b/aws-aps-workspace/inputs/inputs_1_invalid.json @@ -1,3 +1,3 @@ { "Alias": "123412341234123455551234123412341234555512341234123412345555123412341234123455551234123412341234555512341234123412345555" -} +} \ No newline at end of file diff --git a/aws-aps-workspace/inputs/inputs_1_update.json b/aws-aps-workspace/inputs/inputs_1_update.json index de932da..7850d12 100644 --- a/aws-aps-workspace/inputs/inputs_1_update.json +++ b/aws-aps-workspace/inputs/inputs_1_update.json @@ -7,4 +7,4 @@ "Value": "Cheese" } ] -} +} \ No newline at end of file diff --git a/aws-aps-workspace/inputs/inputs_2_create.json b/aws-aps-workspace/inputs/inputs_2_create.json index 6a54751..44d8430 100644 --- a/aws-aps-workspace/inputs/inputs_2_create.json +++ b/aws-aps-workspace/inputs/inputs_2_create.json @@ -1,4 +1,10 @@ { "Alias": "SpaceMonkey", - "AlertManagerDefinition": "alertmanager_config: |\n templates:\n - 'default_template'\n route:\n receiver: example-sns\n receivers:\n - name: example-sns\n sns_configs:\n - topic_arn: {{AlertManagerTestSNSExport}}" -} + "AlertManagerDefinition": "alertmanager_config: |\n templates:\n - 'default_template'\n route:\n receiver: example-sns\n receivers:\n - name: example-sns\n sns_configs:\n - topic_arn: {{AlertManagerTestSNSExport}}", + "Tags": [ + { + "Key": "FavoriteDrink", + "Value": "Latte" + } + ] +} \ No newline at end of file diff --git a/aws-aps-workspace/inputs/inputs_2_update.json b/aws-aps-workspace/inputs/inputs_2_update.json index e896061..32293b6 100644 --- a/aws-aps-workspace/inputs/inputs_2_update.json +++ b/aws-aps-workspace/inputs/inputs_2_update.json @@ -1,4 +1,10 @@ { "Alias": "SpaceMonkey", - "AlertManagerDefinition": "alertmanager_config: |\n templates:\n - 'default_template'\n route:\n receiver: cheese-sns\n receivers:\n - name: cheese-sns\n sns_configs:\n - topic_arn: {{AlertManagerTestSNSExport}}" -} + "AlertManagerDefinition": "alertmanager_config: |\n templates:\n - 'default_template'\n route:\n receiver: cheese-sns\n receivers:\n - name: cheese-sns\n sns_configs:\n - topic_arn: {{AlertManagerTestSNSExport}}", + "Tags": [ + { + "Key": "FavoriteDrink", + "Value": "Cappuccino" + } + ] +} \ No newline at end of file diff --git a/aws-aps-workspace/resource-role.yaml b/aws-aps-workspace/resource-role.yaml index da08872..996e5c8 100644 --- a/aws-aps-workspace/resource-role.yaml +++ b/aws-aps-workspace/resource-role.yaml @@ -24,16 +24,20 @@ Resources: - Effect: Allow Action: - "aps:CreateAlertManagerDefinition" + - "aps:CreateLoggingConfiguration" - "aps:CreateWorkspace" - "aps:DeleteAlertManagerDefinition" + - "aps:DeleteLoggingConfiguration" - "aps:DeleteWorkspace" - "aps:DescribeAlertManagerDefinition" + - "aps:DescribeLoggingConfiguration" - "aps:DescribeWorkspace" - "aps:ListTagsForResource" - "aps:ListWorkspaces" - "aps:PutAlertManagerDefinition" - "aps:TagResource" - "aps:UntagResource" + - "aps:UpdateLoggingConfiguration" - "aps:UpdateWorkspaceAlias" Resource: "*" Outputs: diff --git a/buildspec.yml b/buildspec.yml index 70bb945..b57b287 100644 --- a/buildspec.yml +++ b/buildspec.yml @@ -3,7 +3,7 @@ phases: install: runtime-versions: python: 3.7 - golang: 1.13 + golang: 1.16 commands: - pip install -U 'six~=1.15' - pip install -U 'pyyaml~=5.4' diff --git a/go.mod b/go.mod index cdaec85..a13a865 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.16 require ( github.com/aws-cloudformation/cloudformation-cli-go-plugin v1.0.3 - github.com/aws/aws-sdk-go v1.42.12 + github.com/aws/aws-sdk-go v1.44.106 github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.5.6 github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index 4a2dded..f06b5f4 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,11 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/avast/retry-go v2.6.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws-cloudformation/cloudformation-cli-go-plugin v1.0.3 h1:VVCgZgPclpSoihsmOiY+EdKKygFN947wgX8Fb80UoL8= github.com/aws-cloudformation/cloudformation-cli-go-plugin v1.0.3/go.mod h1:VeczpujuRwIkmEaDfVQd8kIzJcz3qijMADj2LBx9a70= +github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.42.12 h1:zVrAgi3/HuMPygZknc+f2KAHcn+Zuq767857hnHBMPA= -github.com/aws/aws-sdk-go v1.42.12/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.44.106 h1:FzINxRGt0gAzz01ixtKfkjiDOnnpd/uNbstW/qPW2QE= +github.com/aws/aws-sdk-go v1.44.106/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -27,6 +28,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/ksuid v1.0.2 h1:9yBfKyw4ECGTdALaF09Snw3sLJmYIX6AbPJrAy6MrDc= github.com/segmentio/ksuid v1.0.2/go.mod h1:BXuJDr2byAiHuQaQtSKoXh1J0YmUDurywOXgB2w+OSU= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -36,20 +38,21 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21 h1:2QQcyaEBdpfjjYkF0MXc69jZbHb4IOYuXz2UwsmVM8k= gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= diff --git a/internal/aps.go b/internal/aps.go index 991ba0f..b6f7d02 100644 --- a/internal/aps.go +++ b/internal/aps.go @@ -114,11 +114,19 @@ func ParseARN(value string) (*arn.ARN, string, error) { } type APSService interface { + CreateWorkspace(input *prometheusservice.CreateWorkspaceInput) (*prometheusservice.CreateWorkspaceOutput, error) + UpdateWorkspaceAlias(input *prometheusservice.UpdateWorkspaceAliasInput) (*prometheusservice.UpdateWorkspaceAliasOutput, error) DescribeWorkspace(input *prometheusservice.DescribeWorkspaceInput) (*prometheusservice.DescribeWorkspaceOutput, error) DescribeAlertManagerDefinition(input *prometheusservice.DescribeAlertManagerDefinitionInput) (*prometheusservice.DescribeAlertManagerDefinitionOutput, error) CreateAlertManagerDefinition(input *prometheusservice.CreateAlertManagerDefinitionInput) (*prometheusservice.CreateAlertManagerDefinitionOutput, error) DeleteAlertManagerDefinition(input *prometheusservice.DeleteAlertManagerDefinitionInput) (*prometheusservice.DeleteAlertManagerDefinitionOutput, error) PutAlertManagerDefinition(input *prometheusservice.PutAlertManagerDefinitionInput) (*prometheusservice.PutAlertManagerDefinitionOutput, error) + CreateLoggingConfiguration(input *prometheusservice.CreateLoggingConfigurationInput) (*prometheusservice.CreateLoggingConfigurationOutput, error) + UpdateLoggingConfiguration(input *prometheusservice.UpdateLoggingConfigurationInput) (*prometheusservice.UpdateLoggingConfigurationOutput, error) + DeleteLoggingConfiguration(input *prometheusservice.DeleteLoggingConfigurationInput) (*prometheusservice.DeleteLoggingConfigurationOutput, error) + DescribeLoggingConfiguration(input *prometheusservice.DescribeLoggingConfigurationInput) (*prometheusservice.DescribeLoggingConfigurationOutput, error) + TagResource(input *prometheusservice.TagResourceInput) (*prometheusservice.TagResourceOutput, error) + UntagResource(input *prometheusservice.UntagResourceInput) (*prometheusservice.UntagResourceOutput, error) } func NewAPS(sess *session.Session) *prometheusservice.PrometheusService { @@ -169,3 +177,17 @@ func StringMapDifference(current, previous map[string]*string) (toChange map[str return } + +func MergeMaps(from, to map[string]*string) { + for key, val := range from { + to[key] = val + } +} + +func ToAWSStringMap(fromMap map[string]string) map[string]*string { + result := make(map[string]*string) + for key, val := range fromMap { + result[key] = aws.String(val) + } + return result +}