AWS Cloud Static Website Hosting

Many years ago when AWS began 'giving away' free servers (EC2 instances) I signed up.
For 12 months you get a free t2.micro (about as powerful as a mid range laptop) instance to do with what you want.

Naturally I thought I would use the server to host my personal website.
This website went though many generations, eventually I installed a LAMP stack and wordpress and just left it running.

I have a love/hate relationship with Wordpress. When it works as you want it to it's so very easy.
But then when you go to view your website after a few weeks absence and you get a MySQL error, you begin to realise you may have made a mistake. It is not quite as fire and forget as they'd like you to believe.. And it's an absolute honey pot for hackers.

After many years the EC2 hosting costs for this t2.micro instance began to creep up from $8 a month to over $20.
At that point I decided that I didn't really need an actual server of my own as I never really got around to using it's full potential.
I knew I could reduce my costs by just flattening the HTML out and uploading it to S3 as a static site, but then I'd forever lose any ability to render server side code.
So I began a quest to create an AWS hosted S3 based static website fronted by CloudFront, and with a lambda element allowing the rendering of server side code if necessary.

Being me I didn't just do this through the AWS console. I wanted to be able to recreate it if something went wrong, or delete it if it became too expensive.
So I created some IAC to create the entire thing. This bash script is the meat of that code.

A quick guide.

You'll have to pick the bones out of it as I can't be bothered explaining what it is and does. You can see that I used generative AI to intially create the script using the conventions in the top of the file.
But right now (2024) the AI produced running code, that just didn't work. There's many hours of work in this.


    #!/bin/bash
    ######################################
    ## Script creates a static S3 website
    ## Static content is pulled from GitHub
    ######################################
    # AI prompts
    # When writing a bash script which makes use of the AWS CLI I always use the following conventions:
    # * All calls to aws cli functions should have the '--profile' and '--region' parameters set from global variables, and those parameters should come immediately after the opening 'aws' keyword of the cli command
    # * If an aws resource (a role, lambda, ec2 instance etc) is being created, it should be done so in a bash function
    # * All bash functions should be prefixed with the 'function' keyword
    # * Any local variables inside bash functions should be declared 'local' using the 'local' keyword
    # * If a resource has a create function, a matching 'isExist' function should be created which checks for the existinance of that resource
    # * Any bash function which checks for the existance of a resource should be named 'isResourceExists' where 'resource' is replaced with the type of resource (ie isEc2Exists)
    # * Any bash function which returns a boolean should use 'return 0' and 'return 1' such that it can be used like 'if functionname; then'
    # * All bash functions and variables should be named in camel case (ie isEc2Exists, createEc2Instance, instanceId etc.)
    # * if a resource has a create function and an isExists function it should also have a 'destroy' function
    # * In the create and destroy functions for any given resource, a check should first be made to the isExists function before the create or destroy is attempted
    # * Any resource required to create any other resource (such as a role required for a lambda function) must also have create, exists and destroy functions such that when a resource is destroyed all it's dependent resources can also be destroyed
    # * where possible resources should be created with a tag or other field that allows these resources to be found using that tag or comment etc.
    # * exists methods should then use the tags or comment fields to find the requested resource
    # * If a resource requires JSON or YAML config to be sent, the config should be created inline inside the script using multiline comments rather than references to external files
    # * If during the generation of the script you reach a limit on the number of output lines, you should continue with a second response until the script is complete.
    # * All create and delete functions should be called from global 'create' and 'destroy' functions
    # * Presently I will ask you to create a bash script using these conventions, no response is required right now

    # Using these conventions please create a bash script which sets up an S3 and cloudfront website
    # The cloudfront distribution should return HTTP protocol, and the defaultRootObject should be index.html
    # The S3 bucket should be only accessible by cloudfront
    # There should also be a second origin in the cloudfront configuration that sends any request to the /lambda context root to a lambda function instead of a file got from S3
    # That lambda function should be created in the script and the function should be created to respond to any HTTP get or post with a response containing the body 'Hello World'
    #####
    # OLD EC2 IP : 54.93.65.166
    #####
    # AWS shenannigans
    export AWS_PAGER=""
    # Variables
    DOMAIN_NAME="richardsenior.net"
    BUCKET_NAME="richardsenior"
    REGION="eu-central-1"
    DISTRIBUTION_COMMENT="S3 bucket exposed via CloudFront"
    PROFILE="rms"
    TAG_NAME="richardsenior"
    GITHUB_REPO="richardsenior.net"
    GITHUB_SECRET_NAME="CLOUDFRONT_DISTRIBUTION_ID"
    LAMBDA_FUNCTION_NAME="richardsenior-lambda"
    LAMBDA_ROLE_NAME="rms-lambda-role"

    function isPythonModuleInstalled {
        if [ -z "$1" ]; then
          echo "must supply the name of the module in the first parameter"
          return 1
        fi
        python3 -c "import sys, pkgutil; sys.exit(0 if pkgutil.find_loader(sys.argv[1]) else 1)" $1
    }

    function isApplicationInstalled() {
        if [ -z "$1" ]; then
          echo "must supply the name of the command in the first parameter"
          return 1
        fi
        if [ -z "$(command -v $1)" ]; then
            return 1
        else
            return 0
        fi
    }

    function deleteRole {
      if [ -z "$1" ]; then
          echo "must supply role name in second parameter"
          exit 1
      fi
      local INSTANCE_PROFILES=$(aws --profile $PROFILE --region $REGION iam list-instance-profiles-for-role --role-name $1 --query "InstanceProfiles[].InstanceProfileId" --output text)

      if [ ! -z "$INSTANCE_PROFILES" ]; then
        echo "found instance profiles for this role"
        for IPR in $INSTANCE_PROFILES; do
          echo "$IPR"
        done
        #TODO something like aws ec2 describe-iam-instance-profile-associations
        #is instance still existing?
        echo "Role may still be in use.. ignoring it"
        return
      fi

      #attached policies
      local POLICIES=$(aws --profile $PROFILE --region $REGION iam list-attached-role-policies --role-name $1 --query "AttachedPolicies[].PolicyArn" --output text)
      if [ ! -z "$POLICIES" ]; then
        for POLICY in $POLICIES; do
          local ATTACHMENT_COUNT=$(aws --profile $PROFILE --region $REGION iam get-policy --policy-arn $POLICY --query "Policy.AttachmentCount" --output text)
          echo "detaching $POLICY from $1"
          aws --profile $PROFILE --region $REGION iam detach-role-policy --role-name $1 --policy-arn $POLICY
          if [ 1 == $ATTACHMENT_COUNT ]; then
            echo "leaving policies alone as they are service managed"
            #echo "Policy is only attached to $ATTACHMENT_COUNT roles, deleting it."
            #aws --profile $PROFILE --region $REGION iam delete-policy --policy-arn $POLICY
          else
            echo "Detatch but don't delete policy because it's attached to $ATTACHMENT_COUNT roles"
          fi
        done
      fi

      #Inline policies
      local POLICIES=$(aws --profile $PROFILE --region $REGION iam list-role-policies --role-name $1 --query "PolicyNames[]" --output text)
      if [ ! -z "$POLICIES" ]; then
        for POLICY in $POLICIES; do
          aws --profile $PROFILE --region $REGION iam delete-role-policy --role-name $1 --policy-name $POLICY
        done
      fi

      echo "Deleting role $1"
      aws --profile $PROFILE --region $REGION iam delete-role --role-name $1
    }


    function getAccountId {
        local ret=$(aws --profile rms --region $REGION sts get-caller-identity --query "Account" --output text)
        if [ -z "$ret" ]; then
            echo "couldn't get account number"
            exit 1
        fi
        echo "$ret"
    }
    # Function to check if S3 bucket exists
    function isBucketExists {
        local bucket_name=$(aws --profile $PROFILE --region $REGION s3api list-buckets --query "Buckets[?contains(Name, '$BUCKET_NAME')].Name" --output text)
        if [ -z "$bucket_name" ]; then
            return 1
        else
            return 0
        fi
    }

    function waitForDistributionDeployment {
        local did=$(getDistributionId)
        echo 'Waiting for distribution to be deployed...'
        aws --profile "$PROFILE" --region "$REGION" cloudfront wait distribution-deployed --id $did
    }

    function getDistributionStatus {
        local did=$(getDistributionId)
        local status=$(aws --profile "$PROFILE" --region "$REGION" cloudfront get-distribution --id $did --query "Distribution.Status" --output text)
        echo "$status"
    }

    function getDistributionArn {
        local did=$(getDistributionId)
        local arn=$(aws --profile "$PROFILE" --region "$REGION" cloudfront get-distribution --id $did --query "Distribution.ARN" --output text)
        echo "$arn"
    }


    function getDistributionUrl {
        local did=$(getDistributionId)
        local dn=$(aws --profile "$PROFILE" --region "$REGION" cloudfront get-distribution --id $did --query "Distribution.DomainName" --output text)
        local dn="https://$dn"
        echo "$dn"
    }

    # Notes: fix jq errors related to quoting by passing in a parameter in bash
    function getDistributionId {
        local ret=$(aws --profile "$PROFILE" --region "$REGION" cloudfront list-distributions | jq -r --arg origin_id "$TAG_NAME" '.DistributionList.Items[] | select(.Origins.Items[].Id == $origin_id) | .Id')
        echo "$ret"
    }

    function isDistributionExists {
        local did=$(getDistributionId)
        if [ -z "$did" ]; then
            return 1
        else
            return 0
        fi
    }

    # Function to create S3 bucket
    function create_s3_bucket {
        if isBucketExists; then
            echo "bucket exists"
            return;
        fi
        echo "creating bucket"
        if ! isOaiExists; then
            create_oai
        fi
        local oaiid=$(getOaiId)
        if [ -z "$oaiid" ]; then
            echo "failed to get oai id"
            exit 1
        else
            echo "OAI id is $oaiid"
        fi
        aws --profile $PROFILE --region $REGION s3api create-bucket --bucket $BUCKET_NAME --create-bucket-configuration LocationConstraint=$REGION
    local p=$(cat <<-END
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {
                    "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity $oaiid"
                },
                "Action": "s3:GetObject",
                "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
            }
        ]
    }
    END
    )
        echo "policy is \n $p"
        aws --profile $PROFILE --region $REGION s3api put-bucket-policy --bucket $BUCKET_NAME --policy "$p"
        aws --profile $PROFILE --region $REGION s3api put-bucket-tagging --bucket $BUCKET_NAME --tagging "TagSet=[{Key=Name,Value=$TAG_NAME}]"
        #echo "bucket created, now enabling static website hosting"
        #aws --profile $PROFILE --region $REGION  s3 website s3://$BUCKET_NAME/ --index-document index.html --error-document error.html
        echo "Uploading a couple of files just for testing purposes"
        uploadInitalFiles
    }

    function getOaiId {
        local ol=$(aws --profile $PROFILE --region $REGION cloudfront list-cloud-front-origin-access-identities --query 'CloudFrontOriginAccessIdentityList.Items[*].Id' --output text)
        if [ 0 -ne $? ]; then return; fi
        if [ -z "$ol" ]; then return 1; fi
        for id in $ol; do
            local comment=$(aws --profile $PROFILE --region $REGION cloudfront get-cloud-front-origin-access-identity --id $id --query "CloudFrontOriginAccessIdentity.CloudFrontOriginAccessIdentityConfig.Comment" --output text)
            if [ -z "$comment" ]; then continue; fi
            if [ "$TAG_NAME" != "$comment" ]; then continue; fi
            echo "$id"
            return
        done
    }

    function isOaiExists {
        local ol=$(aws --profile $PROFILE --region $REGION cloudfront list-cloud-front-origin-access-identities --query 'CloudFrontOriginAccessIdentityList.Items[*].Id' --output text)
        if [ -z "$ol" ]; then return 1; fi
        for id in $ol; do
            local comment=$(aws --profile $PROFILE --region $REGION cloudfront get-cloud-front-origin-access-identity --id $id --query "CloudFrontOriginAccessIdentity.CloudFrontOriginAccessIdentityConfig.Comment" --output text)
            if [ -z "$comment" ]; then
                echo "oai with ID $id has no comment"
                continue;
            fi
            if [ "$TAG_NAME" != "$comment" ]; then
                echo "oai $comment does not equal $TAG_NAME"
                continue;
            fi
            return 0
        done
        return 1
    }

    function isOacExists {
        local id=$(getOacId)
        if [ -z "$id" ]; then return 1; else return 0; fi
    }

    function getOacId {
        #aws --profile rms --region eu-central-1 cloudfront list-origin-access-controls --query 'OriginAccessControlList.Items[*].Id' --output text
        local acs=$(aws --profile $PROFILE --region $REGION cloudfront list-origin-access-controls --query 'OriginAccessControlList.Items[*].Id' --output text)
        if [ 0 -ne $? ]; then return; fi
        if [ -z "$acs" ]; then return; fi
        for id in $acs; do
            # aws --profile rms --region eu-central-1 cloudfront get-origin-access-control --id E5ZOMM9UC8YWY --query "OriginAccessControl.OriginAccessControlConfig.Name" --output text
            local name=$(aws --profile $PROFILE --region $REGION cloudfront get-origin-access-control --id $id --query "OriginAccessControl.OriginAccessControlConfig.Name" --output text)
            if [ -z "$name" ]; then continue; fi
            if [ "$TAG_NAME" != "$name" ]; then continue; fi
            echo "$id"
            return
        done
    }

    function deleteOac {
        local id=$(getOacId)
        if [ -z "$id" ]; then return;fi
        local oac=$(aws --profile $PROFILE --region $REGION cloudfront get-origin-access-control --id $id --output json)
        local etag=$(echo "$oac" | jq -r '.ETag')
        aws --profile $PROFILE --region $REGION cloudfront delete-origin-access-control --id $id --if-match $etag
    }

    function createOac {
        local id=$(getOacId)
        if [ ! -z "$id" ]; then return; fi
        aws --profile $PROFILE --region $REGION cloudfront create-origin-access-control --origin-access-control-config "Name=$TAG_NAME,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=lambda"
        #aws --profile rms --region eu-central-2 cloudfront create-origin-access-control --origin-access-control-config "Name=richardsenior,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=lambda" --query "Id" --output text
    }

    function create_oai {
        if isOaiExists; then return; fi
        echo "creating OAI with id:"
        #aws --profile $PROFILE --region $REGION cloudfront create-cloud-front-origin-access-identity --cloud-front-origin-access-identity-config CallerReference=$(date +%s),Comment="$TAG_NAME" --query 'CloudFrontOriginAccessIdentity.Id' --output text
        aws --profile $PROFILE --region $REGION cloudfront create-cloud-front-origin-access-identity --cloud-front-origin-access-identity-config CallerReference=$TAG_NAME,Comment="$TAG_NAME" --query 'CloudFrontOriginAccessIdentity.Id' --output text
    }

    function delete_oai {
        local ol=$(aws --profile $PROFILE --region $REGION cloudfront list-cloud-front-origin-access-identities --query 'CloudFrontOriginAccessIdentityList.Items[*].Id' --output text)
        if [ -z "$ol" ]; then return 1; fi
        for id in $ol; do
            if [ -z "$id" ]; then continue; fi
            if [ "None" == "$id" ]; then continue; fi
            local comment=$(aws --profile $PROFILE --region $REGION cloudfront get-cloud-front-origin-access-identity --id $id --query "CloudFrontOriginAccessIdentity.CloudFrontOriginAccessIdentityConfig.Comment" --output text)
            if [ -z "$comment" ]; then continue; fi
            # TODO put this back in when it's all working
            #if [ "$TAG_NAME" != "$comment" ]; then
            #    echo "$TAG_NAME is not equal to $comment"
            #    continue;
            #fi
            local etag=$(aws --profile $PROFILE --region $REGION cloudfront get-cloud-front-origin-access-identity-config --id "$id" --query 'ETag' --output text)
            aws --profile $PROFILE --region $REGION cloudfront delete-cloud-front-origin-access-identity --id "$id" --if-match "$etag"
            echo "deleted $id"
        done
    }

    function getDistributionConfig {
        local enabled="true"
        if [ ! -z "$1" ]; then enabled="false";fi
        local oaid=$(getOaiId)
        local oaiconf="origin-access-identity/cloudfront/$oaid"
        if [ ! -z "$1" ]; then oaiconf="";fi
        #ambda.$REGION.amazonaws.com
        local fdom=$(getFunctionDomain)
        local oacid=$(getOacId)
        # website url would be : $BUCKET_NAME.s3-website.$REGION.amazonaws.com
        # "CallerReference": "$(date +%s)",
    local distribution_config=$(cat <<-END
    {
        "CallerReference": "$TAG_NAME",
        "Comment": "$DISTRIBUTION_COMMENT",
        "Enabled": $enabled,
        "PriceClass": "PriceClass_100",
        "IsIPV6Enabled": false,
        "HttpVersion": "http2",
        "ViewerCertificate": {
            "CloudFrontDefaultCertificate": true
        },
        "Origins": {
            "Quantity": 2,
            "Items": [
                {
                    "Id": "$BUCKET_NAME",
                    "DomainName": "$BUCKET_NAME.s3.$REGION.amazonaws.com",
                    "S3OriginConfig": {
                        "OriginAccessIdentity": "$oaiconf"
                    }
                },
                {
                    "Id": "$LAMBDA_FUNCTION_NAME",
                    "OriginAccessControlId": "$oacid",
                    "DomainName": "$fdom",
                    "CustomOriginConfig": {
                        "HTTPPort": 80,
                        "HTTPSPort": 443,
                        "OriginProtocolPolicy": "https-only"
                    }
                }
            ]
        },
        "DefaultRootObject": "index.html",
        "DefaultCacheBehavior": {
            "TargetOriginId": "$BUCKET_NAME",
            "ViewerProtocolPolicy": "allow-all",
            "AllowedMethods": {
                "Quantity": 2,
                "Items": ["GET", "HEAD"],
                "CachedMethods": {
                    "Quantity": 2,
                    "Items": ["GET", "HEAD"]
                }
            },
            "ForwardedValues": {
                "QueryString": false,
                "Cookies": {
                    "Forward": "none"
                }
            },
            "MinTTL": 0,
            "MaxTTL": 0
        },
        "CacheBehaviors": {
            "Quantity": 1,
            "Items": [
                {
                    "PathPattern": "/lambda*",
                    "TargetOriginId": "$LAMBDA_FUNCTION_NAME",
                    "ViewerProtocolPolicy": "allow-all",
                    "AllowedMethods": {
                        "Quantity": 7,
                        "Items": ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"],
                        "CachedMethods": {
                            "Quantity": 2,
                            "Items": ["HEAD", "GET"]
                        }
                    },
                    "ForwardedValues": {
                        "QueryString": false,
                        "Cookies": {
                            "Forward": "none"
                        }
                    },
                    "MinTTL": 0,
                    "MaxTTL": 0
                }
            ]
        }
    }
    END
    )
    echo "$distribution_config"
    }

    # Function to create CloudFront distribution
    function create_cloudfront_distribution {
        if isDistributionExists; then
            echo "distribution already exists"
            return
        fi
        local conf=$(getDistributionConfig)
        echo "creating distribution with config : $conf"
        aws --profile $PROFILE --region $REGION cloudfront create-distribution --distribution-config "$conf" --output json
        # now update lambda permissions etc.
        local arn=$(getDistributionArn)
        aws --profile $PROFILE --region $REGION lambda add-permission \
            --statement-id "AllowCloudFrontServicePrincipal" \
            --action "lambda:InvokeFunctionUrl" \
            --principal "cloudfront.amazonaws.com" \
            --source-arn "$arn" \
            --function-name $LAMBDA_FUNCTION_NAME
    }

    # Function to update GitHub secret
    function update_github_secret {
        if [ -z "$1" ]; then
          echo "must supply distribution id in first parameter"
          return 1
        fi
        local secret_value=$1
        local public_key=$(curl -s -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/repos/$GITHUB_REPO/actions/secrets/public-key)
        local key_id=$(echo $public_key | jq -r '.key_id')
        local key=$(echo $public_key | jq -r '.key')
        local encrypted_value=$(echo -n $secret_value | openssl rsautl -encrypt -pubin -inkey <(echo $key | base64 -d) | base64)

        curl -X PUT -H "Authorization: token $GITHUB_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"encrypted_value\":\"$encrypted_value\",\"key_id\":\"$key_id\"}" \
            https://api.github.com/repos/$GITHUB_REPO/actions/secrets/$GITHUB_SECRET_NAME
    }

    function getLambdaRoleArn {
        local foo=$(aws --profile $PROFILE --region $REGION iam get-role --role-name $LAMBDA_ROLE_NAME --query 'Role.Arn' --output text 2>/dev/null | xargs)
        if [ -z "$foo" ]; then
            echo "failed to get role arn.. Fatal"
            exit 1
        fi
        echo "$foo"
    }

    function isLambdaRoleExists {
        local foo=$(aws --profile $PROFILE --region $REGION iam get-role --role-name $LAMBDA_ROLE_NAME --query 'Role.RoleName' --output text 2>/dev/null | xargs)
        if [ -z "$foo" ]; then
            return 1
        else
            return 0
        fi
    }

    # Function to create IAM role for Lambda
    function create_lambda_role {
        if ! isLambdaRoleExists; then
        echo "Creating Lambda Role"
    local p=$(cat <<-END
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {
                    "Service": [
                        "lambda.amazonaws.com",
                        "edgelambda.amazonaws.com"
                    ]
                },
                "Action": "sts:AssumeRole"
            }
        ]
    }
    END
    )
            aws --profile $PROFILE --region $REGION iam create-role --role-name $LAMBDA_ROLE_NAME --assume-role-policy-document "$p"
            aws --profile $PROFILE --region $REGION iam tag-role --role-name $LAMBDA_ROLE_NAME --tags Key=Name,Value=$TAG_NAME
            aws --profile $PROFILE --region $REGION iam attach-role-policy --role-name $LAMBDA_ROLE_NAME --policy-arn arn:aws:iam::aws:policy/service-role/AmazonS3ObjectLambdaExecutionRolePolicy
        fi
    }

    # Function to delete IAM role for Lambda
    function delete_lambda_role {
        if ! isLambdaRoleExists; then
            echo "no lambda role exists"
            return
        fi
        deleteRole "$LAMBDA_ROLE_NAME"
    }

    function getLambdafunctionArn {
        local ret=$(aws --profile $PROFILE --region $REGION lambda get-function --function-name $LAMBDA_FUNCTION_NAME --query "Configuration.FunctionArn" --output text)
        echo "$ret"
    }

    function isLambdaFunctionExists {
      aws --profile $PROFILE --region $REGION lambda get-function --function-name $LAMBDA_FUNCTION_NAME > /dev/null 2>&1
      if [ 0 -eq $? ]; then
        return 0
      else
        return 1
      fi
    }

    function createLambdaZip {
        echo "deleting current lambda zip if it exists.."
        if [ -f "../lambda/lambda.zip" ]; then
            echo "lambda zip exists, deleting.."
            rm ../lambda/lambda.zip 2> /dev/null
        else
            echo "no lambda zip exists"
        fi
        echo "zipping lambda function.."
        zip -r ../lambda/lambda.zip ../lambda/ -x '**/.DS_Store' -j
        if [ ! -f "../lambda/lambda.zip" ]; then
            echo "FAILED TO BUILD THE LAMBDA ZIP"
            return
        else
            echo "zip created"
        fi
    }

    function syncS3 {
        buildHtml
        echo "running : aws --region $REGION S3 sync ../output s3://$BUCKET_NAME --size-only --delete"
        aws --profile $PROFILE --region $REGION s3 sync ../output s3://$BUCKET_NAME --size-only --delete
    }

    function doGithub {
        local did=$(aws --region "$REGION" cloudfront list-distributions | jq -r --arg origin_id "$TAG_NAME" '.DistributionList.Items[] | select(.Origins.Items[].Id == $origin_id) | .Id')
        if [ 0 -nq $? ]; then
            echo "Failed to find the distribution id (bad response from aws call) [$?]"
            exit 1
        fi
        if [ -z "$did" ]; then
            echo "Failed to find the distribution id (null response from aws call) [$did]"
            exit 1
        fi
        echo "--- BUILDING HTML FROM JINJA TEMPLATES AND DEPLOYING TO S3 ---"
        buildHtml
        echo "running : aws --region $REGION S3 sync ../output s3://$BUCKET_NAME --size-only --delete"
        aws --region $REGION s3 sync ../output s3://$BUCKET_NAME --size-only --delete
        echo "--- CREATING LAMBDA ZIP, AND UPDATING LAMBDA SOURCE CODE ---"
        createLambdaZip
        echo "updating lambda in github pipeline"
        aws --region $REGION  lambda update-function-code --function-name $LAMBDA_FUNCTION_NAME --zip-file fileb://../lambda/lambda.zip
        echo "--- CREATING CLOUDFRONT CACHE INVALIDATION TO UPDATE THE SITE ---"
        aws --region $REGION cloudfront create-invalidation --distribution-id "$did" --paths "/*"
        local dn=$(aws --region "$REGION" cloudfront get-distribution --id $did --query "Distribution.DomainName" --output text)
        local dn="https://$dn"
        echo "THE CLOUDFRONT DISTRIBUTION URL IS $dn"
    }

    function isLambdaHasUrlConfig {
        aws --profile $PROFILE --region $REGION  lambda get-function-url-config --function-name $LAMBDA_FUNCTION_NAME > /dev/null 2>&1
        if [ 0 -eq $? ]; then
            return 0
        else
            return 1
        fi
    }

    function getFunctionDomain {
        local u=$(getFunctionUrl)
        local d=$(echo "$u" | cut -d '/' -f 3)
        echo "$d"
    }

    function getFunctionUrl {
        if ! isLambdaHasUrlConfig; then
            echo "Cannot get function url if there is no config for it"
            exit 1
        fi
        local ret=$(aws --profile $PROFILE --region $REGION lambda get-function-url-config --function-name $LAMBDA_FUNCTION_NAME --query "FunctionUrl" --output text)
        echo "$ret"
    }

    function deleteLambdaUrlConfig {
        if ! isLambdaHasUrlConfig; then return; fi
        aws --profile $PROFILE --region $REGION lambda delete-function-url-config --function-name $LAMBDA_FUNCTION_NAME
    }

    function createLambdaUrlConfig {
        if isLambdaHasUrlConfig; then return; fi
        echo "setting lambda auth-type"
        # now set the auth type to AWS_IAM so that only cloudfront can invoke it
        aws --profile $PROFILE --region $REGION lambda create-function-url-config \
            --function-name $LAMBDA_FUNCTION_NAME \
            --auth-type "AWS_IAM"
    }

    # Function to create Lambda function
    function create_lambda_function {
        if ! isLambdaRoleExists; then
            echo "Lambda role not created? Creating.."
            create_lambda_role;
        fi
        if isLambdaFunctionExists; then return; fi
        echo "creating lambda function"
        if [ ! -f "../lambda/lambda.zip" ]; then
            createLambdaZip
        fi
        local zippath=$(pathname=../lambda/lambda.zip)
        echo "Zip is at $zippath"
        createLambdaZip
        # problem with aws api's, mitigated by sleeping
        sleep 10
        local rolearn=$(getLambdaRoleArn)
        echo "$rolearn"
        aws --profile $PROFILE --region $REGION lambda create-function \
            --function-name $LAMBDA_FUNCTION_NAME \
            --runtime python3.12 \
            --timeout 300 \
            --role $rolearn \
            --handler lambda_function.lambda_handler \
            --zip-file fileb://../lambda/lambda.zip \
            --tags "Name=$TAG_NAME"
    }

    # Function to delete Lambda function
    function delete_lambda_function {
        if ! isLambdaFunctionExists; then
            echo "no lambda function to delete"
            return
        fi
        echo "deleting lambda function"
        aws --profile $PROFILE --region $REGION lambda delete-function --function-name $LAMBDA_FUNCTION_NAME
    }

    # Function to delete S3 bucket
    function delete_s3_bucket {
        if ! isBucketExists; then
            echo "No bucket to delete"
            return
        else
            echo "bucket found"
        fi
        echo "deleting s3 bucket"
        aws --profile $PROFILE --region $REGION s3 rb s3://$BUCKET_NAME --force
        echo "bucket deleted"
    }

    # Function to delete CloudFront distribution
    function delete_cloudfront_distribution {
        if ! isDistributionExists; then
            echo "distribution does not exist"
            return
        fi
        echo "Disabling cloudfront distribution"
        local did=$(getDistributionId)
        local conf=$(aws --profile $PROFILE --region $REGION cloudfront get-distribution-config --id $did --output json)
        local ifmatch=$(echo "$conf" | jq -r '.ETag')
        local conf=$(echo "$conf" | jq -r '.DistributionConfig')
        local conf=$(echo "$conf" | jq '.Enabled=false')
        local conf=$(echo "$conf" | jq '.Origins.Items[0].S3OriginConfig.OriginAccessIdentity=""')
        aws --profile $PROFILE --region $REGION cloudfront update-distribution --id $did --if-match "$ifmatch" --distribution-config "$conf"
        # here we should poll until the distribution is deletable
        echo "waiting for distribution to be deployed or undeployed"
        waitForDistributionDeployment
        echo "deleting distribution $did"
        # Get the etag again.. Nasty
        local conf=$(aws --profile $PROFILE --region $REGION cloudfront get-distribution-config --id $did --output json)
        local ifmatch=$(echo "$conf" | jq -r '.ETag')
        aws --profile $PROFILE --region $REGION cloudfront delete-distribution --id $did --if-match "$ifmatch" --output text
        echo "CloudFront Distribution ID $did deleted"
    }

    function buildHtml {
        if ! isApplicationInstalled "python3"; then
            echo "you must install python3 to run this script"
            exit 1
        fi
        if ! isPythonModuleInstalled "jinja2"; then
            echo "you must pip install the python Jinja2 module"
            exit 1
        fi
        python3 ../build.py
    }

    function uploadInitalFiles {
        # just upload the index.html file for now
        aws --profile $PROFILE --region $REGION s3api put-object --bucket $BUCKET_NAME --key index.html --body ../output/index.html --content-type="text/html"
        aws --profile $PROFILE --region $REGION s3api put-object --bucket $BUCKET_NAME --key error.html --body ../output/error.html --content-type="text/html"
    }

    # Main function to create resources
    function site_create {
        # slurp in some values we don't want ending up in github
        # the setenv file will be in the gitignore
        # rm -R ../output  2> /dev/null
        if [ -f ./setenv.sh ]; then
            source ./setenv.sh
        else
            echo "You must create a ./setenv.sh file and it should export GITHUB_TOKEN"
            exit 0
        fi
        createLambdaZip
        create_oai
        createOac
        create_s3_bucket
        syncS3
        create_lambda_role
        create_lambda_function
        if ! isLambdaFunctionExists; then
            echo "failed to create lambda function, retrying"
            sleep 10
            create_lambda_function
        fi
        if ! isLambdaFunctionExists; then
            echo "Failed to create lambda after retry, exiting"
            exit 1
        fi
        createLambdaUrlConfig
        create_cloudfront_distribution
        local url=$(getDistributionUrl)
        echo "CLOUDFRONT DISTRIBUTION URL: $url"
    }

    # Main function to delete resources
    function site_destroy {
        delete_cloudfront_distribution
        delete_oai
        deleteOac
        delete_s3_bucket
        delete_lambda_function
        delete_lambda_role
        deleteLambdaUrlConfig
    }

    # NEXT:
    # * Go to aws certificate manager console and request a PUBLIC certificate
    # * If your domain name is richardsenior.net (for example) then make sure to add *.richardsenior.net, www.richardsenior.net and richardsenior.net etc.
    # * Go to your dns host (in my case astutium) and add a CNAME record that matches the information certificate manager gave you.
    #   For example CNAME NAME will be something like : _e46fddcad9665e4c663df910ed58df08.richardsenior.net.
    #   So that is what you enter for the name in your CNAME entry in your dns host dashboard (not in aws dashboard).
    #   If your host automatically adds the domainname (ie richard.net) then just chop off the raw domain (ie enter _e46fddcad9665e4c663df910ed58df08)
    #   The value of the CNAME entry will be something like _c1f4a4838f242504d86b235be793c2f1.kirrbxfjtw.acm-validations.aws.
    # * This will cause AWS to send dns requests to  _e46fddcad9665e4c663df910ed58df08.richardsenior.net etc, expecting a resolution of the value know (ie, confirmation we own the domain)
    # * Wait for the certificate to be validated (hours)
    # * Go into aws console into your cloudfront distribution, add your hostnames as 'alias' (*.richardsenior.net, www.richardsenior.net etc.), and select the newly created certificate.
    # * Make sure that your DNS host also has an entry for www.YOURHOSTNAME pointing to the cloudfront distrubition URL (something like d1izdntgees9tap.cloudfront.net etc.)
    # * You should now be able to enter https://www.YOURDOMAIN and get a response!