|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +# ------------------------------------------------------------- |
| 5 | +# Add CloudFront in front of an existing S3 bucket |
| 6 | +# |
| 7 | +# Usage: |
| 8 | +# bash add-s3-cloudfront.sh <bucket-name> [profile-name] |
| 9 | +# [--public-path <path>]... |
| 10 | +# |
| 11 | +# Example: |
| 12 | +# bash add-s3-cloudfront.sh my-bucket admin \ |
| 13 | +# --public-path tutorials \ |
| 14 | +# --public-path publicuploads |
| 15 | +# ------------------------------------------------------------- |
| 16 | + |
| 17 | +# Ensure jq available |
| 18 | +JQ_CMD=$(command -v jq 2>/dev/null || echo "/usr/bin/jq" || echo "/usr/local/bin/jq") |
| 19 | +if [[ ! -x "$JQ_CMD" ]]; then |
| 20 | + echo "Error: jq command not found. Please install jq." |
| 21 | + exit 1 |
| 22 | +fi |
| 23 | + |
| 24 | +if [[ $# -lt 1 ]]; then |
| 25 | + echo "Usage: $0 <bucket-name> [profile-name] [--public-path <path>]" |
| 26 | + exit 1 |
| 27 | +fi |
| 28 | + |
| 29 | +BUCKET="$1" |
| 30 | +PROFILE="${2:-admin}" |
| 31 | +shift || true |
| 32 | +shift || true |
| 33 | + |
| 34 | +PUBLIC_PATHS=() |
| 35 | + |
| 36 | +while [[ $# -gt 0 ]]; do |
| 37 | + case "$1" in |
| 38 | + --public-path) |
| 39 | + PUBLIC_PATHS+=("$2") |
| 40 | + shift 2 |
| 41 | + ;; |
| 42 | + *) |
| 43 | + echo "Unknown argument: $1" |
| 44 | + exit 1 |
| 45 | + ;; |
| 46 | + esac |
| 47 | +done |
| 48 | + |
| 49 | +ACCOUNT_ID=$(aws sts get-caller-identity \ |
| 50 | + --profile "$PROFILE" \ |
| 51 | + --query Account \ |
| 52 | + --output text) |
| 53 | + |
| 54 | +echo "=========================================" |
| 55 | +echo "🪣 Bucket: $BUCKET" |
| 56 | +echo "👔 Profile: $PROFILE" |
| 57 | +echo "🌐 Public CloudFront paths: ${PUBLIC_PATHS[*]:-(none)}" |
| 58 | +echo "=========================================" |
| 59 | + |
| 60 | +# ------------------------------------------------------------- |
| 61 | +# 1. Create or retrieve Origin Access Control |
| 62 | +# ------------------------------------------------------------- |
| 63 | +OAC_NAME="${BUCKET}-oac" |
| 64 | +echo "Checking for existing Origin Access Control: ${OAC_NAME}..." |
| 65 | + |
| 66 | +# Try to find existing OAC |
| 67 | +EXISTING_OAC=$(aws cloudfront list-origin-access-controls \ |
| 68 | + --profile "$PROFILE" \ |
| 69 | + --query "OriginAccessControlList.Items[?Name=='${OAC_NAME}'].Id | [0]" \ |
| 70 | + --output text 2>/dev/null || echo "None") |
| 71 | + |
| 72 | +if [[ "$EXISTING_OAC" != "None" && -n "$EXISTING_OAC" ]]; then |
| 73 | + echo "✓ Found existing OAC: ${EXISTING_OAC}" |
| 74 | + OAC_ID="$EXISTING_OAC" |
| 75 | +else |
| 76 | + echo "Creating new Origin Access Control..." |
| 77 | + OAC_ID=$(aws cloudfront create-origin-access-control \ |
| 78 | + --origin-access-control-config "{ |
| 79 | + \"Name\": \"${OAC_NAME}\", |
| 80 | + \"Description\": \"OAC for ${BUCKET}\", |
| 81 | + \"SigningProtocol\": \"sigv4\", |
| 82 | + \"SigningBehavior\": \"always\", |
| 83 | + \"OriginAccessControlOriginType\": \"s3\" |
| 84 | + }" \ |
| 85 | + --profile "$PROFILE" \ |
| 86 | + --query "OriginAccessControl.Id" \ |
| 87 | + --output text) |
| 88 | + echo "✓ Created OAC: ${OAC_ID}" |
| 89 | +fi |
| 90 | + |
| 91 | +# ------------------------------------------------------------- |
| 92 | +# 2. Create or retrieve cache policy for presigned URLs |
| 93 | +# ------------------------------------------------------------- |
| 94 | +CACHE_POLICY_NAME="${BUCKET}-presigned-cache" |
| 95 | +echo "Checking for existing cache policy: ${CACHE_POLICY_NAME}..." |
| 96 | + |
| 97 | +EXISTING_POLICY=$(aws cloudfront list-cache-policies \ |
| 98 | + --profile "$PROFILE" \ |
| 99 | + --query "CachePolicyList.Items[?CachePolicy.CachePolicyConfig.Name=='${CACHE_POLICY_NAME}'].CachePolicy.Id | [0]" \ |
| 100 | + --output text 2>/dev/null || echo "None") |
| 101 | + |
| 102 | +if [[ "$EXISTING_POLICY" != "None" && -n "$EXISTING_POLICY" ]]; then |
| 103 | + echo "✓ Found existing cache policy: ${EXISTING_POLICY}" |
| 104 | + CACHE_POLICY_ID="$EXISTING_POLICY" |
| 105 | +else |
| 106 | + echo "Creating cache policy for presigned URLs..." |
| 107 | + CACHE_POLICY_ID=$(aws cloudfront create-cache-policy \ |
| 108 | + --cache-policy-config "{ |
| 109 | + \"Name\": \"${CACHE_POLICY_NAME}\", |
| 110 | + \"Comment\": \"Low-TTL cache for presigned URLs and COG imagery\", |
| 111 | + \"MinTTL\": 0, |
| 112 | + \"DefaultTTL\": 300, |
| 113 | + \"MaxTTL\": 3600, |
| 114 | + \"ParametersInCacheKeyAndForwardedToOrigin\": { |
| 115 | + \"EnableAcceptEncodingGzip\": false, |
| 116 | + \"EnableAcceptEncodingBrotli\": false, |
| 117 | + \"QueryStringsConfig\": { |
| 118 | + \"QueryStringBehavior\": \"all\" |
| 119 | + }, |
| 120 | + \"HeadersConfig\": { |
| 121 | + \"HeaderBehavior\": \"whitelist\", |
| 122 | + \"Headers\": { |
| 123 | + \"Quantity\": 3, |
| 124 | + \"Items\": [\"Origin\", \"Access-Control-Request-Method\", \"Access-Control-Request-Headers\"] |
| 125 | + } |
| 126 | + }, |
| 127 | + \"CookiesConfig\": { |
| 128 | + \"CookieBehavior\": \"none\" |
| 129 | + } |
| 130 | + } |
| 131 | + }" \ |
| 132 | + --profile "$PROFILE" \ |
| 133 | + --query "CachePolicy.Id" \ |
| 134 | + --output text) |
| 135 | + echo "✓ Created cache policy: ${CACHE_POLICY_ID}" |
| 136 | +fi |
| 137 | + |
| 138 | +# ------------------------------------------------------------- |
| 139 | +# 3. Build CloudFront behaviors |
| 140 | +# ------------------------------------------------------------- |
| 141 | + |
| 142 | +DEFAULT_BEHAVIOR=$(cat <<EOF |
| 143 | +{ |
| 144 | + "TargetOriginId": "S3-${BUCKET}", |
| 145 | + "ViewerProtocolPolicy": "redirect-to-https", |
| 146 | + "AllowedMethods": { |
| 147 | + "Quantity": 3, |
| 148 | + "Items": ["GET", "HEAD", "OPTIONS"], |
| 149 | + "CachedMethods": { |
| 150 | + "Quantity": 2, |
| 151 | + "Items": ["GET", "HEAD"] |
| 152 | + } |
| 153 | + }, |
| 154 | + "CachePolicyId": "${CACHE_POLICY_ID}", |
| 155 | + "OriginRequestPolicyId": "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", |
| 156 | + "Compress": false |
| 157 | +} |
| 158 | +EOF |
| 159 | +) |
| 160 | + |
| 161 | +if [[ ${#PUBLIC_PATHS[@]} -gt 0 ]]; then |
| 162 | + PATHS_JSON=$(printf '%s\n' "${PUBLIC_PATHS[@]}" | "$JQ_CMD" -R . | "$JQ_CMD" -s .) |
| 163 | + |
| 164 | + ORDERED_BEHAVIORS=$( |
| 165 | + "$JQ_CMD" -n \ |
| 166 | + --arg bucket "$BUCKET" \ |
| 167 | + --argjson paths "$PATHS_JSON" \ |
| 168 | + '{ |
| 169 | + Quantity: ($paths | length), |
| 170 | + Items: [ |
| 171 | + $paths[] | { |
| 172 | + PathPattern: ("/" + . + "/*"), |
| 173 | + TargetOriginId: ("S3-" + $bucket), |
| 174 | + ViewerProtocolPolicy: "redirect-to-https", |
| 175 | + AllowedMethods: { |
| 176 | + Quantity: 3, |
| 177 | + Items: ["GET", "HEAD", "OPTIONS"], |
| 178 | + CachedMethods: { |
| 179 | + Quantity: 2, |
| 180 | + Items: ["GET", "HEAD"] |
| 181 | + } |
| 182 | + }, |
| 183 | + CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", |
| 184 | + OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", |
| 185 | + Compress: false |
| 186 | + } |
| 187 | + ] |
| 188 | + }' |
| 189 | + ) |
| 190 | +else |
| 191 | + ORDERED_BEHAVIORS='{"Quantity":0}' |
| 192 | +fi |
| 193 | + |
| 194 | +# ------------------------------------------------------------- |
| 195 | +# 4. Create or retrieve CloudFront distribution |
| 196 | +# ------------------------------------------------------------- |
| 197 | +echo "Checking for existing CloudFront distribution for bucket: ${BUCKET}..." |
| 198 | + |
| 199 | +# Find distribution with matching origin |
| 200 | +EXISTING_DIST=$(aws cloudfront list-distributions \ |
| 201 | + --profile "$PROFILE" \ |
| 202 | + --query "DistributionList.Items[?Origins.Items[?DomainName=='${BUCKET}.s3.amazonaws.com']].Id | [0]" \ |
| 203 | + --output text 2>/dev/null || echo "None") |
| 204 | + |
| 205 | +if [[ "$EXISTING_DIST" != "None" && -n "$EXISTING_DIST" ]]; then |
| 206 | + echo "✓ Found existing distribution: ${EXISTING_DIST}" |
| 207 | + DIST_ID="$EXISTING_DIST" |
| 208 | + |
| 209 | + # Get existing distribution details |
| 210 | + DIST_JSON=$(aws cloudfront get-distribution \ |
| 211 | + --id "$DIST_ID" \ |
| 212 | + --profile "$PROFILE") |
| 213 | + |
| 214 | + CLOUDFRONT_DOMAIN=$(echo "$DIST_JSON" | "$JQ_CMD" -r .Distribution.DomainName) |
| 215 | + ETAG=$(echo "$DIST_JSON" | "$JQ_CMD" -r .ETag) |
| 216 | + |
| 217 | + echo "⚠️ Distribution already exists. To update it, you would need to modify the config." |
| 218 | + echo " For now, using existing distribution as-is." |
| 219 | +else |
| 220 | + echo "Creating new CloudFront distribution..." |
| 221 | + DIST_JSON=$(aws cloudfront create-distribution \ |
| 222 | + --profile "$PROFILE" \ |
| 223 | + --distribution-config "{ |
| 224 | + \"CallerReference\": \"$(date +%s)\", |
| 225 | + \"Comment\": \"CDN for ${BUCKET} bucket\", |
| 226 | + \"Origins\": { |
| 227 | + \"Quantity\": 1, |
| 228 | + \"Items\": [{ |
| 229 | + \"Id\": \"S3-${BUCKET}\", |
| 230 | + \"DomainName\": \"${BUCKET}.s3.amazonaws.com\", |
| 231 | + \"S3OriginConfig\": { |
| 232 | + \"OriginAccessIdentity\": \"\" |
| 233 | + }, |
| 234 | + \"OriginAccessControlId\": \"${OAC_ID}\" |
| 235 | + }] |
| 236 | + }, |
| 237 | + \"DefaultCacheBehavior\": ${DEFAULT_BEHAVIOR}, |
| 238 | + \"CacheBehaviors\": ${ORDERED_BEHAVIORS}, |
| 239 | + \"Enabled\": true |
| 240 | + }") |
| 241 | + |
| 242 | + DIST_ID=$(echo "$DIST_JSON" | "$JQ_CMD" -r .Distribution.Id) |
| 243 | + CLOUDFRONT_DOMAIN=$(echo "$DIST_JSON" | "$JQ_CMD" -r .Distribution.DomainName) |
| 244 | + echo "✓ Created distribution: ${DIST_ID}" |
| 245 | +fi |
| 246 | + |
| 247 | +# ------------------------------------------------------------- |
| 248 | +# 5. Apply S3 bucket policy (CloudFront-only access) |
| 249 | +# ------------------------------------------------------------- |
| 250 | +echo "Applying bucket policy..." |
| 251 | +aws s3api put-bucket-policy \ |
| 252 | + --bucket "$BUCKET" \ |
| 253 | + --profile "$PROFILE" \ |
| 254 | + --policy "{ |
| 255 | + \"Version\": \"2012-10-17\", |
| 256 | + \"Statement\": [{ |
| 257 | + \"Effect\": \"Allow\", |
| 258 | + \"Principal\": { |
| 259 | + \"Service\": \"cloudfront.amazonaws.com\" |
| 260 | + }, |
| 261 | + \"Action\": \"s3:GetObject\", |
| 262 | + \"Resource\": \"arn:aws:s3:::${BUCKET}/*\", |
| 263 | + \"Condition\": { |
| 264 | + \"StringEquals\": { |
| 265 | + \"AWS:SourceArn\": \"arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}\" |
| 266 | + } |
| 267 | + } |
| 268 | + }] |
| 269 | + }" |
| 270 | + |
| 271 | +echo "✓ Bucket policy applied" |
| 272 | + |
| 273 | +# ------------------------------------------------------------- |
| 274 | +# 6. Output |
| 275 | +# ------------------------------------------------------------- |
| 276 | +echo |
| 277 | +echo "✅ CloudFront setup complete!" |
| 278 | +echo "-----------------------------------------" |
| 279 | +echo "Distribution ID: ${DIST_ID}" |
| 280 | +echo "CloudFront URL:" |
| 281 | +echo "https://${CLOUDFRONT_DOMAIN}/" |
| 282 | +echo |
| 283 | + |
| 284 | +if [[ "$EXISTING_DIST" == "None" ]]; then |
| 285 | + echo "⏳ Distribution is now deploying (15-30 minutes)" |
| 286 | + echo " Check status in AWS Console or run:" |
| 287 | + echo " aws cloudfront get-distribution --id ${DIST_ID} --profile ${PROFILE}" |
| 288 | +else |
| 289 | + echo "ℹ️ Using existing distribution (already deployed)" |
| 290 | +fi |
| 291 | +echo |
| 292 | + |
| 293 | +if [[ ${#PUBLIC_PATHS[@]} -gt 0 ]]; then |
| 294 | + echo "🌍 Public paths (aggressive caching):" |
| 295 | + for PATH in "${PUBLIC_PATHS[@]}"; do |
| 296 | + echo " https://${CLOUDFRONT_DOMAIN}/${PATH}/" |
| 297 | + done |
| 298 | + echo |
| 299 | +fi |
| 300 | + |
| 301 | +echo "-----------------------------------------" |
| 302 | +echo "📝 Configuration:" |
| 303 | +echo " • Default paths: 5-min cache (presigned URLs, COG)" |
| 304 | +echo " • Public paths: 24-hour cache (static content)" |
| 305 | +echo " • Query strings: Forwarded (presigned URLs work)" |
| 306 | +echo " • Range requests: ✓ Enabled (COG tiles work)" |
| 307 | +echo " • Methods: GET, HEAD, OPTIONS (CORS enabled)" |
| 308 | +echo " • Compression: Disabled (preserves imagery)" |
0 commit comments