AWSTemplateFormatVersion: '2010-09-09'
Description: 'Dynatrace Data Acquisition AWS monitoring integration - Dynatrace configuration API client  (v1.0.0 2026-01-15 15:52)'

Metadata:
  Version:
    Number: v1.0.0
    ReleaseDate: 2026-01-15 15:52

Parameters:
  pDynatraceUrl:
    Type: String
    Description: 'Your Dynatrace environment url'
    AllowedPattern: '^https:\/\/.+[^\/]$'
  pDtApiToken:
    Type: String
    Description: 'Dynatrace settings token'
    AllowedPattern: '(^dt0s16\.[A-Z0-9]{8}\.[A-Z0-9]{64}$)'
    NoEcho: true
  pUseCMK:
    Type: String
    Default: 'FALSE'

Conditions:
  cUseCMK: !Equals [ !Ref pUseCMK, 'TRUE' ]

Resources:
  DynatraceKMSKey:
    Type: AWS::KMS::Key
    Condition: cUseCMK
    Properties:
      Description: 'Dynatrace Data Acquisition encryption key'
      KeyPolicy:
        Version: '2012-10-17'
        Statement:
          - Sid: Enable IAM User Permissions
            Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
            Action: 'kms:*'
            Resource: '*'
          - Sid: Allow CloudWatch Logs use of the key
            Effect: Allow
            Principal:
              Service: !Sub 'logs.${AWS::Region}.amazonaws.com'
            Action:
              - 'kms:Encrypt'
              - 'kms:Decrypt'
              - 'kms:ReEncrypt*'
              - 'kms:GenerateDataKey*'
              - 'kms:CreateGrant'
              - 'kms:DescribeKey'
            Resource: '*'
            Condition:
              ArnLike:
                'kms:EncryptionContext:aws:logs:arn': !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*DynatraceApiClient*'
              StringEquals:
                'aws:SourceAccount': !Ref AWS::AccountId
          - Sid: AllowSecretsManagerUseOfKey
            Effect: Allow
            Principal:
              AWS: '*'
            Action:
              - 'kms:Encrypt'
              - 'kms:Decrypt'
              - 'kms:ReEncrypt*'
              - 'kms:GenerateDataKey*'
              - 'kms:CreateGrant'
              - 'kms:DescribeKey'
            Resource: '*'
            Condition:
              StringEquals:
                'kms:CallerAccount': !Ref AWS::AccountId
                'kms:ViaService': !Sub 'secretsmanager.${AWS::Region}.amazonaws.com'
      Tags:
        - Key: 'dt:CreatedBy'
          Value: 'Dynatrace'
        - Key: 'dt:EnvironmentID'
          Value: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ]

  DynatraceKMSKeyAlias:
    Type: AWS::KMS::Alias
    Condition: cUseCMK
    Properties:
      TargetKeyId: !Ref DynatraceKMSKey
      AliasName: !Sub
        - 'alias/dynatrace/${DynatraceEnvironmentId}/keys/aws/integration/${DeploymentHash}'
        - { DeploymentHash: !Select [ 0, !Split [ '-', !Select [ 2, !Split [ '/', !Ref AWS::StackId ] ] ] ], DynatraceEnvironmentId: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ] }

  DynatraceSettingsTokenSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub
        - '/dynatrace/${DynatraceEnvironmentId}/tokens/aws/integration/${DeploymentHash}'
        - { DeploymentHash: !Select [ 0, !Split [ '-', !Select [ 2, !Split [ '/', !Ref AWS::StackId ] ] ] ], DynatraceEnvironmentId: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ] }
      Description: 'Dynatrace settings platform token secret'
      SecretString: !Sub '{"dynatrace_api_token": "${pDtApiToken}"}'
      KmsKeyId: !If [ cUseCMK, !GetAtt DynatraceKMSKey.Arn, !Ref AWS::NoValue ]
      Tags:
        - Key: 'dt:CreatedBy'
          Value: 'Dynatrace'
        - Key: 'dt:EnvironmentID'
          Value: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ]

  DynatraceApiClientRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub
        - 'DynatraceApiClientRole-${DynatraceEnvironmentId}-${DeploymentHash}'
        - { DeploymentHash: !Select [ 0, !Split [ '-', !Select [ 2, !Split [ '/', !Ref AWS::StackId ] ] ] ], DynatraceEnvironmentId: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ] }
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action: 'sts:AssumeRole'
            Principal:
              Service: 'lambda.amazonaws.com'
      Policies:
        - PolicyName: DynatraceApiClientLambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: !GetAtt DynatraceApiClientLogGroup.Arn
              - Effect: Allow
                Action:
                  - 'secretsmanager:GetSecretValue'
                Resource: !Ref DynatraceSettingsTokenSecret
              - !If
                - cUseCMK
                - Effect: Allow
                  Action:
                    - 'kms:Decrypt'
                  Resource: !GetAtt DynatraceKMSKey.Arn
                - !Ref AWS::NoValue
      Tags:
        - Key: 'dt:CreatedBy'
          Value: 'Dynatrace'
        - Key: 'dt:EnvironmentID'
          Value: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ]

  DynatraceApiClientLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub
        - '/aws/lambda/DynatraceApiClientFunction-${DynatraceEnvironmentId}-${DeploymentHash}'
        - { DeploymentHash: !Select [ 0, !Split [ '-', !Select [ 2, !Split [ '/', !Ref AWS::StackId ] ] ] ], DynatraceEnvironmentId: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ] }
      RetentionInDays: 30
      KmsKeyId: !If [ cUseCMK, !GetAtt DynatraceKMSKey.Arn, !Ref AWS::NoValue ]
      Tags:
        - Key: 'dt:CreatedBy'
          Value: 'Dynatrace'
        - Key: 'dt:EnvironmentID'
          Value: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ]

  DynatraceApiClientFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub
        - 'DynatraceApiClientFunction-${DynatraceEnvironmentId}-${DeploymentHash}'
        - { DeploymentHash: !Select [ 0, !Split [ '-', !Select [ 2, !Split [ '/', !Ref AWS::StackId ] ] ] ], DynatraceEnvironmentId: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ] }
      Handler: index.handler
      Runtime: python3.13
      MemorySize: 128
      Timeout: 120
      LoggingConfig:
        LogGroup: !Ref DynatraceApiClientLogGroup
      Role: !GetAtt DynatraceApiClientRole.Arn
      Environment:
        Variables:
          LOG_LEVEL: INFO
          DT_ENVIRONMENT_URL: !Ref pDynatraceUrl
          DT_API_SECRET_ARN: !Ref DynatraceSettingsTokenSecret
      Code:
        ZipFile: |
          
            import boto3
            import json
            import os
            import string
            from random import choices
            import cfnresponse
            import logging
            from enum import Enum
            import urllib3
            
            session = boto3.session.Session()
            sm_client = session.client('secretsmanager')
            
            
            class DtApiException(Exception):
                pass
            
            
            class Step(Enum):
                HAS_CONFIGURATION = 1
                HAS_MONITORING_ROLE = 2
                MONITORING_CONFIGURATION = 3
                REPORT_STATUS = 4
                REPORT_COMPLETE = 5
            
            
            PROP_STEP = 'Step'
            PROP_HAS_ID = 'SettingsObjectId'
            PROP_HAS_NAME = 'HASConnectionName'
            PROP_MONITORING_ROLE = 'MonitoringRole'
            PROP_MONITORING_CONFIG_ID = 'MonitoringConfigurationId'
            PROP_ORGANIZATION_ID = 'AwsOrganizationId'
            
            LOG = logging.getLogger(__name__)
            LOG.setLevel(logging.INFO)
            
            TOKENS_API_PATH = '/api/v2/apiTokens'
            SETTINGS_OBJECTS_API_PATH = '/platform/classic/environment-api/v2/settings/objects'
            SETTINGS_UPDATE_OBJECT_API_PATH = '/platform/classic/environment-api/v2/settings/objects/{object_id}'
            MONITORING_CONFIGURATIONS_API_PATH = '/platform/extensions/v1/{extension_name}/monitoring-configuration'
            MONITORING_CONFIGURATION_GET_UPDATE_API_PATH = '/platform/extensions/v1/{extension_name}/monitoring-configuration/{configuration_id}'
            
            AWS_MONITORING_EXTENSION_NAME = 'com.dynatrace.extension.da-aws'
            
            
            def _get_random_string(size=8):
                return ''.join(choices(string.ascii_lowercase + string.digits, k=size))
            
            
            def _get_create_has_settings_body(connection_name, arn):
                return {
                    'schemaId': 'builtin:hyperscaler-authentication.connections.aws',
                    'value': {
                        'name': connection_name,
                        'type': 'awsRoleBasedAuthentication',
                        'awsRoleBasedAuthentication': {
                            'roleArn': arn,
                            'consumers': ['SVC:com.dynatrace.da']
                        }
                    }
                }
            
            
            dt_api_http_client = urllib3.PoolManager()
            
            
            def _send_request(path, method, payload_obj=None):
                payload = json.dumps(payload_obj)
                dt_environment_url = os.environ['DT_ENVIRONMENT_URL']
                token = _get_dt_api_key(os.environ['DT_API_SECRET_ARN'])
                dt_api_url = dt_environment_url + path
                headers = {
                    'Authorization': 'Bearer ' + token,
                    'accept': 'application/json; charset=utf-8',
                    'Content-Type': 'application/json; charset=utf-8'
                }
                if method in ['PUT', 'POST']:
                    response = dt_api_http_client.request(method, dt_api_url, headers=headers, body=payload)
                else:
                    response = dt_api_http_client.request(method, dt_api_url, headers=headers)
                if response.status > 299:
                    try:
                        response_obj = json.loads(response.data) if response.data else None
                        if isinstance(response_obj, list) and len(response_obj) > 0:
                            response_obj = response_obj[0]
                        if isinstance(response_obj, dict):
                            error = response_obj.get('error', {})
                        else:
                            error = {'message': response_obj}
                    except json.JSONDecodeError:
                        error = {'message': response.data.decode(encoding="utf-8")[:500]}
            
                    msg = f"Dynatrace API {path} {method} request failed: HTTP status {response.status} Error: {error.get('code', None)} {error.get('message', 'no message')}{error.get('constraintViolations', '')}"
            
                    constraint_violations = str(error.get('constraintViolations', ''))
                    if 'Account ID must be unique' in constraint_violations:
                        msg = f"This AWS account is already monitored by Dynatrace. {msg}"
            
                    raise DtApiException(msg)
                return json.loads(response.data) if response.data else {}
            
            
            def _get_dt_api_key(secret_arn):
                get_secret_value_response = sm_client.get_secret_value(SecretId=secret_arn)
                secret = get_secret_value_response['SecretString']
                try:
                    api_key = json.loads(secret)['dynatrace_api_token']
                except (json.JSONDecodeError, KeyError):
                    api_key = None
                    LOG.error("Invalid content of the secret %s", secret_arn)
                return api_key
            
            
            def create_has_settings():
                connection_name = 'da_aws_connection-' + _get_random_string()
                response = _send_request(SETTINGS_OBJECTS_API_PATH, 'POST',
                                         [_get_create_has_settings_body(connection_name, '')])
                return response[0]['objectId'], connection_name
            
            
            def update_has_settings(object_id, connection_name, monitoring_role_arn):
                path = SETTINGS_UPDATE_OBJECT_API_PATH.format(object_id=object_id)
                payload = _get_create_has_settings_body(connection_name, monitoring_role_arn)
                response = _send_request(path, 'PUT', payload)
                return response['objectId']
            
            
            def get_monitoring_configuration(configuration_id):
                path = (MONITORING_CONFIGURATION_GET_UPDATE_API_PATH
                        .format(extension_name=AWS_MONITORING_EXTENSION_NAME, configuration_id=configuration_id))
                response = _send_request(path, 'GET')
                return response
            
            
            def create_monitoring_configuration(payload):
                path = MONITORING_CONFIGURATIONS_API_PATH.format(extension_name=AWS_MONITORING_EXTENSION_NAME)
                response = _send_request(path, 'POST', payload)
                return response[0]['objectId']
            
            
            def update_monitoring_configuration(configuration_id, payload):
                path = (MONITORING_CONFIGURATION_GET_UPDATE_API_PATH
                        .format(extension_name=AWS_MONITORING_EXTENSION_NAME, configuration_id=configuration_id))
                response = _send_request(path, 'PUT', payload)
                return response['objectId']
            
            def has_properties_from_physical_id(physical_resource_id):
                settings_obj_id, connection_name =  physical_resource_id.split(':')
                return settings_obj_id, connection_name
            
            def create_has_configuration(cfn_action, physical_resource_id):
                if cfn_action == 'Create':
                    LOG.info('[%s] Creating HAS settings', os.environ['DT_ENVIRONMENT_URL'])
                    settings_obj_id, connection_name = create_has_settings()
                    physical_resource_id = f'{settings_obj_id}:{connection_name}'
                else:
                    settings_obj_id, connection_name =  has_properties_from_physical_id(physical_resource_id)
                return physical_resource_id, {PROP_HAS_ID: settings_obj_id, PROP_HAS_NAME: connection_name}
            
            
            def update_has_configuration(properties, old_properties):
                has_settings_id = properties[PROP_HAS_ID]
                if old_properties:
                    return {PROP_HAS_ID: has_settings_id}
                monitoring_role_arn = properties[PROP_MONITORING_ROLE]
                has_connection_name = properties[PROP_HAS_NAME]
                LOG.info('[%s] Updating HAS settings with monitoring role arn', os.environ['DT_ENVIRONMENT_URL'])
                settings_obj_id = update_has_settings(has_settings_id, has_connection_name, monitoring_role_arn)
                return {PROP_HAS_ID: settings_obj_id}
            
            
            def configure_monitoring(physical_resource_id, properties, old_properties):
                if old_properties and properties.get(PROP_HAS_ID) == old_properties.get(PROP_HAS_ID):
                    return physical_resource_id, {PROP_MONITORING_CONFIG_ID: physical_resource_id}
                monitoring_configuration_id = properties[PROP_MONITORING_CONFIG_ID]
                aws_account_id = get_account_id_from_arn(properties[PROP_MONITORING_ROLE])
                connection_id = properties[PROP_HAS_ID]
                aws_organization_id = properties.get(PROP_ORGANIZATION_ID, None)
                configuration_object = get_monitoring_configuration(monitoring_configuration_id)
                if aws_organization_id:
                    validate_organization_configuration(configuration_object, aws_organization_id)
                monitoring_enabled = connection_id is not None
                configuration_object['value']['aws']['credentials'] = [{
                    'description': 'HAS connection',
                    'enabled': monitoring_enabled,
                    'connectionId': connection_id,
                    'accountId': aws_account_id
                }]
                configuration_object['value']['enabled'] = monitoring_enabled
                configuration_object['value']['aws']['automatedDeploymentTemplateVersion'] = 'v1.0.0'
                if aws_organization_id:
                    LOG.info('[%s] Creating AWS monitoring configuration for member - %s', os.environ['DT_ENVIRONMENT_URL'], aws_account_id)
                    configuration_object['value']['description'] = f'member-{aws_organization_id}-{aws_account_id}'
                    configuration_object['value']['aws']['credentials'][0]['rootOrganizationId'] = aws_organization_id
                    configuration_object['value']['aws']['credentials'][0]['parentConfigurationId'] = monitoring_configuration_id
                    configuration_object['value']['aws']['deploymentScope'] = 'SINGLE_ACCOUNT'
                    configuration_object['value']['aws']['deploymentMode'] = 'AUTOMATED'
                    configuration_object['value']['aws']['automatedDeploymentStatus'] = 'RUNNING'
                    configuration_id = create_monitoring_configuration([configuration_object])
                else:
                    LOG.info('[%s] Updating AWS monitoring configuration', os.environ['DT_ENVIRONMENT_URL'])
                    configuration_id = update_monitoring_configuration(monitoring_configuration_id, configuration_object)
                return configuration_id, {PROP_MONITORING_CONFIG_ID: configuration_id}
            
            
            def validate_organization_configuration(configuration_object, aws_organization_id):
                aws_configuration_object = configuration_object['value']['aws']
            
                deployment_scope = aws_configuration_object['deploymentScope']
                if deployment_scope != 'ORGANIZATION':
                    raise DtApiException(f"Incorrect deploymentScope provided in the monitoring configuration template (Current: {deployment_scope}, Expected: ORGANIZATION)")
            
                if 'accountId' in aws_configuration_object['credentials'][0]:
                    raise DtApiException("accountId property must be null for the organization monitoring configuration template")
            
                if 'rootOrganizationId' not in aws_configuration_object['credentials'][0]:
                    raise DtApiException("rootOrganizationId property must be set for the organization monitoring configuration template")
            
                root_organization_id = aws_configuration_object['credentials'][0]['rootOrganizationId']
                if root_organization_id != aws_organization_id:
                    raise DtApiException(f"Incorrect rootOrganizationId provided in the monitoring configuration (Current: {root_organization_id}, Expected: {aws_organization_id})")
            
            
            def delete_has_configuration(physical_resource_id):
                result_status = cfnresponse.SUCCESS
                result_reason = None
                settings_object_id, _ =  has_properties_from_physical_id(physical_resource_id)
                try:
                    dt_environment_url = os.environ['DT_ENVIRONMENT_URL']
                    LOG.info('[%s] Deleting HAS AWS settings object - %s', dt_environment_url, settings_object_id)
                    path = SETTINGS_UPDATE_OBJECT_API_PATH.format(object_id=settings_object_id)
                    _send_request(path, 'DELETE', None)
                except Exception as e:
                    LOG.error(e)
                    result_status = cfnresponse.SUCCESS
                    result_reason = f"Failed to remove monitoring configuration ({settings_object_id}). It needs to be removed manually."
                return result_status, result_reason
            
            
            def report_deployment_status(monitoring_configuration_id, deployment_status):
                try:
                    dt_environment_url = os.environ['DT_ENVIRONMENT_URL']
                    LOG.info('[%s] Reporting deployment status - %s', dt_environment_url, deployment_status)
                    configuration_object = get_monitoring_configuration(monitoring_configuration_id)
                    configuration_object['value']['aws']['automatedDeploymentStatus'] = deployment_status
                    configuration_object['value']['aws']['automatedDeploymentTemplateVersion'] = 'v1.0.0'
                    update_monitoring_configuration(monitoring_configuration_id, configuration_object)
                except Exception as e:
                    LOG.error(e)
                    LOG.warning("Failed to report status to monitoring configuration.")
            
            def get_account_id_from_arn(arn):
                # Example ARN: arn:aws:iam::123456789012:resource_type/resource_id
                if not arn or len(arn.split(':')) < 5:
                    raise Exception("Invalid role ARN provided: %s.")
                return arn.split(':')[4]
            
            def handler(event, context):
                result_status = cfnresponse.SUCCESS
                response_data = {}
                result_reason = ""
                cfn_action = event['RequestType']
                properties = event['ResourceProperties']
                old_properties = event.get('OldResourceProperties')
                try:
                    if PROP_STEP not in properties.keys():
                        cfnresponse.send(event, context, cfnresponse.FAILED, {}, reason=f"Missing {PROP_STEP} property")
                        return
            
                    step = properties[PROP_STEP]
                    physical_resource_id = event.get('PhysicalResourceId', step)
            
                    if step == Step.HAS_CONFIGURATION.name and cfn_action in ['Create', 'Update']:
                        physical_resource_id, response_data = create_has_configuration(cfn_action, physical_resource_id)
                    elif step == Step.HAS_CONFIGURATION.name and cfn_action == 'Delete':
                        result_status, result_reason = delete_has_configuration(physical_resource_id)
                    elif step == Step.HAS_MONITORING_ROLE.name and cfn_action in ['Create', 'Update']:
                        response_data = update_has_configuration(properties, old_properties)
                    elif step == Step.MONITORING_CONFIGURATION.name and cfn_action in ['Create', 'Update']:
                        physical_resource_id, response_data = configure_monitoring(physical_resource_id, properties, old_properties)
                    elif step == Step.REPORT_STATUS.name and cfn_action != 'Delete':
                        report_deployment_status(properties[PROP_MONITORING_CONFIG_ID], "RUNNING")
                    elif step == Step.REPORT_COMPLETE.name and cfn_action != 'Delete':
                        report_deployment_status(properties[PROP_MONITORING_CONFIG_ID], "COMPLETE")
                    else:
                        msg = f'Dynatrace AWS monitoring activation stack {cfn_action} {properties[PROP_STEP]} - nothing to do'
                        LOG.info(msg)
                        result_reason = msg
            
                except DtApiException as e:
                    result_reason = e.args[0]
                    LOG.error(result_reason)
                    result_status = cfnresponse.FAILED
                except Exception as e:
                    result_reason = str(e)
                    LOG.error(e)
                    result_status = cfnresponse.FAILED
            
            
                cfnresponse.send(event, context, result_status, response_data, physicalResourceId=physical_resource_id,
                                 reason=result_reason)
      Tags:
        - Key: 'dt:CreatedBy'
          Value: 'Dynatrace'
        - Key: 'dt:EnvironmentID'
          Value: !Select [ 0, !Split [ ".", !Select [ 1, !Split [ "https://", !Ref pDynatraceUrl ] ] ] ]

Outputs:
  DynatraceApiClientFunctionArn:
    Value: !GetAtt DynatraceApiClientFunction.Arn
  DynatraceSettingsTokenSecretArn:
    Value: !Ref DynatraceSettingsTokenSecret
