Created
January 27, 2026 14:31
-
-
Save steve-fraser/1133db0de0661ccdea41b8dc6e044a13 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -euo pipefail | |
| echo "Starting AWS integration setup" | |
| : ${INTEGRATION_NAME:="AWS discovery"} | |
| : ${CASTAI_API_URL:="https://api.cast.ai"} | |
| : ${ROLE_NAME:="castai-discovery-role"} | |
| : ${STACKSET_NAME:="castai-discovery-roles"} | |
| : ${CASTAI_INTEGRATION_SCOPE:="ALL"} | |
| : ${CASTAI_INTEGRATION_SETTINGS:="{}"} | |
| : ${AWS_PERMISSIONS:=""} | |
| : ${AWS_MANAGED_POLICIES:=""} | |
| : ${AWS_CUR_S3_BUCKET_NAME:=""} | |
| : ${AWS_CUR_S3_REGION:=""} | |
| : ${AWS_CUR_S3_ACCOUNT_ID:=""} | |
| : ${AWS_FORCE_ACCOUNT_SCOPE:="false"} | |
| : ${AWS_ACCOUNT_IDS:=""} | |
| : ${AWS_EKS_K8S_SYNC_ENABLED:="true"} | |
| : ${AWS_EKS_CLUSTER_ARNS:=""} | |
| if [ -z "${CASTAI_API_KEY}" ]; then | |
| echo "Error: Required environment variable CASTAI_API_KEY is not set." | |
| exit 1 | |
| fi | |
| if [ -z "${CASTAI_ORGANIZATION_ID}" ]; then | |
| echo "Error: Required environment variable CASTAI_ORGANIZATION_ID is not set." | |
| exit 1 | |
| fi | |
| if [ -z "${AWS_CAST_USER_ARN}" ]; then | |
| echo "Error: Required environment variable AWS_CAST_USER_ARN is not set." | |
| exit 1 | |
| fi | |
| # Validate CUR S3 bucket environment variables - both must be provided or none | |
| if ([ -n "${AWS_CUR_S3_BUCKET_NAME}" ] && [ -z "${AWS_CUR_S3_REGION}" ]) || ([ -z "${AWS_CUR_S3_BUCKET_NAME}" ] && [ -n "${AWS_CUR_S3_REGION}" ]); then | |
| echo "Error: Both AWS_CUR_S3_BUCKET_NAME and AWS_CUR_S3_REGION must be provided together or neither should be provided." | |
| exit 1 | |
| fi | |
| # Build reusable S3 read-only statement scoped to CUR bucket if provided | |
| S3_READ_STATEMENT="" | |
| if [ -n "${AWS_CUR_S3_BUCKET_NAME}" ] && [ -n "${AWS_CUR_S3_REGION}" ]; then | |
| S3_READ_STATEMENT=$(cat <<EOF | |
| { | |
| "Effect": "Allow", | |
| "Action": [ | |
| "s3:Get*", | |
| "s3:List*" | |
| ], | |
| "Resource": [ | |
| "arn:aws:s3:::${AWS_CUR_S3_BUCKET_NAME}", | |
| "arn:aws:s3:::${AWS_CUR_S3_BUCKET_NAME}/*" | |
| ] | |
| } | |
| EOF | |
| ) | |
| fi | |
| convert_to_json_array() { | |
| local input="$1" | |
| local default="$2" | |
| if [ -n "$input" ]; then | |
| echo "$input" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//' | sed 's/^/[/;s/$/]/' | |
| else | |
| echo "$default" | |
| fi | |
| } | |
| PERMISSIONS_JSON=$(convert_to_json_array "${AWS_PERMISSIONS}" "") | |
| # Convert comma-separated managed policy names to ARNs and then to JSON array | |
| MANAGED_POLICIES_JSON="[]" | |
| if [ -n "${AWS_MANAGED_POLICIES}" ]; then | |
| # Convert policy names to ARNs | |
| MANAGED_POLICY_ARNS=$(echo "$AWS_MANAGED_POLICIES" | tr ',' '\n' | sed 's/^/arn:aws:iam::aws:policy\//' | tr '\n' ',' | sed 's/,$//') | |
| # Convert ARNs to JSON array | |
| MANAGED_POLICIES_JSON=$(convert_to_json_array "$MANAGED_POLICY_ARNS" "[]") | |
| fi | |
| # Check if we're in a management account | |
| if [ "$AWS_FORCE_ACCOUNT_SCOPE" = "true" ]; then | |
| echo "AWS_FORCE_ACCOUNT_SCOPE is set to true. Skipping management account detection and setting up account-scoped integration." | |
| ORG_SCOPE=false | |
| else | |
| echo "Checking if current account is an AWS management account..." | |
| MANAGEMENT_ACCOUNT_ID="" | |
| if aws organizations describe-organization --no-cli-pager &>/dev/null; then | |
| MANAGEMENT_ACCOUNT_ID=$(aws organizations describe-organization --query 'Organization.MasterAccountId' --output text --no-cli-pager) | |
| CURRENT_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) | |
| if [ "$MANAGEMENT_ACCOUNT_ID" == "$CURRENT_ACCOUNT_ID" ]; then | |
| echo "Detected management account. Setting up org-scoped integration." | |
| ORG_SCOPE=true | |
| else | |
| echo "Not a management account. Setting up account-scoped integration." | |
| ORG_SCOPE=false | |
| fi | |
| else | |
| echo "No AWS Organizations access or not in an organization. Setting up account-scoped integration." | |
| ORG_SCOPE=false | |
| fi | |
| fi | |
| if [ "$ORG_SCOPE" = true ]; then | |
| echo "Setting up org-scoped integration with service-managed stackset..." | |
| DISCOVERY_POLICY_NAME="castai-discovery-readonly-policy" | |
| # Validate that we have either custom permissions or managed policies | |
| if [ -z "${AWS_PERMISSIONS}" ] && [ -z "${AWS_MANAGED_POLICIES}" ]; then | |
| echo "Error: Either AWS_PERMISSIONS or AWS_MANAGED_POLICIES environment variable is required." | |
| exit 1 | |
| fi | |
| # Create IAM role in management account for org-wide operations | |
| echo "Creating management account role: $ROLE_NAME" | |
| MANAGEMENT_TRUST_POLICY=$(cat <<EOF | |
| { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Principal": { | |
| "AWS": "${AWS_CAST_USER_ARN}" | |
| }, | |
| "Action": "sts:AssumeRole", | |
| "Condition": { | |
| "StringEquals": { | |
| "sts:ExternalId": "${CASTAI_ORGANIZATION_ID}" | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| EOF | |
| ) | |
| if aws iam get-role --role-name "$ROLE_NAME" --no-cli-pager &>/dev/null; then | |
| echo "Management role '$ROLE_NAME' already exists. Updating trust policy." | |
| MANAGEMENT_ROLE_ARN=$(aws iam get-role --role-name "$ROLE_NAME" --query 'Role.Arn' --output text --no-cli-pager) | |
| aws iam update-assume-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-document "${MANAGEMENT_TRUST_POLICY}" \ | |
| --no-cli-pager | |
| else | |
| echo "Creating management role '$ROLE_NAME'." | |
| MANAGEMENT_ROLE_ARN=$(aws iam create-role \ | |
| --role-name "$ROLE_NAME" \ | |
| --assume-role-policy-document "${MANAGEMENT_TRUST_POLICY}" \ | |
| --query 'Role.Arn' --output text --no-cli-pager) | |
| fi | |
| # Create management account policy for org operations | |
| MANAGEMENT_POLICY_NAME="castai-org-management-policy" | |
| MANAGEMENT_POLICY=$(cat <<EOF | |
| { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Action": [ | |
| "organizations:ListAccounts", | |
| "organizations:DescribeOrganization" | |
| ], | |
| "Resource": "*" | |
| } | |
| ] | |
| } | |
| EOF | |
| ) | |
| MANAGEMENT_POLICY_ARN="arn:aws:iam::${CURRENT_ACCOUNT_ID}:policy/${MANAGEMENT_POLICY_NAME}" | |
| echo "Checking for management policy: $MANAGEMENT_POLICY_NAME" | |
| if ! aws iam get-policy --policy-arn "$MANAGEMENT_POLICY_ARN" --no-cli-pager &>/dev/null; then | |
| echo "Creating management policy '$MANAGEMENT_POLICY_NAME'." | |
| aws iam create-policy \ | |
| --policy-name "$MANAGEMENT_POLICY_NAME" \ | |
| --policy-document "$MANAGEMENT_POLICY" \ | |
| --no-cli-pager > /dev/null | |
| else | |
| echo "Management policy '$MANAGEMENT_POLICY_NAME' already exists." | |
| fi | |
| # Attach management policy to management role | |
| if ! aws iam list-attached-role-policies --role-name "$ROLE_NAME" --no-cli-pager | grep -q "$MANAGEMENT_POLICY_NAME"; then | |
| echo "Attaching management policy to management role." | |
| aws iam attach-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-arn "$MANAGEMENT_POLICY_ARN" \ | |
| --no-cli-pager | |
| else | |
| echo "Management policy already attached to management role." | |
| fi | |
| # Determine if management account should have discovery permissions | |
| # Only attach discovery permissions if: | |
| # - AWS_ACCOUNT_IDS is empty (sync all accounts), OR | |
| # - AWS_ACCOUNT_IDS contains the management account ID (explicitly allowed) | |
| ATTACH_DISCOVERY_TO_MANAGEMENT=false | |
| if [ -z "${AWS_ACCOUNT_IDS}" ]; then | |
| echo "No account IDs specified - management account will have discovery permissions to sync all accounts." | |
| ATTACH_DISCOVERY_TO_MANAGEMENT=true | |
| else | |
| # Check if management account ID is in the list | |
| for account_id in $(echo "$AWS_ACCOUNT_IDS" | tr ',' ' '); do | |
| if [ "$account_id" = "$CURRENT_ACCOUNT_ID" ]; then | |
| echo "Management account ID found in AWS_ACCOUNT_IDS - management account will have discovery permissions." | |
| ATTACH_DISCOVERY_TO_MANAGEMENT=true | |
| break | |
| fi | |
| done | |
| if [ "$ATTACH_DISCOVERY_TO_MANAGEMENT" = false ]; then | |
| echo "Management account ID not in AWS_ACCOUNT_IDS - management account will NOT have discovery permissions (only org management permissions)." | |
| fi | |
| fi | |
| # Attach discovery managed policies to management role if provided and allowed | |
| if [ "$ATTACH_DISCOVERY_TO_MANAGEMENT" = true ] && [ -n "${AWS_MANAGED_POLICIES}" ]; then | |
| echo "Attaching discovery managed policies to management role: ${AWS_MANAGED_POLICIES}" | |
| echo "$AWS_MANAGED_POLICIES" | tr ',' '\n' | while read -r policy_name; do | |
| if [ -n "$policy_name" ]; then | |
| policy_arn="arn:aws:iam::aws:policy/$policy_name" | |
| echo "Checking if managed policy is attached: $policy_arn" | |
| if ! aws iam list-attached-role-policies --role-name "$ROLE_NAME" --no-cli-pager | grep -q "$(basename $policy_arn)"; then | |
| echo "Attaching managed policy to management role: $policy_arn" | |
| aws iam attach-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-arn "$policy_arn" \ | |
| --no-cli-pager | |
| else | |
| echo "Managed policy already attached: $policy_arn" | |
| fi | |
| fi | |
| done | |
| fi | |
| # Create and attach discovery custom policy to management role if permissions are provided and allowed | |
| if [ "$ATTACH_DISCOVERY_TO_MANAGEMENT" = true ] && [ -n "${AWS_PERMISSIONS}" ]; then | |
| DISCOVERY_CUSTOM_POLICY=$(cat <<EOF | |
| { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Action": $PERMISSIONS_JSON, | |
| "Resource": "*" | |
| }$(if [ -n "${S3_READ_STATEMENT}" ]; then echo ", ${S3_READ_STATEMENT}"; fi) | |
| ] | |
| } | |
| EOF | |
| ) | |
| DISCOVERY_CUSTOM_POLICY_ARN="arn:aws:iam::${CURRENT_ACCOUNT_ID}:policy/${DISCOVERY_POLICY_NAME}" | |
| echo "Checking for discovery custom policy: $DISCOVERY_POLICY_NAME" | |
| if ! aws iam get-policy --policy-arn "$DISCOVERY_CUSTOM_POLICY_ARN" --no-cli-pager &>/dev/null; then | |
| echo "Creating discovery custom policy '$DISCOVERY_POLICY_NAME'." | |
| aws iam create-policy \ | |
| --policy-name "$DISCOVERY_POLICY_NAME" \ | |
| --policy-document "$DISCOVERY_CUSTOM_POLICY" \ | |
| --no-cli-pager > /dev/null | |
| else | |
| echo "Discovery custom policy '$DISCOVERY_POLICY_NAME' already exists. Updating with new version." | |
| # Delete old non-default versions to avoid hitting the 5-version limit | |
| OLD_VERSIONS=$(aws iam list-policy-versions --policy-arn "$DISCOVERY_CUSTOM_POLICY_ARN" --no-cli-pager --query 'Versions[?!IsDefaultVersion].VersionId' --output text) | |
| for version in $OLD_VERSIONS; do | |
| echo "Deleting old policy version: $version" | |
| aws iam delete-policy-version \ | |
| --policy-arn "$DISCOVERY_CUSTOM_POLICY_ARN" \ | |
| --version-id "$version" \ | |
| --no-cli-pager | |
| done | |
| # Create new policy version and set as default | |
| echo "Creating new policy version for '$DISCOVERY_POLICY_NAME'." | |
| aws iam create-policy-version \ | |
| --policy-arn "$DISCOVERY_CUSTOM_POLICY_ARN" \ | |
| --policy-document "$DISCOVERY_CUSTOM_POLICY" \ | |
| --set-as-default \ | |
| --no-cli-pager > /dev/null | |
| fi | |
| # Attach discovery custom policy to management role | |
| echo "Checking if discovery policy '$DISCOVERY_POLICY_NAME' is attached to management role." | |
| if ! aws iam list-attached-role-policies --role-name "$ROLE_NAME" --no-cli-pager | grep -q "$DISCOVERY_POLICY_NAME"; then | |
| echo "Attaching discovery policy '$DISCOVERY_POLICY_NAME' to management role." | |
| aws iam attach-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-arn "$DISCOVERY_CUSTOM_POLICY_ARN" \ | |
| --no-cli-pager | |
| else | |
| echo "Discovery policy '$DISCOVERY_POLICY_NAME' already attached to management role." | |
| fi | |
| fi | |
| # Generate Lambda resources for EKS access configuration if k8s sync is enabled | |
| EKS_LAMBDA_RESOURCES="" | |
| INCLUDE_EKS_LAMBDA=false | |
| if [ "$AWS_EKS_K8S_SYNC_ENABLED" = "true" ] && ([ "$CASTAI_INTEGRATION_SCOPE" = "ALL" ] || [ "$CASTAI_INTEGRATION_SCOPE" = "ALL_MINIMAL" ]); then | |
| echo "Including EKS access Lambda in StackSet template for automatic cluster access configuration..." | |
| INCLUDE_EKS_LAMBDA=true | |
| # Lambda code (Python) - handles CloudFormation Custom Resource to configure EKS access on stack deployment | |
| LAMBDA_CODE='import boto3 | |
| import json | |
| import urllib.request | |
| import os | |
| def send_cfn_response(event, context, status, data=None): | |
| body = json.dumps({"Status": status, "Reason": "See CloudWatch", "PhysicalResourceId": context.log_stream_name, "StackId": event["StackId"], "RequestId": event["RequestId"], "LogicalResourceId": event["LogicalResourceId"], "Data": data or {}}).encode() | |
| req = urllib.request.Request(event["ResponseURL"], data=body, headers={"Content-Type": ""}, method="PUT") | |
| urllib.request.urlopen(req) | |
| def get_allowed_arns(): | |
| allowed = os.environ.get("ALLOWED_CLUSTER_ARNS", "") | |
| return set(allowed.split(",")) if allowed else None | |
| def is_cluster_allowed(cluster_arn, allowed_arns): | |
| if allowed_arns is None: | |
| return True | |
| return cluster_arn in allowed_arns | |
| def handler(event, context): | |
| print(f"Event: {json.dumps(event)}") | |
| if event.get("RequestType") == "Delete": | |
| send_cfn_response(event, context, "SUCCESS") | |
| return | |
| try: | |
| result = configure_all_clusters(os.environ["CAST_ROLE_ARN"]) | |
| send_cfn_response(event, context, "SUCCESS", result) | |
| except Exception as e: | |
| print(f"Error: {e}") | |
| send_cfn_response(event, context, "FAILED", {"Error": str(e)}) | |
| def configure_all_clusters(role_arn): | |
| ec2 = boto3.client("ec2") | |
| sts = boto3.client("sts") | |
| account_id = sts.get_caller_identity()["Account"] | |
| allowed_arns = get_allowed_arns() | |
| configured, failed, skipped = [], [], [] | |
| regions = [r["RegionName"] for r in ec2.describe_regions()["Regions"]] | |
| print(f"Scanning {len(regions)} regions for EKS clusters") | |
| for region in regions: | |
| try: | |
| eks = boto3.client("eks", region_name=region) | |
| for page in eks.get_paginator("list_clusters").paginate(): | |
| for c in page["clusters"]: | |
| cluster_arn = f"arn:aws:eks:{region}:{account_id}:cluster/{c}" | |
| if not is_cluster_allowed(cluster_arn, allowed_arns): | |
| print(f"Skipping cluster {c} - ARN {cluster_arn} not in allowed list") | |
| skipped.append(cluster_arn) | |
| continue | |
| if configure_cluster(c, region, role_arn): | |
| configured.append(cluster_arn) | |
| else: | |
| failed.append(cluster_arn) | |
| except Exception as e: | |
| print(f"Error scanning region {region}: {e}") | |
| return {"configured": configured, "failed": failed, "skipped": skipped} | |
| def configure_cluster(name, region, role_arn): | |
| eks = boto3.client("eks", region_name=region) | |
| policy = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminViewPolicy" | |
| try: | |
| eks.describe_access_entry(clusterName=name, principalArn=role_arn) | |
| print(f"Access entry exists for {name}") | |
| except eks.exceptions.ResourceNotFoundException: | |
| try: | |
| eks.create_access_entry(clusterName=name, principalArn=role_arn, type="STANDARD") | |
| print(f"Created access entry for {name}") | |
| except Exception as e: | |
| print(f"Failed to create access entry for {name}: {e}") | |
| return False | |
| except Exception as e: | |
| print(f"Skipping {name}: {e}") | |
| return False | |
| try: | |
| resp = eks.list_associated_access_policies(clusterName=name, principalArn=role_arn) | |
| if policy not in [p["policyArn"] for p in resp.get("associatedAccessPolicies", [])]: | |
| eks.associate_access_policy(clusterName=name, principalArn=role_arn, policyArn=policy, accessScope={"type": "cluster"}) | |
| print(f"Associated policy for {name}") | |
| except Exception as e: | |
| print(f"Failed to associate policy for {name}: {e}") | |
| return False | |
| return True | |
| ' | |
| # Escape the Lambda code for JSON embedding (escape backslashes, quotes, and newlines) | |
| LAMBDA_CODE_ESCAPED=$(echo "$LAMBDA_CODE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))') | |
| # Build Lambda environment variables - include ALLOWED_CLUSTER_ARNS if specified | |
| LAMBDA_ENV_VARS='{"CAST_ROLE_ARN": {"Fn::GetAtt": ["CastDiscoveryRole", "Arn"]}' | |
| if [ -n "${AWS_EKS_CLUSTER_ARNS}" ]; then | |
| LAMBDA_ENV_VARS="${LAMBDA_ENV_VARS}, \"ALLOWED_CLUSTER_ARNS\": \"${AWS_EKS_CLUSTER_ARNS}\"" | |
| echo "Lambda will only configure EKS access for specified cluster ARNs: ${AWS_EKS_CLUSTER_ARNS}" | |
| fi | |
| LAMBDA_ENV_VARS="${LAMBDA_ENV_VARS}}" | |
| EKS_LAMBDA_RESOURCES=', | |
| "EksAccessLambdaRole": { | |
| "Type": "AWS::IAM::Role", | |
| "Properties": { | |
| "RoleName": {"Fn::Sub": "${RoleName}-eks-lambda"}, | |
| "AssumeRolePolicyDocument": { | |
| "Version": "2012-10-17", | |
| "Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}] | |
| }, | |
| "ManagedPolicyArns": ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"], | |
| "Policies": [{ | |
| "PolicyName": "eks-access-management", | |
| "PolicyDocument": { | |
| "Version": "2012-10-17", | |
| "Statement": [{"Effect": "Allow", "Action": ["ec2:DescribeRegions", "eks:ListClusters", "eks:DescribeCluster", "eks:CreateAccessEntry", "eks:DescribeAccessEntry", "eks:AssociateAccessPolicy", "eks:ListAssociatedAccessPolicies"], "Resource": "*"}] | |
| } | |
| }] | |
| } | |
| }, | |
| "EksAccessLambda": { | |
| "Type": "AWS::Lambda::Function", | |
| "Properties": { | |
| "FunctionName": {"Fn::Sub": "${RoleName}-eks-access"}, | |
| "Runtime": "python3.11", | |
| "Handler": "index.handler", | |
| "Timeout": 300, | |
| "MemorySize": 256, | |
| "Role": {"Fn::GetAtt": ["EksAccessLambdaRole", "Arn"]}, | |
| "Environment": {"Variables": '"$LAMBDA_ENV_VARS"'}, | |
| "Code": {"ZipFile": '"$LAMBDA_CODE_ESCAPED"'} | |
| }, | |
| "DependsOn": ["CastDiscoveryRole"] | |
| }, | |
| "EksAccessTrigger": { | |
| "Type": "Custom::EksAccessConfiguration", | |
| "Properties": {"ServiceToken": {"Fn::GetAtt": ["EksAccessLambda", "Arn"]}}, | |
| "DependsOn": ["EksAccessLambda"] | |
| }' | |
| fi | |
| # Create CloudFormation template for member account roles | |
| MEMBER_ROLE_TEMPLATE=$(cat <<EOF | |
| { | |
| "AWSTemplateFormatVersion": "2010-09-09", | |
| "Description": "CAST AI discovery role for member accounts - ${CASTAI_INTEGRATION_SCOPE} scope", | |
| "Parameters": { | |
| "CastUserArn": { | |
| "Type": "String", | |
| "Description": "CAST AI user ARN" | |
| }, | |
| "OrganizationId": { | |
| "Type": "String", | |
| "Description": "CAST AI organization ID" | |
| }, | |
| "RoleName": { | |
| "Type": "String", | |
| "Description": "IAM role name for discovery" | |
| } | |
| }, | |
| "Resources": { | |
| "CastDiscoveryRole": { | |
| "Type": "AWS::IAM::Role", | |
| "Properties": { | |
| "RoleName": {"Ref": "RoleName"}, | |
| "AssumeRolePolicyDocument": { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Principal": { | |
| "AWS": {"Ref": "CastUserArn"} | |
| }, | |
| "Action": "sts:AssumeRole", | |
| "Condition": { | |
| "StringEquals": { | |
| "sts:ExternalId": {"Ref": "OrganizationId"} | |
| } | |
| } | |
| } | |
| ] | |
| }, | |
| "ManagedPolicyArns": $MANAGED_POLICIES_JSON | |
| } | |
| }$(if [ -n "${AWS_PERMISSIONS}" ]; then echo ', | |
| "PermissionsPolicy": { | |
| "Type": "AWS::IAM::Policy", | |
| "Properties": { | |
| "PolicyName": "'${DISCOVERY_POLICY_NAME}'", | |
| "Roles": [{"Ref": "CastDiscoveryRole"}], | |
| "PolicyDocument": { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Action": '$PERMISSIONS_JSON', | |
| "Resource": "*" | |
| }'$(if [ -n "${S3_READ_STATEMENT}" ]; then echo ", ${S3_READ_STATEMENT}"; fi)' | |
| ] | |
| } | |
| } | |
| }'; fi)${EKS_LAMBDA_RESOURCES} | |
| }, | |
| "Outputs": { | |
| "RoleArn": { | |
| "Description": "ARN of the created role", | |
| "Value": {"Fn::GetAtt": ["CastDiscoveryRole", "Arn"]} | |
| } | |
| } | |
| } | |
| EOF | |
| ) | |
| # Create stackset for member account roles | |
| echo "Creating CloudFormation stackset: $STACKSET_NAME" | |
| # Check if stackset already exists | |
| if aws cloudformation describe-stack-set --stack-set-name "$STACKSET_NAME" --no-cli-pager &>/dev/null; then | |
| echo "Stackset '$STACKSET_NAME' already exists. Updating..." | |
| aws cloudformation update-stack-set \ | |
| --stack-set-name "$STACKSET_NAME" \ | |
| --template-body "$MEMBER_ROLE_TEMPLATE" \ | |
| --parameters ParameterKey=CastUserArn,ParameterValue="$AWS_CAST_USER_ARN" \ | |
| ParameterKey=OrganizationId,ParameterValue="$CASTAI_ORGANIZATION_ID" \ | |
| ParameterKey=RoleName,ParameterValue="$ROLE_NAME" \ | |
| --permission-model SERVICE_MANAGED \ | |
| --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \ | |
| --capabilities CAPABILITY_NAMED_IAM \ | |
| --no-cli-pager | |
| else | |
| echo "Creating new stackset '$STACKSET_NAME'..." | |
| aws cloudformation create-stack-set \ | |
| --stack-set-name "$STACKSET_NAME" \ | |
| --template-body "$MEMBER_ROLE_TEMPLATE" \ | |
| --parameters ParameterKey=CastUserArn,ParameterValue="$AWS_CAST_USER_ARN" \ | |
| ParameterKey=OrganizationId,ParameterValue="$CASTAI_ORGANIZATION_ID" \ | |
| ParameterKey=RoleName,ParameterValue="$ROLE_NAME" \ | |
| --permission-model SERVICE_MANAGED \ | |
| --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \ | |
| --capabilities CAPABILITY_NAMED_IAM \ | |
| --no-cli-pager | |
| fi | |
| # Deploy stack instances to member accounts | |
| echo "Deploying stack instances to member accounts..." | |
| # Get root OU IDs (always needed for SERVICE_MANAGED StackSets) | |
| ROOT_OU_IDS=$(aws organizations list-roots --query 'Roots[].Id' --output text --no-cli-pager) | |
| ROOT_OU_IDS_ARRAY=$(echo $ROOT_OU_IDS | tr '\t' ',' | sed 's/^/[/;s/$/]/') | |
| if [ -n "${AWS_ACCOUNT_IDS}" ]; then | |
| # Filter out management account from the list since SERVICE_MANAGED StackSets cannot deploy to management account | |
| FILTERED_ACCOUNT_IDS="" | |
| for account_id in $(echo "$AWS_ACCOUNT_IDS" | tr ',' ' '); do | |
| if [ "$account_id" != "$CURRENT_ACCOUNT_ID" ]; then | |
| if [ -z "$FILTERED_ACCOUNT_IDS" ]; then | |
| FILTERED_ACCOUNT_IDS="$account_id" | |
| else | |
| FILTERED_ACCOUNT_IDS="$FILTERED_ACCOUNT_IDS,$account_id" | |
| fi | |
| fi | |
| done | |
| if [ -n "$FILTERED_ACCOUNT_IDS" ]; then | |
| # Use INTERSECTION to deploy only to accounts that are in both the OU AND the account list | |
| ACCOUNT_IDS_ARRAY=$(echo "$FILTERED_ACCOUNT_IDS" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//' | sed 's/^/[/;s/$/]/') | |
| DEPLOYMENT_TARGETS="OrganizationalUnitIds=$ROOT_OU_IDS_ARRAY,Accounts=$ACCOUNT_IDS_ARRAY,AccountFilterType=INTERSECTION" | |
| echo "Targeting specified member accounts in root OUs: $ACCOUNT_IDS_ARRAY" | |
| aws cloudformation create-stack-instances \ | |
| --stack-set-name "$STACKSET_NAME" \ | |
| --deployment-targets $DEPLOYMENT_TARGETS \ | |
| --regions $(aws ec2 describe-regions --query 'Regions[?RegionName==`us-east-1`].RegionName' --output text --no-cli-pager) \ | |
| --operation-preferences MaxConcurrentPercentage=100 \ | |
| --no-cli-pager | |
| else | |
| echo "Only management account specified in AWS_ACCOUNT_IDS. Skipping StackSet deployment (management account role already created)." | |
| fi | |
| else | |
| # Deploy to all member accounts via root OUs (no account filtering) | |
| DEPLOYMENT_TARGETS="OrganizationalUnitIds=$ROOT_OU_IDS_ARRAY" | |
| echo "Targeting all accounts via root OUs: $ROOT_OU_IDS_ARRAY" | |
| aws cloudformation create-stack-instances \ | |
| --stack-set-name "$STACKSET_NAME" \ | |
| --deployment-targets $DEPLOYMENT_TARGETS \ | |
| --regions $(aws ec2 describe-regions --query 'Regions[?RegionName==`us-east-1`].RegionName' --output text --no-cli-pager) \ | |
| --operation-preferences MaxConcurrentPercentage=100 \ | |
| --no-cli-pager | |
| fi | |
| # Use management role ARN for the integration | |
| ROLE_ARN="$MANAGEMENT_ROLE_ARN" | |
| else | |
| echo "Setting up account-scoped integration..." | |
| TRUST_POLICY=$(cat <<EOF | |
| { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Principal": { | |
| "AWS": "${AWS_CAST_USER_ARN}" | |
| }, | |
| "Action": "sts:AssumeRole", | |
| "Condition": { | |
| "StringEquals": { | |
| "sts:ExternalId": "${CASTAI_ORGANIZATION_ID}" | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| EOF | |
| ) | |
| echo "Checking for IAM role: $ROLE_NAME" | |
| if aws iam get-role --role-name "$ROLE_NAME" --no-cli-pager &>/dev/null; then | |
| echo "IAM role '$ROLE_NAME' already exists. Updating trust policy." | |
| ROLE_ARN=$(aws iam get-role --role-name "$ROLE_NAME" --query 'Role.Arn' --output text --no-cli-pager) | |
| aws iam update-assume-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-document "${TRUST_POLICY}" \ | |
| --no-cli-pager | |
| else | |
| echo "IAM role '$ROLE_NAME' not found. Creating new role." | |
| ROLE_ARN=$(aws iam create-role \ | |
| --role-name "$ROLE_NAME" \ | |
| --assume-role-policy-document "${TRUST_POLICY}" \ | |
| --query 'Role.Arn' --output text --no-cli-pager) | |
| fi | |
| echo "IAM role ARN: $ROLE_ARN" | |
| ATTACH_POLICY_ARN="" | |
| POLICY_NAME="" | |
| if [ -z "${AWS_PERMISSIONS}" ] && [ -z "${AWS_MANAGED_POLICIES}" ]; then | |
| echo "Error: Either AWS_PERMISSIONS or AWS_MANAGED_POLICIES environment variable is required." | |
| exit 1 | |
| fi | |
| # Attach managed policies if provided | |
| if [ -n "${AWS_MANAGED_POLICIES}" ]; then | |
| echo "Attaching managed policies: ${AWS_MANAGED_POLICIES}" | |
| echo "$AWS_MANAGED_POLICIES" | tr ',' '\n' | while read -r policy_name; do | |
| if [ -n "$policy_name" ]; then | |
| policy_arn="arn:aws:iam::aws:policy/$policy_name" | |
| echo "Attaching managed policy: $policy_arn" | |
| aws iam attach-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-arn "$policy_arn" \ | |
| --no-cli-pager | |
| fi | |
| done | |
| fi | |
| # Create custom policy if permissions are provided | |
| if [ -n "${AWS_PERMISSIONS}" ]; then | |
| POLICY_NAME="castai-$(echo "$CASTAI_INTEGRATION_SCOPE" | tr '[:upper:]' '[:lower:]' | tr '_' '-')-readonly-policy" | |
| CUSTOM_POLICY=$(cat <<EOF | |
| { | |
| "Version": "2012-10-17", | |
| "Statement": [ | |
| { | |
| "Effect": "Allow", | |
| "Action": $PERMISSIONS_JSON, | |
| "Resource": "*" | |
| }$(if [ -n "${S3_READ_STATEMENT}" ]; then echo ", ${S3_READ_STATEMENT}"; fi) | |
| ] | |
| } | |
| EOF | |
| ) | |
| ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) | |
| CUSTOM_POLICY_ARN="arn:aws:iam::${ACCOUNT_ID}:policy/${POLICY_NAME}" | |
| echo "Checking for custom policy: $POLICY_NAME" | |
| if ! aws iam get-policy --policy-arn "$CUSTOM_POLICY_ARN" --no-cli-pager &>/dev/null; then | |
| echo "Creating custom policy '$POLICY_NAME'." | |
| aws iam create-policy \ | |
| --policy-name "$POLICY_NAME" \ | |
| --policy-document "$CUSTOM_POLICY" \ | |
| --no-cli-pager > /dev/null | |
| else | |
| echo "Custom policy '$POLICY_NAME' already exists. Updating with new version." | |
| # Delete old non-default versions to avoid hitting the 5-version limit | |
| OLD_VERSIONS=$(aws iam list-policy-versions --policy-arn "$CUSTOM_POLICY_ARN" --no-cli-pager --query 'Versions[?!IsDefaultVersion].VersionId' --output text) | |
| for version in $OLD_VERSIONS; do | |
| echo "Deleting old policy version: $version" | |
| aws iam delete-policy-version \ | |
| --policy-arn "$CUSTOM_POLICY_ARN" \ | |
| --version-id "$version" \ | |
| --no-cli-pager | |
| done | |
| # Create new policy version and set as default | |
| echo "Creating new policy version for '$POLICY_NAME'." | |
| aws iam create-policy-version \ | |
| --policy-arn "$CUSTOM_POLICY_ARN" \ | |
| --policy-document "$CUSTOM_POLICY" \ | |
| --set-as-default \ | |
| --no-cli-pager > /dev/null | |
| fi | |
| ATTACH_POLICY_ARN="$CUSTOM_POLICY_ARN" | |
| fi | |
| # Attach custom policy if it was created | |
| if [ -n "${ATTACH_POLICY_ARN:-}" ]; then | |
| POLICY_BASENAME=$(basename "$ATTACH_POLICY_ARN") | |
| echo "Checking if policy '$POLICY_BASENAME' is attached to role '$ROLE_NAME'." | |
| if ! aws iam list-attached-role-policies --role-name "$ROLE_NAME" --no-cli-pager | grep -q "$POLICY_BASENAME"; then | |
| echo "Attaching policy '$POLICY_BASENAME' to role." | |
| aws iam attach-role-policy \ | |
| --role-name "$ROLE_NAME" \ | |
| --policy-arn "$ATTACH_POLICY_ARN" \ | |
| --no-cli-pager | |
| else | |
| echo "Policy '$POLICY_BASENAME' already attached." | |
| fi | |
| fi | |
| fi | |
| # Setup EKS cluster access for k8s resource discovery (requires opt-in and ALL/ALL_MINIMAL scope) | |
| K8S_OBJECTS_SYNC_ENABLED=false | |
| if [ "$AWS_EKS_K8S_SYNC_ENABLED" = "true" ] && ([ "$CASTAI_INTEGRATION_SCOPE" = "ALL" ] || [ "$CASTAI_INTEGRATION_SCOPE" = "ALL_MINIMAL" ]); then | |
| K8S_OBJECTS_SYNC_ENABLED=true | |
| if [ "$ORG_SCOPE" = true ]; then | |
| # For org-scoped integrations, Lambda deployed via StackSet handles EKS access configuration | |
| echo "EKS cluster access for member accounts will be configured automatically via Lambda (deployed by StackSet)" | |
| echo "Note: Only existing EKS clusters will be configured. For new clusters, re-run the StackSet deployment." | |
| else | |
| # For account-scoped integrations, configure EKS access directly | |
| echo "Configuring EKS cluster access for k8s resource discovery..." | |
| ALL_REGIONS=$(aws ec2 describe-regions --query 'Regions[].RegionName' --output text --no-cli-pager 2>/dev/null || echo "") | |
| if [ -n "$ALL_REGIONS" ]; then | |
| TOTAL_CLUSTERS_CONFIGURED=0 | |
| if [ -n "${AWS_EKS_CLUSTER_ARNS}" ]; then | |
| echo "EKS cluster discovery limited to specified cluster ARNs: ${AWS_EKS_CLUSTER_ARNS}" | |
| SPECIFIED_CLUSTER_ARNS=$(echo "$AWS_EKS_CLUSTER_ARNS" | tr ',' ' ') | |
| else | |
| echo "Discovering all EKS clusters in all regions" | |
| SPECIFIED_CLUSTER_ARNS="" | |
| fi | |
| for region in $ALL_REGIONS; do | |
| CLUSTER_NAMES=$(aws eks list-clusters --region "$region" --query 'clusters[]' --output text --no-cli-pager 2>/dev/null || echo "") | |
| if [ -n "$CLUSTER_NAMES" ]; then | |
| echo "Found EKS clusters in $region: $(echo $CLUSTER_NAMES | tr '\t' ' ')" | |
| ACCOUNT_ID_FOR_ARN=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "") | |
| for cluster_name in $CLUSTER_NAMES; do | |
| CLUSTER_ARN="arn:aws:eks:${region}:${ACCOUNT_ID_FOR_ARN}:cluster/${cluster_name}" | |
| if [ -n "$SPECIFIED_CLUSTER_ARNS" ]; then | |
| CLUSTER_MATCHED=false | |
| for specified_arn in $SPECIFIED_CLUSTER_ARNS; do | |
| if [ "$CLUSTER_ARN" = "$specified_arn" ]; then | |
| CLUSTER_MATCHED=true | |
| break | |
| fi | |
| done | |
| if [ "$CLUSTER_MATCHED" = false ]; then | |
| echo " Skipping cluster $cluster_name (ARN $CLUSTER_ARN not in AWS_EKS_CLUSTER_ARNS)" | |
| continue | |
| fi | |
| fi | |
| echo " Configuring access for cluster: $cluster_name" | |
| if aws eks describe-access-entry \ | |
| --cluster-name "$cluster_name" \ | |
| --principal-arn "$ROLE_ARN" \ | |
| --region "$region" \ | |
| --no-cli-pager &>/dev/null; then | |
| echo " Access entry already exists for $cluster_name" | |
| else | |
| if CREATE_ACCESS_ENTRY_OUTPUT=$(aws eks create-access-entry \ | |
| --cluster-name "$cluster_name" \ | |
| --principal-arn "$ROLE_ARN" \ | |
| --type STANDARD \ | |
| --region "$region" \ | |
| --no-cli-pager 2>&1); then | |
| echo " Created access entry for $cluster_name" | |
| else | |
| echo " Warning: Failed to create access entry for $cluster_name: $CREATE_ACCESS_ENTRY_OUTPUT" | |
| continue | |
| fi | |
| fi | |
| POLICY_ARN="arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminViewPolicy" | |
| ASSOCIATED_POLICIES=$(aws eks list-associated-access-policies \ | |
| --cluster-name "$cluster_name" \ | |
| --principal-arn "$ROLE_ARN" \ | |
| --region "$region" \ | |
| --query 'associatedAccessPolicies[].policyArn' \ | |
| --output text \ | |
| --no-cli-pager 2>/dev/null || echo "") | |
| if echo "$ASSOCIATED_POLICIES" | grep -q "$POLICY_ARN"; then | |
| echo " Policy already associated for $cluster_name" | |
| else | |
| if ASSOCIATE_POLICY_OUTPUT=$(aws eks associate-access-policy \ | |
| --cluster-name "$cluster_name" \ | |
| --principal-arn "$ROLE_ARN" \ | |
| --policy-arn "$POLICY_ARN" \ | |
| --access-scope type=cluster \ | |
| --region "$region" \ | |
| --no-cli-pager 2>&1); then | |
| echo " Associated read-only policy for $cluster_name" | |
| TOTAL_CLUSTERS_CONFIGURED=$((TOTAL_CLUSTERS_CONFIGURED + 1)) | |
| else | |
| echo " Warning: Failed to associate policy for $cluster_name: $ASSOCIATE_POLICY_OUTPUT" | |
| fi | |
| fi | |
| done | |
| fi | |
| done | |
| if [ $TOTAL_CLUSTERS_CONFIGURED -gt 0 ]; then | |
| echo "Successfully configured EKS access for $TOTAL_CLUSTERS_CONFIGURED cluster(s)" | |
| else | |
| echo "No EKS clusters were configured (clusters may not support access entries or require manual setup)" | |
| fi | |
| else | |
| echo "Warning: Could not list AWS regions. Skipping EKS cluster access configuration." | |
| fi | |
| fi | |
| else | |
| echo "Skipping EKS cluster access configuration (requires AWS_EKS_K8S_SYNC_ENABLED=true and ALL/ALL_MINIMAL scope)" | |
| fi | |
| echo "Creating the cloud asset integration in CAST AI for scope: ${CASTAI_INTEGRATION_SCOPE}" | |
| # Prepare metadata based on scope | |
| METADATA_JSON="{\"crossRoleUserArn\": \"$AWS_CAST_USER_ARN\"}" | |
| if [ "$ORG_SCOPE" = true ]; then | |
| METADATA_JSON="{\"crossRoleUserArn\": \"$AWS_CAST_USER_ARN\", \"organizationScope\": true}" | |
| fi | |
| # Add CUR S3 bucket information if provided | |
| if [ -n "${AWS_CUR_S3_BUCKET_NAME}" ] && [ -n "${AWS_CUR_S3_REGION}" ]; then | |
| CUR_S3_BUCKET_JSON=", \"curS3Bucket\": {\"name\": \"$AWS_CUR_S3_BUCKET_NAME\", \"region\": \"$AWS_CUR_S3_REGION\"" | |
| if [ -n "${AWS_CUR_S3_ACCOUNT_ID}" ]; then | |
| CUR_S3_BUCKET_JSON="$CUR_S3_BUCKET_JSON, \"accountId\": \"$AWS_CUR_S3_ACCOUNT_ID\"" | |
| fi | |
| CUR_S3_BUCKET_JSON="$CUR_S3_BUCKET_JSON}" | |
| METADATA_JSON=$(echo "$METADATA_JSON" | sed "s/}$/$CUR_S3_BUCKET_JSON}/") | |
| fi | |
| # Add account IDs if provided (only for org-scoped integrations) | |
| if [ "$ORG_SCOPE" = true ] && [ -n "${AWS_ACCOUNT_IDS}" ]; then | |
| ACCOUNT_IDS_JSON=$(echo "$AWS_ACCOUNT_IDS" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//' | sed 's/^/[/;s/$/]/') | |
| ACCOUNT_IDS_METADATA_JSON=", \"accountIds\": $ACCOUNT_IDS_JSON" | |
| METADATA_JSON=$(echo "$METADATA_JSON" | sed "s/}$/$ACCOUNT_IDS_METADATA_JSON}/") | |
| fi | |
| # Add k8s objects sync flag | |
| if [ "$K8S_OBJECTS_SYNC_ENABLED" = true ]; then | |
| K8S_SYNC_METADATA_JSON=", \"k8sObjectsSyncEnabled\": true" | |
| METADATA_JSON=$(echo "$METADATA_JSON" | sed "s/}$/$K8S_SYNC_METADATA_JSON}/") | |
| fi | |
| # Add EKS cluster ARNs if provided | |
| if [ -n "${AWS_EKS_CLUSTER_ARNS}" ]; then | |
| EKS_CLUSTER_ARNS_JSON=$(echo "$AWS_EKS_CLUSTER_ARNS" | tr ',' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//' | sed 's/^/[/;s/$/]/') | |
| # Use | as delimiter since ARNs contain / | |
| METADATA_JSON="${METADATA_JSON%\}}, \"eksClusterArns\": $EKS_CLUSTER_ARNS_JSON}" | |
| fi | |
| RESPONSE=$(curl -s -w "\n%{http_code}" -X 'POST' \ | |
| "$CASTAI_API_URL/inventory/v1beta/organizations/$CASTAI_ORGANIZATION_ID/cloud-asset-integrations" \ | |
| -H 'accept: application/json' \ | |
| -H "X-API-Key: $CASTAI_API_KEY" \ | |
| -H 'Content-Type: application/json' \ | |
| -d "{ | |
| \"enabled\": true, | |
| \"name\": \"$INTEGRATION_NAME\", | |
| \"provider\": \"AWS\", | |
| \"scope\": \"$CASTAI_INTEGRATION_SCOPE\", | |
| \"aws_credentials\": { | |
| \"assume_role_arn\": \"$ROLE_ARN\" | |
| }, | |
| \"settings\": $CASTAI_INTEGRATION_SETTINGS, | |
| \"metadata\": $METADATA_JSON | |
| }") | |
| STATUS_CODE=$(echo "$RESPONSE" | tail -n1) | |
| RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d') | |
| if [ "$STATUS_CODE" -ne 200 ]; then | |
| echo "Error: Failed to create integration. Status code: $STATUS_CODE" | |
| echo "Response: $RESPONSE_BODY" | |
| exit 1 | |
| fi | |
| echo "AWS integration setup is complete" | |
| if [ "$ORG_SCOPE" = true ]; then | |
| echo "Organization-scoped integration created with service-managed stackset deployment." | |
| if [ "$INCLUDE_EKS_LAMBDA" = true ]; then | |
| echo "EKS access Lambda will be deployed to member accounts for automatic cluster access configuration." | |
| fi | |
| else | |
| echo "Account-scoped integration created." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment