AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  KloudCare self-service AWS assessment. Runs a read-only assessment inside
  this account and stores the HTML/JSON report in a private S3 bucket in this
  account. Nothing is sent to KloudCare. Delete the stack to remove everything.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: {default: Assessment scope}
        Parameters: [Regions, ExpectedRegions, RequiredTags, ObservationDays, IncludeCostExplorer]
      - Label: {default: Report storage}
        Parameters: [ReportRetentionDays]
    ParameterLabels:
      Regions: {default: Regions to assess}
      ExpectedRegions: {default: Regions you expect to use}
      RequiredTags: {default: Required tag keys}
      ObservationDays: {default: Observation window (days)}
      IncludeCostExplorer: {default: Include Cost Explorer spend}
      ReportRetentionDays: {default: Keep reports for (days)}

Parameters:
  Regions:
    Type: String
    Default: ""
    Description: Comma-separated, e.g. eu-west-2,eu-west-1. Leave blank to assess every enabled region.
  ExpectedRegions:
    Type: String
    Default: ""
    Description: Optional. Resources outside these regions are flagged. Leave blank to skip this check.
  RequiredTags:
    Type: String
    Default: "Owner,Environment"
    Description: Comma-separated tag keys every resource should have.
  ObservationDays:
    Type: Number
    Default: 14
    MinValue: 1
    MaxValue: 63
    Description: CloudWatch history used for utilisation checks.
  IncludeCostExplorer:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: AWS charges USD 0.01 per Cost Explorer request (one request per run).
  ReportRetentionDays:
    Type: Number
    Default: 30
    MinValue: 1
    Description: Reports are deleted automatically after this many days.

Resources:
  ReportBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault: {SSEAlgorithm: AES256}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules: [{ObjectOwnership: BucketOwnerEnforced}]
      LifecycleConfiguration:
        Rules:
          - Id: ExpireReports
            Status: Enabled
            ExpirationInDays: !Ref ReportRetentionDays

  ReportBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref ReportBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: DenyInsecureTransport
            Effect: Deny
            Principal: "*"
            Action: "s3:*"
            Resource: [!GetAtt ReportBucket.Arn, !Sub "${ReportBucket.Arn}/*"]
            Condition: {Bool: {aws:SecureTransport: "false"}}

  BuildLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/codebuild/${AWS::StackName}"
      RetentionInDays: 30

  AssessmentRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: {Service: codebuild.amazonaws.com}
            Action: sts:AssumeRole
            Condition:
              StringEquals: {aws:SourceAccount: !Ref AWS::AccountId}
      Policies:
        - PolicyName: KloudCareAssessmentReadOnly
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: KloudCareAssessmentInventory
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                  - ec2:DescribeInstances
                  - ec2:DescribeVolumes
                  - ec2:DescribeAddresses
                  - ec2:DescribeNatGateways
                  - ec2:DescribeSecurityGroups
                  - ec2:DescribeSnapshots
                  - ec2:GetEbsEncryptionByDefault
                  - rds:DescribeDBInstances
                  - rds:ListTagsForResource
                  - s3:ListAllMyBuckets
                  - s3:GetBucketLocation
                  - s3:GetLifecycleConfiguration
                  - s3:GetBucketTagging
                  - s3:GetBucketVersioning
                  - s3:GetBucketPolicyStatus
                  - s3:GetAccountPublicAccessBlock
                  - ecs:ListClusters
                  - ecs:ListServices
                  - ecs:DescribeServices
                  - lambda:ListFunctions
                  - lambda:ListTags
                  - elasticloadbalancing:DescribeLoadBalancers
                  - elasticloadbalancing:DescribeTargetGroups
                  - elasticloadbalancing:DescribeTargetHealth
                  - elasticloadbalancing:DescribeTags
                  - cloudwatch:GetMetricStatistics
                  - cloudwatch:DescribeAlarms
                  - ce:GetCostAndUsage
                  - compute-optimizer:GetEnrollmentStatus
                  - compute-optimizer:GetEC2InstanceRecommendations
                  - iam:GetAccountSummary
                  - iam:ListUsers
                  - iam:ListAccessKeys
                  - iam:ListMFADevices
                  - iam:GetLoginProfile
                  - cloudtrail:DescribeTrails
                  - cloudtrail:GetTrailStatus
                  - guardduty:ListDetectors
                  - backup:ListBackupPlans
                  - backup:ListProtectedResources
                Resource: "*"
        - PolicyName: WriteReportAndLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub "${ReportBucket.Arn}/reports/*"
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !Sub "${BuildLogGroup.Arn}:*"

  AssessmentProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Ref AWS::StackName
      Description: KloudCare read-only AWS assessment. Start a build to run it again.
      ServiceRole: !GetAtt AssessmentRole.Arn
      TimeoutInMinutes: 60
      Artifacts: {Type: NO_ARTIFACTS}
      LogsConfig:
        CloudWatchLogs: {Status: ENABLED, GroupName: !Ref BuildLogGroup}
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:7.0
        EnvironmentVariables:
          - {Name: TOOL_URL, Value: "https://www.kloudcare.co.uk/downloads/kloudcare_assessment-0.3.6-py3-none-any.whl"}
          - {Name: TOOL_FILE, Value: "kloudcare_assessment-0.3.6-py3-none-any.whl"}
          - {Name: TOOL_SHA256, Value: "307309366ef6a929cdb033a9657464818369ad69bb1ce3944f8eef6214e5b3a6"}
          - {Name: REPORT_BUCKET, Value: !Ref ReportBucket}
          - {Name: REGIONS, Value: !Ref Regions}
          - {Name: EXPECTED_REGIONS, Value: !Ref ExpectedRegions}
          - {Name: REQUIRED_TAGS, Value: !Ref RequiredTags}
          - {Name: OBSERVATION_DAYS, Value: !Ref ObservationDays}
          - {Name: INCLUDE_COST_EXPLORER, Value: !Ref IncludeCostExplorer}
          - {Name: ASSESSMENT_STACK, Value: !Ref AWS::StackName}
      Source:
        Type: NO_SOURCE
        BuildSpec: |
          version: 0.2
          env:
            shell: bash
          phases:
            install:
              runtime-versions:
                python: 3.12
              commands:
                - curl -fsSL -o "/tmp/$TOOL_FILE" "$TOOL_URL"
                - echo "$TOOL_SHA256  /tmp/$TOOL_FILE" | sha256sum -c -
                - pip install --quiet "/tmp/$TOOL_FILE"
            build:
              commands:
                - |
                  args=(--output-dir reports --days "$OBSERVATION_DAYS" --required-tags "$REQUIRED_TAGS"
                        --exclude-stack "$ASSESSMENT_STACK")
                  if [ -n "$REGIONS" ]; then args+=(--regions "$REGIONS"); fi
                  if [ -n "$EXPECTED_REGIONS" ]; then args+=(--expected-regions "$EXPECTED_REGIONS"); fi
                  if [ "$INCLUDE_COST_EXPLORER" != "true" ]; then args+=(--skip-cost-explorer); fi
                  kloudcare-assessment "${args[@]}"
                - aws s3 cp reports/ "s3://$REPORT_BUCKET/reports/$(date -u +%Y-%m-%dT%H%MZ)/" --recursive --sse AES256

  StarterRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: {Service: lambda.amazonaws.com}
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      Policies:
        - PolicyName: StartAssessmentAndCleanUp
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: codebuild:StartBuild
                Resource: !GetAtt AssessmentProject.Arn
              - Effect: Allow
                Action: s3:ListBucket
                Resource: !GetAtt ReportBucket.Arn
              - Effect: Allow
                Action: s3:DeleteObject
                Resource: !Sub "${ReportBucket.Arn}/*"

  StarterFunction:
    Type: AWS::Lambda::Function
    Properties:
      Description: Starts the first assessment on stack creation and empties the report bucket on stack deletion.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 300
      Role: !GetAtt StarterRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              props = event["ResourceProperties"]
              status = cfnresponse.SUCCESS
              try:
                  if event["RequestType"] == "Create":
                      boto3.client("codebuild").start_build(projectName=props["ProjectName"])
                  elif event["RequestType"] == "Delete":
                      boto3.resource("s3").Bucket(props["BucketName"]).objects.all().delete()
              except Exception as exc:
                  print(f"{event['RequestType']} failed: {exc}")
                  # A failed clean-up must not block deletion; the bucket deletion reports the problem.
                  if event["RequestType"] == "Create":
                      status = cfnresponse.FAILED
              cfnresponse.send(event, context, status, {})

  StartAssessment:
    Type: Custom::StartAssessment
    Properties:
      ServiceToken: !GetAtt StarterFunction.Arn
      ProjectName: !Ref AssessmentProject
      BucketName: !Ref ReportBucket

Outputs:
  Reports:
    Description: Open this link, then the newest folder, and download aws-assessment-*.html (ready about 5-10 minutes after creation)
    Value: !Sub "https://s3.console.aws.amazon.com/s3/buckets/${ReportBucket}?region=${AWS::Region}&prefix=reports/"
  Progress:
    Description: Assessment run history and logs. Use "Start build" to run the assessment again.
    Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codebuild/projects/${AssessmentProject}/history?region=${AWS::Region}"
  ReportBucketName:
    Value: !Ref ReportBucket
