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

[Feature] support multiple volumes #59

Merged
merged 4 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions ContainerManager/leaf_stack/NestedStacks/EcsAsg.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def __init__(
task_definition: ecs.Ec2TaskDefinition,
ec2_config: dict,
sg_container_traffic: ec2.SecurityGroup,
efs_file_system: efs.FileSystem,
host_access_point: efs.AccessPoint,
efs_file_systems: list[efs.FileSystem],
efs_ap_acl: efs.AccessPoint,
**kwargs,
) -> None:
super().__init__(scope, "EcsAsgNestedStack", **kwargs)
Expand All @@ -60,8 +60,6 @@ def __init__(
assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"),
description="The instance's permissions (HOST of the container)",
)
## Give it root access to the EFS:
efs_file_system.grant_root_access(self.ec2_role)

## Let the instance register itself to a ecs cluster:
# https://docs.aws.amazon.com/AmazonECS/latest/developerguide/security-iam-awsmanpol.html#instance-iam-role-permissions
Expand All @@ -75,16 +73,30 @@ def __init__(
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.UserData.html
self.ec2_user_data = ec2.UserData.for_linux() # (Can also set to python, etc. Default bash)

## Mount the EFS volume:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs-readme.html#mounting-the-file-system-using-user-data
# (the first few commands on that page aren't needed. Since we're a optimized ecs image, we have those packages already)
efs_mount_point = "/mnt/efs"
self.ec2_user_data.add_commands(
f'mkdir -p "{efs_mount_point}"',
### Tie all the EFS's to the host:
for efs_file_system in efs_file_systems:
# Mount on host, each has to be unique. (/mnt/efs/Efs-1, /mnt/efs/Efs-2, etc.)
efs_mount_point = f"/mnt/efs/{efs_file_system.node.id}"
### Give it root access to the EFS:
efs_file_system.grant_root_access(self.ec2_role)

### Create a access point for the host:
## Creating an access point:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs.FileSystem.html#addwbraccesswbrpointid-accesspointoptions
## What it returns:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs.AccessPoint.html
host_access_point = efs_file_system.add_access_point("efs-access-point-host", create_acl=efs_ap_acl, path="/")
# NOTE: The docs didn't have 'iam', but you get permission denied without it:
# (You can also mount efs directly by removing the accesspoint flag)
# https://docs.aws.amazon.com/efs/latest/ug/mounting-access-points.html
f'echo "{efs_file_system.file_system_id}:/ {efs_mount_point} efs defaults,tls,iam,_netdev,accesspoint={host_access_point.access_point_id} 0 0" >> /etc/fstab',
self.ec2_user_data.add_commands(
f'mkdir -p "{efs_mount_point}"',
f'echo "{efs_file_system.file_system_id}:/ {efs_mount_point} efs defaults,tls,iam,_netdev,accesspoint={host_access_point.access_point_id} 0 0" >> /etc/fstab',
)
## Actually mount the EFS volumes:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs-readme.html#mounting-the-file-system-using-user-data
# (the first few commands on that page aren't needed. Since we're a optimized ecs image, we have those packages already)
self.ec2_user_data.add_commands(
'mount -a -t efs,nfs4 defaults',
)

Expand Down
102 changes: 0 additions & 102 deletions ContainerManager/leaf_stack/NestedStacks/Efs.py

This file was deleted.

100 changes: 100 additions & 0 deletions ContainerManager/leaf_stack/NestedStacks/Volumes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@

"""
This module contains the Volumes NestedStack class.
"""

from aws_cdk import (
NestedStack,
RemovalPolicy,
aws_ec2 as ec2,
aws_ecs as ecs,
aws_efs as efs,
)
from constructs import Construct



### Nested Stack info:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.NestedStack.html
class Volumes(NestedStack):
"""
This sets up the persistent storage for the ECS container.
"""
def __init__(
self,
scope: Construct,
vpc: ec2.Vpc,
task_definition: ecs.Ec2TaskDefinition,
container: ecs.ContainerDefinition,
volumes_config: dict,
sg_efs_traffic: ec2.SecurityGroup,
**kwargs,
) -> None:
super().__init__(scope, "VolumesNestedStack", **kwargs)

########################
### EFS FILE SYSTEMS ###
########################

### Settings for ALL access points:
## Create ACL:
# (From the docs, if the `path` above does not exist, you must specify this)
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs.AccessPointOptions.html#createacl
self.efs_ap_acl = efs.Acl(owner_gid="1000", owner_uid="1000", permissions="700")
self.efs_file_systems = []
# i: each construct must have a different name inside the for-loop.
for i, volume_config in enumerate(volumes_config, start=1):
if not volume_config["Type"] == "EFS":
continue

efs_file_system = efs.FileSystem(
self,
f"Efs-{i}",
vpc=vpc,
removal_policy=volume_config["_removal_policy"],
security_group=sg_efs_traffic,
allow_anonymous_access=False,
enable_automatic_backups=volume_config["EnableBackups"],
encrypted=True,
## No need to set, only in one AZ/Subnet already. If user increases that
## number, they probably *want* more backups. There's no other reason to:
# one_zone=True,
)
self.efs_file_systems.append(efs_file_system)


## Tell the EFS side that the task can access it:
efs_file_system.grant_read_write(task_definition.task_role)
## (NOTE: There's another grant_root_access in EcsAsg.py ec2-role.
# I just didn't see a way to move it here without moving the role.)

### Create mounts and attach them to the container:
for volume_info in volume_config["Paths"]:
volume_path = volume_info["Path"]
read_only = volume_info["ReadOnly"]
## Create a UNIQUE name, ID of game + (modified) path:
# (Will be something like: `Minecraft-data` or `Valheim-opt-valheim`)
volume_id = f"{container.container_name}{volume_path.replace('/','-')}"
# Another access point, for the container (each volume gets it's own):
access_point = efs_file_system.add_access_point(volume_id, create_acl=self.efs_ap_acl, path=volume_path)
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.TaskDefinition.html#aws_cdk.aws_ecs.TaskDefinition.add_volume
task_definition.add_volume(
name=volume_id,
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.EfsVolumeConfiguration.html
efs_volume_configuration=ecs.EfsVolumeConfiguration(
file_system_id=efs_file_system.file_system_id,
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.AuthorizationConfig.html
authorization_config=ecs.AuthorizationConfig(
access_point_id=access_point.access_point_id,
iam="ENABLED",
),
transit_encryption="ENABLED",
),
)
container.add_mount_points(
ecs.MountPoint(
container_path=volume_path,
source_volume=volume_id,
read_only=read_only,
)
)
2 changes: 1 addition & 1 deletion ContainerManager/leaf_stack/NestedStacks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
from .Container import Container
from .Dashboard import Dashboard
from .EcsAsg import EcsAsg
from .Efs import Efs
from .Volumes import Volumes
from .SecurityGroups import SecurityGroups
from .Watchdog import Watchdog
14 changes: 7 additions & 7 deletions ContainerManager/leaf_stack/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def get_logical_id(self, element):
if "NestedStackResource" in element.node.id:
match = re.search(r'([a-zA-Z0-9]+)\.NestedStackResource', element.node.id)
if match:
# Returns "EfsNestedStack" instead of "EfsNestedStackEfsNestedStackResource..."
# Returns "VolumesNestedStack" instead of "VolumesNestedStackVolumesNestedStackResource..."
return match.group(1)
# Fail fast. If the logical_id ever changes on a existing stack, you replace everything and might loose data.
raise RuntimeError(f"Could not find 'NestedStackResource' in {element.node.id}. Did a CDK update finally fix NestedStack names?")
Expand Down Expand Up @@ -92,14 +92,14 @@ def __init__(
container_config=config["Container"],
)

### All the info for EFS Stuff
self.efs_nested_stack = NestedStacks.Efs(
### All the info for Volumes Stuff
self.volumes_nested_stack = NestedStacks.Volumes(
self,
description=f"EFS Logic for {construct_id}",
description=f"Volume Logic for {construct_id}",
vpc=base_stack.vpc,
task_definition=self.container_nested_stack.task_definition,
container=self.container_nested_stack.container,
volume_config=config["Volume"],
volumes_config=config["Volumes"],
sg_efs_traffic=self.sg_nested_stack.sg_efs_traffic,
)

Expand All @@ -117,8 +117,8 @@ def __init__(
task_definition=self.container_nested_stack.task_definition,
ec2_config=config["Ec2"],
sg_container_traffic=self.sg_nested_stack.sg_container_traffic,
efs_file_system=self.efs_nested_stack.efs_file_system,
host_access_point=self.efs_nested_stack.host_access_point,
efs_file_systems=self.volumes_nested_stack.efs_file_systems,
efs_ap_acl=self.volumes_nested_stack.efs_ap_acl,
)

### All the info for the Watchdog Stuff
Expand Down
71 changes: 44 additions & 27 deletions ContainerManager/utils/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from aws_cdk import (
Duration,
RemovalPolicy,
aws_sns as sns,
aws_ecs as ecs,
)
Expand Down Expand Up @@ -125,33 +126,49 @@ def _parse_container(config: dict) -> None:


def _parse_volume(config: dict, maturity: str) -> None:
if "Volume" not in config:
config["Volume"] = {}
assert isinstance(config["Volume"], dict)

### KeepOnDelete
if "KeepOnDelete" not in config["Volume"]:
# If the maturity is prod, default to keep the data safe:
config["Volume"]["KeepOnDelete"] = bool(maturity == "prod")
assert isinstance(config["Volume"]["KeepOnDelete"], bool)

### EnableBackups
if "EnableBackups" not in config["Volume"]:
# If the maturity is prod, default to keep the data safe:
config["Volume"]["EnableBackups"] = bool(maturity == "prod")
assert isinstance(config["Volume"]["EnableBackups"], bool)

### Paths
if "Paths" not in config["Volume"]:
config["Volume"]["Paths"] = []
assert isinstance(config["Volume"]["Paths"], list)
for path in config["Volume"]["Paths"]:
if "Path" not in path:
raise_missing_key_error("Volume.Paths[*].Path")
assert isinstance(path["Path"], str)
if "ReadOnly" not in path:
path["ReadOnly"] = False
assert isinstance(path["ReadOnly"], bool)
if "Volumes" not in config:
config["Volumes"] = []
assert isinstance(config["Volumes"], list)

### Parse Each Volume:
for volume in config["Volumes"]:
### Type
if "Type" not in volume:
volume["Type"] = "EFS" # Default to EFS
volume["Type"] = volume["Type"].upper()
assert volume["Type"] in ["EFS"] # Will support 'S3' soon!

## EnableBackups
if "EnableBackups" not in volume:
# If the maturity is prod, default to keep the data safe:
volume["EnableBackups"] = bool(maturity == "prod")
assert isinstance(volume["EnableBackups"], bool)

## KeepOnDelete
if "KeepOnDelete" not in volume:
# If the maturity is prod, default to keep the data safe:
volume["KeepOnDelete"] = bool(maturity == "prod")
assert isinstance(volume["KeepOnDelete"], bool)
# Private var, another good excuse to turn this into a class:
# https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_efs.FileSystem.html
if volume["KeepOnDelete"]:
volume["_removal_policy"] = RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE
else:
volume["_removal_policy"] = RemovalPolicy.DESTROY
del volume["KeepOnDelete"]


## Paths
if "Paths" not in volume:
raise_missing_key_error("Volumes[*].Paths")
assert isinstance(volume["Paths"], list)
for path in volume["Paths"]:
if "Path" not in path:
raise_missing_key_error("Volumes[*].Paths[*].Path")
assert isinstance(path["Path"], str)
if "ReadOnly" not in path:
path["ReadOnly"] = False
assert isinstance(path["ReadOnly"], bool)

def _parse_ec2(config: dict) -> None:
if "Ec2" not in config:
Expand Down
Loading