schlarpc / overengineered-cloudfront-s3-static-website

The objectively correct static website host with S3 + CloudFront
5 stars 0 forks source link

Support deployment in regions other than us-east-1 #2

Open schlarpc opened 3 years ago

schlarpc commented 3 years ago

With the introduction of the AWS::CloudFormation::StackSet resource, it should now be possible to delegate only the ACM certificate creation to us-east-1 as a separate stack: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-stackset.html

This will allow the S3 bucket (and logs, etc) to live in a region other than us-east-1.

There are two primary challenges:

schlarpc commented 3 years ago

Stacksets could also be used to capture Lambda@Edge log groups in all regions. Not sure yet how to get a list of regions to feed into StackInstances.Regions, or how we'd deal with opt-in regions or regions launched between deployments of the stack.

schlarpc commented 3 years ago

I've added a stackset to capture Lambda@Edge logs in all regions.

I'm not sure that adding cross-region capabilities for the rest of the template is worth the effort though. Most of the services in use - CloudFront (+ Lambda@Edge), Route 53, and ACM - have to be configured in the partition's primary region. If deployed in another region, only S3 (content storage + log destination), CloudWatch metrics, CloudWatch Logs, and Lambda (for log ingestion from S3) would be homed in that region. This could be significant for someone who has a crappy connection to us-east-1 and needs to upload a lot of content, or has unique data sovereignty needs, but that isn't me.

schlarpc commented 2 years ago

If this ever gets done, the CloudFront log bucket can't live in an opt-in region, so we'll need logic to handle that:

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html#access-logs-choosing-s3-bucket

Don’t choose an Amazon S3 bucket in any of the following Regions, because CloudFront doesn’t deliver access logs to buckets in these Regions:

schlarpc commented 1 year ago

Both challenges are solvable by using SSM documents to define the CFN template. This allows persisting the certificate ID into an SSM document in us-east-1, then instantiating that document as a nested stack in the origin region. No additional deployment artifacts are needed and the template is fairly clean, all considered.

Proof of concept:

from troposphere import (
    Template,
    Join,
    Region,
    Partition,
    AccountId,
    Sub,
    Output,
    Parameter,
    Select,
    Split,
    StackId,
    StackName,
)
from troposphere.ssm import Document
from troposphere.cloudformation import (
    Stack,
    StackSet,
    DeploymentTargets,
    StackInstances,
    Parameter as StackSetParameter,
    OperationPreferences,
    WaitConditionHandle,
)
from troposphere.iam import Role, PolicyType, Policy
from awacs.aws import Statement, Principal, PolicyDocument, Allow, Action
from awacs import sts
import json

def create_passback_stack():
    """
    The intrinsic functions in this template get evaluated in the remote region, at the deploy time
    of create_remote_stack. By the time this is instantiated in the origin region, the values have
    been resolved to primitives.
    """
    template = Template()
    template.add_resource(WaitConditionHandle("X"))
    template.add_output(Output("Value", Value=Region))
    return template

def create_remote_stack():
    """
    In a real scenario, this stack would do something useful, like create an ACM certificate in
    the remote region, then use the SSM document to pass back values to the origin region.
    """
    template = Template()

    document_name = template.add_parameter(Parameter("DocumentName", Type="String"))

    document = template.add_resource(
        Document(
            "Document",
            Name=document_name.ref(),
            DocumentType="CloudFormation",
            UpdateMethod="NewVersion",
            Content={
                "schemaVersion": "1.0",
                "templateBody": json.loads(create_passback_stack().to_json()),
            },
        )
    )

    return template

def create_template():
    """
    Most of this is stacksets boilerplate. At the bottom is a nested stack using an SSM
    document stored in the remote region, which allows us to pull the resolved values from
    the remote stack back into the origin region.
    """

    template = Template()

    stack_set_administration_role = template.add_resource(
        Role(
            "StackSetAdministrationRole",
            AssumeRolePolicyDocument=PolicyDocument(
                Version="2012-10-17",
                Statement=[
                    Statement(
                        Effect=Allow,
                        Principal=Principal("Service", "cloudformation.amazonaws.com"),
                        Action=[sts.AssumeRole],
                    ),
                ],
            ),
        )
    )

    stack_set_execution_role = template.add_resource(
        Role(
            "StackSetExecutionRole",
            AssumeRolePolicyDocument=PolicyDocument(
                Version="2012-10-17",
                Statement=[
                    Statement(
                        Effect=Allow,
                        Principal=Principal(
                            "AWS", stack_set_administration_role.get_att("Arn")
                        ),
                        Action=[sts.AssumeRole],
                    ),
                ],
            ),
            Policies=[
                Policy(
                    PolicyName="create-stackset-instances",
                    PolicyDocument=PolicyDocument(
                        Version="2012-10-17",
                        Statement=[
                            Statement(
                                Effect=Allow,
                                Action=[Action("*")],
                                Resource=["*"],
                            ),
                        ],
                    ),
                ),
            ],
        )
    )

    stack_set_administration_role_policy = template.add_resource(
        PolicyType(
            "StackSetAdministrationRolePolicy",
            PolicyName="assume-execution-role",
            PolicyDocument=PolicyDocument(
                Version="2012-10-17",
                Statement=[
                    Statement(
                        Effect=Allow,
                        Action=[sts.AssumeRole],
                        Resource=[stack_set_execution_role.get_att("Arn")],
                    ),
                ],
            ),
            Roles=[stack_set_administration_role.ref()],
        )
    )

    stack_uuid = Select(2, Split("/", StackId))
    stack_uuid_partial = Select(4, Split("-", stack_uuid))

    remote_document_name = Join("-", [StackName, stack_uuid_partial, "Template"])
    remote_region = "us-east-2"

    stack_set = template.add_resource(
        StackSet(
            "StackSet",
            Capabilities=["CAPABILITY_IAM"],
            ManagedExecution={"Active": True},
            Parameters=[
                StackSetParameter(
                    ParameterKey="DocumentName",
                    ParameterValue=remote_document_name,
                ),
            ],
            OperationPreferences=OperationPreferences(
                FailureToleranceCount=0,
                MaxConcurrentPercentage=100,
                RegionConcurrencyType="PARALLEL",
            ),
            AdministrationRoleARN=stack_set_administration_role.get_att("Arn"),
            ExecutionRoleName=stack_set_execution_role.ref(),
            StackInstancesGroup=[
                StackInstances(
                    DeploymentTargets=DeploymentTargets(
                        Accounts=[AccountId],
                    ),
                    Regions=[remote_region],
                ),
            ],
            PermissionModel="SELF_MANAGED",
            StackSetName=Join("-", [StackName, stack_uuid_partial, "StackSet"]),
            TemplateBody=create_remote_stack().to_json(
                sort_keys=True, indent=None, separators=(",", ":")
            ),
            DependsOn=[stack_set_administration_role_policy],
        )
    )

    stack = template.add_resource(
        Stack(
            "Stack",
            TemplateURL=Join(
                "",
                [
                    "ssm-doc://",
                    Join(
                        ":",
                        [
                            "arn",
                            Partition,
                            "ssm",
                            remote_region,
                            AccountId,
                            Join("/", ["document", remote_document_name]),
                        ],
                    ),
                ],
            ),
            DependsOn=[stack_set],
        )
    )

    template.add_output(Output("Value", Value=stack.get_att("Outputs.Value")))

    return template

if __name__ == "__main__":
    print(create_template().to_json())