Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
schlarpc opened this issue Sep 22, 2020 · 4 comments
Open

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

schlarpc opened this issue Sep 22, 2020 · 4 comments

Comments

@schlarpc
Copy link
Owner

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:

  • Dealing with multiple deployment artifacts
    • Currently, this project is a single template file, so it doesn't require a packaging/upload step to S3. If we introduce a child stack, this might become necessary.
    • A possible hack here is to reuse the same template file, just with conditionals on every single resource to differentiate regionally at execution time. We could then grab the TemplateBody from the currently executing stack using AWS::StackId in a custom resource. This is disgusting and I love it.
  • Retrieving the certificate ID
    • The StackSet resource doesn't give you access to the Outputs of the child stacks. Since the certificate ID contains a UUID, we need to hoist it back up into the parent stack somehow.
    • One potential approach that is, again, disgusting:
      • Create an SSM parameter in the parent stack, and pass its name as a String parameter to the child stackset.
      • Write the certificate ID into the SSM parameter as part of the certificate validator Lambda from us-east-1 (cross-region).
      • In the parent stack, have another (non-stackset) child stack that depends on the stackset.
      • Pass the SSM parameter to this child stack as an AWS::SSM::Parameter::Value<String> passed immediately back into an Output, allowing it to be retrieved in the parent stack with GetAtt.
@schlarpc
Copy link
Owner Author

schlarpc commented Sep 22, 2020

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
Copy link
Owner Author

schlarpc commented Dec 7, 2020

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
Copy link
Owner Author

schlarpc commented Dec 1, 2021

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
Copy link
Owner Author

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())

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant