AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  KloudCare read-only AWS assessment (AWS Marketplace edition). Runs a read-only
  assessment once as an AWS Fargate task 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]
      - Label: {default: Network (optional)}
        Parameters: [VpcId, SubnetId, AssignPublicIp]
      - Label: {default: Container image}
        Parameters: [ContainerImage]
    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)}
      VpcId: {default: Existing VPC (optional)}
      SubnetId: {default: Existing subnet (optional)}
      AssignPublicIp: {default: Assign a public IP to the task}
      ContainerImage: {default: Assessment container image}

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.
  VpcId:
    Type: String
    Default: ""
    Description: Leave blank to create a small dedicated VPC (no inbound access, no NAT gateway). If set, also set Subnet.
  SubnetId:
    Type: String
    Default: ""
    Description: Leave blank with the VPC. The subnet must reach AWS APIs (public subnet with a public IP, or a NAT gateway).
  AssignPublicIp:
    Type: String
    Default: "ENABLED"
    AllowedValues: ["ENABLED", "DISABLED"]
    Description: Keep ENABLED for the dedicated VPC or a public subnet. Use DISABLED only for a private subnet with a NAT gateway.
  ContainerImage:
    Type: String
    Default: "709825985650.dkr.ecr.us-east-1.amazonaws.com/kloudcare/kloudcare-assessment@sha256:d4dd6183e716b4ec87534c9449cb3e268da4b66506c0bd508478e72c17dddfc3"
    Description: The assessment image (version 0.3.6) in the AWS Marketplace repository. Leave unchanged.
  MarketplaceUrl:
    Type: String
    Default: "https://aws.amazon.com/marketplace/seller-profile?id=seller-ohucuvzmso6rk"
    Description: AWS Marketplace page linked from the report (KloudCare's seller profile). Clear it to show no link.

Rules:
  NetworkSettingsTogether:
    Assertions:
      - Assert: !Or
          - !And [!Equals [!Ref VpcId, ""], !Equals [!Ref SubnetId, ""]]
          - !And [!Not [!Equals [!Ref VpcId, ""]], !Not [!Equals [!Ref SubnetId, ""]]]
        AssertDescription: Set both the existing VPC and subnet, or leave both blank to create a dedicated VPC.

Conditions:
  CreateNetwork: !Equals [!Ref SubnetId, ""]
  IncludeCost: !Equals [!Ref IncludeCostExplorer, "true"]

Resources:
  # ---- Report storage (same as the self-service template) ----
  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"}}

  TaskLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs/${AWS::StackName}"
      RetentionInDays: 30

  # ---- Dedicated network (only when no subnet is given): one public subnet, no inbound access, no NAT ----
  Vpc:
    Type: AWS::EC2::VPC
    Condition: CreateNetwork
    Properties:
      CidrBlock: 10.20.0.0/24
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-assessment"}]

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Condition: CreateNetwork

  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Condition: CreateNetwork
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  Subnet:
    Type: AWS::EC2::Subnet
    Condition: CreateNetwork
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.20.0.0/26
      AvailabilityZone: !Select [0, !GetAZs ""]
      Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-assessment"}]

  RouteTable:
    Type: AWS::EC2::RouteTable
    Condition: CreateNetwork
    Properties:
      VpcId: !Ref Vpc

  DefaultRoute:
    Type: AWS::EC2::Route
    Condition: CreateNetwork
    DependsOn: GatewayAttachment
    Properties:
      RouteTableId: !Ref RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  SubnetRouteTable:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: CreateNetwork
    Properties:
      SubnetId: !Ref Subnet
      RouteTableId: !Ref RouteTable

  TaskSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Metadata:
      cfn-lint:
        config:
          # VpcId is blank by default, but it is only used when an existing subnet is given (see Rules).
          ignore_checks: [W1030]
    Properties:
      GroupDescription: KloudCare assessment task. No inbound access; outbound HTTPS to AWS APIs only.
      VpcId: !If [CreateNetwork, !Ref Vpc, !Ref VpcId]
      SecurityGroupEgress:
        - {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0, Description: AWS APIs over HTTPS}

  # ---- Roles ----
  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: {Service: ecs-tasks.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: WriteReport
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub "${ReportBucket.Arn}/reports/*"

  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: {Service: ecs-tasks.amazonaws.com}
            Action: sts:AssumeRole
            Condition:
              StringEquals: {aws:SourceAccount: !Ref AWS::AccountId}
      Policies:
        - PolicyName: PullImageAndWriteLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # The image lives in the AWS Marketplace ECR registry, so its ARN is not known here.
              - Effect: Allow
                Action: [ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage]
                Resource: "*"
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !Sub "${TaskLogGroup.Arn}:*"

  # ---- Fargate task ----
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Ref AWS::StackName

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "${AWS::StackName}-assessment"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: "512"
      Memory: "1024"
      RuntimePlatform: {OperatingSystemFamily: LINUX, CpuArchitecture: X86_64}
      TaskRoleArn: !GetAtt TaskRole.Arn
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      Volumes:
        - Name: reports
      ContainerDefinitions:
        - Name: assessment
          Image: !Ref ContainerImage
          Essential: true
          ReadonlyRootFilesystem: true
          MountPoints:
            # The image declares /reports as a volume owned by the non-root user.
            - {SourceVolume: reports, ContainerPath: /reports}
          Environment:
            - {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: SKIP_COST_EXPLORER, Value: !If [IncludeCost, "false", "true"]}
            - {Name: ASSESSMENT_STACK, Value: !Ref AWS::StackName}
            - {Name: MARKETPLACE_URL, Value: !Ref MarketplaceUrl}
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref TaskLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: assessment

  # ---- Runs the task once on creation; empties the bucket on deletion ----
  StarterRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: {Service: lambda.amazonaws.com}
            Action: sts:AssumeRole
      Policies:
        - PolicyName: RunAssessmentAndCleanUp
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Write to the stack's own log group only. No logs:CreateLogGroup, so late log lines
              # cannot re-create the group after the stack has deleted it.
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !Sub "${StarterLogGroup.Arn}:*"
              - Effect: Allow
                Action: ecs:RunTask
                Resource: !Sub "arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/${AWS::StackName}-assessment:*"
                Condition:
                  ArnEquals: {ecs:cluster: !GetAtt Cluster.Arn}
              - Effect: Allow
                Action: iam:PassRole
                Resource: [!GetAtt TaskRole.Arn, !GetAtt ExecutionRole.Arn]
                Condition:
                  StringEquals: {iam:PassedToService: ecs-tasks.amazonaws.com}
              - Effect: Allow
                Action: s3:ListBucket
                Resource: !GetAtt ReportBucket.Arn
              - Effect: Allow
                Action: s3:DeleteObject
                Resource: !Sub "${ReportBucket.Arn}/*"
              # New accounts may not have the ECS service-linked role yet; allow creating that role only.
              - Effect: Allow
                Action: iam:CreateServiceLinkedRole
                Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS"
                Condition:
                  StringEquals: {iam:AWSServiceName: ecs.amazonaws.com}

  # Owned by the stack, so deleting the stack also removes the starter function's logs.
  StarterLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-starter"
      RetentionInDays: 30

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

          def ensure_ecs_service_role():
              try:
                  boto3.client("iam").create_service_linked_role(AWSServiceName="ecs.amazonaws.com")
                  print("Created the ECS service-linked role")
              except Exception as exc:
                  # Usually it already exists ("has been taken"); anything else is reported by run_task.
                  print(f"ECS service-linked role: {exc}")

          def run_once(props):
              ecs = boto3.client("ecs")
              for attempt in range(12):
                  try:
                      result = ecs.run_task(
                          cluster=props["Cluster"],
                          taskDefinition=props["TaskDefinition"],
                          launchType="FARGATE",
                          count=1,
                          networkConfiguration={"awsvpcConfiguration": {
                              "subnets": [props["Subnet"]],
                              "securityGroups": [props["SecurityGroup"]],
                              "assignPublicIp": props["AssignPublicIp"],
                          }},
                      )
                      if result.get("failures"):
                          raise RuntimeError(result["failures"])
                      return
                  except Exception as exc:
                      # A newly created service-linked role takes a short while to become usable.
                      if "service linked role" not in str(exc).lower() or attempt == 11:
                          raise
                      print(f"Waiting for the ECS service-linked role (attempt {attempt + 1}): {exc}")
                      time.sleep(10)

          def handler(event, context):
              props = event["ResourceProperties"]
              status = cfnresponse.SUCCESS
              try:
                  if event["RequestType"] == "Create":
                      ensure_ecs_service_role()
                      run_once(props)
                  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
      Cluster: !Ref Cluster
      TaskDefinition: !Ref TaskDefinition
      Subnet: !If [CreateNetwork, !Ref Subnet, !Ref SubnetId]
      SecurityGroup: !Ref TaskSecurityGroup
      AssignPublicIp: !Ref AssignPublicIp
      BucketName: !Ref ReportBucket
      # The route must exist before the task starts, or it cannot reach AWS APIs.
      NetworkReady: !If [CreateNetwork, !Ref DefaultRoute, "existing-subnet"]
      RouteAssociation: !If [CreateNetwork, !Ref SubnetRouteTable, "existing-subnet"]

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 task history; open a task's Logs tab to follow a run.
    Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/ecs/v2/clusters/${Cluster}/tasks?region=${AWS::Region}"
  RunAgain:
    Description: AWS CLI command to run the assessment again (or use Run new task in the ECS console with the same settings).
    Value: !Sub
      - >-
        aws ecs run-task --region ${AWS::Region} --cluster ${Cluster} --task-definition ${TaskDefinition}
        --launch-type FARGATE --network-configuration
        "awsvpcConfiguration={subnets=[${SubnetValue}],securityGroups=[${TaskSecurityGroup}],assignPublicIp=${AssignPublicIp}}"
      - SubnetValue: !If [CreateNetwork, !Ref Subnet, !Ref SubnetId]
  ReportBucketName:
    Value: !Ref ReportBucket
