diff --git a/.changelog/44487.txt b/.changelog/44487.txt new file mode 100644 index 000000000000..98eb0eca07a4 --- /dev/null +++ b/.changelog/44487.txt @@ -0,0 +1,3 @@ +```release-note:new-action +aws_events_put_events +``` \ No newline at end of file diff --git a/internal/service/events/put_events_action.go b/internal/service/events/put_events_action.go new file mode 100644 index 000000000000..aefc90cfc5dc --- /dev/null +++ b/internal/service/events/put_events_action.go @@ -0,0 +1,140 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package events + +import ( + "context" + "strconv" + + "github.com/aws/aws-sdk-go-v2/service/eventbridge" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + fwflex "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @Action(aws_events_put_events, name="Put Events") +// nosemgrep: ci.events-in-func-name -- "PutEvents" matches AWS API operation name (PutEvents). Required for consistent generated/action naming; safe to ignore. +func newPutEventsAction(_ context.Context) (action.ActionWithConfigure, error) { + return &putEventsAction{}, nil +} + +var ( + _ action.Action = (*putEventsAction)(nil) +) + +type putEventsAction struct { + framework.ActionWithModel[putEventsActionModel] +} + +type putEventsActionModel struct { + framework.WithRegionModel + Entry fwtypes.ListNestedObjectValueOf[putEventEntryModel] `tfsdk:"entry"` +} + +type putEventEntryModel struct { + Detail types.String `tfsdk:"detail"` + DetailType types.String `tfsdk:"detail_type"` + EventBusName types.String `tfsdk:"event_bus_name"` + Resources fwtypes.ListValueOf[types.String] `tfsdk:"resources"` + Source types.String `tfsdk:"source"` + Time timetypes.RFC3339 `tfsdk:"time"` +} + +func (a *putEventsAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Sends custom events to Amazon EventBridge so that they can be matched to rules.", + Blocks: map[string]schema.Block{ + "entry": schema.ListNestedBlock{ + Description: "The entry that defines an event in your system.", + CustomType: fwtypes.NewListNestedObjectTypeOf[putEventEntryModel](ctx), + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "detail": schema.StringAttribute{ + Description: "A valid JSON string. There is no other schema imposed.", + Optional: true, + }, + "detail_type": schema.StringAttribute{ + Description: "Free-form string used to decide what fields to expect in the event detail.", + Optional: true, + }, + "event_bus_name": schema.StringAttribute{ + Description: "The name or ARN of the event bus to receive the event.", + Optional: true, + }, + names.AttrResources: schema.ListAttribute{ + Description: "AWS resources, identified by Amazon Resource Name (ARN), which the event primarily concerns.", + CustomType: fwtypes.ListOfStringType, + Optional: true, + }, + names.AttrSource: schema.StringAttribute{ + Description: "The source of the event.", + Required: true, + }, + "time": schema.StringAttribute{ + Description: "The time stamp of the event, per RFC3339.", + Optional: true, + CustomType: timetypes.RFC3339Type{}, + }, + }, + }, + }, + }, + } +} + +func (a *putEventsAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var model putEventsActionModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + conn := a.Meta().EventsClient(ctx) + + tflog.Info(ctx, "Putting events", map[string]any{ + "entry_count": len(model.Entry.Elements()), + }) + + resp.SendProgress(action.InvokeProgressEvent{ + Message: "Putting events to EventBridge...", + }) + + var input eventbridge.PutEventsInput + resp.Diagnostics.Append(fwflex.Expand(ctx, model, &input)...) + if resp.Diagnostics.HasError() { + return + } + + output, err := conn.PutEvents(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + "Putting Events", + "Could not put events: "+err.Error(), + ) + return + } + + if output.FailedEntryCount > 0 { + resp.Diagnostics.AddError( + "Putting Events", + strconv.Itoa(int(output.FailedEntryCount))+" entries failed to be processed", + ) + return + } + + resp.SendProgress(action.InvokeProgressEvent{ + Message: "Events put successfully", + }) + + tflog.Info(ctx, "Put events completed", map[string]any{ + "successful_entries": len(output.Entries), + }) +} diff --git a/internal/service/events/put_events_action_test.go b/internal/service/events/put_events_action_test.go new file mode 100644 index 000000000000..3c51187378d2 --- /dev/null +++ b/internal/service/events/put_events_action_test.go @@ -0,0 +1,386 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package events_test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/eventbridge" + "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccEventsPutEventsAction_basic(t *testing.T) { + ctx := acctest.Context(t) + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EventsServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + CheckDestroy: testAccCheckBusDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccPutEventsActionConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckPutEventsDelivered(ctx, rName, 1), + ), + }, + }, + }) +} + +func TestAccEventsPutEventsAction_multipleEntries(t *testing.T) { + ctx := acctest.Context(t) + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EventsServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + CheckDestroy: testAccCheckBusDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccPutEventsActionConfig_multipleEntries(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckPutEventsDelivered(ctx, rName, 2), + ), + }, + }, + }) +} + +func TestAccEventsPutEventsAction_customBus(t *testing.T) { + ctx := acctest.Context(t) + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EventsServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + CheckDestroy: testAccCheckBusDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccPutEventsActionConfig_customBus(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckPutEventsDelivered(ctx, rName, 1), + ), + }, + }, + }) +} + +// nosemgrep: ci.events-in-func-name -- Verification helper for PutEvents delivery +func testAccCheckPutEventsDelivered(ctx context.Context, rName string, expected int) resource.TestCheckFunc { + return func(s *terraform.State) error { + meta := acctest.Provider.Meta().(*conns.AWSClient) + evConn := meta.EventsClient(ctx) + sqsConn := meta.SQSClient(ctx) + + // Ensure bus exists (sanity) + if _, err := evConn.DescribeEventBus(ctx, &eventbridge.DescribeEventBusInput{Name: &rName}); err != nil { + return fmt.Errorf("event bus %s not found: %w", rName, err) + } + + // Discover queue URL via name convention + queueName := rName + "-events-test" + getOut, err := sqsConn.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{QueueName: &queueName}) + if err != nil { + return fmt.Errorf("getting queue url: %w", err) + } + + deadline := time.Now().Add(2 * time.Minute) + received := 0 + marker := rName + for time.Now().Before(deadline) && received < expected { + // Long poll + msgOut, err := sqsConn.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ + QueueUrl: getOut.QueueUrl, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 10, + }) + if err != nil { + // transient network errors: retry + continue + } + for _, m := range msgOut.Messages { + if m.Body == nil { + continue + } + // EventBridge SQS target wraps the event as JSON; look for marker inside detail + if strings.Contains(*m.Body, marker) { + // Optionally parse to verify structure + var parsed map[string]any + _ = json.Unmarshal([]byte(*m.Body), &parsed) + received++ + } + } + } + + if received < expected { + return fmt.Errorf("expected %d events delivered to SQS, received %d", expected, received) + } + return nil + } +} + +// nosemgrep: ci.events-in-func-name -- Function reflects PutEvents operation naming for consistency. +func testAccPutEventsActionConfig_basic(rName string) string { + return fmt.Sprintf(` +resource "aws_cloudwatch_event_bus" "test" { + name = %[1]q +} + +resource "aws_cloudwatch_event_rule" "test" { + name = %[1]q + event_bus_name = aws_cloudwatch_event_bus.test.name + event_pattern = jsonencode({ + source = ["test.application"] + }) +} + +resource "aws_sqs_queue" "events_target" { + name = "%[1]s-events-test" +} + +resource "aws_sqs_queue_policy" "events_target" { + queue_url = aws_sqs_queue.events_target.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowEventBridgeSendMessage" + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.events_target.arn + Condition = { + ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.test.arn } + } + } + ] + }) +} + +resource "aws_cloudwatch_event_target" "test" { + rule = aws_cloudwatch_event_rule.test.name + event_bus_name = aws_cloudwatch_event_bus.test.name + target_id = "sqs" + arn = aws_sqs_queue.events_target.arn +} + +action "aws_events_put_events" "test" { + config { + entry { + source = "test.application" + detail_type = "Test Event" + event_bus_name = aws_cloudwatch_event_bus.test.name + detail = jsonencode({ + marker = %[1]q + action = "test" + }) + } + } +} + +resource "terraform_data" "trigger" { + input = "trigger" + lifecycle { + action_trigger { + events = [after_create, before_update] + actions = [action.aws_events_put_events.test] + } + } + depends_on = [ + aws_cloudwatch_event_target.test, + aws_sqs_queue_policy.events_target + ] +} +`, rName) +} + +// nosemgrep: ci.events-in-func-name -- Function reflects PutEvents operation naming for consistency. +func testAccPutEventsActionConfig_multipleEntries(rName string) string { + return fmt.Sprintf(` +resource "aws_cloudwatch_event_bus" "test" { + name = %[1]q +} + +resource "aws_cloudwatch_event_rule" "test" { + name = %[1]q + event_bus_name = aws_cloudwatch_event_bus.test.name + event_pattern = jsonencode({ + source = ["test.application", "test.orders"] + }) +} + +resource "aws_sqs_queue" "events_target" { + name = "%[1]s-events-test" +} + +resource "aws_sqs_queue_policy" "events_target" { + queue_url = aws_sqs_queue.events_target.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowEventBridgeSendMessage" + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.events_target.arn + Condition = { + ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.test.arn } + } + } + ] + }) +} + +resource "aws_cloudwatch_event_target" "test" { + rule = aws_cloudwatch_event_rule.test.name + event_bus_name = aws_cloudwatch_event_bus.test.name + target_id = "sqs" + arn = aws_sqs_queue.events_target.arn +} + +action "aws_events_put_events" "test" { + config { + entry { + source = "test.application" + detail_type = "User Action" + event_bus_name = aws_cloudwatch_event_bus.test.name + detail = jsonencode({ + marker = %[1]q + action = "login" + }) + } + + entry { + source = "test.orders" + detail_type = "Order Created" + event_bus_name = aws_cloudwatch_event_bus.test.name + detail = jsonencode({ + marker = %[1]q + amount = 99.99 + }) + } + } +} + +resource "terraform_data" "trigger" { + input = "trigger" + lifecycle { + action_trigger { + events = [after_create, before_update] + actions = [action.aws_events_put_events.test] + } + } + depends_on = [ + aws_cloudwatch_event_target.test, + aws_sqs_queue_policy.events_target + ] +} +`, rName) +} + +// nosemgrep: ci.events-in-func-name -- Function reflects PutEvents operation naming for consistency. +func testAccPutEventsActionConfig_customBus(rName string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} + +resource "aws_cloudwatch_event_bus" "test" { + name = %[1]q +} + +resource "aws_cloudwatch_event_rule" "test" { + name = %[1]q + event_bus_name = aws_cloudwatch_event_bus.test.name + event_pattern = jsonencode({ + source = ["custom.source"] + detail-type = ["Custom Event"] + }) +} + +resource "aws_sqs_queue" "events_target" { + name = "%[1]s-events-test" +} + +resource "aws_sqs_queue_policy" "events_target" { + queue_url = aws_sqs_queue.events_target.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowEventBridgeSendMessage" + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.events_target.arn + Condition = { + ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.test.arn } + } + } + ] + }) +} + +resource "aws_cloudwatch_event_target" "test" { + rule = aws_cloudwatch_event_rule.test.name + event_bus_name = aws_cloudwatch_event_bus.test.name + target_id = "sqs" + arn = aws_sqs_queue.events_target.arn +} + +action "aws_events_put_events" "test" { + config { + entry { + source = "custom.source" + detail_type = "Custom Event" + event_bus_name = aws_cloudwatch_event_bus.test.name + time = "2023-01-01T12:00:00Z" + resources = ["arn:${data.aws_partition.current.partition}:s3:::example-bucket"] + detail = jsonencode({ + custom_field = "custom_value" + marker = %[1]q + timestamp = "2023-01-01T12:00:00Z" + }) + } + } +} + +resource "terraform_data" "trigger" { + input = "trigger" + lifecycle { + action_trigger { + events = [after_create, before_update] + actions = [action.aws_events_put_events.test] + } + } + depends_on = [ + aws_cloudwatch_event_target.test, + aws_sqs_queue_policy.events_target + ] +} +`, rName) +} diff --git a/internal/service/events/service_package_gen.go b/internal/service/events/service_package_gen.go index 926a7e2ffea2..082a8622fb80 100644 --- a/internal/service/events/service_package_gen.go +++ b/internal/service/events/service_package_gen.go @@ -17,6 +17,17 @@ import ( type servicePackage struct{} +func (p *servicePackage) Actions(ctx context.Context) []*inttypes.ServicePackageAction { + return []*inttypes.ServicePackageAction{ + { + Factory: newPutEventsAction, + TypeName: "aws_events_put_events", + Name: "Put Events", + Region: unique.Make(inttypes.ResourceRegionDefault()), + }, + } +} + func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*inttypes.ServicePackageFrameworkDataSource { return []*inttypes.ServicePackageFrameworkDataSource{ { diff --git a/website/docs/actions/events_put_events.html.markdown b/website/docs/actions/events_put_events.html.markdown new file mode 100644 index 000000000000..9a111e3ae24f --- /dev/null +++ b/website/docs/actions/events_put_events.html.markdown @@ -0,0 +1,146 @@ +--- +subcategory: "EventBridge" +layout: "aws" +page_title: "AWS: aws_events_put_events" +description: |- + Sends custom events to Amazon EventBridge so that they can be matched to rules. +--- + +# Action: aws_events_put_events + +~> **Note:** `aws_events_put_events` is in beta. Its interface and behavior may change as the feature evolves, and breaking changes are possible. It is offered as a technical preview without compatibility guarantees until Terraform 1.14 is generally available. + +Sends custom events to Amazon EventBridge so that they can be matched to rules. This action provides an imperative way to emit events from Terraform plans (e.g., deployment notifications) while still allowing Terraform to manage when the emission occurs through `action_trigger` lifecycle events. + +## Example Usage + +### Basic Event + +```terraform +action "aws_events_put_events" "example" { + config { + entry { + source = "mycompany.myapp" + detail_type = "User Action" + detail = jsonencode({ + user_id = "12345" + action = "login" + }) + } + } +} +``` + +### Multiple Events + +```terraform +action "aws_events_put_events" "batch" { + config { + entry { + source = "mycompany.orders" + detail_type = "Order Created" + detail = jsonencode({ + order_id = "order-123" + amount = 99.99 + }) + } + + entry { + source = "mycompany.orders" + detail_type = "Order Updated" + detail = jsonencode({ + order_id = "order-456" + status = "shipped" + }) + } + } +} +``` + +### Custom Event Bus + +```terraform +resource "aws_cloudwatch_event_bus" "example" { + name = "custom-bus" +} + +action "aws_events_put_events" "custom_bus" { + config { + entry { + source = "mycompany.analytics" + detail_type = "Page View" + event_bus_name = aws_cloudwatch_event_bus.example.name + detail = jsonencode({ + page = "/home" + user = "anonymous" + }) + } + } +} +``` + +### Event with Resources and Timestamp + +```terraform +action "aws_events_put_events" "detailed" { + config { + entry { + source = "aws.ec2" + detail_type = "EC2 Instance State-change Notification" + time = "2023-01-01T12:00:00Z" # RFC3339 + resources = ["arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"] + detail = jsonencode({ + instance_id = "i-1234567890abcdef0" + state = "running" + }) + } + } +} +``` + +### Triggered by Terraform Data + +```terraform +resource "terraform_data" "deploy" { + input = var.deployment_id + + lifecycle { + action_trigger { + events = [before_create, before_update] + actions = [action.aws_events_put_events.deployment] + } + } +} + +action "aws_events_put_events" "deployment" { + config { + entry { + source = "mycompany.deployments" + detail_type = "Deployment Complete" + detail = jsonencode({ + deployment_id = var.deployment_id + environment = var.environment + timestamp = timestamp() + }) + } + } +} +``` + +## Argument Reference + +This action supports the following arguments: + +* `entry` - (Required) One or more `entry` blocks defining events to send. Multiple blocks may be specified. See [below](#entry-block). +* `region` - (Optional) AWS region override. Defaults to the provider region if omitted. + +### `entry` Block + +Each `entry` block supports: + +* `source` - (Required) The source identifier for the event (e.g., `mycompany.myapp`). +* `detail_type` - (Optional) Free-form string used to decide what fields to expect in the event detail. +* `detail` - (Optional) JSON string (use `jsonencode()`) representing the event detail payload. +* `event_bus_name` - (Optional) Name or ARN of the event bus. Defaults to the account's default bus. +* `resources` - (Optional) List of ARNs the event primarily concerns. +* `time` - (Optional) RFC3339 timestamp for the event. If omitted, the receive time is used.