Skip to content

sprint3 documento tecnico

csolanor22 edited this page Nov 27, 2023 · 26 revisions

Documentación Técnica

Tabla de contenido

  1. Implementación Front-end y App mobile - Amplify
  2. Tácticas de arquitectura para favorecer la Facilidad de modificación
  3. Tácticas de arquitectura para favorecer la Disponibilidad
  4. Tácticas de arquitectura para favorecer la Escalabilidad
  5. Tácticas de arquitectura para favorecer la Seguridad

Diagrama de arquitectura actualizado

A partir del diagrama de despliegue de los servicios de negocio y de infraestructura implementados en el proyecto, se procede con la revisión de los elementos de arquitetectura definidos en la visión.

image

Implementación Front-end y App mobile - Amplify

Tecnología escogida para el desarrollo

Como tecnología para el desarrollo de la aplicación de Frontend se escogió NextJs. Esta decisión se basa en el conocimiento previo de este framework, su versatilidad, la capacidad de tener componentes del lado del cliente así como del lado del servidor y poder tener su propio servidor y capacidad de crear un api de ser necesario.

Adicionalmente, esta tecnología permite crear una PWA de forma sencilla, lo que hace que la aplicación web potencialmente pueda convertirse en una aplicación móvil híbrida. Con el uso de TWAs (Trusted Web Apps) podemos generar un proyecto móvil en Kotlin que sea un wrapper para la aplicación web, permitiendo su instalación como cualquier aplicación móvil.

Lo anterior, favorece la facilidad de modificación de la aplicación web, ya que no es necesario administrar y desarrollar más de un proyecto para suplir las necesidades de web y móvil. Un cambio en el proyecto, una vez desplegado, es aplicado de inmediato en la aplicación móvil que se tenga instalada.

Aspectos generales de la aplicación

Como aspectos a destacar de la aplicación tenemos:

  • Se hace uso de componentes de la librería de Material UI, lo cuál permite incluir accesibilidad, una UI limpia y elegante con componentes out-of-the-box que permiten tener una mayor velocidad en el desarrollo
  • Incluye internacionalización, con soporte en los idiomas Inglés y Español. Además con la flexibilidad para incluir idiomas con modo de lectura LTR en poco tiempo.
  • Cache del lado del servidor de la aplicación, para ofrecer una mejor experiencia de usuario y una apariencia de menor latencia.

Uso de AWS Amplify

Cómo servicio para el alojamiento y proceso de CI/CD se escogió Amplify de AWS. Este es un servicio que funciona como un PaaS, ofrece una plataforma para integración y despliegue continuo con mínima configuración.

Amplify se conectó al repositorio en Github para generar el proceso de integración continua.

2 Integracion continua Amplify

Para el despliegue continuo de configuró a través de un archivo .yml los pasos del build de la aplicación:

version: 1
frontend:
  phases:
    preBuild:
      commands:
        - npm ci
    build:
      commands:
        - npm run build
        - env | grep -e NEXT_PUBLIC_ >> .env.production
        - env | grep -e NEXTAUTH_ >> .env.production
  artifacts:
    baseDirectory: .next
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*

3 Configuracion build amplify

Con esta configuración se tiene un proceso de CI/CD en mayor parte administrado por AWS. Lo que nos permitió enfocarnos en el desarrollo de las funcionalidades requeridas.

1 Despliegue continuo Amplify

Monitoreo

También se hace uso de herramientas de monitoreo de la aplicación, se revisaron las métricas y setearon alarmas para identificar errores que podríamos tener. También tuvimos acceso a log que permitieron identificar errores en la aplicación desplegada.

Métricas:

4 metrics

Alarmas:

5 alarms

Logs de Acceso:

6 access logs

Logs Cloudwatch:

7 logs cloudwatch

Configuración de PWA y TWA

Para la configuración de la PWA (Progressive Web App) y TWA (Trusted Web App) se siguieron los lineamientos de Google para que una aplicación pueda ser una PWA. Esto es generar la metadata necesaria, poner públicos los ícono e imágenes en las diferentes resoluciones, entre otros requisitos.

next.config.js

/** @type {import('next').NextConfig} */

const withPWA = require("@ducanh2912/next-pwa").default({
  dest: "public",
});

const nextConfig = withPWA({});

module.exports = nextConfig;

Metadata in src/app/[locale]/layout.tsx

const APP_NAME = 'ABC Jobs';
const APP_DEFAULT_TITLE = 'ABC Criollos App';
const APP_TITLE_TEMPLATE = '%s - ABC Criollos App';
const APP_DESCRIPTION = 'ABC App created as the final project for the MISO Master Degree in the University of Los Andes';

export const metadata: Metadata = {
  title: {
    default: APP_DEFAULT_TITLE,
    template: APP_TITLE_TEMPLATE,
  },
  description: APP_DESCRIPTION,
  applicationName: APP_NAME,
  manifest: "/manifest.json",
  appleWebApp: {
    capable: true,
    title: APP_DEFAULT_TITLE,
    statusBarStyle: 'black-translucent',
  },
  formatDetection: {
    telephone: false,
  },
  themeColor: 'black',
  icons: {
    icon: '/icon-512x512.png',
    shortcut: '/icon-512x512.png',
    apple: '/apple-icon-180.png',
  },
  twitter: {
    card: 'summary_large_image',
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
  },
  openGraph: {
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
    siteName: APP_NAME,
    locale: 'es_CO',
    type: 'website',
  },
  viewport: {
    width: 'device-width',
    initialScale: 1,
    maximumScale: 1,
  },
}

Esta configuración es necesaria, entre otras cosas porque permite la creación de Service Workers los cuales hacen posible cachear adecuadamente los recursos de un sitio web haciéndolos disponibles cuando el dispositivo del usuario está online. Esto es necesario para poder convertir la aplicación web en una aplicación móvil.

También es necesario la configuración e inclusión de un archivo manifest.json, el cual contiene información relativa a la configuración de la aplicación móvil:

{
  "orientation": "landscape",
  "theme_color": "#000000",
  "background_color": "#FFFFFF",
  "display": "standalone",
  "scope": "/",
  "start_url": "/",
  "name": "ABC Jobs",
  "short_name": "ABC",
  "description": "ABC App created as the final project for the MISO Master Degree in the University of Los Andes",
  "icons": [
    {
      "src": "public/manifest-icon-192.maskable.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "public/manifest-icon-192.maskable.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "public/manifest-icon-512.maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "public/manifest-icon-512.maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "Dashboard",
      "url": "/dashboard",
      "description": "Takes you to the dashboard",
      "icons": [
        {
          "src": "icon-96x96.png",
          "type": "image/png",
          "sizes": "96x96"
        }
      ]
    }
  ],
  "dir": "ltr",
  "lang": "es",
  "prefer_related_applications": false
}

Finalmente, para poder establecer una relación de confianza entre la aplicación web y la aplicación móvil, en la aplicación web se debe subir un archivo assetlinks.json en la carpeta public/.well-known. Este archivo incluye una relación que está firmada pon un sha256 certificate el cuál comparte con la app móvil

[{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.amplifyapp.d26g72fqjv2yn8.develop.twa",
        "sha256_cert_fingerprints": ["72:EE..."]
      }
    }]

Finalmente, para la generación de APK a partir de la aplicación de NextJs se usó la herramienta PWABuilder.

Tácticas de arquitectura para favorecer la Facilidad de modificación

  • Mantener una alta cohesión
  • Mantener un bajo acoplamiento
  • Reducir el tamaño de los módulos - dividir módulos.

Para favorecer el atributo de facilidad de modificación, se diseñaron e implementaron componentes tipo microservicios, aplicando tácticas de componentes de de bajo tamaño, con única responsabilidad, bajo acoplamiento y alta cohesión, permitiendo que a futuro los cambios solicitados a futuro impacten solo los servicios requeridos, sin genear impacto en el resto de servicios del sistema.

Para la implementción de cada micro, inicialmente se define el modelo del dominio de negocio, por ejemplo para la prueba, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_cmd/model.py:

class Test(db.Model):
    __tablename__ = "tests"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50))
    numQuestions = db.Column(db.Integer)
    minLevel = db.Column(db.Integer)
    profiles = db.relationship('Profile', cascade='all, delete, delete-orphan') #, back_populates="test"
    techSkills = db.relationship("TechnicalSkill", cascade='all, delete, delete-orphan')
    questions = db.relationship("Question", cascade='all, delete, delete-orphan')
    createdAt = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
...
class Question(db.Model):
    __tablename__ = "tests_questions"
    id = db.Column(db.Integer, primary_key=True)
    testId = db.Column(db.Integer, db.ForeignKey('tests.id'))
    question = db.Column(db.String(512))
    level = db.Column(db.Integer)
    url = db.Column(db.String(256))
    answers = db.relationship("Answer", cascade='all, delete, delete-orphan') # , back_populates="question"
    # test = db.relationship("Test", back_populates="questions")
    # def __repr__(self):
    #     return f'<Question "{self.testId, self.level, self.question, self.answers}">'

class Answer(db.Model):
    __tablename__ = "tests_answers"
    id = db.Column(db.Integer, primary_key=True)
    questionId = db.Column(db.Integer, db.ForeignKey('tests_questions.id'))
    answer = db.Column(db.String(240))
    correct = db.Column(db.Boolean, default=False)
    # question = db.relationship("Question", back_populates="answers")
    # def __repr__(self):
    #     return f'<Answer "{self.questionId, self.answer, self.correct}">'

Luego se implementan los métodos de cada servicio a exponer, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_cmd/view.py

class CreateTest(Resource):

    def post(self):
        
        filename = None
        if 'file' not in request.files:
            return "Test was not created - file missing", 400

        file = request.files['file']
        if file.filename == '':
            return "Test was not created - no selected file", 400
        ...

        #data = request.get_json()
        data = json.load(file)
        name = type = numQuestions = minLEvel = profiles = techSkills = questions = None

        ...

        for item in data["questions"]:
            if "question" not in item or item["question"] is None:
                return "Test was not created - question is required", 400
            elif "level" not in item or item["level"] is None:
                return "Test was not created - level's question is required", 400
            elif "url" not in item or item["url"] is None:
                return "Test was not created - url's question is required", 400
            elif "answers" not in item or item["answers"] is None:
                return "Test was not created - answers' question are required", 400
            for item_answer in item["answers"]:
                if "answer" not in item_answer or item_answer["answer"] is None:
                    return "Test was not created - answer is required", 400
                elif "correct" not in item_answer or item_answer["correct"] is None:
                    return "Test was not created - correctness' answer is required", 400                
        ...                

        for item in questions:
            new_question = Question(question=item["question"], level=item["level"], url=item["url"], testId=new_test.id)
            for item_answer in item["answers"]:
                new_answer = Answer(answer=item_answer["answer"], correct=item_answer["correct"], questionId=new_question.id) #questionIdId=item_tech["id"]
                new_question.answers.append(new_answer)
                #print("    answer: ", new_answer)
            new_test.questions.append(new_question)
            #print("  question: ", new_question)    
        #print("")
        #print("done: ", new_test)
        db.session.add(new_test)
        db.session.commit()

        test_created = Test.query.filter(Test.name == name).filter(Test.minLevel==minLevel).order_by(Test.createdAt.desc()).first()
        return test_schema.dump(test_created), 201

Posteriormente se definen las rutas de los endpoints y su respectivo controller, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_cmd/app.py

api = Api(app)
api.add_resource(CreateTest, '/tests' )
api.add_resource(HealthCheck,'/tests/ping' )

En este punto procedemos con las pruebas unitaras para cada flujo dentro de cada servicio, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_cmd/tests/test_pruebas_cmd.py, utilizando la libreria faker para generar la data de la prueba:

class PruebasCmd(TestCase):

    def setUp(self):
        self.client = app.test_client()
        self.fake = Faker()
        ...
        numQuestions = self.fake.random_element(elements=(5, 10, 15, 20))

        testProfiles = [
            {"profile": name1 + self.fake.job()},
            {"profile": name1 + self.fake.job()}
        ]

        testTechSkills = [
            {"skill": self.fake.word(ext_word_list=skills1)}, 
            {"skill": self.fake.word(ext_word_list=skills2)}
        ]

        testQuestions = []
        for x in range(minLevel, 6): 
            for y in range(numQuestions*3):
                answers = []
                for z in range(5):
                    answers.append({"answer": name1 + self.fake.bs(), "correct": False})
                answers[self.fake.random_int(0, 4)] = {"answer": name1 + name3 + " "+ self.fake.bs(), "correct": True}

                question = {
                    "question": name1 + name2 + self.fake.bs() +"?", 
                    "level": x, 
                    "url": self.fake.url(), 
                    "answers": answers
                }
                testQuestions.append(question)

        self.prueba = {
            "name":  "Test "+ name1 + name2 + name3, 
            "numQuestions": numQuestions, 
            "minLevel": minLevel, 
            "profiles": testProfiles, 
            "techSkills": testTechSkills, 
            "questions": testQuestions,
        }
        #self.prueba = copy.deepcopy(self.test)
        
        self.data = {}
        self.endpoint = '/tests'
        self.endpoint_health = '/tests/ping'
    ... 
    def test_health_check(self):
        req_health = self.client.get(self.endpoint_health, headers={'Content-Type': 'application/json'})
        self.assertEqual(req_health.status_code, 200)

    def test_create_test_400_file_missing(self):
        resp_create = self.client.post(self.endpoint, headers=self.headers_token, data=json.dumps(self.prueba)) 
        data = json.loads(resp_create.get_data())
        self.assertEqual(resp_create.status_code, 400)
        self.assertEqual(data, "Test was not created - file missing")

    def test_create_test_400_no_selected_file(self):
        input_json = json.dumps({}, indent=4).encode("utf-8")        
        self.data['file'] = FileStorage( stream=io.BytesIO(input_json), content_type="application/json", filename='') 
        resp_create = self.client.post(self.endpoint, headers=self.headers_token, content_type='multipart/form-data', data=self.data) # follow_redirects=True
        data = json.loads(resp_create.get_data())
        self.assertEqual(resp_create.status_code, 400)
        self.assertEqual(data, "Test was not created - no selected file")

    ... 
    def test_create_test_412_num_questions(self):
        self.prueba["numQuestions"] = self.fake.country_code()
        input_json = json.dumps(self.prueba, indent=4).encode("utf-8")
        self.data['file'] = FileStorage( stream=io.BytesIO(input_json), content_type="application/json", filename="test.json", )
        resp_create = self.client.post(self.endpoint, headers=self.headers_token, content_type='multipart/form-data', data=self.data) # follow_redirects=True
        data = json.loads(resp_create.get_data())
        self.assertEqual(resp_create.status_code, 412)
        self.assertEqual(data, "Test was not created - number of questions is not valid")

    def test_create_test_412_num_questions_greater_than_50(self):
        self.prueba["numQuestions"] = self.fake.random_int(51, 99)
        input_json = json.dumps(self.prueba, indent=4).encode("utf-8")
        self.data['file'] = FileStorage( stream=io.BytesIO(input_json), content_type="application/json", filename="test.json", )
        resp_create = self.client.post(self.endpoint, headers=self.headers_token, content_type='multipart/form-data', data=self.data) # follow_redirects=True
        data = json.loads(resp_create.get_data())
        self.assertEqual(resp_create.status_code, 412)
        self.assertEqual(data, "Test was not created - number of questions is not valid")

    def test_create_test_201(self):
        # print()
        # print(json.dumps(self.prueba, indent=4))
        input_json = json.dumps(self.prueba, indent=4).encode("utf-8") #sort_keys=True, indent=4
        self.data['file'] = FileStorage( stream=io.BytesIO(input_json), content_type="application/json", filename="test.json", )
        resp_create = self.client.post(self.endpoint, headers=self.headers_token, content_type='multipart/form-data', data=self.data) # follow_redirects=True
        data = json.loads(resp_create.get_data())
        self.assertEqual(resp_create.status_code, 201)
        self.assertEqual(data["name"], self.prueba["name"])
        self.assertEqual(data["minLevel"], self.prueba["minLevel"])
        self.assertEqual(data["numQuestions"], self.prueba["numQuestions"])

Una vez pasadas con éxito las pruebas unitarias y además de cumplir un coverage superior al 80%:

cd ../pruebas_cmd
python -m unittest tests/test_pruebas_cmd.py -v
pipenv run pytest --cov=. -v -s --cov-fail-under=80

se genera el dockerfile que será utilizado por el pipeline para construir la imagen y cargarla al respectivo repositorio ECR del servicio:

https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_cmd/Dockerfile

FROM python:3.11-alpine
LABEL author="m.agonf@uniandes.edu.co"
EXPOSE 80
WORKDIR /app
COPY requirements.txt /app
RUN apk add --update curl
RUN pip install -r requirements.txt
COPY . /app
CMD [ "flask", "run", "--host=0.0.0.0", "--port=80" ]

El pipeline CI/CD llama un workflow reutilizable por cada servicio que se requiera desplegar, previa creación de la respectiva infraestructura, utilizando terraform:

https://github.com/andergcp2/backend-proyecto-final/blob/main/.github/workflows/dev.yml

name: Grupo-18 CI/CD Dev

on:
  push:
    branches:
      - develop
  pull_request:
    branches: 
      - develop

# check for run job based on file changes in GitHub Actions
# https://how.wtf/run-workflow-step-or-job-based-on-file-changes-github-actions.html
jobs:
  projects:
    uses: ./.github/workflows/test-build-deploy.yml
    with:
       microservice: projects
       microservice-path: ./projects
    secrets: inherit
...

En el workflow reutilizable se ejecutan las pruebas unitarias del respectivo micro:

https://github.com/andergcp2/backend-proyecto-final/blob/main/.github/workflows/test-build-deploy.yml

 unit-test:
    name: unit tests
    needs: changes
    if: ${{ needs.changes.outputs.src == 'true' }}
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Setup Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
    - name: Install dependencies
      working-directory: ${{ inputs.microservice-path }} 
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    - name: Test with pytest
      working-directory: ${{ inputs.microservice-path }} 
      run: |
        pip install pytest pytest-cov
        pytest --cov=. -v -s --cov-fail-under=80

Además se construye la imagen, se carga al repositorio ECR y se actualiza el servicio en el cluster ECS:

  build-image-deploy:
    name: push image and update service
    runs-on: ubuntu-latest
    needs: unit-test
    steps:
    - uses: actions/checkout@v4
    - name: Configurar Credenciales AWS
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    - name: Iniciar sesión en AWS ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v2

    - name: Crear, etiquetar y enviar imágen a AWS ECR
      working-directory: ${{ inputs.microservice-path }}    
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: ${{ inputs.microservice }}
        IMAGE_TAG: latest
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

    - name: Actualizar versión en ECS
      run: |
        aws ecs update-service --cluster ${{ env.ECS_CLUSTER }} --service ${{ inputs.microservice }}-service --task-definition ${{ inputs.microservice }}-task --force-new-deployment

Una vez se actualice la rama feature en el repositorio del backend y se integren los cambios a la rama develop, se ejecuta el pipeline:

image

Se actualiza el repositorio ECR:

image

Se actualiza la definición de la tarea:

image

Se actualiza el servicio ECS de cada micro en el cluster ECS (infraestructura previamente creada con terraform):

image

image

Además de las reglas del listener del Application Load Balancer:

image

Y los respectivos logs CloudWatch para facilitar el monitoreo de eventos:

image

Tácticas de arquitectura para favorecer la Disponibilidad

  • Detectar fallas: healthcheck
  • Enmascarar fallas: identificar y retirar componente defectuoso
  • Recuperación de fallos: reinicio de componente

Para favorecer el atributo de disponibilidad se implementa el estilo de arquitectura Microservicios para aprovechar las funcionalidades de Elastic Container Service para la detección de fallas, el retiro de componente(s) defectuoso(s) y su respectivo reinicio.

En cada micro se implementa un endpoint para el health-check, por ejemplo:

https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_taker/view.py

class HealthCheck(Resource):
    def get(self):
        return "ok"

https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_taker/app.py

api = Api(app)
api.add_resource(HealthCheck, '/tests-taker/ping')
api.add_resource(PruebaInit, '/tests-taker/init/<candidatoId>/<pruebaId>')
api.add_resource(PruebaNext, '/tests-taker/next/<candidatoId>/<pruebaId>')
api.add_resource(PruebaDone, '/tests-taker/done/<candidatoId>/<pruebaId>')
  

Y luego, para cada micro, se configura la respectiva ruta de healt-check dentro del archivo de variables terraform, https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/terraform.tfvars:

  "pruebas-taker" = {
    name             = "pruebas-taker"
    ... 
    alb_target_group = {
      port              = 80
      protocol          = "HTTP"
      path_pattern      = ["/tests-taker*"]
      health_check_path = "/tests-taker/ping"
      priority          = 1
    }
    ...
  }

La cual será configurada dentro del target group del Application Load Balancer:

https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/modules/alb/main.tf

#Dynamically create the alb target groups for app services
resource "aws_alb_target_group" "alb_target_group" {
  for_each = var.target_groups
  name = "${lower(each.key)}-tg"
  port = each.value.port
  protocol = each.value.protocol
  target_type = "ip"
  vpc_id = var.vpc_id

  health_check {
    path = each.value.health_check_path
    protocol = each.value.protocol
  }
}

Application Load Balancer:

image

El target group se evidencia el estado saludable del servicio:

image

Finalmente, a nivel de infraestructura, en el archivo de variables terraform https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/terraform.tfvars, se definen dos zonas de disponibilidad de la región us-east-1:

## Application configurations
account      = 101526122836
region       = "us-east-1"
profile      = "default"
app_name     = "abcjobs"
env          = "dev"
app_services = ["interviews", "pruebas-taker", "pruebas-qry", "pruebas-cmd", "candidatos-tests", "candidatos-qry", "candidatos-cmd", "collaborators", "companies", "projects"]

#VPC configurations
cidr               = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
public_subnets     = ["10.10.50.0/24", "10.10.51.0/24"]
private_subnets    = ["10.10.0.0/24", "10.10.1.0/24"]

Tácticas de arquitectura para favorecer la Escalabilidad

Para favorecer la escalabilidad, dentro de la táctica de manejo de recursos compartidos, se implementan múltiples copias de computación y múltiples copias de datos con el objetivo de crecer y decrecer horizontalmente de forma tal que permita atender la demanda de los diferentes servicios.

Manejo de recursos compartidos - múltiples copias de computación

Para múltiples copias de computación, se definen para su respectivo aprovisionamiento en AWS, dentro del archivo de variables terraform https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/terraform.tfvars, los parámetros de auto-scaling (capacidad mínima y máxima, además de la política de uso de memoria y cpu) :

  "pruebas-taker" = {
    name             = "pruebas-taker"
    ...
    auto_scaling = {
      max_capacity = 2
      min_capacity = 1
      cpu          = {
        target_value = 75
      }
      memory = {
        target_value = 75
      }
    }
  }

image

Manejo de recursos compartidos - múltiples copias de datos - ElastiCache for Redis

Para múltiples copias de datos, se define para su respectivo aprovisionamiento en AWS, dentro del archivo principal de terraform https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/main.tf, un Cluster ElastiCache for Redis:

resource "aws_elasticache_cluster" "redis" {
  cluster_id              = "abcjobs-redis-cluster"
  engine                  = "redis"
  node_type               = "cache.t3.micro"
  num_cache_nodes         = 1
  port                    = 6379
  subnet_group_name       = aws_elasticache_subnet_group.default.name
  security_group_ids      = [aws_security_group.redis.id]
  #security_group_ids     = ["${aws_security_group.redis.id}"]
  #parameter_group_name   = "default.redis3.2"
  tags = {
    Name = "redis-cluster"
  }
}

image

El almacén de datos en memoria ElastiCache for Redis brinda latencias inferior a un milisegundo para aplicaciones en tiempo real, útil para favorecer los ASRs de disponibilidad y latencia relacionados con la presentación de las pruebas técnicas, implementadas en el micro pruebas-taker, el cual recibe los parámetros de la cache como variable de ambiente, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_taker/app.py,

    app.config['CACHE_HOST']  = str(os.environ.get("CACHE_PATH"))
    app.config['CACHE_PORT']  = str(os.environ.get("CACHE_PORT"))
    app.config['CANDIDATOS_QUERY'] = str(os.environ.get("CANDIDATOS_PATH"))
    app.config['PRUEBAS_QUERY'] = str(os.environ.get("PRUEBAS_PATH"))
    app.config['CANDIDATOS_PRUEBAS'] = str(os.environ.get("CANDIDATOS_PRUEBAS_PATH"))

Nota: el paso de las variables de ambiente se realiza en el archivo principal del módulo ECS de los scripts terraform del proyecto, las cuales se configuran previamente en AWS Secrets Manager:

aws secretsmanager create-secret --name redis_host --secret-string abcjobs-redis-cluster.kneypx.0001.use1.cache.amazonaws.com
aws secretsmanager create-secret --name redis_port --secret-string 6379

image

https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/modules/ecs/main.tf

  container_definitions = jsonencode([
    {
      name         = each.value.name
      #image        = "${var.account}.dkr.ecr.${var.region}.amazonaws.com/${lower(var.app_name)}-${lower(each.value.name)}:latest"
      image        = "${var.account}.dkr.ecr.${var.region}.amazonaws.com/${lower(each.value.name)}:latest"
      cpu          = each.value.cpu
      memory       = each.value.memory
      essential    = true
      secrets = [
        {
          name      = "CACHE_PATH"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:redis_host-bDx9aw"
        },
        {
          name      = "CACHE_PORT"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:redis_port-xRwupn"
        },
        {
          name      = "CANDIDATOS_PATH"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:candidatos_qry_path-UqZKuK"
        },
        {
          name      = "PRUEBAS_PATH"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:pruebas_qry_path-Mcg4ke"
        },
        ...

Durante la presentación de la prueba, se consulta inicialmente el banco de preguntas y respiuesta de la prueba para su almacenamiento y uso a partir del caché, https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_taker/view.py, utilizando como llave el id de la prueba y el id del candidato:

def deleteCache(self, key):
    self.redis.delete(key)
    print("delete-cache")

def setCache(self, key, data):
    self.redis.set(key, json.dumps(data))

def getCache(self, key):
    return json.loads(self.redis.get(key))
def setupCache(self, fase):
    print("setup-cache")
    print(fase, current_app.config['CACHE_HOST'], current_app.config['CACHE_PORT'] )
    #self.redis = redis.Redis(host=current_app.config['CACHE_HOST'], port=current_app.config['CACHE_PORT'], decode_responses=True, ssl=True) #encoding="utf-8"
    pool = redis.ConnectionPool(host=current_app.config['CACHE_HOST'], port=current_app.config['CACHE_PORT'], db=0)
    self.redis = redis.Redis(connection_pool=pool)
    try:
        cache_is_working = self.redis.ping()    
        print(fase, "connected to redis")
    except Exception as ex:
        print(fase, 'exception: host could not be accessed: {}'.format(ex))
...
        data = {
            'pruebaId': prueba['id'],
            'candidatoId': candidato['id'],
            "totalQuestions": prueba['numQuestions'],
            "numQuestion": 1, 
            "answersOK": 0, 
            "prueba": prueba,
            "candidato": candidato,
        }

        idcache = pruebaId+"-"+candidatoId
        deleteCache(self, idcache)
        setCache(self, idcache, data)
        test = getCache(self, idcache)
...
        idcache = pruebaId+"-"+candidatoId
        test = getCache(self, idcache)
        # 404 si no existen los parametros como llave en la cache
        if(test is None):
            return "this test was not started by candidate", 404

        # 412 no debe ser la ultima pregunta
        if (numQuestion == totalQuestions):
            return "this question should not be the last question {}/{}".format(numQuestion, totalQuestions), 412

        idx = numQuestion
        numQuestion +=1

        respuestas = test['prueba']['questions'][idx-1]['answers']
        for x in range(len(respuestas)):
            if(respuestas[x]['id'] == answerId and respuestas[x]['correct']):
                test['answersOK'] = test['answersOK'] +1
                setCache(self, idcache, test)
                #print("next-question ok ", idx, test['answersOK'])

        answers = []
        respuestas = test['prueba']['questions'][idx]['answers']
        for x in range(len(respuestas)):
            answers.append({"id": respuestas[x]['id'], "answer": respuestas[x]['answer']})

        resp_next = {
            'pruebaId': test['prueba']['id'],
            'candidatoId': test['candidato']['id'],
            'question': {'id': test['prueba']['questions'][idx]['id'], 'question': test['prueba']['questions'][idx]['question']},
            'answers': answers,
            'totalQuestions': test['prueba']['numQuestions'], 
            'numQuestion': numQuestion, 
        }

Manejo de recursos compartidos - múltiples copias de datos - Amazon Aurora

Finalmente, para múltiples copias de datos, con el objetivo de implementar el patrón CQRS, se configura un cluster de instancias de bases de datos Amazon Aurora, incluyendo una réplica en modo Read Only. De esta manera, se optimizan las consultas de los micros candidatos-qry y pruebas-qry, utilizados en el proceso de presentación de la prueba, favoreciendo atributos relacionados al desempeño.

https://github.com/andergcp2/backend-proyecto-final/blob/main/pruebas_qry/app.py

    app.config['SQLALCHEMY_DATABASE_URI'] = "postgresql://"+ str(os.environ.get("DB_USER")) +":"+ str(os.environ.get("DB_PASSWORD")) +"@"+ str(os.environ.get("DB_HOST_READ")) +":"+ str(os.environ.get("DB_PORT")) +"/"+ str(os.environ.get("DB_NAME"))
    print("prod: ", app.config['SQLALCHEMY_DATABASE_URI'], app.config['USERS'])

Nuevamente, estas variables de ambiente se configuran desde la definición de la tarea en el módulo ECS de los scripts terraform:

https://github.com/andergcp2/backend-proyecto-final/blob/main/aws-tf/modules/ecs/main.tf

        ...
        {
          name      = "DB_USER"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:rds_usr-qt5O4R"
        }, 
        {
          name      = "DB_PASSWORD"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:rds_pwd-i8ebsf"
        }, 
        {
          name      = "DB_HOST"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:rds_host-EkVWVQ"
        }, 
        {
          name      = "DB_HOST_READ"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:aurora_host_read-5osQT0"
        }, 
        {
          name      = "DB_PORT"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:rds_port-xETcnO"
        }, 
        {
          name      = "DB_NAME"
          valueFrom = "arn:aws:secretsmanager:us-east-1:101526122836:secret:rds_name-KBdDNB"
        },  
        ...

Implementación de Aurora en el cluster

image

Como estrategia para mejorar la disponibilidad y el desempeño de la base de datos se escogió Aurora. A este se le agregó una instancia de escritura mejorando las capacidades del cluster para atender las peticiones y beneficiar varios atributos que rigen nuestro proyecto. Si se incrementa la cantidad de usuarios concurrentes de la aplicación se pueden configurar más instancias que soporten la nueva carga.

Los benerficios de usar varias instancias de base de datos son:

  • Ajuste de escala para réplicas de lectura bajo demanda
  • Replicas dedicadas y optimizadas para operaciones de lectura
  • El volumen del clúster se comparte entre todas las instancias en su clúster de base de datos Aurora PostgreSQL. Por lo tanto, no se necesita trabajo adicional para replicar una copia de los datos de cada réplica Aurora
  • Aurora PostgreSQL mejora la disponibilidad de lectura en el clúster de base de datos al atender continuamente las solicitudes de lectura cuando la instancia de base de datos del escritor se reinicia o cuando la réplica de Aurora no puede seguir el ritmo del tráfico de escritura.
  • Adicionalmente las instancias que se usan son Serverless v2 en la cuales la capacidad se ajusta automáticamente en función de la demanda de la aplicación.

Tomado de: https://docs.aws.amazon.com/es_es/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.Replication.html#AuroraPostgreSQL.Replication.Replicas.SRO

Tácticas de arquitectura para favorecer la Seguridad

Cognito en conjunto con el Api Gateway se usaron para para proteger y administrar el acceso de los recursos de los clientes. La configuración se hizo de la siguiente manera, Creación de Cognito User Pool con terraform: https://github.com/andergcp2/backend-proyecto-final/blob/main/aws/terraform/1-cognito.tf

resource "aws_cognito_user_pool" "mi_user_pool" {
  name = "user-pool"
  
  password_policy {
    minimum_length = 6
  }

  verification_message_template {
    default_email_option = "CONFIRM_WITH_CODE"
    email_subject = "Account Confirmation"
    email_message = "Your confirmation code is {####}"
  }

  schema {
    attribute_data_type      = "String"
    developer_only_attribute = false
    mutable                  = true
    name                     = "email"
    required                 = true

    string_attribute_constraints {
      min_length = 1
      max_length = 256
    }
  }

  lambda_config {
    pre_sign_up    = aws_lambda_function.pre_sign_up.arn
  }
}

resource "aws_cognito_user_pool_client" "mi_user_pool_client" {
  name = "cognito-client"

  user_pool_id = aws_cognito_user_pool.mi_user_pool.id
  generate_secret = false
  refresh_token_validity = 90
  prevent_user_existence_errors = "ENABLED"
  explicit_auth_flows = [
    "ALLOW_REFRESH_TOKEN_AUTH",
    "ALLOW_USER_PASSWORD_AUTH",
    "ALLOW_ADMIN_USER_PASSWORD_AUTH"
  ]
  
}

Consola de administración de Cognito

image

Para la creación de usuarios se creo un Lambda y se expuso desde el Api Gateway. Esta se comunica directamente con Cognito permitiendo agregar nuevos usuarios y definir su Rol. https://github.com/andergcp2/backend-proyecto-final/blob/main/aws/terraform/Lambda/login/index.js

const AWS = require('aws-sdk');
const cognito = new AWS.CognitoIdentityServiceProvider();

exports.handler = async (event, context) => {
    const { username, password, email, groupName, idDb } = event;

    if (!event || !event.username || !event.password || !event.email || !event.groupName || !event.idDb) {
        console.error('Missing required input parameters');
        throw new Error('Missing required input parameters');
    }

    const cognitoClientId = process.env.COGNITO_CLIENT_ID; // Retrieve Cognito Client ID from environment variable

    // Sign up the user in Cognito
    const signUpParams = {
        ClientId: cognitoClientId,
        Username: username,
        Password: password,
        UserAttributes: [
            {
                Name: 'email',
                Value: email,
            },
            {
                Name: 'custom:Role',
                Value: groupName,
            },
            {
                Name: 'custom:idDb', 
                Value: idDb,
            }
        ],
    };

    try {
        await cognito.signUp(signUpParams).promise();
        console.log('User successfully signed up');
    } catch (error) {
        if (error.code === 'UsernameExistsException') {
            console.error('Username already exists:', error);
            throw new Error('User with this username already exists');
        } else {
            console.error('Error signing up:', error);
            throw new Error('Sign-up failed');
        }
    }
    return {
        statusCode: 200,
        body: JSON.stringify({ message: 'User signed up successfully' }),
    };
};

Para la obtención del token se generó una Lambda que obtenía de Cognito el JWT, este se provisionaba en las peticiones y eran verificadas por el Api Gateway y su integración con Cognito https://github.com/andergcp2/backend-proyecto-final/blob/main/aws/terraform/Lambda/login/index.js

const AWS = require('aws-sdk');
const cognito = new AWS.CognitoIdentityServiceProvider();

exports.handler = async (event, context) => {
  const params = {
    AuthFlow: 'USER_PASSWORD_AUTH',
    AuthParameters: {
      USERNAME: event.username,
      PASSWORD: event.password
    },
    ClientId: process.env.COGNITO_CLIENT_ID // Reemplaza con el ID de tu cliente de Cognito
  };

  try {
    const response = await cognito.initiateAuth(params).promise();
    return {
      statusCode: 200,
      body: JSON.stringify(response.AuthenticationResult)
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(error.message)
    };
  }
};

### Api Gateway integración
Se crea Api Gateway con el script de terraform: https://github.com/andergcp2/backend-proyecto-final/blob/main/aws/terraform/2-apigtw.tf
resource "aws_api_gateway_rest_api" "mi_api" {
  name        = "Simple ABC (Terraform)"
  description = "Mi API Gateway"
}

# Recurso de Rol IAM para el autorizador de Cognito
resource "aws_iam_role" "mi_iam_role" {
  name = "mi-iam-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = "sts:AssumeRole",
        Effect = "Allow",
        Principal = {
          Federated = "cognito-identity.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_api_gateway_authorizer" "mi_authorizer" {
  name            = "mi-authorizer"
  rest_api_id      = aws_api_gateway_rest_api.mi_api.id
  type            = "COGNITO_USER_POOLS"
  identity_source = "method.request.header.Authorization"
  provider_arns   = [aws_cognito_user_pool.mi_user_pool.arn]
  authorizer_credentials = aws_iam_role.mi_iam_role.arn
}

image

Se agregó la instancia de Cognito para administrar la autorización

image

En el Api Gateway se crearon los recursos que queríamos exponer al app en Amplify, indicando cual debía ser protegido con Cognito. Estor recursos eran direccionados como proxy a los servicios expuestos por el Load Balancer.

image

Clone this wiki locally