diff --git a/docs/syntax/compose_x/rds.rst b/docs/syntax/compose_x/rds.rst index 1202460f..d4b814ef 100644 --- a/docs/syntax/compose_x/rds.rst +++ b/docs/syntax/compose_x/rds.rst @@ -44,6 +44,7 @@ Access Access: DBCluster: RO + GenerateConnectionStringSecret: The only valid key for Access is DBCluster. The only valid value is ``RO`` for read-only, which allows IAM calls to RDS to describe the cluster. @@ -77,6 +78,27 @@ with the ARN of the secret Access: RW GrantTaskAccess: True # Grants access to the secret, not setting an env var +.. _rds_generate_connection_string: + +GenerateConnectionStringSecret +--------------------------------- + +This option enables to create a new secret that will be generated from the RDS Secret. +The feature works for both newly created DBs and existing DBs (using Lookup). + +.. warning:: + + Once the secret has been created, if the root secret of the DB has changed, the value in the generated secret will + not be updated! Use at your own risks + + .. hint:: + + Avoid to require the DB Connection string and have a separate environment variable for each part of the + connection. + +Example: ``postgres://username:password@cluster-hostname:port/dbname`` + + .. _rds_db_secrets_mappings: SecretsMapping diff --git a/ecs_composex/common/troposphere_tools.py b/ecs_composex/common/troposphere_tools.py index 631b732f..bdcc6471 100644 --- a/ecs_composex/common/troposphere_tools.py +++ b/ecs_composex/common/troposphere_tools.py @@ -201,6 +201,19 @@ def add_resource(template, resource, replace=False) -> AWSObject: return resource +def set_get_resource(template, resource) -> (AWSObject, bool): + """ + Function to add resource to template if the resource does not already exist + Returns the resource if it already does. + """ + if ( + resource not in template.resources.values() + and resource.title not in template.resources.keys() + ): + return template.add_resource(resource), False + return template.resources[resource.title], True + + def add_defaults(template): """Function to CFN parameters and conditions to the template which are used across ECS ComposeX diff --git a/ecs_composex/rds/rds_db_template.py b/ecs_composex/rds/rds_db_template.py index fb36e10e..9205b850 100644 --- a/ecs_composex/rds/rds_db_template.py +++ b/ecs_composex/rds/rds_db_template.py @@ -515,6 +515,8 @@ def create_from_properties(db_template: Template, db: Rds) -> None: rds_class = determine_resource_type(db.name, db.properties) if rds_class: rds_props = import_record_properties(db.properties, rds_class) + if not keyisset("DatabaseName", rds_props): + rds_props["DatabaseName"] = Ref(DB_NAME) override_set_properties(rds_props, db) db.cfn_resource = rds_class(db.logical_name, **rds_props) add_resource(db_template, db.cfn_resource) diff --git a/ecs_composex/rds/x-rds.spec.json b/ecs_composex/rds/x-rds.spec.json index b3694711..2f85f305 100644 --- a/ecs_composex/rds/x-rds.spec.json +++ b/ecs_composex/rds/x-rds.spec.json @@ -53,6 +53,10 @@ "SecretsMappings": { "$ref": "#/definitions/SecretsMappingsDef" }, + "GenerateConnectionStringSecret": { + "type": "string", + "description": "If set, creates an additional secret that will represent the connection string to the DB, and sets the environment variable" + }, "ReturnValues": { "type": "object", "description": "Set the CFN Return Value and the environment variable name you want to expose to the service", diff --git a/ecs_composex/rds_resources_settings.py b/ecs_composex/rds_resources_settings.py index 0c00c18e..6c0a9a5b 100644 --- a/ecs_composex/rds_resources_settings.py +++ b/ecs_composex/rds_resources_settings.py @@ -31,7 +31,11 @@ from ecs_composex.common.aws import find_aws_resource_arn_from_tags_api from ecs_composex.common.logging import LOG -from ecs_composex.common.troposphere_tools import add_resource, add_update_mapping +from ecs_composex.common.troposphere_tools import ( + add_resource, + add_update_mapping, + set_get_resource, +) from ecs_composex.compose.compose_services.helpers import ( extend_container_envvars, extend_container_secrets, @@ -222,6 +226,61 @@ def define_secrets_keys_mappings(mappings_definition): return rendered_mappings +def generate_secret_string( + secret_var_name: str, secret_import, db: DatabaseXResource, family: ComposeFamily +) -> list: + """ + Generates an additional secret that will put together the connection string that some services require in order + to connect to the DB. Generally, not recommended. + """ + from troposphere.secretsmanager import Secret + + param_name = secret_import.data["Ref"] + secret, already_set = set_get_resource( + family.template, + Secret( + f"{db.logical_name}ConnectionStringSecret", + Description=Sub(f"Connection string secret for {db.logical_name}"), + SecretString=Sub( + "${ENGINE}://${USERNAME}:${PASSWORD}@${HOST}:${PORT}/${DBNAME}", + ENGINE=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:engine}}", + ), + USERNAME=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:username}}", + ), + PASSWORD=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:password}}", + ), + HOST=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:host}}", + ), + PORT=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:port}}", + ), + DBNAME=Sub( + "{{resolve:secretsmanager:" + + f"${{{param_name}}}" + + ":SecretString:dbname}}", + ), + ), + ), + ) + if not already_set: + add_secrets_access_policy(family, Ref(secret), db, False) + return [EcsSecret(Name=secret_var_name, ValueFrom=Ref(secret))] + + def generate_secrets_from_secrets_mappings( db, secrets_list, secret_definition, mappings_definition ): @@ -292,6 +351,10 @@ def define_db_secrets(db: DatabaseXResource, secret_import, target: tuple) -> li " - No SecretsMappings set. Exposing the secrets content as-is." ) secrets.append(EcsSecret(Name=db.name, ValueFrom=secret_import)) + if keyisset("GenerateConnectionStringSecret", target[-1]): + secrets += generate_secret_string( + target[-1]["GenerateConnectionStringSecret"], secret_import, db, target[0] + ) return secrets @@ -341,7 +404,6 @@ def generate_rds_secrets_permissions(resources, db_name: str) -> dict: :return: """ return { - "Sid": f"AccessTo{db_name}Secret", "Effect": "Allow", "Action": ["secretsmanager:GetSecretValue", "secretsmanager:GetSecret"], "Resource": resources if isinstance(resources, list) else [resources], @@ -349,19 +411,15 @@ def generate_rds_secrets_permissions(resources, db_name: str) -> dict: def add_secrets_access_policy( - target: tuple, + service_family: ComposeFamily, secret_import, - db, + db: DatabaseXResource, use_task_role: Union[bool, dict] = False, -): +) -> None: """ Function to add or append policy to access DB Secret for the Execution Role - - :param tuple target: - :param secret_import: - :return: + If the use_task_role true, also allows the task role access to the secret. """ - service_family = target[0] db_policy_statement = generate_rds_secrets_permissions( secret_import, db.logical_name ) @@ -455,7 +513,7 @@ def handle_db_secret_to_services( grant_task_role_access = set_else_none( "GrantTaskAccess", target[-1], alt_value=False ) - add_secrets_access_policy(target, secret_import, db, grant_task_role_access) + add_secrets_access_policy(target[0], secret_import, db, grant_task_role_access) def handle_import_dbs_to_services( @@ -481,7 +539,7 @@ def handle_import_dbs_to_services( "GrantTaskAccess", target[-1], alt_value=False ) add_secrets_access_policy( - target, + target[0], db.attributes_outputs[db.db_secret_arn_parameter]["ImportValue"], db, use_task_role=grant_task_role_access, diff --git a/ecs_composex/secrets/__init__.py b/ecs_composex/secrets/__init__.py index f96158e8..dbde55b0 100644 --- a/ecs_composex/secrets/__init__.py +++ b/ecs_composex/secrets/__init__.py @@ -5,6 +5,13 @@ Package to handle recurring Secrets tasks """ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from troposphere import Template + from troposphere import Parameter, Ref, Sub from troposphere.docdb import DBCluster as DocdbCluster from troposphere.docdb import DBInstance as DocdbInstance @@ -19,13 +26,8 @@ from ecs_composex.common.troposphere_tools import add_parameters -def add_db_secret(template, resource_title): - """ - Function to add a Secrets Manager secret that will be associated with the DB - - :param template.Template template: The template to add the secret to. - :param str resource_title: The Logical name of the resource associated to that secret - """ +def add_db_secret(template: Template, resource_title: str) -> Secret: + """Function to add a Secrets Manager secret that will be associated with the DB""" username = Parameter( f"{resource_title}Username", Type="String", diff --git a/use-cases/rds/rds_basic.yml b/use-cases/rds/rds_basic.yml index d0f5a1cf..5f45a3cb 100644 --- a/use-cases/rds/rds_basic.yml +++ b/use-cases/rds/rds_basic.yml @@ -38,10 +38,12 @@ x-rds: Services: app01: Access: RW + GenerateConnectionStringSecret: APPO1_DB_B_CONNECTION_STRING app03: Access: RW GrantTaskAccess: SecretEnvName: DB_B_SECRET + GenerateConnectionStringSecret: APP03_DB_B_CONN youtoo: Access: RW GrantTaskAccess: True diff --git a/use-cases/rds/rds_import.yml b/use-cases/rds/rds_import.yml index f86747ca..f9ecc131 100644 --- a/use-cases/rds/rds_import.yml +++ b/use-cases/rds/rds_import.yml @@ -30,6 +30,7 @@ x-rds: Access: DBCluster: RO GrantTaskAccess: true + GenerateConnectionStringSecret: DB_CONN_STRING Lookup: cluster: Name: database-1 @@ -46,6 +47,7 @@ x-rds: DBCluster: RO GrantTaskAccess: SecretEnvName: DB_C_SECRET + GenerateConnectionStringSecret: DB_CONN_STRING Lookup: cluster: Name: database-1