diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index dcac48a..3e1e6db 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -1,9 +1,65 @@ - name: CD on: push: branches: - - main + - dev + - homolog + - prod workflow_dispatch: + +jobs: + DeployToAWS: + environment: + name: ${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Set AWS Account ID and other variables + run: | + if [[ "${{ github.ref_name }}" == "dev" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_DEV }}" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "homolog" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_HOML }}" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "prod" ]]; then + echo "AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID_PROD }}" >> $GITHUB_ENV + else + echo "Invalid branch name!" && exit 1 + fi + echo "STACK_NAME=PortfolioStackBack${{github.ref_name}}" >> $GITHUB_ENV + + - name: Setup AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/GithubActionsRole + role-session-name: github-action + + - name: Installing Dependencies + run: | + npm install -g aws-cdk + cd iac + pip install -r requirements-infra.txt + + - name: DeployWithCDK + run: | + cd iac + cdk synth + cdk deploy --require-approval never + env: + AWS_ACCOUNT_ID: ${{ env.AWS_ACCOUNT_ID }} + AWS_REGION: ${{ vars.AWS_REGION }} + STACK_NAME: ${{ env.STACK_NAME }} + GITHUB_REF_NAME: ${{ github.ref_name }} \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4d12145..8b5589d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,4 +7,4 @@ on: jobs: testApp: - uses: maua-dev/ci_workflows_reusable/.github/workflows/pytest_ci.yml@V1.0 + uses: maua-dev/ci_workflows_reusable/.github/workflows/pytest_ci_313.yml@V1.3 diff --git a/README.md b/README.md index ac415f1..2bea733 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# clean_mss_template 🌡🍽 +# clean_mss_template 🌡🍽 Template for microservices repositories based in Clean Arch diff --git a/iac/adjust_layer_directory.py b/iac/adjust_layer_directory.py index ff845c4..86b6b39 100644 --- a/iac/adjust_layer_directory.py +++ b/iac/adjust_layer_directory.py @@ -1,37 +1,53 @@ import os import shutil -from pathlib import Path -import sys - -IAC_DIRECTORY_NAME = "iac" -SOURCE_DIRECTORY_NAME = "src" -LAMBDA_LAYER_PREFIX = os.path.join("python", "src") - - -def adjust_layer_directory(shared_dir_name: str, destination: str): - # Get the root directory of the source directory - root_directory = Path(__file__).parent.parent - iac_directory = os.path.join(root_directory, IAC_DIRECTORY_NAME) - - print(f"Root directory: {root_directory}") - print(f"Root direcotry files: {os.listdir(root_directory)}") - print(f"IaC directory: {iac_directory}") - print(f"IaC directory files: {os.listdir(iac_directory)}") - - - # Get the destination and source directory - destination_directory = os.path.join(root_directory, IAC_DIRECTORY_NAME, destination) - source_directory = os.path.join(root_directory, SOURCE_DIRECTORY_NAME, shared_dir_name) - - # Delete the destination directory if it exists - if os.path.exists(destination_directory): - shutil.rmtree(destination_directory) - - # Copy the source directory to the destination directory - shutil.copytree(source_directory, os.path.join(destination_directory, LAMBDA_LAYER_PREFIX, shared_dir_name)) - print( - f"Copying files from {source_directory} to {os.path.join(destination_directory, LAMBDA_LAYER_PREFIX, shared_dir_name)}") - - +import subprocess +from pathlib import Path + +BUILD_DIRECTORY = "build" +PYTHON_TOP_LEVEL_DIR = os.path.join(BUILD_DIRECTORY, "python") +REQUIREMENTS_FILE = "requirements-app.txt" + +PROJECT_ROOT = Path(__file__).parent.parent +SHARED_CODE_SOURCE = os.path.join(PROJECT_ROOT, "src", "shared") + + +def adjust_layer_directory(): + """ + Prepara um diretório 'build' para uma Lambda Layer do AWS CDK. + + A função junta o código local compartilhado e as dependências externas (pip) + na estrutura de pastas que a Lambda espera (/python). + """ + + # Garante que o build seja sempre limpo, removendo qualquer artefato antigo. + if os.path.exists(BUILD_DIRECTORY): + shutil.rmtree(BUILD_DIRECTORY) + + # Cria a estrutura de pastas 'build/python/src/'. + # Isso é necessário para que os imports 'from src.shared...' funcionem na Lambda. + shared_code_intermediate_dir = os.path.join(PYTHON_TOP_LEVEL_DIR, "src") + os.makedirs(shared_code_intermediate_dir) + + # Copia o código compartilhado (de 'src/shared') para dentro da estrutura da Layer. + # O resultado final será 'build/python/src/shared'. + print(f"Copiando código de: {SHARED_CODE_SOURCE}") # Adicionado para debug + shared_code_dest = os.path.join(shared_code_intermediate_dir, os.path.basename(SHARED_CODE_SOURCE)) + shutil.copytree(SHARED_CODE_SOURCE, shared_code_dest) + + # Se o arquivo de dependências existir, instala todas as bibliotecas. + # O arquivo de requirements também precisa ser lido a partir da raiz. + requirements_path = os.path.join(PROJECT_ROOT, REQUIREMENTS_FILE) + if os.path.exists(requirements_path): + # Instala os pacotes diretamente na pasta 'build/python'. + # Isso permite que a Lambda importe as bibliotecas de forma padrão (ex: import requests). + subprocess.check_call( + ["pip", "install", "-r", requirements_path, "-t", PYTHON_TOP_LEVEL_DIR, "--no-cache-dir"] + ) + else: + # Apenas um aviso caso o arquivo não seja encontrado. + print(f"Aviso: Arquivo '{requirements_path}' não encontrado. Nenhuma dependência externa será instalada.") + + +# Ponto de entrada do script. if __name__ == '__main__': - adjust_layer_directory(shared_dir_name="shared", destination="lambda_layer_out_temp") \ No newline at end of file + adjust_layer_directory() \ No newline at end of file diff --git a/iac/app.py b/iac/app.py index 4b09603..1b69f8b 100644 --- a/iac/app.py +++ b/iac/app.py @@ -2,16 +2,14 @@ import os import aws_cdk as cdk -from adjust_layer_directory import adjust_layer_directory - -from iac.template_stack import TemplateStack - +from adjust_layer_directory import adjust_layer_directory +from stack.iac_stack import IacStack print("Starting the CDK") print("Adjusting the layer directory") -adjust_layer_directory(shared_dir_name="shared", destination="lambda_layer_out_temp") +adjust_layer_directory() print("Finished adjusting the layer directory") @@ -21,26 +19,26 @@ aws_account_id = os.environ.get("AWS_ACCOUNT_ID") stack_name = os.environ.get("STACK_NAME") -if 'prod' in stack_name: - stage = 'PROD' - -elif 'homolog' in stack_name: - stage = 'HOMOLOG' - -elif 'dev' in stack_name: - stage = 'DEV' - -else: - stage = 'TEST' +stage = os.environ.get("GITHUB_REF_NAME").capitalize() +stack_name = os.environ.get("STACK_NAME") tags = { - 'project': 'Template', + 'project': 'PortfolioApi', 'stage': stage, - 'stack': 'BACK', - 'owner': 'DevCommunity' + 'stack': stack_name, + 'owner': 'DevCommunity', } -TemplateStack(app, stack_name=stack_name, env=cdk.Environment(account=aws_account_id, region=aws_region), tags=tags) - - -app.synth() +IacStack( + app, + stack_id=stack_name, + stack_name=stack_name, + stage=stage, + env=cdk.Environment( + account=aws_account_id, + region=aws_region + ), + tags=tags +) + +app.synth() \ No newline at end of file diff --git a/iac/cdk.json b/iac/cdk.json index 99841c9..def2d49 100644 --- a/iac/cdk.json +++ b/iac/cdk.json @@ -1,5 +1,5 @@ { - "app": "python app.py", + "app": "python -m app", "watch": { "include": [ "**" diff --git a/iac/components/apigw_construct.py b/iac/components/apigw_construct.py new file mode 100644 index 0000000..3f8f426 --- /dev/null +++ b/iac/components/apigw_construct.py @@ -0,0 +1,60 @@ +from aws_cdk import aws_apigateway as apigateway +from constructs import Construct +from aws_cdk.aws_apigateway import RestApi, Cors, CorsOptions + +class ApigwConstruct(Construct): + rest_api: RestApi + + def __init__( + self, + scope: Construct, + construct_id: str, + stage: str, + **kwargs + ): + + super().__init__(scope, construct_id, **kwargs) + + self.stage = stage + + cors_options = CorsOptions( + allow_origins=Cors.ALL_ORIGINS, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "QUERY"], + allow_headers=Cors.DEFAULT_HEADERS + ) + + self.rest_api = RestApi( + self, + id=f"Portfolio_RestApi_{self.stage}", + rest_api_name=f"Portfolio_RestApi_{self.stage}", + description=f"This is the Portfolio RestApi for {self.stage}", + deploy_options=apigateway.StageOptions( + stage_name=stage.lower(), + logging_level=apigateway.MethodLoggingLevel.OFF, + data_trace_enabled=False, + metrics_enabled=True, + ), + default_cors_preflight_options=cors_options, + ) + + # implementação de uma key para mínima proteção de rotas abertas sensíveis + # não precisa ser necessariamente usado + + api_key = self.rest_api.add_api_key( + id="AdminApiKey", + api_key_name="portfolio-apigw-admin-key" + ) + + plan = self.rest_api.add_usage_plan("UsagePlan", + name="AdminPlan", + api_stages=[apigateway.UsagePlanPerApiStage( + api=self.rest_api, + stage=self.rest_api.deployment_stage, + )] + ) + plan.add_api_key(api_key) + + self.api_gateway_resource = self.rest_api.root.add_resource( + path_part="portfolio", + default_cors_preflight_options=cors_options + ) \ No newline at end of file diff --git a/iac/components/dynamo_construct.py b/iac/components/dynamo_construct.py new file mode 100644 index 0000000..bdf1448 --- /dev/null +++ b/iac/components/dynamo_construct.py @@ -0,0 +1,50 @@ +from aws_cdk import ( + RemovalPolicy, + aws_dynamodb as dynamodb, +) +from constructs import Construct + +# Manter alinhado com src.shared.infra.external.dynamo/..._naming/..._TABLE_PREFIX +_PORTFOLIO_TABLE_PREFIX = "PortfolioTable" + +RETAINED_STAGES = {"prod", "homolog"} + + +class DynamoConstruct(Construct): + + portfolio_table: dynamodb.Table + + def __init__( + self, + scope: Construct, + construct_id: str, + stack_name: str, + stage: str, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + stage_lower = stage.lower() + + removal_policy = ( + RemovalPolicy.RETAIN if stage_lower in RETAINED_STAGES else RemovalPolicy.DESTROY + ) + + self.portfolio_table = dynamodb.Table( + self, + id="PortfolioTable", + partition_key=dynamodb.Attribute( + name="pk", + type=dynamodb.AttributeType.STRING, + ), + sort_key=dynamodb.Attribute( + name="sk", + type=dynamodb.AttributeType.STRING, + ), + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + table_name=f"{_PORTFOLIO_TABLE_PREFIX}-{stage_lower}", + point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification( + point_in_time_recovery_enabled=(stage_lower == "prod") + ), + ) \ No newline at end of file diff --git a/iac/components/lambda_construct.py b/iac/components/lambda_construct.py new file mode 100644 index 0000000..4a22b14 --- /dev/null +++ b/iac/components/lambda_construct.py @@ -0,0 +1,130 @@ +from aws_cdk import ( + aws_lambda as lambda_, + aws_s3 as s3, + aws_s3_notifications as s3n, + Duration +) +from aws_cdk import aws_iam as iam +from constructs import Construct +from aws_cdk.aws_apigateway import Resource, LambdaIntegration + + +class LambdaConstruct(Construct): + + stage: str + stack_name: str + funtions_that_need_dynamo_db_access: list[lambda_.Function] = [] + functions_that_need_s3_access: list[lambda_.Function] = [] + + def create_lambda_api_gateway_integration( + self, + module_name: str, + method: str, + api_resource: Resource, + api_key_required: bool = False, + environment_variables: dict = {"STAGE": "TEST"}, + public: bool = False, + subfolder: str = "", + ) -> lambda_.Function: + + code = lambda_.Code.from_asset(f"../src/modules/{subfolder}/{module_name}") if subfolder else lambda_.Code.from_asset(f"../src/modules/{module_name}") + handler = f"app.{module_name}_presenter.lambda_handler" + + function = lambda_.Function( + self, module_name.title(), + code=code, + handler=handler, + function_name=f"{module_name}-{self.stack_name}-{self.stage}"[:63], + runtime=lambda_.Runtime.PYTHON_3_13, + layers=[self.lambda_layer], + environment=environment_variables, + timeout=Duration.seconds(30), + memory_size=512 + ) + + if public: + api_resource.add_resource("public").add_resource(module_name.replace("_", "-")).add_method( + method, + integration=LambdaIntegration(function), + api_key_required=api_key_required + ) + else: + api_resource.add_resource(module_name.replace("_", "-")).add_method( + method, + integration=LambdaIntegration(function), + api_key_required=api_key_required + ) + + return function + + def __init__( + self, + scope: Construct, + construct_id: str, + stage: str, + stack_name: str, + api_gateway_resource: Resource, + environment_variables: dict, + **kargs + ) -> None: + + super().__init__(scope, construct_id, **kargs) + + self.stage = stage + self.stack_name = stack_name + + self.lambda_layer = lambda_.LayerVersion( + self, + id=f"{stack_name}_LambdaLayer_{stage}", + layer_version_name=f"{stack_name}-LambdaLayer-{self.stage}", + # a pasta .build foi obtida do adjust layer directory, certifique-se de que a configuração da pasta layer gerada la esta igual + code=lambda_.Code.from_asset("./build"), + compatible_runtimes=[lambda_.Runtime.PYTHON_3_13] + ) + + # self.contact_us = self.create_lambda_api_gateway_integration( + # module_name="contact_us", + # method="POST", + # api_resource=api_gateway_resource, + # environment_variables=environment_variables, + # public=True + # ) + + # ses_send_policy = iam.PolicyStatement( + # effect=iam.Effect.ALLOW, + # actions=["ses:SendEmail"], + # resources=["*"], + # conditions={ + # "StringEquals": { + # "ses:FromAddress": environment_variables.get("FROM_EMAIL") + # } + # } + # ) + # self.contact_us.add_to_role_policy(ses_send_policy) + + # self.grade_optimizer_function = self.create_lambda_api_gateway_integration( + # module_name="grade_optmizer", + # method="POST", + # api_resource=api_gateway_resource, + # environment_variables=environment_variables + # ) + + # self.get_all_disciplinas_function = self.create_lambda_api_gateway_integration( + # module_name="get_all_disciplinas", + # method="GET", + # api_resource=api_gateway_resource, + # environment_variables=environment_variables, + # subfolder="disciplina" + # ) + + # self.create_curso_function = self.create_lambda_api_gateway_integration( + # module_name="create_curso", + # method="POST", + # api_resource=api_gateway_resource, + # environment_variables=environment_variables, + # subfolder="curso", + # api_key_required=True + # ) + + # self.funtions_that_need_dynamo_db_access.append(self.grade_optimizer_function) + \ No newline at end of file diff --git a/iac/components/s3_construct.py b/iac/components/s3_construct.py new file mode 100644 index 0000000..b9f66fd --- /dev/null +++ b/iac/components/s3_construct.py @@ -0,0 +1,100 @@ +from constructs import Construct +from aws_cdk import Duration, RemovalPolicy, Aws +from aws_cdk import aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_s3 + + +class S3Construct(Construct): + + entity_assets_bucket: aws_s3.Bucket + cloudfront_distribution_plans: cloudfront.Distribution + cloudfront_distribution_subjects: cloudfront.Distribution + + def _build_distribution( + self, + distribution_id: str, + bucket: aws_s3.Bucket, + stage: str, + default_ttl: Duration, + ) -> cloudfront.Distribution: + cache_policy = cloudfront.CachePolicy( + self, + f"{distribution_id}CachePolicy", + cache_policy_name=f"Portfolio-{distribution_id}-Cache-{stage}", + comment=f"Cache policy for {distribution_id}", + min_ttl=Duration.seconds(1), + max_ttl=Duration.days(365), + default_ttl=default_ttl, + enable_accept_encoding_gzip=True, + enable_accept_encoding_brotli=True, + ) + + origin_request_policy = cloudfront.OriginRequestPolicy( + self, + f"{distribution_id}OriginRequestPolicy", + origin_request_policy_name=f"Portfolio-{distribution_id}-ORP-{stage}", + comment=f"Origin request policy for {distribution_id}", + header_behavior=cloudfront.OriginRequestHeaderBehavior.allow_list( + "Origin", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + ), + ) + + return cloudfront.Distribution( + self, + id=distribution_id, + comment=f"Portfolio {distribution_id} S3 CDN {stage}", + price_class=cloudfront.PriceClass.PRICE_CLASS_ALL, + default_behavior=cloudfront.BehaviorOptions( + origin=origins.S3BucketOrigin.with_origin_access_control(bucket), + compress=True, + allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS, + viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cache_policy=cache_policy, + origin_request_policy=origin_request_policy, + response_headers_policy=cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT, + ), + ) + + def create_bucket_with_distribution( + self, + *, + resource_prefix: str, + bucket_name: str, + default_ttl: Duration, + stage: str, + ) -> tuple[aws_s3.Bucket, cloudfront.Distribution]: + bucket = aws_s3.Bucket( + self, + f"{resource_prefix}Bucket", + bucket_name=bucket_name, + block_public_access=aws_s3.BlockPublicAccess.BLOCK_ALL, + removal_policy=self.removal_policy, + auto_delete_objects=self.removal_policy == RemovalPolicy.DESTROY, + ) + + distribution = self._build_distribution( + distribution_id=f"CloudFrontDistribution{resource_prefix}", + bucket=bucket, + stage=stage, + default_ttl=default_ttl, + ) + + return bucket, distribution + + def __init__(self, scope: Construct, construct_id: str, stage: str, **kwargs): + super().__init__(scope, construct_id, **kwargs) + + self.stage = stage.lower() + self.removal_policy = RemovalPolicy.RETAIN if stage.upper() == "PROD" else RemovalPolicy.DESTROY + + identifier = f"2026-{Aws.ACCOUNT_ID}-{Aws.REGION}" + + self.entity_assets_bucket, self.cloudfront_distribution_entity_assets = self.create_bucket_with_distribution( + resource_prefix="EntityAssets", + bucket_name=f"portfolio-entity-assets-{self.stage}-{identifier}", + default_ttl=Duration.seconds(30), + stage=stage, + ) + \ No newline at end of file diff --git a/iac/components/ssm_construct.py b/iac/components/ssm_construct.py new file mode 100644 index 0000000..080b78b --- /dev/null +++ b/iac/components/ssm_construct.py @@ -0,0 +1,50 @@ +from constructs import Construct +from aws_cdk import Resource, aws_ssm as ssm +from aws_cdk.aws_apigateway import RestApi +from aws_cdk import aws_s3 as s3 + +class SsmConstruct(Construct): + + def __init__( + self, + scope: Construct, + construct_id: str, + stage: str, + mss_name_identification_for_path: str, + api: RestApi, + api_gateway_resource: Resource, + buckets: dict[str, s3.Bucket] = None, + extra_params: dict[str, str] = None, + **kwargs + ): + super().__init__(scope, construct_id, **kwargs) + + # é necessário a '/' após a url pois no CD do front estamos contando como se ela ja estivesse la + + # stage lower é necessário aqui pois no actions do front, stage é recebido como lower + + stage = stage.lower() + + mss_name_identification_for_path = mss_name_identification_for_path.lower().replace("-", "_") + + if api: + ssm.StringParameter(self, + id=f"ApiUrl_{stage}", + parameter_name=f"/{mss_name_identification_for_path}/{stage}/api/url", + string_value=f"{api.url}{api_gateway_resource.path.lstrip('/')}/" + ) + + for logical_name, bucket in (buckets or {}).items(): + ssm.StringParameter(self, + id=f"Bucket_{logical_name}_{stage}", + parameter_name=f"/{mss_name_identification_for_path}/{stage}/buckets/{logical_name}", + string_value=bucket.bucket_name + ) + + for key, value in (extra_params or {}).items(): + safe_id = key.replace("/", "_") + ssm.StringParameter(self, + id=f"Extra_{safe_id}_{stage}", + parameter_name=f"/{mss_name_identification_for_path}/{stage}/{key}", + string_value=value + ) \ No newline at end of file diff --git a/iac/iac/__init__.py b/iac/iac/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/iac/iac/lambda_stack.py b/iac/iac/lambda_stack.py deleted file mode 100644 index 8187d82..0000000 --- a/iac/iac/lambda_stack.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from aws_cdk import ( - aws_lambda as lambda_, - NestedStack, Duration -) -from constructs import Construct -from aws_cdk.aws_apigateway import Resource, LambdaIntegration - - -class LambdaStack(Construct): - functions_that_need_dynamo_permissions = [] - - def create_lambda_api_gateway_integration(self, module_name: str, method: str, mss_student_api_resource: Resource, - environment_variables: dict = {"STAGE": "TEST"}): - function = lambda_.Function( - self, module_name.title(), - code=lambda_.Code.from_asset(f"../src/modules/{module_name}"), - handler=f"app.{module_name}_presenter.lambda_handler", - runtime=lambda_.Runtime.PYTHON_3_9, - layers=[self.lambda_layer, self.lambda_power_tools], - environment=environment_variables, - timeout=Duration.seconds(15) - ) - - mss_student_api_resource.add_resource(module_name.replace("_", "-")).add_method(method, - integration=LambdaIntegration( - function)) - - return function - - def __init__(self, scope: Construct, api_gateway_resource: Resource, environment_variables: dict) -> None: - super().__init__(scope, "Template_Lambdas") - - self.lambda_layer = lambda_.LayerVersion(self, "Template_Layer", - code=lambda_.Code.from_asset("./lambda_layer_out_temp"), - compatible_runtimes=[lambda_.Runtime.PYTHON_3_9] - ) - - self.lambda_power_tools = lambda_.LayerVersion.from_layer_version_arn(self, "Lambda_Power_Tools", layer_version_arn="arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:22") - - self.get_user_function = self.create_lambda_api_gateway_integration( - module_name="get_user", - method="GET", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.create_user_function = self.create_lambda_api_gateway_integration( - module_name="create_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.delete_user_function = self.create_lambda_api_gateway_integration( - module_name="delete_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.update_user_function = self.create_lambda_api_gateway_integration( - module_name="update_user", - method="POST", - mss_student_api_resource=api_gateway_resource, - environment_variables=environment_variables - ) - - self.functions_that_need_dynamo_permissions = [self.get_user_function, self.create_user_function, - self.delete_user_function, self.update_user_function] diff --git a/iac/iac/template_dynamo_table.py b/iac/iac/template_dynamo_table.py deleted file mode 100644 index 9bc82dc..0000000 --- a/iac/iac/template_dynamo_table.py +++ /dev/null @@ -1,30 +0,0 @@ -from decimal import Decimal -from aws_cdk import ( - aws_dynamodb as dynamodb, RemovalPolicy, -) -from constructs import Construct - - -class TemplateDynamoTable(Construct): - table: dynamodb.Table - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.table = dynamodb.Table( - self, "TemplateDynamoTable", - partition_key=dynamodb.Attribute( - name="PK", - type=dynamodb.AttributeType.STRING - ), - sort_key=dynamodb.Attribute( - name="SK", - type=dynamodb.AttributeType.STRING - ), - billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, - removal_policy=RemovalPolicy.DESTROY - ) - - - - diff --git a/iac/iac/template_stack.py b/iac/iac/template_stack.py deleted file mode 100644 index dbaf9b4..0000000 --- a/iac/iac/template_stack.py +++ /dev/null @@ -1,55 +0,0 @@ -from aws_cdk import ( - # Duration, - Stack, - # aws_sqs as sqs, -) -from constructs import Construct -from aws_cdk.aws_apigateway import RestApi, Cors - -from .lambda_stack import LambdaStack -from .template_dynamo_table import TemplateDynamoTable - - -class TemplateStack(Stack): - - def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: - super().__init__(scope, construct_id, **kwargs) - - self.rest_api = RestApi(self, "Template_RestApi", - rest_api_name="Template_RestApi", - description="This is the Template RestApi", - default_cors_preflight_options= - { - "allow_origins": Cors.ALL_ORIGINS, - "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": ["*"] - }, - ) - - api_gateway_resource = self.rest_api.root.add_resource("mss-template", default_cors_preflight_options= - { - "allow_origins": Cors.ALL_ORIGINS, - "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": Cors.DEFAULT_HEADERS - } - ) - - self.dynamo_table = TemplateDynamoTable(self, "TemplateDynamoTable") - - ENVIRONMENT_VARIABLES = { - "STAGE": "DEV", - "DYNAMO_TABLE_NAME": self.dynamo_table.table.table_name, - "DYNAMO_PARTITION_KEY": "PK", - "DYNAMO_SORT_KEY": "SK", - "REGION": self.region, - } - - - - self.lambda_stack = LambdaStack(self, api_gateway_resource=api_gateway_resource, - environment_variables=ENVIRONMENT_VARIABLES) - - for function in self.lambda_stack.functions_that_need_dynamo_permissions: - self.dynamo_table.table.grant_read_write_data(function) - - \ No newline at end of file diff --git a/iac/requirements-dev.txt b/iac/requirements-dev.txt deleted file mode 100644 index 9270945..0000000 --- a/iac/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pytest==6.2.5 diff --git a/iac/requirements-infra.txt b/iac/requirements-infra.txt new file mode 100644 index 0000000..c4987ea --- /dev/null +++ b/iac/requirements-infra.txt @@ -0,0 +1 @@ +aws-cdk-lib==2.211.0 \ No newline at end of file diff --git a/iac/requirements.txt b/iac/requirements.txt deleted file mode 100644 index 82ff4b9..0000000 --- a/iac/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -aws-cdk-lib==2.25.0 -constructs>=10.0.0,<11.0.0 -aws-lambda-powertools==2.9.0 -aws-xray-sdk==2.11.0 -pytest==6.2.5 \ No newline at end of file diff --git a/iac/stack/iac_stack.py b/iac/stack/iac_stack.py new file mode 100644 index 0000000..150f9c3 --- /dev/null +++ b/iac/stack/iac_stack.py @@ -0,0 +1,89 @@ +import os +from aws_cdk import ( + Stack +) +from constructs import Construct + +from components.apigw_construct import ApigwConstruct +from components.dynamo_construct import DynamoConstruct +from components.lambda_construct import LambdaConstruct +from components.s3_construct import S3Construct +from components.ssm_construct import SsmConstruct + +class IacStack(Stack): + + def __init__( + self, + scope: Construct, + stack_id: str, + stack_name: str, + stage: str, + **kwargs + ) -> None: + + super().__init__(scope, stack_id, **kwargs) + + self.github_ref_name = os.environ.get("GITHUB_REF_NAME", "") + self.aws_region = os.environ.get("AWS_REGION") + + self.apigw_construct = ApigwConstruct( + self, + construct_id=f"Apigw", + stage=stage + ) + + self.s3_construct = S3Construct( + self, + construct_id=f"S3", + stage=stage + ) + + self.dynamo_construct = DynamoConstruct( + self, + construct_id=f"Dynamo", + stack_name=stack_name, + stage=stage, + ) + + ENVIRONMENT_VARIABLES = { + "ENTITY_ASSETS_BUCKET_NAME": self.s3_construct.entity_assets_bucket.bucket_name, + "PORTFOLIO_TABLE_NAME": self.dynamo_construct.portfolio_table.table_name, + } + + self.lambda_construct = LambdaConstruct( + self, + construct_id=f"{stack_name}Lambda", + api_gateway_resource=self.apigw_construct.api_gateway_resource, + stage=stage, + stack_name=stack_name, + environment_variables=ENVIRONMENT_VARIABLES + ) + + for function in self.lambda_construct.funtions_that_need_dynamo_db_access: + self.dynamo_construct.academic_catalog_table.grant_read_write_data(function) + + for function in self.lambda_construct.functions_that_need_s3_access: + self.s3_construct.entity_assets_bucket.grant_read_write(function) + + + # instância SSM manager para passar automaticamente variáveis a um hub de segredos + # da prórpia conta, evitando ter que manualmente passa-las para o github secrets + + # isso evita problemas de discrepância nos endpoints + + # atenção aqui, isso deve suprir ao que estamos precisando / pegando de variáveis de + # ambiente no CD do front + + # nesse projeto nao vamos passar cdn pois o acesso vai vir pela entidade retornada ao + # inves de um link fixo tipo no antigo dev medias + + self.ssm_construct = SsmConstruct( + self, + construct_id=f"{stack_name}Ssm", + mss_name_identification_for_path="portfolio", + api=self.apigw_construct.rest_api, + api_gateway_resource=self.apigw_construct.api_gateway_resource, + buckets=None, # o que deve ser salvo são os CDNs, visto que os buckets bloqueiam acesso pela URL publica + extra_params=None, + stage=stage + ) \ No newline at end of file diff --git a/requirements-app.txt b/requirements-app.txt new file mode 100644 index 0000000..fc0fe12 --- /dev/null +++ b/requirements-app.txt @@ -0,0 +1,5 @@ +pytest==8.4.1 +pytest-cov==6.2.1 +pydantic==2.11.7 +python-dotenv==1.1.1 +boto3==1.35.99 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index ab21f95..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest==6.2.5 -pytest-cov==4.0.0 -boto3==1.24.88 -python-dotenv==0.21.0 -aws-lambda-powertools==2.9.0 -aws_xray_sdk==2.11.0 \ No newline at end of file diff --git a/src/modules/get_user/app/get_user_controller.py b/src/modules/get_user/app/get_user_controller.py index 392869e..3f5795c 100644 --- a/src/modules/get_user/app/get_user_controller.py +++ b/src/modules/get_user/app/get_user_controller.py @@ -1,4 +1,3 @@ -from src.shared.infra.external.observability.observability_aws import ObservabilityAWS from .get_user_usecase import GetUserUsecase from .get_user_viewmodel import GetUserViewmodel from src.shared.helpers.errors.controller_errors import MissingParameters, WrongTypeParameter @@ -6,19 +5,15 @@ from src.shared.helpers.errors.usecase_errors import NoItemsFound from src.shared.helpers.external_interfaces.external_interface import IRequest, IResponse from src.shared.helpers.external_interfaces.http_codes import OK, NotFound, BadRequest, InternalServerError -from aws_lambda_powertools import Logger - class GetUserController: - def __init__(self, usecase: GetUserUsecase, observability: ObservabilityAWS): + def __init__(self, usecase: GetUserUsecase): self.GetUserUsecase = usecase - self.observability = observability def __call__(self, request: IRequest) -> IResponse: try: - self.observability.log_controller_in() if request.data.get('user_id') is None: raise MissingParameters('user_id') @@ -32,33 +27,24 @@ def __call__(self, request: IRequest) -> IResponse: if not request.data.get('user_id').isdecimal(): raise EntityError("user_id") - user = self.GetUserUsecase( user_id=int(request.data.get('user_id')) ) viewmodel = GetUserViewmodel(user) - - response = OK(viewmodel.to_dict()) - self.observability.log_controller_out(input=user.user_id) - return response + return OK(viewmodel.to_dict()) except NoItemsFound as err: - self.observability.log_exception(message=err.message) return NotFound(body=err.message) except MissingParameters as err: - self.observability.log_exception(message=err.message) return BadRequest(body=err.message) except WrongTypeParameter as err: - self.observability.log_exception(message=err.message) return BadRequest(body=err.message) except EntityError as err: - self.observability.log_exception(message=err.message) return BadRequest(body=err.message) except Exception as err: - self.observability.log_exception(message=err.args[0]) return InternalServerError(body=err.args[0]) diff --git a/src/modules/get_user/app/get_user_presenter.py b/src/modules/get_user/app/get_user_presenter.py index c00d098..483ad4b 100644 --- a/src/modules/get_user/app/get_user_presenter.py +++ b/src/modules/get_user/app/get_user_presenter.py @@ -2,29 +2,19 @@ from .get_user_usecase import GetUserUsecase from src.shared.environments import Environments from src.shared.helpers.external_interfaces.http_lambda_requests import LambdaHttpRequest, LambdaHttpResponse -from src.shared.infra.external.observability.observability_aws import ObservabilityAWS -from aws_lambda_powertools import Logger, Tracer, Metrics - - -observability = Environments.get_observability()(module_name="get_user") repo = Environments.get_user_repo()() -usecase = GetUserUsecase(repo, observability=observability) -controller = GetUserController(usecase, observability=observability) +usecase = GetUserUsecase(repo) +controller = GetUserController(usecase) + -@observability.presenter_decorators def get_user_presenter(event): httpRequest = LambdaHttpRequest(data=event) response = controller(httpRequest) httpResponse = LambdaHttpResponse(status_code=response.status_code, body=response.body, headers=response.headers) return httpResponse.toDict() -@observability.handler_decorators + def lambda_handler(event, context): - response = get_user_presenter(event) - - observability.add_metric(name="ErrorCount", unit="Count", value=1) if response["statusCode"] != 200 else None # ErrorCount metrics - return response - diff --git a/src/modules/get_user/app/get_user_usecase.py b/src/modules/get_user/app/get_user_usecase.py index 4377d1d..d1cfe89 100644 --- a/src/modules/get_user/app/get_user_usecase.py +++ b/src/modules/get_user/app/get_user_usecase.py @@ -1,18 +1,13 @@ from src.shared.domain.entities.user import User from src.shared.domain.repositories.user_repository_interface import IUserRepository from src.shared.helpers.errors.domain_errors import EntityError -from src.shared.infra.external.observability.observability_aws import ObservabilityAWS class GetUserUsecase: - def __init__(self, repo: IUserRepository, observability: ObservabilityAWS): + def __init__(self, repo: IUserRepository): self.repo = repo - self.observability = observability def __call__(self, user_id: int) -> User: - self.observability.log_usecase_in() if type(user_id) != int: raise EntityError("user_id") - user = self.repo.get_user(user_id) - self.observability.log_usecase_out() - return user + return self.repo.get_user(user_id) diff --git a/src/shared/domain/observability/observability_interface.py b/src/shared/domain/observability/observability_interface.py deleted file mode 100644 index 1a5b1e4..0000000 --- a/src/shared/domain/observability/observability_interface.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import time -from aws_lambda_powertools import Logger, Tracer, Metrics -from abc import ABC, abstractmethod - -class IObservability(ABC): - module_name: str - mss_name: str - - @abstractmethod - def __init__(self, module_name: str) -> None: - self.module_name = module_name - self.mss_name = os.environ.get("MSS_NAME") - - @abstractmethod - def _log_info(self, message: str) -> None: - pass - - @abstractmethod - def log_controller_in(self) -> None: - pass - - @abstractmethod - def log_controller_out(self) -> None: - pass - - @abstractmethod - def log_usecase_in(self) -> None: - pass - - @abstractmethod - def log_usecase_out(self) -> None: - pass - - @abstractmethod - def log_exception(self, message: str) -> None: - pass - - @abstractmethod - def add_metric(self, name: str, unit: str, value: float) -> None: - pass - - @abstractmethod - def presenter_decorators(self, presenter) -> None: - """ - this decorator have the following responsabilities: - 1. calculate the processing time of the presenter - 2. use the @tracer.capture_method to trace the presenter - """ - def presenter_wrapper(event): - pass - pass - - @abstractmethod - def handler_decorators(self, handler) -> None: - """ - this decorator have the following responsabilities: - 1. use the @tracer.capture_lambda_handler - 2. use the @metrics.log_metrics(capture_cold_start_metric=True, default_dimensions={"environment": os.getenv("STAGE", "dev"), "another": "one"}) # ColdStart metrics and adding dimensions - 3. use the @logger.inject_lambda_context(log_event=True) # Log event - """ - def handler_wrapper(event, context): - pass - pass \ No newline at end of file diff --git a/src/shared/environments.py b/src/shared/environments.py index ce8c825..1c8519a 100644 --- a/src/shared/environments.py +++ b/src/shared/environments.py @@ -1,7 +1,6 @@ import enum from enum import Enum import os -from src.shared.domain.observability.observability_interface import IObservability from src.shared.domain.repositories.user_repository_interface import IUserRepository @@ -73,16 +72,6 @@ def get_user_repo() -> IUserRepository: raise Exception("No repository found for this stage") @staticmethod - def get_observability() -> IObservability: - if Environments.get_envs().stage == STAGE.TEST: - from src.shared.infra.external.observability.observability_mock import ObservabilityMock - return ObservabilityMock - elif Environments.get_envs().stage in [STAGE.DEV, STAGE.HOMOLOG, STAGE.PROD]: - from src.shared.infra.external.observability.observability_aws import ObservabilityAWS - return ObservabilityAWS - else: - raise Exception("No observability class found for this stage") - @staticmethod def get_envs() -> "Environments": """ Returns the Environments object. This method should be used to get the Environments object instead of instantiating it directly. diff --git a/src/shared/infra/external/observability/observability_aws.py b/src/shared/infra/external/observability/observability_aws.py deleted file mode 100644 index bdccfb3..0000000 --- a/src/shared/infra/external/observability/observability_aws.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -import time -from aws_lambda_powertools import Logger, Tracer, Metrics -from src.shared.domain.observability.observability_interface import IObservability - -class ObservabilityAWS(IObservability): - tracer: Tracer - logger: Logger - metrics: Metrics - module_name: str - mss_name: str - - def __init__(self, module_name: str): - self.logger = Logger(service=module_name) - self.tracer = Tracer(service=module_name) - self.metrics = Metrics(namespace=module_name) - - super().__init__(module_name=module_name) - - def _log_info(self, message: str) -> None: - self.logger.info(message) - - def log_controller_in(self) -> None: - self._log_info(f"In Controller") - - def log_controller_out(self, input) -> None: - self._log_info(f"Out of Controller with this input: {input}") - - def log_usecase_in(self) -> None: - self._log_info("In Usecase") - - def log_usecase_out(self) -> None: - self._log_info("Out of Usecase") - - def log_exception(self, message: str) -> None: - self.logger.exception(message) - - def add_metric(self, name: str, unit: str, value: float) -> None: - self.metrics.add_metric(name, unit, value) - - def presenter_decorators(self, presenter) -> None: - @self.tracer.capture_method - def presenter_wrapper(event): - start_time = time.monotonic() # Start - ProcessingTime metrics - - response = presenter(event) - - end_time = time.monotonic() - processing_time_ms = (end_time - start_time) * 10**3 - - self.metrics.add_metric(name="ProcessingTime", unit="Milliseconds", value=processing_time_ms) # End - ProcessingTime metrics - - return response - return presenter_wrapper - - def handler_decorators(self, handler) -> None: - @self.tracer.capture_lambda_handler - @self.metrics.log_metrics(capture_cold_start_metric=True, default_dimensions={"environment": os.getenv("STAGE", "dev"), "mss": self.mss_name, "module": self.module_name}) # ColdStart metrics and adding dimensions - @self.logger.inject_lambda_context(log_event=True) # Log event - def handler_wrapper(event, context): - - response = handler(event, context) - - return response - return handler_wrapper \ No newline at end of file diff --git a/src/shared/infra/external/observability/observability_mock.py b/src/shared/infra/external/observability/observability_mock.py deleted file mode 100644 index f0ede9e..0000000 --- a/src/shared/infra/external/observability/observability_mock.py +++ /dev/null @@ -1,44 +0,0 @@ -from src.shared.domain.observability.observability_interface import IObservability - - -class ObservabilityMock(IObservability): - module_name: str - mss_name: str - - def __init__(self, module_name: str) -> None: - super().__init__(module_name=module_name) - - def _log_info(self, message: str) -> None: - print(message) - - def log_controller_in(self) -> None: - self._log_info("In Controller") - - def log_controller_out(self, input) -> None: - self._log_info(f"Out of Controller with this input: {input}") - - def log_usecase_in(self) -> None: - self._log_info("In Usecase") - - def log_usecase_out(self) -> None: - self._log_info("Out of Usecase") - - def log_exception(self, message: str) -> None: - print("Exception message: " + message) - - def add_metric(self, name: str, unit: str, value: float) -> None: - print(f"Metric {name} added with value {value} in {unit}") - - def presenter_decorators(self, presenter) -> None: - def presenter_wrapper(event): - response = presenter(event) - - return response - return presenter_wrapper - - def handler_decorators(self, handler) -> None: - def handler_wrapper(event, context): - response = handler(event, context) - - return response - return handler_wrapper \ No newline at end of file diff --git a/tests/modules/get_user/app/test_get_user_controller.py b/tests/modules/get_user/app/test_get_user_controller.py index 4747fd1..a426cb0 100644 --- a/tests/modules/get_user/app/test_get_user_controller.py +++ b/tests/modules/get_user/app/test_get_user_controller.py @@ -1,16 +1,14 @@ from src.modules.get_user.app.get_user_controller import GetUserController from src.modules.get_user.app.get_user_usecase import GetUserUsecase from src.shared.helpers.external_interfaces.http_models import HttpRequest -from src.shared.infra.external.observability.observability_mock import ObservabilityMock from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock -observability = ObservabilityMock(module_name="get_user") class Test_GetUserController: def test_get_user_controller(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo=repo, observability=observability) - controller = GetUserController(usecase=usecase, observability=observability) + usecase = GetUserUsecase(repo=repo) + controller = GetUserController(usecase=usecase) request = HttpRequest(query_params={ 'user_id': str(repo.users[1].user_id) @@ -26,8 +24,8 @@ def test_get_user_controller(self): def test_get_user_controller_missing_parameters(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo=repo, observability=observability) - controller = GetUserController(usecase=usecase, observability=observability) + usecase = GetUserUsecase(repo=repo) + controller = GetUserController(usecase=usecase) request = HttpRequest(query_params={}) @@ -36,11 +34,10 @@ def test_get_user_controller_missing_parameters(self): assert response.status_code == 400 assert response.body == 'Field user_id is missing' - def test_get_user_contoller_wrong_type_parameter(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo=repo, observability=observability) - controller = GetUserController(usecase=usecase, observability=observability) + usecase = GetUserUsecase(repo=repo) + controller = GetUserController(usecase=usecase) request = HttpRequest(query_params={ 'user_id': 999 @@ -53,8 +50,8 @@ def test_get_user_contoller_wrong_type_parameter(self): def test_get_user_contoller_entity_error(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo=repo, observability=observability) - controller = GetUserController(usecase=usecase, observability=observability) + usecase = GetUserUsecase(repo=repo) + controller = GetUserController(usecase=usecase) request = HttpRequest(query_params={ 'user_id': 'abc' @@ -67,8 +64,8 @@ def test_get_user_contoller_entity_error(self): def test_get_user_controller_no_items_found(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo=repo, observability=observability) - controller = GetUserController(usecase=usecase, observability=observability) + usecase = GetUserUsecase(repo=repo) + controller = GetUserController(usecase=usecase) request = HttpRequest(query_params={ 'user_id': str(999) diff --git a/tests/modules/get_user/app/test_get_user_usecase.py b/tests/modules/get_user/app/test_get_user_usecase.py index cadb599..1ead898 100644 --- a/tests/modules/get_user/app/test_get_user_usecase.py +++ b/tests/modules/get_user/app/test_get_user_usecase.py @@ -3,16 +3,14 @@ from src.modules.get_user.app.get_user_usecase import GetUserUsecase from src.shared.helpers.errors.domain_errors import EntityError from src.shared.helpers.errors.usecase_errors import NoItemsFound -from src.shared.infra.external.observability.observability_mock import ObservabilityMock from src.shared.infra.repositories.user_repository_mock import UserRepositoryMock -observability = ObservabilityMock(module_name="get_user") class Test_GetUserUsecase: def test_get_user(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo, observability=observability) + usecase = GetUserUsecase(repo) user = usecase(user_id=repo.users[1].user_id) @@ -20,14 +18,14 @@ def test_get_user(self): def test_get_user_not_found(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo, observability=observability) + usecase = GetUserUsecase(repo) with pytest.raises(NoItemsFound): - user = usecase(user_id=999) + usecase(user_id=999) def test_get_user_invalid_id(self): repo = UserRepositoryMock() - usecase = GetUserUsecase(repo, observability=observability) + usecase = GetUserUsecase(repo) with pytest.raises(EntityError): - user = usecase(user_id="invalid") + usecase(user_id="invalid")