diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..64ea552e9d Binary files /dev/null and b/.DS_Store differ diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index abe6ae79f3..0000000000 --- a/.gitattributes +++ /dev/null @@ -1,12 +0,0 @@ -# Set default behavior to automatically normalize line endings. -* text=auto - -# Force batch scripts to always use CRLF line endings so that if a repo is accessed -# in Windows via a file share from Linux, the scripts will work. -*.{cmd,[cC][mM][dD]} text eol=crlf -*.{bat,[bB][aA][tT]} text eol=crlf -*.{ics,[iI][cC][sS]} text eol=crlf - -# Force bash scripts to always use LF line endings so that if a repo is accessed -# in Unix via a file share from Windows, the scripts will work. -*.sh text eol=lf diff --git a/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE deleted file mode 100644 index 2ad58e518a..0000000000 --- a/.github/ISSUE_TEMPLATE +++ /dev/null @@ -1,40 +0,0 @@ -** PLEASE ONLY USE THIS ISSUE TRACKER TO SUBMIT ISSUES WITH THE EXAMPLE VOTING APP ** - -* If you have a bug working with Docker itself, not related to these labs, please file the bug on the [Docker repo](https://github.com/docker/docker) * -* If you would like general support figuring out how to do something with Docker, please use the Docker Slack channel. If you're not on that channel, sign up for the [Docker Community](http://dockr.ly/MeetUp) and you'll get an invite. * -* Or go to the [Docker Forums](https://forums.docker.com/) * - -Please provide the following information so we can assess the issue you're having - -**Description** - - - -**Steps to reproduce the issue, if relevant:** -1. -2. -3. - -**Describe the results you received:** - - -**Describe the results you expected:** - - -**Additional information you deem important (e.g. issue happens only occasionally):** - -**Output of `docker version`:** - -``` -(paste your output here) -``` - -**Output of `docker info`:** - -``` -(paste your output here) -``` - -**Additional environment details (AWS, Docker for Mac, Docker for Windows, VirtualBox, physical, etc.):** diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f9ecf576e1..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" diff --git a/.github/workflows/call-docker-build-result.yaml b/.github/workflows/call-docker-build-result.yaml deleted file mode 100644 index a946a87b03..0000000000 --- a/.github/workflows/call-docker-build-result.yaml +++ /dev/null @@ -1,82 +0,0 @@ -name: Build Result -# template source: https://github.com/dockersamples/.github/blob/main/templates/call-docker-build.yaml - -on: - # we want pull requests so we can build(test) but not push to image registry - push: - branches: - - 'main' - # only build when important files change - paths: - - 'result/**' - - '.github/workflows/call-docker-build-result.yaml' - pull_request: - branches: - - 'main' - # only build when important files change - paths: - - 'result/**' - - '.github/workflows/call-docker-build-result.yaml' - -jobs: - call-docker-build: - - name: Result Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: - - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true - - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-result - dockersamples/examplevotingapp_result - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: result - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: - - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 - - ### Create a PR comment with image tags and labels - ### defaults to false - # comment-enable: false diff --git a/.github/workflows/call-docker-build-vote.yaml b/.github/workflows/call-docker-build-vote.yaml deleted file mode 100644 index cb4a484a2a..0000000000 --- a/.github/workflows/call-docker-build-vote.yaml +++ /dev/null @@ -1,82 +0,0 @@ -name: Build Vote -# template source: https://github.com/dockersamples/.github/blob/main/templates/call-docker-build.yaml - -on: - # we want pull requests so we can build(test) but not push to image registry - push: - branches: - - 'main' - # only build when important files change - paths: - - 'vote/**' - - '.github/workflows/call-docker-build-vote.yaml' - pull_request: - branches: - - 'main' - # only build when important files change - paths: - - 'vote/**' - - '.github/workflows/call-docker-build-vote.yaml' - -jobs: - call-docker-build: - - name: Vote Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: - - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true - - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-vote - dockersamples/examplevotingapp_vote - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=before,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=raw,value=after,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: vote - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: - - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 - - ### Create a PR comment with image tags and labels - ### defaults to false - # comment-enable: false diff --git a/.github/workflows/call-docker-build-worker.yaml b/.github/workflows/call-docker-build-worker.yaml deleted file mode 100644 index 5abfb6bc9c..0000000000 --- a/.github/workflows/call-docker-build-worker.yaml +++ /dev/null @@ -1,82 +0,0 @@ -name: Build Worker -# template source: https://github.com/dockersamples/.github/blob/main/templates/call-docker-build.yaml - -on: - # we want pull requests so we can build(test) but not push to image registry - push: - branches: - - 'main' - # only build when important files change - paths: - - 'worker/**' - - '.github/workflows/call-docker-build-worker.yaml' - pull_request: - branches: - - 'main' - # only build when important files change - paths: - - 'worker/**' - - '.github/workflows/call-docker-build-worker.yaml' - -jobs: - call-docker-build: - - name: Worker Call Docker Build - - uses: dockersamples/.github/.github/workflows/reusable-docker-build.yaml@main - - permissions: - contents: read - packages: write # needed to push docker image to ghcr.io - pull-requests: write # needed to create and update comments in PRs - - secrets: - - # Only needed if with:dockerhub-enable is true below - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - - # Only needed if with:dockerhub-enable is true below - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - with: - - ### REQUIRED - ### ENABLE ONE OR BOTH REGISTRIES - ### tell docker where to push. - ### NOTE if Docker Hub is set to true, you must set secrets above and also add account/repo/tags below - dockerhub-enable: true - ghcr-enable: true - - ### REQUIRED - ### A list of the account/repo names for docker build. List should match what's enabled above - ### defaults to: - image-names: | - ghcr.io/dockersamples/example-voting-app-worker - dockersamples/examplevotingapp_worker - - ### REQUIRED set rules for tagging images, based on special action syntax: - ### https://github.com/docker/metadata-action#tags-input - ### defaults to: - tag-rules: | - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=pr - - ### path to where docker should copy files into image - ### defaults to root of repository (.) - context: worker - - ### Dockerfile alternate name. Default is Dockerfile (relative to context path) - # file: Containerfile - - ### build stage to target, defaults to empty, which builds to last stage in Dockerfile - # target: - - ### platforms to build for, defaults to linux/amd64 - ### other options: linux/amd64,linux/arm64,linux/arm/v7 - # FIXME worker arm/v7 support doesn't build in .net core 3.1 with QEMU - # a fix would likely run the .net build on amd64 but with a target of arm/v7 - platforms: linux/amd64,linux/arm64,linux/arm/v7 - - ### Create a PR comment with image tags and labels - ### defaults to false - # comment-enable: false diff --git a/.gitignore b/.gitignore index 9a6f694990..cc8b90586d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ bin/ obj/ .vs/ node_modules/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +.terraform/ +dump.rdb +voting-app-key.pem diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 3e9f0bd7ac..0000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Node: Results debugger", - "type": "node", - "request": "attach", - "port": 9229, - "address": "localhost", - "skipFiles": [ - "/**" - ], - "remoteRoot": "/app", - "localRoot": "${workspaceFolder}/result" - } - ] -} \ No newline at end of file diff --git a/MAINTAINERS b/MAINTAINERS deleted file mode 100644 index a7f9d7c983..0000000000 --- a/MAINTAINERS +++ /dev/null @@ -1,9 +0,0 @@ -Bret Fisher -Michael Irwin - -# Alumni, thanks for your work! -Aanand Prasad -Ben Firshman -Fernando Mayo -Mano Marks -Maxime Heckel diff --git a/README.md b/README.md index 8516424ba1..2374b71a02 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,155 @@ -# Example Voting App + -A simple distributed application running across multiple Docker containers. +--- -## Getting started +# Multi-Stack Voting Application -Download [Docker Desktop](https://www.docker.com/products/docker-desktop) for Mac or Windows. [Docker Compose](https://docs.docker.com/compose) will be automatically installed. On Linux, make sure you have the latest version of [Compose](https://docs.docker.com/compose/install/). +**Welcome to your DevOps practice project!** This repository hosts a multi-stack voting application composed of several services, each implemented in a different language and technology stack. The goal is to help you gain experience with containerization, orchestration, and running a distributed set of services—both individually and as part of a unified system. -This solution uses Python, Node.js, .NET, with Redis for messaging and Postgres for storage. +This application, while simple, uses multiple components commonly found in modern distributed architectures, giving you hands-on practice in connecting services, handling containers, and working with basic infrastructure automation. -Run in this directory to build and run the app: +## Application Overview -```shell -docker compose up -``` +The voting application includes: -The `vote` app will be running at [http://localhost:8080](http://localhost:8080), and the `results` will be at [http://localhost:8081](http://localhost:8081). +- **Vote (Python)**: A Python Flask-based web application where users can vote between two options. +- **Redis (in-memory queue)**: Collects incoming votes and temporarily stores them. +- **Worker (.NET)**: A .NET 7.0-based service that consumes votes from Redis and persists them into a database. +- **Postgres (Database)**: Stores votes for long-term persistence. +- **Result (Node.js)**: A Node.js/Express web application that displays the vote counts in real time. -Alternately, if you want to run it on a [Docker Swarm](https://docs.docker.com/engine/swarm/), first make sure you have a swarm. If you don't, run: +### Why This Setup? -```shell -docker swarm init -``` +The goal is to introduce you to a variety of languages, tools, and frameworks in one place. This is **not** a perfect production design. Instead, it’s intentionally diverse to help you: -Once you have your swarm, in this directory run: +- Work with multiple runtimes and languages (Python, Node.js, .NET). +- Interact with services like Redis and Postgres. +- Containerize applications using Docker. +- Use Docker Compose to orchestrate and manage multiple services together. -```shell -docker stack deploy --compose-file docker-stack.yml vote -``` +By dealing with this “messy” environment, you’ll build real-world problem-solving skills. After this project, you should feel more confident tackling more complex deployments and troubleshooting issues in containerized, multi-service setups. -## Run the app in Kubernetes +--- -The folder k8s-specifications contains the YAML specifications of the Voting App's services. +## How to Run Each Component -Run the following command to create the deployments and services. Note it will create these resources in your current namespace (`default` if you haven't changed it.) +### Running the Vote Service (Python) Locally (No Docker) -```shell -kubectl create -f k8s-specifications/ -``` +1. Ensure you have Python 3.10+ installed. +2. Navigate to the `vote` directory: + ```bash + cd vote + pip install -r requirements.txt + python app.py + ``` + Access the vote interface at [http://localhost:5000](http://localhost:5000). + +### Running Redis Locally (No Docker) + +1. Install Redis on your system ([https://redis.io/docs/getting-started/](https://redis.io/docs/getting-started/)). +2. Start Redis: + ```bash + redis-server + ``` + Redis will be available at `localhost:6379`. + +### Running the Worker (C#/.NET) Locally (No Docker) + +1. Ensure .NET 7.0 SDK is installed. +2. Navigate to `worker`: + ```bash + cd worker + dotnet restore + dotnet run + ``` + The worker will attempt to connect to Redis and Postgres when available. + +### Running Postgres Locally (No Docker) + +1. Install Postgres from [https://www.postgresql.org/download/](https://www.postgresql.org/download/). +2. Start Postgres, note the username and password (default `postgres`/`postgres`): + ```bash + # On many systems, Postgres runs as a service once installed. + ``` + Postgres will be available at `localhost:5432`. + +### Running the Result Service (Node.js) Locally (No Docker) -The `vote` web app is then available on port 31000 on each host of the cluster, the `result` web app is available on port 31001. +1. Ensure Node.js 18+ is installed. +2. Navigate to `result`: + ```bash + cd result + npm install + node server.js + ``` + Access the results interface at [http://localhost:4000](http://localhost:4000). -To remove them, run: +**Note:** To get the entire system working end-to-end (i.e., votes flowing through Redis, processed by the worker, stored in Postgres, and displayed by the result app), you’ll need to ensure each component is running and that connection strings or environment variables point to the correct services. -```shell -kubectl delete -f k8s-specifications/ +--- + +## Running the Entire Stack in Docker + +### Building and Running Individual Services + +You can build each service with Docker and run them individually: + +- **Vote (Python)**: + ```bash + docker build -t myorg/vote:latest ./vote + docker run --name vote -p 8080:80 myorg/vote:latest + ``` + Visit [http://localhost:8080](http://localhost:8080). + +- **Redis** (official image, no build needed): + ```bash + docker run --name redis -p 6379:6379 redis:alpine + ``` + +- **Worker (.NET)**: + ```bash + docker build -t myorg/worker:latest ./worker + docker run --name worker myorg/worker:latest + ``` + +- **Postgres**: + ```bash + docker run --name db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:15-alpine + ``` + +- **Result (Node.js)**: + ```bash + docker build -t myorg/result:latest ./result + docker run --name result -p 8081:80 myorg/result:latest + ``` + Visit [http://localhost:8081](http://localhost:8081). + +### Using Docker Compose + +The easiest way to run the entire stack is via Docker Compose. From the project root directory: + +```bash +docker compose up ``` -## Architecture +This will: + +- Build and run the vote, worker, and result services. +- Run Redis and Postgres from their official images. +- Set up networks, volumes, and environment variables so all services can communicate. -![Architecture diagram](architecture.excalidraw.png) +Visit [http://localhost:8080](http://localhost:8080) to vote and [http://localhost:8081](http://localhost:8081) to see results. -* A front-end web app in [Python](/vote) which lets you vote between two options -* A [Redis](https://hub.docker.com/_/redis/) which collects new votes -* A [.NET](/worker/) worker which consumes votes and stores them in… -* A [Postgres](https://hub.docker.com/_/postgres/) database backed by a Docker volume -* A [Node.js](/result) web app which shows the results of the voting in real time +--- -## Notes +## Notes on Platforms (arm64 vs amd64) + +If you’re on an arm64 machine (e.g., Apple Silicon M1/M2) and encounter issues with images or dependencies that assume amd64, you can use Docker `buildx`: + +```bash +docker buildx build --platform linux/amd64 -t myorg/worker:latest ./worker +``` -The voting application only accepts one vote per client browser. It does not register additional votes if a vote has already been submitted from a client. +This ensures the image is built for the desired platform. -This isn't an example of a properly architected perfectly designed distributed app... it's just a simple -example of the various types of pieces and languages you might see (queues, persistent data, etc), and how to -deal with them in Docker at a basic level. +--- diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..e055ae038c --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,2 @@ +[defaults] +inventory = hosts.ini \ No newline at end of file diff --git a/ansible/backend.yml b/ansible/backend.yml new file mode 100644 index 0000000000..62f1b69fd9 --- /dev/null +++ b/ansible/backend.yml @@ -0,0 +1,131 @@ +--- +#####--------------------------------------------------- Play 1: Docker Installation ---------------------------------------------------##### + +- name: Ensure Docker is installed and running on the 'backend' instance + hosts: backend + become: yes + vars: + postgres_password: "postgres" + postgres_db: "voting_app_db" + postgres_user: "voting_user" + postgres_port: 5432 + tasks: + - name: Update apt package index + apt: + update_cache: yes + + + - name: Install Docker if not present + package: + name: docker.io + state: present + + - name: Add the user to the Docker group + ansible.builtin.user: + name: "ubuntu" + groups: docker + append: yes + state: present + + - name: Ensure Docker network "backend-network" exists + docker_network: + name: backend-network + state: present + ignore_errors: true + +#####--------------------------------------------------- Play 2: Running "Redis" and "Worker" Containers ---------------------------------------------------##### + +- name: Running "Redis" and "Worker" Containers + hosts: backend + become: yes + tasks: + ##### Redis Service ##### + - name: Pull the 'Redis' Docker image + ansible.builtin.docker_image: + name: redis:alpine + source: pull + + - name: Check if port 6379 is in use + shell: | + lsof -i :6379 | grep -v COMMAND | awk '{print $2}' || echo "" + register: port_check + changed_when: port_check.stdout != "" + ignore_errors: true + + - name: Kill process using port 6379 (if any) + shell: | + PID=$(lsof -ti :6379) + if [ -n "$PID" ]; then + kill -9 $PID + fi + register: kill_output + failed_when: false + changed_when: kill_output.stdout != "" + + - name: Stop all running containers that use port 6379 + docker_container: + name: "{{ item }}" + state: stopped + loop: "{{ lookup('docker_container', '', True).splitlines() }}" + when: item.ports is defined and '6379/tcp' in item.ports + failed_when: false + ignore_errors: true + + - name: Wait for port 6379 to be free + wait_for: + host: 10.0.2.150 + port: 6379 + state: stopped + timeout: 10 + when: port_check.stdout != "" + + - name: Run the 'Redis' container + ansible.builtin.docker_container: + name: redis + image: redis:alpine + state: started + networks: + - name: backend-network + restart_policy: always + exposed_ports: + - "6379" + published_ports: + - "6379:6379" + when: port_check.stdout == "" + failed_when: false + + - name: Verify the 'Redis' container is running + ansible.builtin.docker_container_info: + name: redis + register: redis_container_info + + ##### Worker Service ##### + - name: Pull the 'worker' Docker image + ansible.builtin.docker_image: + name: ghenac/voting-app-worker + tag: latest + source: pull + + - name: Run the 'worker' container + ansible.builtin.docker_container: + name: voting-app-worker + image: ghenac/voting-app-worker:latest + state: started + networks: + - name: backend-network + restart_policy: always + env: + REDIS_HOST: "redis" # This is how the worker will connect to Redis container by using the container name as hostname + REDIS_PORT: "6379" + DB_HOST: "10.0.2.174" + DB_USERNAME: "postgres" + DB_PASSWORD: "postgres" + DB_NAME: "voting-app-db" + + volumes: + - /tmp:/tmp + + - name: Verify the worker container is running + ansible.builtin.docker_container_info: + name: voting-app-worker + register: worker_container_info \ No newline at end of file diff --git a/ansible/db.yml b/ansible/db.yml new file mode 100644 index 0000000000..63debf508b --- /dev/null +++ b/ansible/db.yml @@ -0,0 +1,78 @@ +--- +#####--------------------------------------------------- Play 1: Docker Installation ---------------------------------------------------##### + +- name: Ensure Docker is installed and running on the 'DB' instance + hosts: db + become: yes + vars: + postgres_password: "postgres" + postgres_db: "voting_app_db" + postgres_user: "voting_user" + postgres_port: 5432 + tasks: + - name: Update apt package index + ansible.builtin.apt: + update_cache: yes + + - name: Install Docker if not present + package: + name: docker.io + state: present + + - name: Add the user to the Docker group + ansible.builtin.user: + name: "ubuntu" + groups: docker + append: yes + state: present + + - name: Ensure Docker network "voting-app-network" exists + ansible.builtin.command: + cmd: docker network create voting-app-network + register: docker_network_creation + failed_when: docker_network_creation.stderr != "" + ignore_errors: true + +#####--------------------------------------------------- Play 2: Running "DB" Container ---------------------------------------------------##### + +- name: Running "DB" Container + hosts: db + become: yes + tasks: + ##### DB service ##### + - name: Pull the 'PostgreSQL' Docker image + community.docker.docker_image: + name: postgres:15-alpine + source: pull + + - name: Run the 'PostgreSQL' container + community.docker.docker_container: + name: voting-app-db + image: postgres:15-alpine + state: started + restart_policy: always + env: + POSTGRES_PASSWORD: "postgres" + POSTGRES_DB: "voting-app-db" + POSTGRES_USER: "postgres" + exposed_ports: + - "5432" + published_ports: + - "5432:5432" + networks: + - name: voting-app-network + volumes: + - name: pgdata + source: pgdata + target: /var/lib/postgresql/data + type: volume + state: present + + - name: Verify the PostgreSQL container is running + community.docker.docker_container_info: + name: voting-app-db + register: container_info + + - name: Debug container information + debug: + var: container_info diff --git a/ansible/frontend.yml b/ansible/frontend.yml new file mode 100644 index 0000000000..6df4626efa --- /dev/null +++ b/ansible/frontend.yml @@ -0,0 +1,118 @@ +--- +#####--------------------------------------------------- Play 1: Docker Installation ---------------------------------------------------##### + +- name: Ensure Docker is installed and running on the 'frontend' instance + hosts: localhost + become: yes + vars: + postgres_password: "postgres" + postgres_db: "voting_app_db" + postgres_user: "voting_user" + postgres_port: 5432 + tasks: + - name: Update apt package index + ansible.builtin.apt: + update_cache: yes + + - name: Check if Docker is installed + command: docker --version + register: docker_installed + ignore_errors: true + + - name: Install Docker if not present + package: + name: docker.io + state: present + when: docker_installed.rc != 0 + + - name: Ensure Docker is installed + command: docker --version + register: docker_check + failed_when: docker_check.rc != 0 + changed_when: false + + - name: Add the user to the Docker group + ansible.builtin.user: + name: "ubuntu" + groups: docker + append: yes + state: present + + - name: Ensure Docker service is running and enabled + ansible.builtin.service: + name: docker + state: started + enabled: yes + + - name: Ensure Docker network "frontend-network" exists + ansible.builtin.command: + cmd: docker network create frontend-network + register: docker_network_creation + failed_when: docker_network_creation.stderr != "" + ignore_errors: true + +#####--------------------------------------------------- Play 2: Running "vote" and "result" containers ---------------------------------------------------##### + +- name: Running "vote" and "result" containers + hosts: localhost + become: yes + tasks: + ##### Vote Service ##### + - name: Pull the 'vote' Docker image + ansible.builtin.docker_image: + name: ghenac/voting-app-vote + tag: latest + source: pull + + - name: Run the 'vote' container + ansible.builtin.docker_container: + name: voting-app-vote + image: ghenac/voting-app-vote:latest + state: started + networks: + - name: frontend-network + restart_policy: always + env: + REDIS_HOST: "10.0.2.150" # This is how the worker will connect to Redis container by using the container name as hostname + REDIS_PORT: "6379" + exposed_ports: + - "80" + published_ports: + - "8080:80" + + - name: Verify the container is running + ansible.builtin.docker_container_info: + name: voting-app-vote + register: container_info + + ##### Result Service ##### + - name: Pull the 'result' Docker image + ansible.builtin.docker_image: + name: ghenac/voting-app-result + tag: latest + source: pull + + - name: Run the 'result' container + ansible.builtin.docker_container: + name: voting-app-result + image: ghenac/voting-app-result:latest + state: started + networks: + - name: frontend-network + restart_policy: always + exposed_ports: + - "80" + published_ports: + - "8081:80" + env: + PG_HOST: "10.0.2.174" + PG_PORT: "5432" + PG_USER: "postgres" + PG_PASSWORD: "postgres" + PG_DATABASE: "voting-app-db" + + - name: Verify the container is running + ansible.builtin.docker_container_info: + name: voting-app-result + register: container_info + \ No newline at end of file diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 0000000000..58e9321bbc --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,17 @@ +[frontend] +54.167.3.82 ansible_host=54.167.3.82 ansible_user=ubuntu ansible_ssh_private_key_file=/users/ghenadie/desktop/voting_app_solution/example-voting-app/voting-app-key.pem + # Path to the key pair from your local machine + +[backend] +10.0.2.150 ansible_host=10.0.2.150 ansible_user=ubuntu ansible_ssh_private_key_file=/home/ubuntu/voting-app-key.pem + # Path to the key pair from the bastion instance (same path where you copied it) + +[db] +10.0.2.174 ansible_host=10.0.2.174 ansible_user=ubuntu ansible_ssh_private_key_file=/home/ubuntu/voting-app-key.pem + # Path to the key pair from the bastion instance (same path where you copied it) + +[db:vars] +ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p -i /home/ubuntu/voting-app-key.pem ubuntu@54.167.3.82"' + +[backend:vars] +ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p -i /home/ubuntu/voting-app-key.pem ubuntu@54.167.3.82"' diff --git a/ansible/main.yml b/ansible/main.yml new file mode 100644 index 0000000000..cc61a232cf --- /dev/null +++ b/ansible/main.yml @@ -0,0 +1,96 @@ +--- +#####--------------------------------------------------- Play 1: Prepare frontend as a bastion ---------------------------------------------------##### + +- name: Prepare frontend as a bastion to deploy backend and db configurations + hosts: frontend + become: yes + tasks: + - name: Add Ansible PPA repository + apt_repository: + repo: "ppa:ansible/ansible" + state: present + + - name: Update apt package index + apt: + update_cache: yes + + # Install Ansible on the frontend instance + - name: Install Ansible + apt: + name: ansible + state: present + + # Transfer private key for SSH access + - name: Copy private key to the frontend instance + copy: + src: ../voting-app-key.pem # Path to the key pair from your local machine + dest: /home/ubuntu/voting-app-key.pem + owner: ubuntu + group: ubuntu + mode: '0400' + + # This ensures that the destination directory is empty/clean (otherwise it will give an error) + - name: Ensure destination directory is clean + file: + path: /home/ubuntu/ansible-config + state: absent + + # Copy Ansible playbooks and configurations + - name: Transfer Ansible playbooks to frontend instance + copy: + src: /Users/ghenadie/Desktop/voting_app_solution/example-voting-app/ansible/ # Path to the Ansible configurations on your local machine (yes, you need the full path here) + dest: /home/ubuntu/ansible-config/ + owner: ubuntu + group: ubuntu + mode: '0755' + +#####--------------------------------------------------- Play 2: Test SSH connection from "bastion" to "db" and "backend" ---------------------------------------------------##### + +# Test the SSH to "DB" +- name: Test SSH connection through bastion to db instance + hosts: frontend + tasks: + - name: Ping db from the frontend instance + shell: ssh -i "voting-app-key.pem" -o ProxyCommand="ssh -i 'voting-app-key.pem' -W %h:%p ubuntu@54.167.3.82" ubuntu@10.0.2.174 echo "Connection successful" + register: test_ssh + changed_when: false + + - name: Output SSH test results + debug: + var: test_ssh.stdout + +# Test the SSH to "backend" +- name: Test SSH connection through bastion to backend instance + hosts: frontend + tasks: + - name: Ping backend from the frontend instance + shell: ssh -i "voting-app-key.pem" -o ProxyCommand="ssh -i 'voting-app-key.pem' -W %h:%p ubuntu@54.167.3.82" ubuntu@10.0.2.150 echo "Connection successful" + register: test_ssh + changed_when: false + + - name: Output SSH test results + debug: + var: test_ssh.stdout + +#####--------------------------------------------------- Play 3: Run "db" and "backend" configuration playbook ---------------------------------------------------##### + +# Run db configurations on db instance + - name: Run db playbook + shell: | + cd /home/ubuntu/ansible-config && + ansible-playbook -i inventory.ini db.yml + +# Run backend configuration on backend instance + - name: Run backend playbook + shell: | + cd /home/ubuntu/ansible-config && + ansible-playbook -i inventory.ini backend.yml + +#####--------------------------------------------------- Play 4: Run "frontend" configuration playbook ---------------------------------------------------##### + +# Run frontend configurations on the frontend instance + - name: Run frontend playbook + shell: | + cd /home/ubuntu/ansible-config && + ansible-playbook -i inventory.ini frontend.yml + diff --git a/architecture.excalidraw.png b/architecture.excalidraw.png deleted file mode 100644 index 643bacdbe9..0000000000 Binary files a/architecture.excalidraw.png and /dev/null differ diff --git a/docker-compose.images.yml b/docker-compose.images.yml index 8909aae794..719783c62d 100644 --- a/docker-compose.images.yml +++ b/docker-compose.images.yml @@ -1,12 +1,9 @@ -# for running in docker compose with prebuilt images - -# version is now using "compose spec" -# v2 and v3 are now combined! -# docker-compose v1.27+ required +# Ironhack note - This file is for running in docker compose with prebuilt images +# Use these if you're stuck, or if you want to start slowly migrating from external images into your own. services: vote: - image: dockersamples/examplevotingapp_vote + image: ghenac/voting-app:vote depends_on: redis: condition: service_healthy @@ -17,7 +14,7 @@ services: - back-tier result: - image: dockersamples/examplevotingapp_result + image: ghenac/voting-app:result depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 5915ffd741..6ca2914bc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,3 @@ -# version is now using "compose spec" -# v2 and v3 are now combined! -# docker-compose v1.27+ required - services: vote: build: @@ -26,7 +22,6 @@ services: result: build: ./result - # use nodemon rather than node for local dev entrypoint: nodemon --inspect=0.0.0.0 server.js depends_on: db: @@ -41,8 +36,7 @@ services: - back-tier worker: - build: - context: ./worker + build: ./worker depends_on: redis: condition: service_healthy @@ -75,19 +69,6 @@ services: networks: - back-tier - # this service runs once to seed the database with votes - # it won't run unless you specify the "seed" profile - # docker compose --profile seed up -d - seed: - build: ./seed-data - profiles: ["seed"] - depends_on: - vote: - condition: service_healthy - networks: - - front-tier - restart: "no" - volumes: db-data: diff --git a/docker-stack.yml b/docker-stack.yml deleted file mode 100644 index 356b944caf..0000000000 --- a/docker-stack.yml +++ /dev/null @@ -1,53 +0,0 @@ -# this file is meant for Docker Swarm stacks only -# trying it in compose will fail because of multiple replicas trying to bind to the same port -# Swarm currently does not support Compose Spec, so we'll pin to the older version 3.9 - -version: "3.9" - -services: - - redis: - image: redis:alpine - networks: - - frontend - - db: - image: postgres:15-alpine - environment: - POSTGRES_USER: "postgres" - POSTGRES_PASSWORD: "postgres" - volumes: - - db-data:/var/lib/postgresql/data - networks: - - backend - - vote: - image: dockersamples/examplevotingapp_vote - ports: - - 8080:80 - networks: - - frontend - deploy: - replicas: 2 - - result: - image: dockersamples/examplevotingapp_result - ports: - - 8081:80 - networks: - - backend - - worker: - image: dockersamples/examplevotingapp_worker - networks: - - frontend - - backend - deploy: - replicas: 2 - -networks: - frontend: - backend: - -volumes: - db-data: diff --git a/example-voting-app.sln b/example-voting-app.sln new file mode 100644 index 0000000000..cdaa30a460 --- /dev/null +++ b/example-voting-app.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker", "worker\Worker.csproj", "{94048AEC-7282-41A6-8B5B-5FAAF86FD92A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {94048AEC-7282-41A6-8B5B-5FAAF86FD92A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94048AEC-7282-41A6-8B5B-5FAAF86FD92A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94048AEC-7282-41A6-8B5B-5FAAF86FD92A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94048AEC-7282-41A6-8B5B-5FAAF86FD92A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {43E7D34E-C638-4020-A69D-32DF75729BAB} + EndGlobalSection +EndGlobal diff --git a/k8s-specifications/db-deployment.yaml b/k8s-specifications/db-deployment.yaml deleted file mode 100644 index bc94ca7368..0000000000 --- a/k8s-specifications/db-deployment.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: db - name: db -spec: - replicas: 1 - selector: - matchLabels: - app: db - template: - metadata: - labels: - app: db - spec: - containers: - - image: postgres:15-alpine - name: postgres - env: - - name: POSTGRES_USER - value: postgres - - name: POSTGRES_PASSWORD - value: postgres - ports: - - containerPort: 5432 - name: postgres - volumeMounts: - - mountPath: /var/lib/postgresql/data - name: db-data - volumes: - - name: db-data - emptyDir: {} diff --git a/k8s-specifications/db-service.yaml b/k8s-specifications/db-service.yaml deleted file mode 100644 index 104f1e8268..0000000000 --- a/k8s-specifications/db-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: db - name: db -spec: - type: ClusterIP - ports: - - name: "db-service" - port: 5432 - targetPort: 5432 - selector: - app: db - diff --git a/k8s-specifications/redis-deployment.yaml b/k8s-specifications/redis-deployment.yaml deleted file mode 100644 index 24aa52135f..0000000000 --- a/k8s-specifications/redis-deployment.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: redis - name: redis -spec: - replicas: 1 - selector: - matchLabels: - app: redis - template: - metadata: - labels: - app: redis - spec: - containers: - - image: redis:alpine - name: redis - ports: - - containerPort: 6379 - name: redis - volumeMounts: - - mountPath: /data - name: redis-data - volumes: - - name: redis-data - emptyDir: {} diff --git a/k8s-specifications/redis-service.yaml b/k8s-specifications/redis-service.yaml deleted file mode 100644 index 809d31d875..0000000000 --- a/k8s-specifications/redis-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: redis - name: redis -spec: - type: ClusterIP - ports: - - name: "redis-service" - port: 6379 - targetPort: 6379 - selector: - app: redis - diff --git a/k8s-specifications/result-deployment.yaml b/k8s-specifications/result-deployment.yaml deleted file mode 100644 index b85488a667..0000000000 --- a/k8s-specifications/result-deployment.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: result - name: result -spec: - replicas: 1 - selector: - matchLabels: - app: result - template: - metadata: - labels: - app: result - spec: - containers: - - image: dockersamples/examplevotingapp_result - name: result - ports: - - containerPort: 80 - name: result diff --git a/k8s-specifications/result-service.yaml b/k8s-specifications/result-service.yaml deleted file mode 100644 index 0fed5e0cc5..0000000000 --- a/k8s-specifications/result-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: result - name: result -spec: - type: NodePort - ports: - - name: "result-service" - port: 8081 - targetPort: 80 - nodePort: 31001 - selector: - app: result diff --git a/k8s-specifications/vote-deployment.yaml b/k8s-specifications/vote-deployment.yaml deleted file mode 100644 index 165a9478f8..0000000000 --- a/k8s-specifications/vote-deployment.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: vote - name: vote -spec: - replicas: 1 - selector: - matchLabels: - app: vote - template: - metadata: - labels: - app: vote - spec: - containers: - - image: dockersamples/examplevotingapp_vote - name: vote - ports: - - containerPort: 80 - name: vote diff --git a/k8s-specifications/vote-service.yaml b/k8s-specifications/vote-service.yaml deleted file mode 100644 index d7a05b5513..0000000000 --- a/k8s-specifications/vote-service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: vote - name: vote -spec: - type: NodePort - ports: - - name: "vote-service" - port: 8080 - targetPort: 80 - nodePort: 31000 - selector: - app: vote - diff --git a/k8s-specifications/worker-deployment.yaml b/k8s-specifications/worker-deployment.yaml deleted file mode 100644 index 9e35450aec..0000000000 --- a/k8s-specifications/worker-deployment.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - labels: - app: worker - name: worker -spec: - replicas: 1 - selector: - matchLabels: - app: worker - template: - metadata: - labels: - app: worker - spec: - containers: - - image: dockersamples/examplevotingapp_worker - name: worker diff --git a/result/docker-compose.test.yml b/result/docker-compose.test.yml deleted file mode 100644 index 57ddc55d87..0000000000 --- a/result/docker-compose.test.yml +++ /dev/null @@ -1,62 +0,0 @@ -version: '2' - -services: - - sut: - build: ./tests/ - depends_on: - - vote - - result - - worker - networks: - - front-tier - - vote: - build: ../vote/ - ports: ["80"] - depends_on: - - redis - - db - networks: - - front-tier - - back-tier - - result: - build: . - ports: ["80"] - depends_on: - - redis - - db - networks: - - front-tier - - back-tier - - worker: - build: ../worker/ - depends_on: - - redis - - db - networks: - - back-tier - - redis: - image: redis:alpine - networks: - - back-tier - - db: - image: postgres:9.4 - environment: - POSTGRES_USER: "postgres" - POSTGRES_PASSWORD: "postgres" - volumes: - - "db-data:/var/lib/postgresql/data" - networks: - - back-tier - -volumes: - db-data: - -networks: - front-tier: - back-tier: diff --git a/result/server.js b/result/server.js index 1c8593e7ee..5d4f95c8e3 100644 --- a/result/server.js +++ b/result/server.js @@ -2,6 +2,7 @@ var express = require('express'), async = require('async'), { Pool } = require('pg'), cookieParser = require('cookie-parser'), + path = require('path'), app = express(), server = require('http').Server(app), io = require('socket.io')(server); @@ -17,8 +18,19 @@ io.on('connection', function (socket) { }); }); +// Build PostgreSQL connection string dynamically from environment variables +var pgHost = process.env.PG_HOST || 'localhost'; +var pgPort = process.env.PG_PORT || 5432; +var pgUser = process.env.PG_USER || 'postgres'; +var pgPassword = process.env.PG_PASSWORD || 'postgres'; +var pgDatabase = process.env.PG_DATABASE || 'postgres'; + +var connectionString = `postgresql://${pgUser}:${pgPassword}@${pgHost}:${pgPort}/${pgDatabase}`; + +console.log(connectionString) + var pool = new Pool({ - connectionString: 'postgres://postgres:postgres@db/postgres' + connectionString: connectionString }); async.retry( @@ -64,7 +76,7 @@ function collectVotesFromResult(result) { } app.use(cookieParser()); -app.use(express.urlencoded()); +app.use(express.urlencoded({ extended: true })); app.use(express.static(__dirname + '/views')); app.get('/', function (req, res) { @@ -74,4 +86,4 @@ app.get('/', function (req, res) { server.listen(port, function () { var port = server.address().port; console.log('App running on port ' + port); -}); +}); \ No newline at end of file diff --git a/result/tests/Dockerfile b/result/tests/Dockerfile deleted file mode 100644 index b8b6e90520..0000000000 --- a/result/tests/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM node:8.9-slim - -RUN apt-get update -qq && apt-get install -qy \ - ca-certificates \ - bzip2 \ - curl \ - libfontconfig \ - --no-install-recommends -RUN yarn global add phantomjs-prebuilt -ADD . /app -WORKDIR /app -CMD ["/app/tests.sh"] diff --git a/result/tests/render.js b/result/tests/render.js deleted file mode 100644 index 975137bf3b..0000000000 --- a/result/tests/render.js +++ /dev/null @@ -1,15 +0,0 @@ -var system = require('system'); -var page = require('webpage').create(); -var url = system.args[1]; - -page.onLoadFinished = function() { - setTimeout(function(){ - console.log(page.content); - phantom.exit(); - }, 1000); -}; - -page.open(url, function() { - page.evaluate(function() { - }); -}); diff --git a/result/tests/tests.sh b/result/tests/tests.sh deleted file mode 100755 index 448159454b..0000000000 --- a/result/tests/tests.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh - -while ! timeout 1 bash -c "echo > /dev/tcp/vote/80"; do - sleep 1 -done - -curl -sS -X POST --data "vote=b" http://vote > /dev/null -sleep 10 - -if phantomjs render.js http://result | grep -q '1 vote'; then - echo -e "\\e[42m------------" - echo -e "\\e[92mTests passed" - echo -e "\\e[42m------------" - exit 0 -else - echo -e "\\e[41m------------" - echo -e "\\e[91mTests failed" - echo -e "\\e[41m------------" - exit 1 -fi diff --git a/seed-data/Dockerfile b/seed-data/Dockerfile deleted file mode 100644 index f970e42ad4..0000000000 --- a/seed-data/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM python:3.9-slim - -# add apache bench (ab) tool -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - apache2-utils \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /seed - -COPY . . - -# create POST data files with ab friendly formats -RUN python make-data.py - -CMD ["/seed/generate-votes.sh"] diff --git a/seed-data/generate-votes.sh b/seed-data/generate-votes.sh deleted file mode 100755 index 2f82563ccd..0000000000 --- a/seed-data/generate-votes.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -# create 3000 votes (2000 for option a, 1000 for option b) -ab -n 1000 -c 50 -p posta -T "application/x-www-form-urlencoded" http://vote/ -ab -n 1000 -c 50 -p postb -T "application/x-www-form-urlencoded" http://vote/ -ab -n 1000 -c 50 -p posta -T "application/x-www-form-urlencoded" http://vote/ diff --git a/seed-data/make-data.py b/seed-data/make-data.py deleted file mode 100644 index def9f9755d..0000000000 --- a/seed-data/make-data.py +++ /dev/null @@ -1,13 +0,0 @@ -# this creates urlencode-friendly files without EOL -import urllib.parse - -outfile = open('postb', 'w') -params = ({ 'vote': 'b' }) -encoded = urllib.parse.urlencode(params) -outfile.write(encoded) -outfile.close() -outfile = open('posta', 'w') -params = ({ 'vote': 'a' }) -encoded = urllib.parse.urlencode(params) -outfile.write(encoded) -outfile.close() diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000000..b9a094bdc5 --- /dev/null +++ b/terraform/alb.tf @@ -0,0 +1,161 @@ +##### Application Load Balancer ##### +resource "aws_lb" "voting_app_alb" { + name = "voting-app-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.public_security_group.id] + subnets = [ + aws_subnet.public_subnet.id, + aws_subnet.private_subnet.id + ] + + enable_deletion_protection = false + idle_timeout = 60 + drop_invalid_header_fields = true + + tags = { + Name = "voting-app-alb" + } +} + + +##### Target Groups ##### + +# Target group for "frontend" service # +resource "aws_lb_target_group" "frontend_target_group" { + name = "frontend-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.voting-app-vpc.id + target_type = "instance" + tags = { + Name = "frontend-tg" + } +} + +# Target group for "backend" service # +resource "aws_lb_target_group" "backend_target_group" { + name = "backend-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.voting-app-vpc.id + target_type = "instance" + tags = { + Name = "backend-tg" + } +} + +# Target group for "DB" service # +resource "aws_lb_target_group" "db_target_group" { + name = "db-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.voting-app-vpc.id + target_type = "instance" + tags = { + Name = "db-tg" + } +} + +##### Listeners ##### + +# HTTP Listener for ALB # +resource "aws_lb_listener" "voting_app_listener" { + load_balancer_arn = aws_lb.voting_app_alb.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "This is the default action." + status_code = "404" + } + } +} + +##### Listener Rules ##### + +# Rule for "frontend" traffic # +resource "aws_lb_listener_rule" "frontend_rule" { + listener_arn = aws_lb_listener.voting_app_listener.arn + priority = 1 + tags = { + Name = "frontend" + } + + condition { + host_header { + values = ["frontend.example.com"] + } + } + + action { + type = "forward" + target_group_arn = aws_lb_target_group.frontend_target_group.arn + } +} + +# Rule for "backend" traffic # +resource "aws_lb_listener_rule" "backend_rule" { + listener_arn = aws_lb_listener.voting_app_listener.arn + priority = 2 + tags = { + Name = "backend" + } + + condition { + host_header { + values = ["backend.example.com"] + } + } + + action { + type = "forward" + target_group_arn = aws_lb_target_group.backend_target_group.arn + } +} + +# Rule for "DB" traffic # +resource "aws_lb_listener_rule" "db_rule" { + listener_arn = aws_lb_listener.voting_app_listener.arn + priority = 3 + tags = { + Name = "db" + } + + condition { + host_header { + values = ["db.example.com"] + } + } + + action { + type = "forward" + target_group_arn = aws_lb_target_group.db_target_group.arn + } +} + +##### Attach Instances to Target Groups ##### + +# Register "frontend" instance # +resource "aws_lb_target_group_attachment" "frontend_attachment" { + target_group_arn = aws_lb_target_group.frontend_target_group.arn + target_id = aws_instance.frontend.id + port = 80 +} + +# Register "backend" instance # +resource "aws_lb_target_group_attachment" "backend_attachment" { + target_group_arn = aws_lb_target_group.backend_target_group.arn + target_id = aws_instance.backend.id + port = 80 +} + +# Register "DB" instance # +resource "aws_lb_target_group_attachment" "worker_attachment" { + target_group_arn = aws_lb_target_group.db_target_group.arn + target_id = aws_instance.db.id + port = 80 +} diff --git a/terraform/internet-gateway.tf b/terraform/internet-gateway.tf new file mode 100644 index 0000000000..b166a56f16 --- /dev/null +++ b/terraform/internet-gateway.tf @@ -0,0 +1,6 @@ +resource "aws_internet_gateway" "gw" { + vpc_id = aws_vpc.voting-app-vpc.id + tags = { + Name = "internet-gw-voting-app" + } +} \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..90b6a434db --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,189 @@ +##### Providers ##### +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.40.0" + } + } +} + +provider "aws" { + region = "us-east-1" +} + +##### VPC ##### +resource "aws_vpc" "voting-app-vpc" { + cidr_block = var.cidr_block + + tags = { + Name = var.vpc_name + } +} + +##### EC2 Instances ##### +resource "aws_instance" "frontend" { + ami = var.ami_image + instance_type = var.instance_type + key_name = "voting-app-key" + availability_zone = "us-east-1a" + vpc_security_group_ids = [aws_security_group.public_security_group.id] + subnet_id = aws_subnet.public_subnet.id + associate_public_ip_address = true + tags = { + Name = "frontend" + } +} + +resource "aws_instance" "backend" { + ami = var.ami_image + instance_type = var.instance_type + key_name = "voting-app-key" + availability_zone = "us-east-1b" + vpc_security_group_ids = [aws_security_group.private_security_group.id] + subnet_id = aws_subnet.private_subnet.id + associate_public_ip_address = false + tags = { + Name = "backend" + } +} + +resource "aws_instance" "db" { + ami = var.ami_image + instance_type = var.instance_type + key_name = "voting-app-key" + availability_zone = "us-east-1b" + vpc_security_group_ids = [aws_security_group.private_security_group.id] + subnet_id = aws_subnet.private_subnet.id + associate_public_ip_address = false + tags = { + Name = "db" + } +} + +##### Subnets ##### + +# Public # +resource "aws_subnet" "public_subnet" { + vpc_id = aws_vpc.voting-app-vpc.id + cidr_block = var.public_subnet_cidr_block + map_public_ip_on_launch = true + availability_zone = var.availability_zone["public_subnet_az"] + tags = { + Name = "public_subnet" + } +} + +# Private # +resource "aws_subnet" "private_subnet" { + vpc_id = aws_vpc.voting-app-vpc.id + map_public_ip_on_launch = false + availability_zone = var.availability_zone["private_subnet_az"] + cidr_block = var.private_subnet_cidr_block + tags = { + Name = "private_subnet" + } +} + +##### Security Groups ##### + +# Public # +resource "aws_security_group" "public_security_group" { + name = "voting-app_public-sec-gr" + description = "Allow public traffic" + vpc_id = aws_vpc.voting-app-vpc.id + + ingress { + description = "TLS from Internet" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Http from Internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Http from Internet" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Http from Internet" + from_port = 8081 + to_port = 8081 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "PostgreSQL from anywhere" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [] + security_groups = [aws_security_group.private_security_group.id] + } + + tags = { + Name = "voting-app_public-sec-gr" + } +} + +# Private # +resource "aws_security_group" "private_security_group" { + name = "voting-app_private-sec-gr" + description = "Allow private traffic " + vpc_id = aws_vpc.voting-app-vpc.id + + # Allow SSH from the frontend EC2 instance to backend and db instances + ingress { + description = "Allow SSH from Frontend EC2" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [] + security_groups = [aws_security_group.public_security_group.id] +} + + ingress { + description = "Allow communication with backend and DB instances" + from_port = 0 + to_port = 65535 + protocol = "tcp" + security_groups = [aws_security_group.public_security_group.id] +} + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + tags = { + Name = "voting-app_private-sec-gr" + } +} \ No newline at end of file diff --git a/terraform/nat-gw.tf b/terraform/nat-gw.tf new file mode 100644 index 0000000000..87555a764e --- /dev/null +++ b/terraform/nat-gw.tf @@ -0,0 +1,16 @@ +resource "aws_nat_gateway" "nat_gw" { + allocation_id = aws_eip.nat_eip.id + subnet_id = aws_subnet.public_subnet.id + tags = { + Name = "nat-gw_voting-app" + } +} + +# Elastic IP for the NAT Gateway + +resource "aws_eip" "nat_eip" { + domain = "vpc" + tags = { + Name = "nat-gw_voting-app_eip" + } +} \ No newline at end of file diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..b5f89dd268 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,43 @@ +##### Outputs for EC2 Instances ##### +output "frontend_instance_public_ip" { + description = "The public IP address of the 'frontend' EC2 instance" + value = aws_instance.frontend.public_ip +} + +output "backend_instance_public_ip" { + description = "The public IP address of the 'backend' EC2 instance" + value = aws_instance.backend.public_ip +} + +output "db_instance_public_ip" { + description = "The public IP address of the 'DB' EC2 instance" + value = aws_instance.db.public_ip +} + +##### Outputs for Subnets ##### +output "public_subnet_id" { + description = "The ID of the public subnet" + value = aws_subnet.public_subnet.id +} + +output "private_subnet_id" { + description = "The ID of the private subnet" + value = aws_subnet.private_subnet.id +} + +##### Outputs for VPC ##### +output "vpc_id" { + description = "The ID of the VPC" + value = aws_vpc.voting-app-vpc.id +} + +##### Outputs for Security Groups ##### +output "public_security_group_id" { + description = "The ID of the public security group" + value = aws_security_group.public_security_group.id +} + +output "private_security_group_id" { + description = "The ID of the private security group" + value = aws_security_group.private_security_group.id +} diff --git a/terraform/routes.tf b/terraform/routes.tf new file mode 100644 index 0000000000..ccc4903d5d --- /dev/null +++ b/terraform/routes.tf @@ -0,0 +1,38 @@ +##### Public Route ##### + +resource "aws_route_table" "public_route" { + vpc_id = aws_vpc.voting-app-vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.gw.id + } + tags = { + Name = "public_route" + } +} + +resource "aws_route_table_association" "public_association" { + subnet_id = aws_subnet.public_subnet.id + route_table_id = aws_route_table.public_route.id +} + + +##### Private Route ##### + +resource "aws_route_table" "private_route" { + vpc_id = aws_vpc.voting-app-vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_nat_gateway.nat_gw.id + } + tags = { + Name = "private_route" + } +} + +resource "aws_route_table_association" "private_association" { + subnet_id = aws_subnet.private_subnet.id + route_table_id = aws_route_table.private_route.id +} \ No newline at end of file diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 0000000000..86e9bceb15 --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,14 @@ +region = "us-east-1" + +availability_zone = { + public_subnet_az = "us-east-1a" + private_subnet_az = "us-east-1b" +} + +cidr_block = "10.0.0.0/16" +vpc_name = "voting-app-vpc" +public_subnet_cidr_block = "10.0.1.0/24" +private_subnet_cidr_block = "10.0.2.0/24" +ami_image = "ami-0e2c8caa4b6378d8c" +instance_type = "t2.micro" +inbound_rule = "yes" \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..f8bdad3dba --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,65 @@ +variable "cidr_block" { + type = string + description = "CIDR block for VPC" + default = "10.0.0.0/16" +} + +variable "voting-app-vpc" { + type = string + description = "voting-app-vpc" + default = "yes" +} + +variable "vpc_name" { + type = string + description = "vpc name" + default = "voting-app-vpc" +} + +variable "public_subnet_cidr_block" { + type = string + default = "10.0.1.0/24" + description = "cidr range for public subnet" +} + +variable "private_subnet_cidr_block" { + type = string + default = "10.0.2.0/24" + description = "cidr range for private subnet" +} + +variable "region" { + type = string + default = "us-east-1" + description = "default region where infrastructures will be provisioned" +} + +variable "availability_zone" { + type = map(string) + default = { + public_subnet_az = "us-east-1a" + private_subnet_az = "us-east-1b" + } +} + +variable "ami_image" { + type = string + default = "ami-0e2c8caa4b6378d8c" +} + +variable "instance_type" { + type = string + default = "t2.micro" +} + +variable "inbound_rule" { + description = "Enable inboud rule" + type = string + default = "yes" +} + +variable "subnets" { + description = "List of subnets" + type = list(string) + default = ["public_subnet", "private_subnet"] +} \ No newline at end of file diff --git a/vote/Dockerfile b/vote/Dockerfile index 2681083600..da82be4e28 100644 --- a/vote/Dockerfile +++ b/vote/Dockerfile @@ -13,10 +13,15 @@ WORKDIR /usr/local/app COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt +# Set environment variables for Redis configuration (can be overridden at runtime) +ENV REDIS_HOST=redis +ENV REDIS_PORT=6379 + # dev defines a stage for development, where it'll watch for filesystem changes FROM base AS dev RUN pip install watchdog ENV FLASK_ENV=development + CMD ["python", "app.py"] # final defines the stage that will bundle the application for production diff --git a/vote/app.py b/vote/app.py index 596546612a..2272d10a93 100644 --- a/vote/app.py +++ b/vote/app.py @@ -6,8 +6,11 @@ import json import logging +# Read options and Redis configuration from environment variables option_a = os.getenv('OPTION_A', "Cats") option_b = os.getenv('OPTION_B', "Dogs") +redis_host = os.getenv('REDIS_HOST', 'redis') # Default to 'redis' for compatibility +redis_port = int(os.getenv('REDIS_PORT', 6379)) # Default Redis port is 6379 hostname = socket.gethostname() app = Flask(__name__) @@ -18,7 +21,7 @@ def get_redis(): if not hasattr(g, 'redis'): - g.redis = Redis(host="redis", db=0, socket_timeout=5) + g.redis = Redis(host=redis_host, port=redis_port, db=0, socket_timeout=5) return g.redis @app.route("/", methods=['POST','GET']) diff --git a/worker/.dockerignore b/worker/.dockerignore new file mode 100644 index 0000000000..295aa4ff92 --- /dev/null +++ b/worker/.dockerignore @@ -0,0 +1,7 @@ +# Exclude build artifacts and unnecessary files +bin/ +obj/ +*.md +*.git +*.gitignore +.vscode/ diff --git a/worker/Dockerfile b/worker/Dockerfile index a3f92d7e94..919712525c 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,28 +1,28 @@ -# because of dotnet, we always build on amd64, and target platforms in cli -# dotnet doesn't support QEMU for building or running. -# (errors common in arm/v7 32bit) https://github.com/dotnet/dotnet-docker/issues/1537 -# https://hub.docker.com/_/microsoft-dotnet -# hadolint ignore=DL3029 -# to build for a different platform than your host, use --platform= -# for example, if you were on Intel (amd64) and wanted to build for ARM, you would use: -# docker buildx build --platform "linux/arm64/v8" . +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src -# build compiles the program for the builder's local platform -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:7.0 AS build -ARG TARGETPLATFORM -ARG TARGETARCH -ARG BUILDPLATFORM -RUN echo "I am running on $BUILDPLATFORM, building for $TARGETPLATFORM" - -WORKDIR /source -COPY *.csproj . -RUN dotnet restore -a $TARGETARCH +# Copy the project file and restore dependencies +COPY Worker.csproj . +RUN dotnet restore +# Copy the rest of the source code and build COPY . . -RUN dotnet publish -c release -o /app -a $TARGETARCH --self-contained false --no-restore +RUN dotnet publish -c Release -o /app/publish -# app image -FROM mcr.microsoft.com/dotnet/runtime:7.0 +# Stage 2: Runtime +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime WORKDIR /app -COPY --from=build /app . + +# Copy the published output from the build stage +COPY --from=build /app/publish ./ + +# Set environment variables (can be overridden at runtime) +ENV DB_HOST=db +ENV DB_USERNAME=postgres +ENV DB_PASSWORD=postgres +ENV DB_NAME=postgres +ENV REDIS_HOST=redis + +# Define the entry point ENTRYPOINT ["dotnet", "Worker.dll"] diff --git a/worker/Program.cs b/worker/Program.cs index 9b5fb74d1a..d2b6b84a6e 100644 --- a/worker/Program.cs +++ b/worker/Program.cs @@ -16,8 +16,21 @@ public static int Main(string[] args) { try { - var pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); - var redisConn = OpenRedisConnection("redis"); + // Fetch configuration from environment variables + var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "db"; + var dbUsername = Environment.GetEnvironmentVariable("DB_USERNAME") ?? "postgres"; + var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? "postgres"; + var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "postgres"; + + var redisHost = Environment.GetEnvironmentVariable("REDIS_HOST") ?? "redis"; + + // Construct the connection strings + var pgConnectionString = $"Server={dbHost};Username={dbUsername};Password={dbPassword};Database={dbName}"; + Console.WriteLine($"Database connection string: {pgConnectionString}"); + var redisConnectionString = redisHost; + + var pgsql = OpenDbConnection(pgConnectionString); + var redisConn = OpenRedisConnection(redisConnectionString); var redis = redisConn.GetDatabase(); // Keep alive is not implemented in Npgsql yet. This workaround was recommended: @@ -32,21 +45,24 @@ public static int Main(string[] args) Thread.Sleep(100); // Reconnect redis if down - if (redisConn == null || !redisConn.IsConnected) { + if (redisConn == null || !redisConn.IsConnected) + { Console.WriteLine("Reconnecting Redis"); - redisConn = OpenRedisConnection("redis"); + redisConn = OpenRedisConnection(redisConnectionString); redis = redisConn.GetDatabase(); } + string json = redis.ListLeftPopAsync("votes").Result; if (json != null) { var vote = JsonConvert.DeserializeAnonymousType(json, definition); Console.WriteLine($"Processing vote for '{vote.vote}' by '{vote.voter_id}'"); + // Reconnect DB if down if (!pgsql.State.Equals(System.Data.ConnectionState.Open)) { Console.WriteLine("Reconnecting DB"); - pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); + pgsql = OpenDbConnection(pgConnectionString); } else { // Normal +1 vote requested @@ -151,4 +167,4 @@ private static void UpdateVote(NpgsqlConnection connection, string voterId, stri } } } -} \ No newline at end of file +} diff --git a/worker/Worker.csproj b/worker/Worker.csproj index 00845078ef..a7bce45ca5 100644 --- a/worker/Worker.csproj +++ b/worker/Worker.csproj @@ -2,13 +2,15 @@ Exe - net7.0 + net8.0 + enable + enable - - + + - \ No newline at end of file +