diff --git a/infrastructure/somleng/core/.terraform.lock.hcl b/infrastructure/somleng/core/.terraform.lock.hcl index cf7b9c5d..e68b4f0f 100644 --- a/infrastructure/somleng/core/.terraform.lock.hcl +++ b/infrastructure/somleng/core/.terraform.lock.hcl @@ -2,24 +2,24 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "5.32.1" + version = "5.34.0" constraints = ">= 5.20.0" hashes = [ - "h1:QABqkHM6/fMi9RGbAzTx0/gy+6tDl2RYsVn5YGtKa90=", - "zh:0c603e0ea9ec481f1588ca44d3464fe43ed936a8452e0c70d347c8e71a1b19a4", - "zh:0d43c845330ea4aaa152caf35819069215fcf17e4468b9d94c631f7d4178b1ac", - "zh:1211275208e8142bfa27987fdeb3eae40075ff569bf198330975f470bc4f5137", - "zh:1d8e7e4a2ff45a8b56037d030e2978fc04007941f62f1e265e251801a1d0c3cc", - "zh:4f6a8a6c9413b8b9267673cb7fb9dee7dc81946f7cc17d23e2104304f4ec4472", - "zh:6d769c74f8157260a37a32a1036b77f9795e21df2df7cadf4c7acc85b2dfd96e", - "zh:778fd9bf80424a62ebf5f059dcabfc4a588b0791ba18c1cf727bbdc1aed40351", - "zh:7bf1b063065bbe39b71e2a5895915fcbcc0cf7f553f84388e81888506d292fce", + "h1:CUCoX4ax5hrP6BH4973oP+hgz8VR2GuNPQil3FYwEqQ=", + "zh:01bb20ae12b8c66f0cacec4f417a5d6741f018009f3a66077008e67cce127aa4", + "zh:3b0c9bdbbf846beef2c9573fc27898ceb71b69cf9d2f4b1dd2d0c2b539eab114", + "zh:5226ecb9c21c2f6fbf1d662ac82459ffcd4ad058a9ea9c6200750a21a80ca009", + "zh:6021b905d9b3cd3d7892eb04d405c6fa20112718de1d6ef7b9f1db0b0c97721a", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b57506c3f46e850543fc1ee9522f231311e8540730db76bbf7a3f4d81777a4bd", - "zh:d37c8655b2a31435a116a1af7031f2bcdecf4c7e7e74903b88203798fb39043e", - "zh:db369802896eb10bbfed00bf3bd568b35fb5d903d3624d555b6574c5c4e2d94e", - "zh:e9992bfccf8205c495aebb7da917404496f96b5d3ea4a915a8884994ca8d860c", - "zh:ed1e0ef83cde313f1ccb3e18fc9dc63bf6ca473ec07554df5e24c706708a6866", - "zh:f0d19ed41352da9be308dff72899ecf5af7a42b592cf37fb98e9064e7622d35e", + "zh:9e61b8e0ccf923979cd2dc1f1140dbcb02f92248578e10c1996f560b6306317c", + "zh:ad6bf62cdcf531f2f92f6416822918b7ba2af298e4a0065c6baf44991fda982d", + "zh:b698b041ef38837753bbe5265dddbc70b76e8b8b34c5c10876e6aab0eb5eaf63", + "zh:bb799843c534f6a3f072a99d93a3b53ff97c58a96742be15518adf8127706784", + "zh:cebee0d942c37cd3b21e9050457cceb26d0a6ea886b855dab64bb67d78f863d1", + "zh:e061fdd1cb99e7c81fb4485b41ae000c6792d38f73f9f50aed0d3d5c2ce6dcfb", + "zh:eeb4943f82734946362696928336357cd1d36164907ae5905da0316a67e275e1", + "zh:ef09b6ad475efa9300327a30cbbe4373d817261c8e41e5b7391750b16ef4547d", + "zh:f01aab3881cd90b3f56da7c2a75f83da37fd03cc615fc5600a44056a7e0f9af7", + "zh:fcd0f724ebc4b56a499eb6c0fc602de609af18a0d578befa2f7a8df155c55550", ] } diff --git a/infrastructure/somleng/core/caller_identity.tf b/infrastructure/somleng/core/caller_identity.tf new file mode 100644 index 00000000..8fc4b38c --- /dev/null +++ b/infrastructure/somleng/core/caller_identity.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/infrastructure/somleng/core/pinpoint_sms.rb b/infrastructure/somleng/core/pinpoint_sms.rb new file mode 100644 index 00000000..43654bec --- /dev/null +++ b/infrastructure/somleng/core/pinpoint_sms.rb @@ -0,0 +1,346 @@ +#!/usr/bin/env ruby + +# Usage: +# +# ./pinpoint_sms create +# ./pinpoint_sms destroy + +require "bundler/inline" + +gemfile do + source "https://rubygems.org" + + gem "aws-sdk-pinpointsmsvoicev2" + gem "aws-sdk-cloudwatchlogs" + gem "aws-sdk-iam" + gem "pry" + gem "ox" +end + +class PinpointSMSClient + attr_reader :client + + def initialize(params = {}) + @client = params.fetch(:client) { Aws::PinpointSMSVoiceV2::Client.new } + end + + def phone_numbers(number_type: "SIMULATOR") + client.describe_phone_numbers( + filters: [ + { + name: "number-type", + values: [number_type] + } + ] + ).phone_numbers + end + + def sender_ids(params = {}) + client.describe_sender_ids(**params).sender_ids + end + + def phone_pools(params = {}) + client.describe_pools(params).pools + end + + def list_tags(resource_arn:) + client.list_tags_for_resource(resource_arn:).tags + end + + def configuration_sets(params = {}) + client.describe_configuration_sets(params).configuration_sets + end + + def verified_destination_phone_numbers(params = {}) + client.describe_verified_destination_numbers(**params).verified_destination_numbers + end + + def delete_configuration_set(configuration_set_name:) + client.delete_configuration_set(configuration_set_name:) + rescue Aws::PinpointSMSVoiceV2::Errors::ResourceNotFoundException + end + + def delete_phone_pool(pool_id:) + client.delete_pool(pool_id:) + rescue Aws::PinpointSMSVoiceV2::Errors::ResourceNotFoundException + end + + def request_phone_number(**params) + client.request_phone_number( + iso_country_code: "US", + message_type: "TRANSACTIONAL", + number_capabilities: ["SMS"], + number_type: "SIMULATOR", + **params + ) + end + + def request_sender_id(**params) + client.request_sender_id( + message_types: ["TRANSACTIONAL"], + **params + ) + end + + def release_phone_number(phone_number_id:) + client.release_phone_number(phone_number_id:) + end + + def release_sender_id(params) + client.release_sender_id(params) + end + + def create_phone_pool(origination_identity:, **params) + client.create_pool( + origination_identity: origination_identity, + iso_country_code: "US", + message_type: "TRANSACTIONAL", + **params + ) + end + + def create_configuration_set(params = {}) + client.create_configuration_set(params) + end + + def create_event_destination(configuration_set_name:, **params) + client.create_event_destination( + configuration_set_name:, + **params, + ) + end + + def create_verified_destination_number(params) + client.create_verified_destination_number(params) + end +end + +class CloudwatchClient + attr_reader :client + + def initialize(params = {}) + @client = params.fetch(:client) { Aws::CloudWatchLogs::Client.new } + end + + def log_groups(log_group_name_prefix:, **params) + client.describe_log_groups(log_group_name_prefix:, **params).log_groups + end +end + +class IAMClient + attr_reader :client + + def initialize(params = {}) + @client = params.fetch(:client) { Aws::IAM::Client.new } + end + + def find_role(name:) + client.get_role(role_name: name).role + end +end + +class BootstrapPinpointSMS + attr_reader :pinpoint_sms_client, :cloudwatch_client, :iam_client, + :name, :identifier, :sender_id, :sender_id_country_code, + :phone_pool_name, :configuration_set_name, :log_group_name, + :cloudwatch_iam_role, :sns_topic_name, :destination_phone_numbers + + def initialize(params = {}) + @pinpoint_sms_client = params.fetch(:pinpoint_sms_client) { PinpointSMSClient.new } + @cloudwatch_client = params.fetch(:cloudwatch_client) { CloudwatchClient.new } + @iam_client = params.fetch(:iam_client) { IAMClient.new } + @name = params.fetch(:name) + @sender_id_country_code = params.fetch(:sender_id_country_code) + @identifier = params.fetch(:identifier, "#{name}-pinpoint-sms") + @sender_id = params.fetch(:sender_id, name) + @phone_pool_name = params.fetch(:phone_pool_name, identifier) + @configuration_set_name = params.fetch(:configuration_set_name, identifier) + @log_group_name = params.fetch(:log_group_name, identifier) + @cloudwatch_iam_role = params.fetch(:cloudwatch_iam_role, "#{identifier}-cloudwatch") + @sns_topic_name = params.fetch(:sns_topic_name, identifier) + @destination_phone_numbers = Array(params.fetch(:destination_phone_numbers)) + end + + def create + phone_number = find_or_request_phone_number + sender_id = find_or_request_sender_id + phone_pool = find_or_create_phone_pool(phone_number:) + configuration_set = find_or_create_configuration_set + find_or_create_cloudwatch_event_destination(configuration_set:) + find_or_create_verified_destination_numbers + # find_or_create_sns_event_destination(configuration_set:) + end + + def destroy + destroy_configuration_set + destroy_phone_pool + release_phone_number + release_sender_id + end + + private + + def destroy_configuration_set + pinpoint_sms_client.delete_configuration_set(configuration_set_name: configuration_set_name) + end + + def destroy_phone_pool + phone_pool = pinpoint_sms_client.phone_pools.find do |phone_pool| + tags = pinpoint_sms_client.list_tags(resource_arn: phone_pool.pool_arn) + tags.any? { |t| t.key == "Name" && t.value == phone_pool_name } + end + + return if phone_pool.nil? + + pinpoint_sms_client.delete_phone_pool(pool_id: phone_pool.pool_id) + end + + def release_phone_number + return if simulator_phone_numbers.empty? + + pinpoint_sms_client.release_phone_number( + phone_number_id: simulator_phone_numbers.first.phone_number_id + ) + end + + def release_sender_id + return if requested_sender_ids.empty? + + pinpoint_sms_client.release_sender_id( + sender_id:, + iso_country_code: sender_id_country_code + ) + end + + def find_or_request_phone_number + return simulator_phone_numbers.first if simulator_phone_numbers.any? + + pinpoint_sms_client.request_phone_number(number_type: "SIMULATOR") + end + + def find_or_request_sender_id + return requested_sender_ids.first if requested_sender_ids.any? + + pinpoint_sms_client.request_sender_id( + sender_id:, + iso_country_code: sender_id_country_code, + message_types: ["TRANSACTIONAL"] + ) + end + + def find_or_create_phone_pool(phone_number:) + phone_pools = pinpoint_sms_client.phone_pools + return phone_pools.first if phone_pools.any? + + pinpoint_sms_client.create_phone_pool( + origination_identity: phone_number.phone_number_arn, + tags: [ + { + key: "Name", + value: phone_pool_name + } + ] + ) + end + + def find_or_create_verified_destination_numbers + destination_phone_numbers.each do |destination_phone_number| + existing_verified_numbers = pinpoint_sms_client.verified_destination_phone_numbers( + destination_phone_numbers: [destination_phone_number] + ) + + next if existing_verified_numbers.any? + + pinpoint_sms_client.create_verified_destination_number( + destination_phone_number: destination_phone_number + ) + end + end + + def find_or_create_configuration_set + configuration_sets = pinpoint_sms_client.configuration_sets + return configuration_sets.first if configuration_sets.any? + + pinpoint_sms_client.create_configuration_set(configuration_set_name:) + pinpoint_sms_client.configuration_sets.first + end + + def find_or_create_cloudwatch_event_destination(configuration_set:) + return if configuration_set.event_destinations.any? do |event_destination| + !event_destination&.cloud_watch_logs_destination.nil? + end + + log_group = find_log_group + cloudwatch_iam_role = find_cloudwatch_iam_role + + pinpoint_sms_client.create_event_destination( + configuration_set_name: configuration_set.configuration_set_name, + event_destination_name: "cloudwatch", + matching_event_types: ["ALL"], + cloud_watch_logs_destination: { + iam_role_arn: cloudwatch_iam_role.arn, + log_group_arn: log_group.arn + } + ) + end + + def find_or_create_sns_event_destination(configuration_set:) + return if configuration_set.event_destinations.any? do |event_destination| + !event_destination&.sns_destination.nil? + end + + sns_topic = find_sns_topic + + pinpoint_sms_client.create_event_destination( + configuration_set_name: configuration_set.configuration_set_name, + event_destination_name: "cloudwatch", + matching_event_types: ["ALL"], + sns_destination: { + topic_arn: sns_topic.arn + } + ) + end + + def find_cloudwatch_iam_role + iam_client.find_role(name: cloudwatch_iam_role) + end + + def find_log_group + cloudwatch_client.log_groups(log_group_name_prefix: log_group_name).first + end + + def find_sns_topic + sns_client.find_topic(name: sns_topic_name) + end + + def simulator_phone_numbers + @simulator_phone_numbers ||= pinpoint_sms_client.phone_numbers(number_type: "SIMULATOR") + end + + def requested_sender_ids + @requested_sender_ids ||= pinpoint_sms_client.sender_ids( + filters: [ + { + name: "sender-id", + values: [sender_id] + }, + { + name: "iso-country-code", + values: [sender_id_country_code] + } + ] + ) + end +end + +action, = ARGV + +raise "Invalid action #{action}. Must be either create or destroy" unless %w[create destroy].include?(action) + +workflow = BootstrapPinpointSMS.new( + name: "somleng", + sender_id_country_code: "KH", + destination_phone_numbers: "+855715100860" +) + +workflow.public_send(action) diff --git a/infrastructure/somleng/core/pinpoint_sms.tf b/infrastructure/somleng/core/pinpoint_sms.tf new file mode 100644 index 00000000..bf5ee070 --- /dev/null +++ b/infrastructure/somleng/core/pinpoint_sms.tf @@ -0,0 +1,53 @@ + +resource "aws_cloudwatch_log_group" "pinpoint_sms" { + name = "somleng-pinpoint-sms" + retention_in_days = 7 +} + +resource "aws_iam_role" "pinpoint_sms_cloudwatch" { + name = "somleng-pinpoint-sms-cloudwatch" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "sms-voice.amazonaws.com" + } + Condition = { + StringEquals = { + "aws:SourceAccount": data.aws_caller_identity.current.account_id + } + } + }, + ] + }) +} + +resource "aws_iam_policy" "pinpoint_sms_cloudwatch" { + name = "somleng-pinpoint-sms-cloudwatch" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = [ + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents" + ] + Effect = "Allow" + Resource = [ + aws_cloudwatch_log_group.pinpoint_sms.arn + ] + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "pinpoint_sms_cloudwatch" { + role = aws_iam_role.pinpoint_sms_cloudwatch.name + policy_arn = aws_iam_policy.pinpoint_sms_cloudwatch.arn +}