From 77755c9cd09818587530f127f5813bccb69d1fb2 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 11:56:36 +0100 Subject: [PATCH 001/185] refactor: Updated to use app.config Added to use config instead of none to be able to use env instead. Followed github readme. --- microblog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/microblog.py b/microblog.py index d66bdb01..ce046aa6 100644 --- a/microblog.py +++ b/microblog.py @@ -1,9 +1,9 @@ from app import create_app, db from app.models import User, Post -# from app.config import DevConfig +from app.config import DevConfig -# app = create_app(DevConfig) -app = create_app() +app = create_app(DevConfig) +# app = create_app() @app.shell_context_processor def make_shell_context(): From 3b565ac7dadce2d4a978748f6df95abddbb0fbe4 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 12:04:24 +0100 Subject: [PATCH 002/185] chore: Added git commit template Keeping Git Commit Messages Consistent with a Custom Template --- gitConfigs/commit-conventions.txt | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 gitConfigs/commit-conventions.txt diff --git a/gitConfigs/commit-conventions.txt b/gitConfigs/commit-conventions.txt new file mode 100644 index 00000000..847d7ce2 --- /dev/null +++ b/gitConfigs/commit-conventions.txt @@ -0,0 +1,30 @@ +# ---------------------------------------------------------- +# Header - type(scope): Brief description +# ---------------------------------------------------------- +# * feat A new feature - SemVar PATCH +# * fix A bug fix - SemVar MINOR +# * BREAKING CHANGE Breaking API change - SemVar MAJOR +# * docs Change to documentation only +# * style Change to style (whitespace, etc.) +# * refactor Change not related to a bug or feat +# * perf Change that affects performance +# * test Change that adds/modifies tests +# * build Change to build system +# * ci Change to CI pipeline/workflow +# * chore General tooling/config/min refactor +# ---------------------------------------------------------- + + +# ---------------------------------------------------------- +# Body - More description, if necessary +# ---------------------------------------------------------- +# * Motivation behind changes, more detail into how +# functionality might be affected, etc. +# ---------------------------------------------------------- + + +# ---------------------------------------------------------- +# Footer - Associated issues, PRs, etc. +# ---------------------------------------------------------- +# * Ex: Resolves Issue #207, see PR #15, etc. +# ---------------------------------------------------------- From 1597aa4a8ab0daf12383ca336f06a0a88f77ed61 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 12:08:48 +0100 Subject: [PATCH 003/185] build: Docker build for prod the microblog Added dockerfile to build the microblog easy in a container --- docker/Dockerfile_prod | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docker/Dockerfile_prod diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod new file mode 100644 index 00000000..add1489a --- /dev/null +++ b/docker/Dockerfile_prod @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.4 +FROM python:3.8-alpine +RUN adduser -D microblog + +WORKDIR /home/microblog + +# COPY . . +COPY app app +COPY migrations migrations +COPY requirements requirements +COPY requirements.txt microblog.py boot.sh ./ + +RUN <<-EOF + python -m venv .venv && \ + .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ + chmod +x boot.sh && \ + chown -R microblog:microblog ./ +EOF + +ENV FLASK_APP microblog.py + +USER microblog + +EXPOSE 5000 +ENTRYPOINT ["./boot.sh"] \ No newline at end of file From 5b8991e41db3323b771de65574900661c02fd4f5 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 12:12:54 +0100 Subject: [PATCH 004/185] build: Script to start the webserver The script activates the virtual environment, upgrades the database and starts the server with gunicorn. --- boot.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 boot.sh diff --git a/boot.sh b/boot.sh new file mode 100644 index 00000000..20f9e032 --- /dev/null +++ b/boot.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +source .venv/bin/activate +while true; do + flask db upgrade + if [[ "$?" == "0" ]]; then + break + fi + echo Upgrade command failed, retrying in 5 secs... + sleep 5 +done +exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app \ No newline at end of file From 6d73b6667ae8666323e6bc7e28b57e90841e2238 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 12:17:10 +0100 Subject: [PATCH 005/185] style: Using autopep8 Added style guide to use autopep8 in vscode --- .vscode/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5c80254d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8" + }, + "python.formatting.provider": "none" +} From abe9a9838b13f36073e5d137b75005d4f72e6450 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 12:57:28 +0100 Subject: [PATCH 006/185] docs: Added Changelog for version history A curated, chronologically ordered list of notable changes for each version of the project. --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5cdeb97a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] + +### From 22888fd375827682f28cc12744cf3916aa69e29e Mon Sep 17 00:00:00 2001 From: Kasper Falk Date: Mon, 6 Nov 2023 14:23:42 +0100 Subject: [PATCH 007/185] Create python ci for tests ci: Added Github Action for python to unit tests, integration tests and validate the code. A action that runs when push and pr is done that runs the unit tests, integration tests and validate the python code. --- .github/workflows/python-ci.yml | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 00000000..5eb477e9 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,36 @@ +name: Python application + +on: + push: + branches: [ "development, master" ] + pull_request: + branches: [ "development, master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f test.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From 8f7c2ba11ff1c420f3825af62e1f88a99e2d0405 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 14:39:35 +0100 Subject: [PATCH 008/185] ci: Fixed python ci tests Fixed the strings on branches name. Added to run unit tests and integration tests seperate. Fixed the pip install to use folder requirements when install test.txt. --- .github/workflows/python-ci.yml | 48 +++++++++++++++++---------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 5eb477e9..1f37965d 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -1,36 +1,38 @@ -name: Python application +name: Python CI Tests on: push: - branches: [ "development, master" ] + branches: ["development", "master"] pull_request: - branches: [ "development, master" ] + branches: ["development", "master"] permissions: contents: read jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f test.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements/test.txt ]; then pip install -r requirements/requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run unit tests + run: | + pytest tests/unit + - name: Run integration tests + run: | + pytest tests/integration From d7eff29f49553b9894ce6e39238ec8fb4ab87d8d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 14:45:40 +0100 Subject: [PATCH 009/185] ci: Changed requiremets.txt to text.txt Fixed so it use text.txt when pip install instead of requirements.txt. --- .github/workflows/python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 1f37965d..9dc32073 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -23,7 +23,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pytest - if [ -f requirements/test.txt ]; then pip install -r requirements/requirements.txt; fi + if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From ac35b394b218fac22d3f323ac2f20537e54058dc Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 14:52:26 +0100 Subject: [PATCH 010/185] docs: Added badge for Python Ci Tests --- README.md | 44 +++++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2f65f69a..ff1e84b9 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -Microblog -=================== +# Microblog + +![Python CI Tests](https://github.com/falkendev/microblog/actions/workflows/python-ci.yml/badge.svg) [![Join the chat at https://gitter.im/dbwebb-se/devops](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/dbwebb-se/devops?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) @@ -9,42 +10,39 @@ Released as part of a University course: https://dbwebb.se/kurser/devops The application used in this course is based on [The flask mega tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world). - - - -Dev environment ------------------- +## Dev environment Here is how you setup the development environment and start the application. - - ### Packages Create a virtual environment and install packages: + ``` python3 -m venv venv source venv/bin/activate make install-dev ``` -If you are on Windows and Cygwin you will probably have troubles installing the pip package `cryptography`. Common errors are missing `python.h`, `gcc`, `cffi` and `openssl`. - +If you are on Windows and Cygwin you will probably have troubles installing the pip package `cryptography`. Common errors are missing `python.h`, `gcc`, `cffi` and `openssl`. ### Database Setup SQLite database if `migrations` folder already exist: + ``` flask db upgrade ``` If you have upgraded the code for any SQLAlchemy models: + ``` flask db migrate -m '' flask db upgrade ``` You probably won't need to do this. But if you need to recreate `app.db` and migrations folder: + ``` flask db init flask db migrate -m '' @@ -52,54 +50,46 @@ flask db upgrade ``` If you have the wrong migrations version in the database when you want to upgrade it you can change it with: + ``` flask db stamp head flask db upgrade ``` - - ### Test application There are several make commands for testing the application. Use `make help` to see which. To run all tests and validation use: + ``` make test ``` - - ### Run application Start byt setting the FLASK_APP and FLASK_ENV env vars: + ``` export FLASK_APP=microblog.py export FLASK_ENV=development ``` + Change to use the DevConfig in `microblog.py`, uncomment `# from app.config import DevConfig` and `# app = create_app(DevConfig)` (comment `app = create_app()`). Start the app with the following command and go to `localhost:5000` in your browser. + ``` flask run ``` - - -Production environment ------------------- +## Production environment Follow the scripts in `scripts/` or [Driftsätta en flask app](https://dbwebb.se/kunskap/driftsatta-en-flask-app). - - -License -------------------- +## License This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, 444 Castro Street, Suite 900, Mountain View, California, 94041, USA. - - -Acknowledgement -------------------- +## Acknowledgement This is a co-effort of several people using freely available documentation and tools from the open source community. From 4b4aa3dc7dcfe49dc27d695b0c2565231fdf2ec9 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 15:10:13 +0100 Subject: [PATCH 011/185] chore: Added Template for Pull Requests --- .github/pull_request_template.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..cf0002e9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +# PR "Enter the feature here" + +## What kmom + +Include which kmom it is for. + +## Whats changed + +Include a summary of the change and which issue is fixed. + +## What issus are solved, if any + +Include the issues that has been solved, if any else remove it. From 12301f205d1b0d3e28fb449e8e7c26402224f882 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 20:45:59 +0100 Subject: [PATCH 012/185] docs: Updated Changelog with the latest features and fixes --- CHANGELOG.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdeb97a..3f6ee7a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +X.X.X - Kmom.Feature.SmallFixes + ## [Unreleased] -## [0.1.0] +### Added + +- **Refactor:** Updated to use app.config (microblog.py) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) +- **Chore:** Added git commit template (gitConfigs/commit-conventions.txt) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Docker build for prod the microblog (Dockerfile_prod) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Added script to start the webserver (boot.sh) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) +- **Docs:** Added Changelog for version history (CHANGELOG.md) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) +- **CI:** Added CI for python to unit tests, integration tests and validate the code. (GitHub Actions) [PR 3 - python-ci-tests](https://github.com/FalkenDev/microblog/pull/3) +- **Chore:** Added template for [PR 5 - Github-Configs](https://github.com/FalkenDev/microblog/pull/5) + +### Changed + +### Deprecated + +### Removed + +### Fixed + +- **Fix:** Added version for SQLAlchemy [PR 1 - dbwebb-se:master](https://github.com/FalkenDev/microblog/pull/1) +- **CI:** Python CI Tests: Fixed the strings on branch names. Added to run unit tests and integration tests seperate. Fixed the pip install to use folder requirements when install test.txt. [PR 4 - python-ci-tests](https://github.com/FalkenDev/microblog/pull/4) -### +### Security From fd3730ac29e7c9bbe5f672dfe3fd3d33cb03dcbc Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 21:27:34 +0100 Subject: [PATCH 013/185] ci: Added workflow_call for reuse in docker cd --- .github/workflows/python-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 9dc32073..505ddd98 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -1,6 +1,7 @@ name: Python CI Tests on: + workflow_call: push: branches: ["development", "master"] pull_request: From 8d8ed03cb5b7ed4473b407f6ef89b172698e45e3 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 21:28:30 +0100 Subject: [PATCH 014/185] ci: Added docker cd for publish image when release The docker runs the publish when a release tag is created. Using the tag for the version. --- .github/workflows/docker-publish.yml | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..db3560b8 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,29 @@ +name: Docker CD Publish Image + +on: + release: + types: [created] + +jobs: + python-ci: + uses: ./.github/workflows/python-ci.yml + + build-and-push: + needs: python-ci + runs-on: ubuntu-latest + if: ${{ github.event_name == 'release' && github.event.action == 'created' }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.ref_name }} From bbccb4c39cb5a0c1d4f9191aa7921fefab109d52 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Mon, 6 Nov 2023 21:39:10 +0100 Subject: [PATCH 015/185] build: Essential docker setup & configuration Add Docker setup for Microblog with production and testing configurations This commit introduces several Docker-related configurations: - A Dockerfile for production (`docker/Dockerfile_prod`) has been created and placed in the `docker` directory. This Dockerfile is configured to start a Microblog container linked to a MySQL container. - The `docker-compose.yml` file has been added to the root of the repository. It includes a service definition that starts the production container (`prod`) alongside a MySQL container. The service can be initiated with `docker-compose up prod`. - A Dockerfile for testing (`docker/Dockerfile_test`) has been created in the `docker` directory. This Dockerfile is set up to run `make test` on startup and then shut down. It uses volumes for the `app` and `tests` directories and installs dependencies from `requirements/test.txt`. - A new script `run_tests.sh`, which triggers `make test` to run all tests. - The `docker-compose.yml` file has been updated with a new service definition for the test container (`test`). This service can be started with `docker-compose up test`. --- Makefile | 3 ++- boot.sh | 12 ++++++++++++ docker-compose.yml | 43 ++++++++++++++++++++++++++++++++++++++++++ docker/Dockerfile_prod | 25 ++++++++++++++++++++++++ docker/Dockerfile_test | 22 +++++++++++++++++++++ microblog.py | 6 +++--- run_tests.sh | 3 +++ 7 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 boot.sh create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile_prod create mode 100644 docker/Dockerfile_test create mode 100644 run_tests.sh diff --git a/Makefile b/Makefile index c96be7c1..7622a96e 100755 --- a/Makefile +++ b/Makefile @@ -93,7 +93,8 @@ info: # target: validate - Validate code with pylint .PHONY: validate validate: - @pylint --rcfile=.pylintrc app tests + @/home/microblog/.venv/bin/pylint --rcfile=.pylintrc app tests + diff --git a/boot.sh b/boot.sh new file mode 100644 index 00000000..20f9e032 --- /dev/null +++ b/boot.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +source .venv/bin/activate +while true; do + flask db upgrade + if [[ "$?" == "0" ]]; then + break + fi + echo Upgrade command failed, retrying in 5 secs... + sleep 5 +done +exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f2719352 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + db: + image: mysql:5.7 + platform: linux/amd64 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_DATABASE: microblog + MYSQL_USER: microblog + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + volumes: + - db_data:/var/lib/mysql + restart: always + + prod: + build: + context: . + dockerfile: docker/Dockerfile_prod + environment: + FLASK_APP: microblog.py + DATABASE_URL: mysql+pymysql://microblog:password@db/microblog + ports: + - "8000:5000" + depends_on: + - db + restart: always + + test: + build: + context: . + dockerfile: docker/Dockerfile_test + volumes: + - ./app:/home/microblog/app + - ./tests:/home/microblog/tests + environment: + DATABASE_URL: mysql+pymysql://microblog:password@db/microblog + depends_on: + - db + +volumes: + db_data: diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod new file mode 100644 index 00000000..238d78f8 --- /dev/null +++ b/docker/Dockerfile_prod @@ -0,0 +1,25 @@ +#syntax=docker/dockerfile:1.4 +FROM python:3.8-alpine +RUN adduser -D microblog + +WORKDIR /home/microblog + +COPY app app +COPY migrations migrations +COPY requirements requirements +COPY requirements.txt microblog.py boot.sh ./ + +RUN <<-EOF + apk add --no-cache make && \ + python -m venv .venv && \ + .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ + chmod +x boot.sh && \ + chown -R microblog:microblog ./ +EOF + +ENV FLASK_APP microblog.py + +USER microblog + +EXPOSE 5000 +ENTRYPOINT ["./boot.sh"] \ No newline at end of file diff --git a/docker/Dockerfile_test b/docker/Dockerfile_test new file mode 100644 index 00000000..c4099c4c --- /dev/null +++ b/docker/Dockerfile_test @@ -0,0 +1,22 @@ +#syntax=docker/dockerfile:1.4 +FROM python:3.8-alpine + +WORKDIR /home/microblog + +COPY requirements/prod.txt ./ +COPY requirements/test.txt ./ +COPY run_tests.sh ./ +COPY Makefile ./ +COPY .pylintrc ./ +COPY pytest.ini ./ + +RUN <<-EOF + apk add --no-cache make && \ + python -m venv .venv && \ + .venv/bin/pip3 install --no-cache-dir -r test.txt && \ + chmod +x run_tests.sh +EOF + +ENV PATH="/home/microblog/.venv/bin:$PATH" + +ENTRYPOINT ["./run_tests.sh"] \ No newline at end of file diff --git a/microblog.py b/microblog.py index d66bdb01..ce046aa6 100644 --- a/microblog.py +++ b/microblog.py @@ -1,9 +1,9 @@ from app import create_app, db from app.models import User, Post -# from app.config import DevConfig +from app.config import DevConfig -# app = create_app(DevConfig) -app = create_app() +app = create_app(DevConfig) +# app = create_app() @app.shell_context_processor def make_shell_context(): diff --git a/run_tests.sh b/run_tests.sh new file mode 100644 index 00000000..702e46bd --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +make test \ No newline at end of file From 92546d5656e71ab844e49d3401866499321b6636 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 21:39:52 +0100 Subject: [PATCH 016/185] cd: Changed to use release tag name Changed to use the github.event.release.tag_name for docker image version instead of github.ref_name --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index db3560b8..ec897a62 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -26,4 +26,4 @@ jobs: uses: docker/build-push-action@v5 with: push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.ref_name }} + tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }} From 18b54e536254ac50c31123638e27502637cfc0c2 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 21:55:22 +0100 Subject: [PATCH 017/185] cd: Added docker image publish for test and prod It now publish both a test docker image and prod docker image to dockerhub. --- .github/workflows/docker-publish.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ec897a62..33fed7ab 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,8 +22,18 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Build and push Docker image + - name: Build and push Docker image for production uses: docker/build-push-action@v5 with: + context: . push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }} + file: ./docker/Dockerfile_prod + tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-prod + + - name: Build and push Docker image for production + uses: docker/build-push-action@v5 + with: + context: . + push: true + file: ./docker/Dockerfile_prod + tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-test From ad7cae0ee83d93cd67e226519ea72c375cf26a6d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 22:04:44 +0100 Subject: [PATCH 018/185] cd: Changed name from production (dublicate) to test --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 33fed7ab..08770e0e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -30,7 +30,7 @@ jobs: file: ./docker/Dockerfile_prod tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-prod - - name: Build and push Docker image for production + - name: Build and push Docker image for testing uses: docker/build-push-action@v5 with: context: . From c7b8efb0b053ffe83353c99c2ad4f410e86f3558 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 6 Nov 2023 22:42:22 +0100 Subject: [PATCH 019/185] docs: Changelog updated Changelog updated to reflect new features and bug fixes and new release 0.1.0. --- CHANGELOG.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f6ee7a5..cd11da92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,29 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -X.X.X - Kmom.Feature.SmallFixes - ## [Unreleased] +- _No unreleased changes at this time._ + +## [0.1.0] - 2023-11-06 + ### Added -- **Refactor:** Updated to use app.config (microblog.py) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) -- **Chore:** Added git commit template (gitConfigs/commit-conventions.txt) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) -- **Build:** Docker build for prod the microblog (Dockerfile_prod) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) -- **Build:** Added script to start the webserver (boot.sh) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) -- **Docs:** Added Changelog for version history (CHANGELOG.md) [PR 2 - initial](https://github.com/FalkenDev/microblog/pull/2) -- **CI:** Added CI for python to unit tests, integration tests and validate the code. (GitHub Actions) [PR 3 - python-ci-tests](https://github.com/FalkenDev/microblog/pull/3) -- **Chore:** Added template for [PR 5 - Github-Configs](https://github.com/FalkenDev/microblog/pull/5) +- **Refactor:** Microblog configuration added [PR 2 - "Update microblog configuration"](https://github.com/FalkenDev/microblog/pull/2) +- **Chore:** Introduced git commit message template to standardize contributions [PR 2 - "Add commit template"](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Production-ready Dockerfile added for Microblog deployment [PR 2 - "Add Dockerfile for production"](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Script for starting the web server (`boot.sh`) implemented [PR 2 - "Add webserver start script"](https://github.com/FalkenDev/microblog/pull/2) +- **Docs:** Changelog initiated to document project evolution [PR 2 - "Initialize CHANGELOG.md"](https://github.com/FalkenDev/microblog/pull/2) +- **CI:** Continuous integration setup with GitHub Actions for automated testing and code validation [PR 3 - "Set up Python CI"](https://github.com/FalkenDev/microblog/pull/3) +- **Chore:** GitHub Pull Request template added to standardize contributions [PR 5 - "Add Pull Request template"](https://github.com/FalkenDev/microblog/pull/5) +- **CD:** Continuous deployment workflow for Docker image publishing [PR 7 - "Setup Docker image CD"](https://github.com/FalkenDev/microblog/pull/7) +- **Build:** Essential Docker setup and configuration for production and testing environments [PR 9 - "Configure Docker setup"](https://github.com/FalkenDev/microblog/pull/9) ### Changed -### Deprecated - -### Removed +- **Docs:** Changelog updated to reflect new features and bug fixes [PR 6 - "Update Changelog"](https://github.com/FalkenDev/microblog/pull/6) +- **CD:** Docker CD workflow modified to use release tag names [PR 8 - "Modify Docker CD to use tags"](https://github.com/FalkenDev/microblog/pull/8) +- **CD:** Docker image CD pipeline adjusted to publish images for both testing and production [PR 10 - "Adjust Docker image CD pipeline"](https://github.com/FalkenDev/microblog/pull/10) +- **Docs:** Changelog updated to reflect new features and bug fixes [PR 11 - "Update Changelog"](https://github.com/FalkenDev/microblog/pull/11) ### Fixed -- **Fix:** Added version for SQLAlchemy [PR 1 - dbwebb-se:master](https://github.com/FalkenDev/microblog/pull/1) -- **CI:** Python CI Tests: Fixed the strings on branch names. Added to run unit tests and integration tests seperate. Fixed the pip install to use folder requirements when install test.txt. [PR 4 - python-ci-tests](https://github.com/FalkenDev/microblog/pull/4) - -### Security +- **Fix:** Pinned SQLAlchemy to a specific version for compatibility, incorporating changes from dbwebb-se's master branch. [PR 1 - "Fix SQLAlchemy version"](https://github.com/FalkenDev/microblog/pull/1) - [dbwebb-se - "Added version for SQLAlchemy"](https://github.com/dbwebb-se/microblog/commit/372175c4b499e62167230025ea6aeca787bbcb8b) +- **CI:** Resolved branch naming issues in CI tests, separated unit tests from integration tests, and refined pip install process for testing [PR 4 - "Fix Python CI tests"](https://github.com/FalkenDev/microblog/pull/4) From 1c9515710a892a845a53ce034d284a3c66781e2e Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 12:29:57 +0100 Subject: [PATCH 020/185] feat: Route for following Routes for following and unfollowing --- app/main/routes.py | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/main/routes.py b/app/main/routes.py index a255cc6d..4fbbf260 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -39,12 +39,10 @@ def index(): flash('Your post is now live!') return redirect(url_for('main.index')) - posts = current_user.posts.all() + posts = current_user.followed_posts().all() return render_template("index.html", title='Home Page', form=form, posts=posts) - - @bp.route('/explore') @login_required def explore(): @@ -63,7 +61,7 @@ def user(username): Route for user """ user_ = User.query.filter_by(username=username).first_or_404() - posts = current_user.posts.all() + posts = user_.posts.all() return render_template('user.html', user=user_, posts=posts) @@ -86,3 +84,39 @@ def edit_profile(): form.about_me.data = current_user.about_me return render_template('edit_profile.html', title='Edit Profile', form=form) + +@bp.route('/follow/') +@login_required +def follow(username): + """ + Follow a User + """ + user_ = User.query.filter_by(username=username).first() + if user_ is None: + flash(f'User {username} not found.') + return redirect(url_for('index')) + if user_ == current_user: + flash('You cannot follow yourself!') + return redirect(url_for('user', username=username)) + current_user.follow(user_) + db.session.commit() + flash(f'You are following {username}!') + return redirect(url_for('main.user', username=username)) + +@bp.route('/unfollow/') +@login_required +def unfollow(username): + """ + Unfollow a User + """ + user_ = User.query.filter_by(username=username).first() + if user is None: + flash(f'User {username} not found.') + return redirect(url_for('index')) + if user_ == current_user: + flash('You cannot unfollow yourself!') + return redirect(url_for('user', username=username)) + current_user.unfollow(user_) + db.session.commit() + flash(f'You are not following {username}.') + return redirect(url_for('main.user', username=username)) \ No newline at end of file From cf64c6803f1c68a5ae2d7b52c413a5eddaebd62a Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 12:31:20 +0100 Subject: [PATCH 021/185] feat: DB model for followers Added model for followers db relationship --- app/models.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/models.py b/app/models.py index d2dc091d..8966763c 100644 --- a/app/models.py +++ b/app/models.py @@ -9,6 +9,11 @@ from flask_login import UserMixin from app import db, login +followers = db.Table('followers', + db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), + db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) +) + class User(UserMixin, db.Model): """ Represetns a system User @@ -20,6 +25,11 @@ class User(UserMixin, db.Model): about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime, default=datetime.utcnow) posts = db.relationship('Post', backref='author', lazy='dynamic') + followed = db.relationship( + 'User', secondary=followers, + primaryjoin=(followers.c.follower_id == id), + secondaryjoin=(followers.c.followed_id == id), + backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') def __repr__(self): return f'' @@ -53,6 +63,25 @@ def avatar(self, size="80"): url = f'https://www.gravatar.com/avatar/{digest}?d=retro&s={size}' current_app.logger.debug(f"Get gravatar {url}") return url + + def follow(self, user): + if not self.is_following(user): + self.followed.append(user) + + def unfollow(self, user): + if self.is_following(user): + self.followed.remove(user) + + def is_following(self, user): + return self.followed.filter( + followers.c.followed_id == user.id).count() > 0 + + def followed_posts(self): + followed = Post.query.join( + followers, (followers.c.followed_id == Post.user_id)).filter( + followers.c.follower_id == self.id) + own = Post.query.filter_by(user_id=self.id) + return followed.union(own).order_by(Post.timestamp.desc()) class Post(db.Model): """ From b85599834314c88ad838b02ab9bcad71a5a02a25 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 12:33:13 +0100 Subject: [PATCH 022/185] feat: follow and unfollow buttons Added buttons for following and unfollowing --- app/templates/user.html | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/templates/user.html b/app/templates/user.html index d4c4e6cd..261d11a3 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -1,3 +1,4 @@ + {% extends "base.html" %} {% block app_content %} @@ -6,14 +7,15 @@

User: {{ user.username }}

- {% if user.about_me %} -

{{ user.about_me }}

- {% endif %} - {% if user.last_seen %} -

Last seen on: {{ moment(user.last_seen).format('LLL') }}

- {% endif %} + {% if user.about_me %}

{{ user.about_me }}

{% endif %} + {% if user.last_seen %}

Last seen on: {{ user.last_seen }}

{% endif %} +

{{ user.followers.count() }} followers, {{ user.followed.count() }} following.

{% if user == current_user %}

Edit your profile

+ {% elif not current_user.is_following(user) %} +

Follow

+ {% else %} +

Unfollow

{% endif %} @@ -22,4 +24,4 @@

User: {{ user.username }}

{% for post in posts %} {% include '_post.html' %} {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} From 38764c86a638be8fcb8007c027cd31e4ac654e94 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 12:34:31 +0100 Subject: [PATCH 023/185] refactor: Migration for following New migration that includes following to the database --- migrations/versions/e96cbeefa9ae_followers.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 migrations/versions/e96cbeefa9ae_followers.py diff --git a/migrations/versions/e96cbeefa9ae_followers.py b/migrations/versions/e96cbeefa9ae_followers.py new file mode 100644 index 00000000..4d531d62 --- /dev/null +++ b/migrations/versions/e96cbeefa9ae_followers.py @@ -0,0 +1,34 @@ +"""followers + +Revision ID: e96cbeefa9ae +Revises: a524776a1ebb +Create Date: 2023-11-07 11:57:54.415897 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e96cbeefa9ae' +down_revision = 'a524776a1ebb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('followers', + sa.Column('follower_id', sa.Integer(), nullable=True), + sa.Column('followed_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ), + info={'bind_key': None} + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('followers') + # ### end Alembic commands ### From 49555a2e8c4ac5d3314d885a064bbe3c27af138f Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 12:36:04 +0100 Subject: [PATCH 024/185] test: testing following feature Added two tests for the follow feature. One for following and one for unfollowing --- tests/unit/models/test_followers.py | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/unit/models/test_followers.py diff --git a/tests/unit/models/test_followers.py b/tests/unit/models/test_followers.py new file mode 100644 index 00000000..bc26293e --- /dev/null +++ b/tests/unit/models/test_followers.py @@ -0,0 +1,74 @@ +# pylint: disable=redefined-outer-name +from datetime import datetime, timedelta +from unittest import mock +import pytest +from app.models import User, Post +from app import db + +def test_follow(test_app): # pylint: disable=unused-argument + """ + Test that follow appends new Users to followed. + Test that unfollow removes the User from followed. + """ + user1 = User(username='john', email='john@example.com') + user2 = User(username='susan', email='susan@example.com') + db.session.add(user1) + db.session.add(user2) + db.session.commit() + assert user1.followed.all() == [] + + user1.follow(user2) + db.session.commit() + + assert user1.is_following(user2) is True + assert user1.followed.count() == 1 + assert user1.followed.first().username == "susan" + assert user2.followers.count() == 1 + assert user2.followers.first().username == "john" + + user1.unfollow(user2) + db.session.commit() + assert user1.is_following(user2) is not True + assert user1.followed.count() == 0 + assert user1.followers.count() == 0 + +def test_follow_posts(test_app): # pylint: disable=unused-argument + """ + Test that all personal and posts from followed users are shown. + """ + # create four users + user1 = User(username='john', email='john@example.com') + user2 = User(username='susan', email='susan@example.com') + user3 = User(username='mary', email='mary@example.com') + user4 = User(username='david', email='david@example.com') + db.session.add_all([user1, user2, user3, user4]) + + # create four posts + now = datetime.utcnow() + post1 = Post(body="post from john", author=user1, + timestamp=now + timedelta(seconds=1)) + post2 = Post(body="post from susan", author=user2, + timestamp=now + timedelta(seconds=4)) + post3 = Post(body="post from mary", author=user3, + timestamp=now + timedelta(seconds=3)) + post4 = Post(body="post from david", author=user4, + timestamp=now + timedelta(seconds=2)) + db.session.add_all([post1, post2, post3, post4]) + db.session.commit() + + # setup the followers + user1.follow(user2) # john follows susan + user1.follow(user4) # john follows david + user2.follow(user3) # susan follows mary + user3.follow(user4) # mary follows david + db.session.commit() + + # check the followed posts of each user + follow1 = user1.followed_posts().all() + follow2 = user2.followed_posts().all() + follow3 = user3.followed_posts().all() + follow4 = user4.followed_posts().all() + assert follow1 == [post2, post4, post1] + assert follow2 == [post2, post3] + assert follow3 == [post3, post4] + assert follow4 == [post4] \ No newline at end of file From 01c91f4aaace7ac2db408f450e3ea117e3ae667c Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 7 Nov 2023 13:18:28 +0100 Subject: [PATCH 025/185] docs: Updated changelog to include pr12 Changelog now includes pr 12 that adds following and unfollowing features --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..db8c0c40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- **Feat:** User Follow/Unfollow Functionality [PR 12 - "Implement User Follow/Unfollow Functionality"](https://github.com/FalkenDev/microblog/pull/12) + +- _No unreleased changes at this time._ + +## [0.1.0] - 2023-11-06 + +### Added + +- **Refactor:** Microblog configuration added [PR 2 - "Update microblog configuration"](https://github.com/FalkenDev/microblog/pull/2) +- **Chore:** Introduced git commit message template to standardize contributions [PR 2 - "Add commit template"](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Production-ready Dockerfile added for Microblog deployment [PR 2 - "Add Dockerfile for production"](https://github.com/FalkenDev/microblog/pull/2) +- **Build:** Script for starting the web server (`boot.sh`) implemented [PR 2 - "Add webserver start script"](https://github.com/FalkenDev/microblog/pull/2) +- **Docs:** Changelog initiated to document project evolution [PR 2 - "Initialize CHANGELOG.md"](https://github.com/FalkenDev/microblog/pull/2) +- **CI:** Continuous integration setup with GitHub Actions for automated testing and code validation [PR 3 - "Set up Python CI"](https://github.com/FalkenDev/microblog/pull/3) +- **Chore:** GitHub Pull Request template added to standardize contributions [PR 5 - "Add Pull Request template"](https://github.com/FalkenDev/microblog/pull/5) +- **CD:** Continuous deployment workflow for Docker image publishing [PR 7 - "Setup Docker image CD"](https://github.com/FalkenDev/microblog/pull/7) +- **Build:** Essential Docker setup and configuration for production and testing environments [PR 9 - "Configure Docker setup"](https://github.com/FalkenDev/microblog/pull/9) + +### Changed + +- **Docs:** Changelog updated to reflect new features and bug fixes [PR 6 - "Update Changelog"](https://github.com/FalkenDev/microblog/pull/6) +- **CD:** Docker CD workflow modified to use release tag names [PR 8 - "Modify Docker CD to use tags"](https://github.com/FalkenDev/microblog/pull/8) +- **CD:** Docker image CD pipeline adjusted to publish images for both testing and production [PR 10 - "Adjust Docker image CD pipeline"](https://github.com/FalkenDev/microblog/pull/10) +- **Docs:** Changelog updated to reflect new features and bug fixes [PR 11 - "Update Changelog"](https://github.com/FalkenDev/microblog/pull/11) + +### Fixed + +- **Fix:** Pinned SQLAlchemy to a specific version for compatibility, incorporating changes from dbwebb-se's master branch. [PR 1 - "Fix SQLAlchemy version"](https://github.com/FalkenDev/microblog/pull/1) - [dbwebb-se - "Added version for SQLAlchemy"](https://github.com/dbwebb-se/microblog/commit/372175c4b499e62167230025ea6aeca787bbcb8b) +- **CI:** Resolved branch naming issues in CI tests, separated unit tests from integration tests, and refined pip install process for testing [PR 4 - "Fix Python CI tests"](https://github.com/FalkenDev/microblog/pull/4) From db14b7807f0c1dabfa8bc16c08e362f14022a42b Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sat, 11 Nov 2023 12:56:36 +0100 Subject: [PATCH 026/185] docs: Changelog updated to reflect the new release [0.2.0] and [1.0.0] --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db8c0c40..77e4b93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **Feat:** User Follow/Unfollow Functionality [PR 12 - "Implement User Follow/Unfollow Functionality"](https://github.com/FalkenDev/microblog/pull/12) - - _No unreleased changes at this time._ +## [1.0.0] - 2023-11-11 + +**Branch:** Push development branch to master branch to reflect kmom01 is done [PR 14 - "Development to Master (kmom01)"](https://github.com/FalkenDev/microblog/issues/14) +**Docs:** Changelog updated to reflect the new release [PR 13 - "Changelog Update"](https://github.com/FalkenDev/microblog/issues/13) + +## [0.2.0] - 2023-11-08 + +- **Feat:** User Follow/Unfollow Functionality [PR 12 - "Implement User Follow/Unfollow Functionality"](https://github.com/FalkenDev/microblog/pull/12) + ## [0.1.0] - 2023-11-06 ### Added From 13ef3c4cfa0e44e525bbaa5081278b23d9d65dbb Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Wed, 15 Nov 2023 09:42:51 +0100 Subject: [PATCH 027/185] Create azure_joel.pub --- ssh/azure_joel.pub | 1 + 1 file changed, 1 insertion(+) create mode 100644 ssh/azure_joel.pub diff --git a/ssh/azure_joel.pub b/ssh/azure_joel.pub new file mode 100644 index 00000000..9956687e --- /dev/null +++ b/ssh/azure_joel.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDHE2fy1hvHrCIMAuSvnNH7cutzYLQFPY/nxmLuGuQ7AE4nvuqsuMhmvG0+d7HZeoQIjshJVuu8ilYIfKSvd3NHBvJ0CUVbC4VIx3veZcJ74D7bEWuUfBPO4NjqPWrIV44ITElGv9ybr/kboD8DRi7SO4MRNYjDyrh0bMIelIY8GpPy+TP8MrvSVFAJFviXUPP7o2kSEhD0Mht0/b/i1TPEmiWp9iG3QZ2i6SB5uNx+xEM0bjeMSB5FDScR1yEx9ibBVYjkzLcxJku7TGUNTWQnh/aOTKfTyDxF5ivL9e5uB3XrHRNxz/HGbpTcW0sLlQE7hO+oYrgu+x8FKck9O1bpdTMuSNjKOUsC6kDv2WwnIZv/WWq+vMDR0YeXIuJ45aW0JYrwAaNrNxzxBGE18CriCfkJ7IklAuxoWAxMDW3jKZf+J0B4ikpXqHmHd3Z+EyKq3hH7DqE76juKSOZZ8F9Gjn4iY4uy3rDQMng1tUoVeRSWyNai7yAj0jQBeIipzIw8yKOzQIhUaLoHD2XE4TIkMkmFmW1gkpo+x9DIhROaT2go6VtWSsJfVexNaox/xl7K11/r2Z3z6ME61TjtDigsAYQMt80t8XYYNECxSNymj01+HnP3JO9ZHQliwcaMpezJtcfNQDPx7eKLbIyIP88034ekDX9k+zemRzw3x+WEZQ== jopl16@student.bth.se From ddfc8f77bbd20567d50ff238ddb54a9409cce5cf Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 09:58:43 +0100 Subject: [PATCH 028/185] SSH Key kasper --- ssh/azure_kasper.pub | 1 + 1 file changed, 1 insertion(+) create mode 100644 ssh/azure_kasper.pub diff --git a/ssh/azure_kasper.pub b/ssh/azure_kasper.pub new file mode 100644 index 00000000..e3dd5ba5 --- /dev/null +++ b/ssh/azure_kasper.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCtwF1FFp8pyTw/sGGQAISs2oNsdCkFlw69geYREnWo+tJm0FYsvF9YT5bNu4sA3aIbfGZSKaRUF2KLvVdo+Xvs5FjzELFwwLf2vHeFvAFki6dBt3Q8cFLKgn+K10NXF0IBorXW9dCpulM+KrmfY0Q3dzrrZkAM8ZbVc1upZfcChskzFvlypLaMJTQxhDrhmXlhtgyhxIpUcP2Mvn3zBcPydQRwIERuFy6yrRmsWFFCyMY9QVqa5AxTcYT2ppqBgRYvGwgXEdEpZHV9MPWnB5FHZNLxmWge95yz1/otUf9iJbMEH1lrSxW+helgFaYK/gTgFcQe+pu3bWN0zIp8icj4Fhxf5+bfUwIhL+hvWuB5yOZRogMi74c9fR/YFYA2DkpaHWkPyKVjk2CbYRjbKFaFeM8nHl/S5Xb48TZ6uqngCNTv8irUDxyBEteqNupwtwsEaumVbQ/qpoQFyjYjmpsPc2avBV/yn40XVoos71rby8C/EbNl7S0kI2WPB+vwztE1vCvGejdMKY2v/MHZSucAACTUu5AFYCQSzpoZ6rDTS4W4tdZS1jaS2d/e+gGNlcfqqWJY/AMkvv+lDweLZh65LK7lzjBJV9EfYfCI2Js39y5eHWSrKKI1cfEoBQARx4QXx8shATrmT7tHCW8JFoTDV9z+FQUHYZUiOcw9t+EJ1Q== kafa21@student.bth.se From 1379fbe02fd06ec6edccd97097531fb7daba3dc1 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Wed, 15 Nov 2023 10:02:45 +0100 Subject: [PATCH 029/185] James SSH nyckel --- ssh/azure_james.pub | 1 + 1 file changed, 1 insertion(+) create mode 100644 ssh/azure_james.pub diff --git a/ssh/azure_james.pub b/ssh/azure_james.pub new file mode 100644 index 00000000..88a7904b --- /dev/null +++ b/ssh/azure_james.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCrwDZnXg718a+IW+tvZpwiAnKfkrHsamZXw3XArxz3es1piXtBj5mBT4hOEhd2KKWcX0FwM5V0Py8Nx/5gZHCe6zMbJ3mF5/RS8J9KKULcTioRiupcstSw0nqLMbzGgKCqkk7PI+8x83+64ESj3TWJ/KefFY7boBsqq8Z1+oAbkvOWGzbYOrLES4R+K49agOHPGWN6l3U7wMWm1FTblqw79Wo4UgG1aGSBLm2/rEISaEEegqYUORjJ43BlJfWwJ1tokZAAaUDl4mkvO9T1y79AV4Z8O52xxxt/CYuNgsXs+80fISoPHaDUppoSyK6YSWJmS4bqK8uh9tRz7JiXJpPOABjbZYtbntObjw4AoJKg++rpT9CMaaas8T15QpiQHUtZ6SUvDKB9C+64Qpv2Oy4H+3SBWi1iFI2V+0V9G8UdbDJ/PpxyWpN83MpttZHmnq63U5OHF8OLRa63AjSeJexcNYonh9AchYCKeveBf9JbiktuXq6BNQEf6nHY6IRxjA5ktXXA+q0aS+XGHEqib3ICF7curH8a87SZppdKrV25Hutz8iJrMT9dW7ZDx8iNR6sEVWvpVYNBOZvoHaK9B/JDNEplkSBt5g+n4tbzD+HqAboWygO+d5hGM+m90pUiBMc3SrVw/Q5bDzlxxaLTVlbvhcPHl13o8u0gpJj4Imcaew== jata20@student.bth.se \ No newline at end of file From 5ee9f9af235b6aaf37ab4e4a3ded61ef48ba8f8f Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 10:44:11 +0100 Subject: [PATCH 030/185] Rename to .ssh --- {ssh => .ssh}/azure_james.pub | 0 {ssh => .ssh}/azure_joel.pub | 0 {ssh => .ssh}/azure_kasper.pub | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {ssh => .ssh}/azure_james.pub (100%) rename {ssh => .ssh}/azure_joel.pub (100%) rename {ssh => .ssh}/azure_kasper.pub (100%) diff --git a/ssh/azure_james.pub b/.ssh/azure_james.pub similarity index 100% rename from ssh/azure_james.pub rename to .ssh/azure_james.pub diff --git a/ssh/azure_joel.pub b/.ssh/azure_joel.pub similarity index 100% rename from ssh/azure_joel.pub rename to .ssh/azure_joel.pub diff --git a/ssh/azure_kasper.pub b/.ssh/azure_kasper.pub similarity index 100% rename from ssh/azure_kasper.pub rename to .ssh/azure_kasper.pub From 498a081d827ee989d4c9c7bc14d8808e50e3d05c Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:00:31 +0100 Subject: [PATCH 031/185] fix: Moved ssh folder files to 10min folder in files --- {.ssh => ansible/roles/10-first-minutes/files}/azure_james.pub | 0 {.ssh => ansible/roles/10-first-minutes/files}/azure_joel.pub | 0 {.ssh => ansible/roles/10-first-minutes/files}/azure_kasper.pub | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {.ssh => ansible/roles/10-first-minutes/files}/azure_james.pub (100%) rename {.ssh => ansible/roles/10-first-minutes/files}/azure_joel.pub (100%) rename {.ssh => ansible/roles/10-first-minutes/files}/azure_kasper.pub (100%) diff --git a/.ssh/azure_james.pub b/ansible/roles/10-first-minutes/files/azure_james.pub similarity index 100% rename from .ssh/azure_james.pub rename to ansible/roles/10-first-minutes/files/azure_james.pub diff --git a/.ssh/azure_joel.pub b/ansible/roles/10-first-minutes/files/azure_joel.pub similarity index 100% rename from .ssh/azure_joel.pub rename to ansible/roles/10-first-minutes/files/azure_joel.pub diff --git a/.ssh/azure_kasper.pub b/ansible/roles/10-first-minutes/files/azure_kasper.pub similarity index 100% rename from .ssh/azure_kasper.pub rename to ansible/roles/10-first-minutes/files/azure_kasper.pub From 39c672e75e42aaea305ecda1071faf3c5404cbef Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:04:59 +0100 Subject: [PATCH 032/185] :build Updated to look for azure ssh keys using users_users var for the file names --- ansible/roles/10-first-minutes/tasks/main.yml | 149 +++++++++--------- 1 file changed, 74 insertions(+), 75 deletions(-) diff --git a/ansible/roles/10-first-minutes/tasks/main.yml b/ansible/roles/10-first-minutes/tasks/main.yml index 6b41ab4c..325a456e 100644 --- a/ansible/roles/10-first-minutes/tasks/main.yml +++ b/ansible/roles/10-first-minutes/tasks/main.yml @@ -1,87 +1,86 @@ --- -- name: Set root password - user: - name: root - password: "{{ root_password }}" +- name: Set root password + user: + name: root + password: "{{ root_password }}" -- name: Update apt-cache and upgrade - apt: - force_apt_get: yes - update_cache: "True" - cache_valid_time: 3600 - upgrade: yes +- name: Update apt-cache and upgrade + apt: + force_apt_get: yes + update_cache: "True" + cache_valid_time: 3600 + upgrade: yes -- name: Install packages - apt: - force_apt_get: yes - name: "{{ packages }}" +- name: Install packages + apt: + force_apt_get: yes + name: "{{ packages }}" -- name: Copy unattended upgrades 10 settings - copy: - mode: "644" - src: files/apt_periodic - dest: /etc/apt/apt.conf.d/10periodic +- name: Copy unattended upgrades 10 settings + copy: + mode: "644" + src: files/apt_periodic + dest: /etc/apt/apt.conf.d/10periodic -- name: Copy unattended upgrades 50 settings - copy: - mode: "644" - src: files/apt_periodic_50 - dest: /etc/apt/apt.conf.d/50unattended-upgrades +- name: Copy unattended upgrades 50 settings + copy: + mode: "644" + src: files/apt_periodic_50 + dest: /etc/apt/apt.conf.d/50unattended-upgrades -- name: Create user - user: - name: "{{ server_user }}" - password: "{{ server_user_pass }}" - state: present - shell: /bin/bash - groups: "{{ server_user_groups }}" +- name: Create user + user: + name: "{{ server_user }}" + password: "{{ server_user_pass }}" + state: present + shell: /bin/bash + groups: "{{ server_user_groups }}" -- name: Add ssh-key for new user - authorized_key: - user: "{{ server_user }}" - state: present - key: "{{ lookup('file', item) }}" - with_items: "{{ pub_ssh_key_location }}" +- name: Set ssh keys for regular users from files + authorized_key: + user: "{{ server_user }}" + key: "{{ lookup('file', item.key) }}" + exclusive: no + with_items: "{{ users_users }}" +- name: Add user to sudoers + lineinfile: + dest: /etc/sudoers + regexp: "{{ server_user }} ALL" + line: "{{ server_user }} ALL=(ALL) NOPASSWD:ALL" + state: present + validate: "/usr/sbin/visudo -cf %s" # kan få fel med line "{{ server_user }} testing" -- name: Add user to sudoers - lineinfile: - dest: /etc/sudoers - regexp: "{{ server_user }} ALL" - line: "{{ server_user }} ALL=(ALL) NOPASSWD:ALL" - state: present - validate: '/usr/sbin/visudo -cf %s' # kan få fel med line "{{ server_user }} testing" +- name: Disallow root and password ssh access + lineinfile: + path: /etc/ssh/sshd_config + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + state: present + validate: "/usr/sbin/sshd -T -f %s" # kan få fel med line "PermitRootLogin testing" + with_items: + - regexp: "^PasswordAuthentication" + line: "PasswordAuthentication no" + - regexp: "^PermitRootLogin" + line: "PermitRootLogin no" + notify: restart ssh -- name: Disallow root and password ssh access - lineinfile: - path: /etc/ssh/sshd_config - regexp: "{{ item.regexp }}" - line: "{{ item.line }}" - state: present - validate: '/usr/sbin/sshd -T -f %s' # kan få fel med line "PermitRootLogin testing" - with_items: - - regexp: "^PasswordAuthentication" - line: "PasswordAuthentication no" - - regexp: "^PermitRootLogin" - line: "PermitRootLogin no" - notify: restart ssh +- name: flush handlers to restart SSH + meta: flush_handlers # we cant do it later because after this we cant ssh as root -- name: flush handlers to restart SSH - meta: flush_handlers # we cant do it later because after this we cant ssh as root +- name: Only allow user to ssh + lineinfile: + path: /etc/ssh/sshd_config + regexp: "^AllowUsers" + line: "AllowUsers {{ server_user }}" + state: present + # ignore_errors: yes -- name: Only allow user to ssh - lineinfile: - path: /etc/ssh/sshd_config - regexp: "^AllowUsers" - line: "AllowUsers {{ server_user }}" - state: present - # ignore_errors: yes - -- name: Remove default user - remote_user: "{{ server_user }}" - user: - name: azureuser - state: absent - force: yes - remove: yes - # ignore_errors: yes +- name: Remove default user + remote_user: "{{ server_user }}" + user: + name: azureuser + state: absent + force: yes + remove: yes + # ignore_errors: yes From 8759fe0260b6a614a3003875ed1da9b003f260d3 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:06:49 +0100 Subject: [PATCH 033/185] build: Added users_users var with the ssh keys --- ansible/roles/10-first-minutes/vars/main.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ansible/roles/10-first-minutes/vars/main.yml b/ansible/roles/10-first-minutes/vars/main.yml index 1f083aa9..9f77bfd9 100644 --- a/ansible/roles/10-first-minutes/vars/main.yml +++ b/ansible/roles/10-first-minutes/vars/main.yml @@ -1,6 +1,11 @@ --- packages: - - fail2ban - - tmux - - git - - unattended-upgrades \ No newline at end of file + - fail2ban + - tmux + - git + - unattended-upgrades + +users_users: + - key: azure_james.pub + - key: azure_joel.pub + - key: azure_kasper.pub From cd272042d7712b4c119b91e54b05bec2c6d93738 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:07:34 +0100 Subject: [PATCH 034/185] build: Fixed python interpreter .venv to use just venv --- ansible/group_vars/local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/group_vars/local.yml b/ansible/group_vars/local.yml index 07bd3c7d..8f01c0d1 100755 --- a/ansible/group_vars/local.yml +++ b/ansible/group_vars/local.yml @@ -1,3 +1,3 @@ --- # Here you can add variabled that will be available when using host `local`. -ansible_python_interpreter: ../.venv/bin/python +ansible_python_interpreter: ../venv/bin/python From 3df44936f214753547ff49f82c01be52d71b8205 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:08:25 +0100 Subject: [PATCH 035/185] build: Added password to root --- ansible/group_vars/devops.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ansible/group_vars/devops.yml b/ansible/group_vars/devops.yml index c56b7a0c..7bf4c9ec 100644 --- a/ansible/group_vars/devops.yml +++ b/ansible/group_vars/devops.yml @@ -1,3 +1,2 @@ - root_user: azureuser -root_password: "" # change me +root_password: "devopsbth" # change me From f671e1af3d20585480fe621f17bf6c7d87fbcdfd Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:56:13 +0100 Subject: [PATCH 036/185] build: Added task to install docker --- ansible/roles/docker-install/tasks/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ansible/roles/docker-install/tasks/main.yml diff --git a/ansible/roles/docker-install/tasks/main.yml b/ansible/roles/docker-install/tasks/main.yml new file mode 100644 index 00000000..f46e72e4 --- /dev/null +++ b/ansible/roles/docker-install/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: Install Docker + apt: + name: docker.io + state: latest + update_cache: yes From 75d1d88a96ed03fe20f898c23e220942c8f0d29d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:57:13 +0100 Subject: [PATCH 037/185] build: Added playbook docker-mysql --- ansible/mysql-docker.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 ansible/mysql-docker.yml diff --git a/ansible/mysql-docker.yml b/ansible/mysql-docker.yml new file mode 100644 index 00000000..41035f46 --- /dev/null +++ b/ansible/mysql-docker.yml @@ -0,0 +1,9 @@ +--- +- hosts: database + connection: database + remote_user: deploy + become: yes + become_method: sudo + roles: + - docker-install + - mysql-docker From f4cced04da20a9a11d2d168b691a235ac715db78 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 15:58:34 +0100 Subject: [PATCH 038/185] build: Added task to run a mysql docker container --- ansible/roles/mysql-docker/tasks/main.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 ansible/roles/mysql-docker/tasks/main.yml diff --git a/ansible/roles/mysql-docker/tasks/main.yml b/ansible/roles/mysql-docker/tasks/main.yml new file mode 100644 index 00000000..e89fc773 --- /dev/null +++ b/ansible/roles/mysql-docker/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Start MySQL Container + docker_container: + name: db + image: "mysql:5.7" + platform: "linux/amd64" + command: "--default-authentication-plugin=mysql_native_password" + env: + MYSQL_DATABASE: "microblog" + MYSQL_USER: "microblog" + MYSQL_PASSWORD: "password" + MYSQL_ROOT_PASSWORD: "password" + volumes: + - mysql_data:/var/lib/mysql + restart_policy: always From 91176914b9656c4b4093a34ab72dd403ffbc85bb Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Wed, 15 Nov 2023 16:24:47 +0100 Subject: [PATCH 039/185] build: loadbalancer ansible play --- ansible/load_balancer.yml | 8 +++ ansible/roles/load_balancer/tasks/main.yml | 53 +++++++++++++++++++ .../tasks/templates/load-balancer.conf.j2 | 35 ++++++++++++ .../tasks/templates/nginx.conf.j2 | 10 ++++ 4 files changed, 106 insertions(+) create mode 100644 ansible/load_balancer.yml create mode 100644 ansible/roles/load_balancer/tasks/main.yml create mode 100644 ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 create mode 100644 ansible/roles/load_balancer/tasks/templates/nginx.conf.j2 diff --git a/ansible/load_balancer.yml b/ansible/load_balancer.yml new file mode 100644 index 00000000..86461acc --- /dev/null +++ b/ansible/load_balancer.yml @@ -0,0 +1,8 @@ +--- +- hosts: + - loadbalancer + remote_user: deploy + become: yes + become_method: sudo + roles: + - load_balancer \ No newline at end of file diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml new file mode 100644 index 00000000..69f863f2 --- /dev/null +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -0,0 +1,53 @@ +--- +- name: Install nginx + apt: name=nginx state=latest + +- name: Install certbot + pip: + name: + - certbot + - certbot-nginx + executable: pip3 + tags: + - nginx + - certbot + +- name: Register certbot + shell: | + certbot -n register --agree-tos --email + touch /etc/letsencrypt/.registered + args: + creates: /etc/letsencrypt/.registered + tags: + - nginx + - certbot + +- name: 'Get certificate' + command: '/usr/local/bin/certbot -n --nginx certonly -d {{ domain_name }}' + args: + creates: '/etc/letsencrypt/live/{{ domain_name }}' + ignore_errors: true + tags: + - nginx + - certbot + +- name: Copy ngnínx.conf.j2 + template: + src: templates/nginx.conf.j2 + dest: /etc/nginx/nginx.conf + +- name: Copy load-balancer.conf.j2 + template: + src: templates/load-balancer.conf.j2 + dest: /etc/nginx/sites-available/load-balancer.conf + +- name: Link load-balancer + ansible.builtin.file: + src: /etc/nginx/sites-available/load-balancer.conf + dest: /etc/nginx/sites-enabled/load-balancer.conf + state: link + +- name: Start nginx + service: + name: nginx + state: started \ No newline at end of file diff --git a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 new file mode 100644 index 00000000..2466b5f2 --- /dev/null +++ b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 @@ -0,0 +1,35 @@ +http { + upstream app-hosts { + {{ lb_method }}; + server {{ groups['appserver'][0] }}:8000; + } + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # This server accepts all traffic to port 80 and passes it to the upstream. + # Notice that the upstream name and the proxy_pass need to match. + + server { + listen 80; + server_name {{ domain_name }} www.{{ domain_name }}; + return 301 https://$server_name$request_uri; + + #location / { + # proxy_pass http://app-hosts; + #} + } + server { + listen 443 ssl; + server_name {{ domain_name }} www.{{ domain_name }}; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; + + ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/cert.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + + location / { + proxy_pass http://app-hosts; + } + } +} \ No newline at end of file diff --git a/ansible/roles/load_balancer/tasks/templates/nginx.conf.j2 b/ansible/roles/load_balancer/tasks/templates/nginx.conf.j2 new file mode 100644 index 00000000..3acc1c2e --- /dev/null +++ b/ansible/roles/load_balancer/tasks/templates/nginx.conf.j2 @@ -0,0 +1,10 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; +include /etc/nginx/sites-enabled/*; + +events { + worker_connections 768; + # multi_accept on; +} \ No newline at end of file From b691c61f4c8df66439ba32975dffde72cc575c72 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 16:36:06 +0100 Subject: [PATCH 040/185] build: Added pip3 and Docker SDK for running the mysql container --- ansible/roles/mysql-docker/tasks/main.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ansible/roles/mysql-docker/tasks/main.yml b/ansible/roles/mysql-docker/tasks/main.yml index e89fc773..3defb663 100644 --- a/ansible/roles/mysql-docker/tasks/main.yml +++ b/ansible/roles/mysql-docker/tasks/main.yml @@ -1,9 +1,18 @@ --- +- name: Install pip3 + apt: + name: python3-pip + state: present + +- name: Install Docker SDK for Python + pip: + name: docker + state: present + - name: Start MySQL Container docker_container: name: db image: "mysql:5.7" - platform: "linux/amd64" command: "--default-authentication-plugin=mysql_native_password" env: MYSQL_DATABASE: "microblog" From 95176be9c42a998779ffa617a9725a761232957c Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 16:38:45 +0100 Subject: [PATCH 041/185] build: Removed connection and added interpreter python3 --- ansible/mysql-docker.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible/mysql-docker.yml b/ansible/mysql-docker.yml index 41035f46..7c8def89 100644 --- a/ansible/mysql-docker.yml +++ b/ansible/mysql-docker.yml @@ -1,6 +1,7 @@ --- - hosts: database - connection: database + vars: + ansible_python_interpreter: /usr/bin/python3 remote_user: deploy become: yes become_method: sudo From 2f774bf7b28288c6a92933918a07904f90a78b53 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Wed, 15 Nov 2023 16:42:21 +0100 Subject: [PATCH 042/185] build Added new appserver aswell as subdomains for each appserver --- .../roles/provision_instances/vars/main.yml | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ansible/roles/provision_instances/vars/main.yml b/ansible/roles/provision_instances/vars/main.yml index a29a5ce4..c031fad3 100644 --- a/ansible/roles/provision_instances/vars/main.yml +++ b/ansible/roles/provision_instances/vars/main.yml @@ -1,12 +1,16 @@ --- instances: - - name: appserver - type: appserver - - name: database - type: database - - name: loadbalancer - type: loadbalancer + - name: appserver1 + type: appserver + - name: appserver2 + type: appserver + - name: database + type: database + - name: loadbalancer + type: loadbalancer relative_names: - - name: www # www. - - name: "@" # so works as a URL. Otherwise only www. would work. + - name: www # www. + - name: "@" # so works as a URL. Otherwise only www. would work. + - name: appserver1 # appserver1. + - name: appserver2 # appserver2. From 5df9c5c658b2df850a3eae4d7dc90ec7453a95ad Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:19:00 +0100 Subject: [PATCH 043/185] chore: Updated .gitignore with ansible/group_vars/all.yml and Makefile Made the update because we insert own data here for example mail, passwords and should not be pushed to github to remove others changes in the config. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7e7712b7..f0daa327 100755 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,7 @@ venv.bak/ # mypy .mypy_cache/ + +# Asnible and Makefile own data +ansible/group_vars/all.yml +Makefile From 6655c1a27142b6892780011f65699ab7995382d7 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:22:51 +0100 Subject: [PATCH 044/185] chore: Removed personal data --- ansible/group_vars/all.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100755 ansible/group_vars/all.yml diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml deleted file mode 100755 index 2603e97f..00000000 --- a/ansible/group_vars/all.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -# Here you can add variables that will be available for all hosts. -ansible_python_interpreter: "python3" - -region: northeurope -resource_group: # Change me -domain_name: # Change me - -admin_email: - -vmtags: - StudentId: # Change me - - -pub_ssh_key_location: '' # Change me, your local ssh key! - - -server_user: "deploy" -server_user_pass: "" # change me -server_user_groups: - - sudo From ffe9d2a4f5902fd4e6e55f38c07e0ec63a51a569 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:23:41 +0100 Subject: [PATCH 045/185] chore: Stop tracking Makefile bcs of personal data --- Makefile | 218 ------------------------------------------------------- 1 file changed, 218 deletions(-) delete mode 100755 Makefile diff --git a/Makefile b/Makefile deleted file mode 100755 index 7622a96e..00000000 --- a/Makefile +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env make -f -# -# Makefile for recipe static site generator -# - -# --------------------------------------------------------------------------- -# -# General setup -# - -# Set default target -.DEFAULT_GOAL := test - -# Decide if use python3 or python -ifeq (, $(@shell which python3)) - py = python3 -else - py = python -endif -# Decide if use pip3 or pip -ifeq (, $(@shell which pip3)) - pip = pip3 -else - pip = pip -endif -# Decide if use firefox or firefox.exe -ifeq (, $(@shell which firefox.exe)) - browser = firefox.exe -else - browser = firefox -endif - - -# Detect OS -OS = $(shell uname -s) - -# Defaults -ECHO = echo - -# Make adjustments based on OS -ifneq (, $(findstring CYGWIN, $(OS))) - ECHO = /bin/echo -e -endif - -# Colors and helptext -NO_COLOR = \033[0m -ACTION = \033[32;01m -OK_COLOR = \033[32;01m -ERROR_COLOR = \033[31;01m -WARN_COLOR = \033[33;01m - -# Which makefile am I in? -WHERE-AM-I = $(CURDIR)/$(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) -THIS_MAKEFILE := $(call WHERE-AM-I) - -# Echo some nice helptext based on the target comment -HELPTEXT = $(ECHO) "$(ACTION)--->" `egrep "^\# target: $(1) " $(THIS_MAKEFILE) | sed "s/\# target: $(1)[ ]*-[ ]* / /g"` "$(NO_COLOR)" - - - -# ---------------------------------------------------------------------------- -# -# Highlevel targets -# -# target: help - Displays help with targets available. -.PHONY: help -help: - @$(call HELPTEXT,$@) - @echo "Usage:" - @echo " make [target] ..." - @echo "target:" - @egrep "^# target:" Makefile | sed 's/# target: / /g' - - - -# target: add-ssh - Add ssh key to agent -.PHONY: add-ssh -add-ssh: - eval `ssh-agent -s` - ssh-add - - - -# target: info - Displays versions. -.PHONY: info -info: - @${py} --version - @${py} -m pip --version -#@virtualenv --version - - - -# target: validate - Validate code with pylint -.PHONY: validate -validate: - @/home/microblog/.venv/bin/pylint --rcfile=.pylintrc app tests - - - - -# target: validate-docker - Validate Dockerfile with hadolint -.PHONY: validate-docker -validate-docker: - @docker run --rm -i hadolint/hadolint < docker/Dockerfile_prod - @docker run --rm -i hadolint/hadolint < docker/Dockerfile_test - - - -# target: validate-ci - Validate CircleCi config with CircleCi CLI -.PHONY: validate-ci -validate-ci: - @circleci config validate - - - -# target: test-integration - Run tests in tests/integration with coverage.py -.PHONY: test-integration -test-integration: clean - @$(ECHO) "$(ACTION)---> Running all tests in tests/integration" "$(NO_COLOR)" - @${py} \ - -m coverage run --rcfile=.coveragerc \ - -m pytest -c pytest.ini tests/integration - $(MAKE) clean-py - - - -# target: test-unit - Run tests in tests/unit with coverage.py -.PHONY: test-unit -test-unit: clean - @$(ECHO) "$(ACTION)---> Running all tests in tests/unit" "$(NO_COLOR)" - @${py} \ - -m coverage run --rcfile=.coveragerc \ - -m pytest -c pytest.ini tests/unit - $(MAKE) clean-py - - - -# target: run-test test=test-file.py - Run one test file -.PHONY: run-test -run-test: - @${py} \ - -m pytest --pylint --pylint-rcfile=.pylintrc $(test) - - - -## target: exec-tests - Run all tests in tests/ with coverage.py -.PHONY: exec-tests -exec-tests: test-unit test-integration - - - -# target: test - Run tests and display code coverage -.PHONY: test -test: validate exec-tests - ${py} -m coverage report --rcfile=.coveragerc - $(MAKE) clean-cov - - - -## target: test-html - Run tests and display detailed code coverage with html -.PHONY: test-html -test-html: exec-tests - ${py} -m coverage html --rcfile=.coveragerc && ${browser} tests/coverage_html/index.html & - - - -## target: clean-py - Remove generated python files -.PHONY: clean-py -clean-py: - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '__pycache__' -exec rm -fr {} + - find . -name '.pytest_cache' -exec rm -fr {} + - - - -## target: clean-cov - Remove generated coverage files -.PHONY: clean-cov -clean-cov: - rm -f .coverage - rm -rf tests/coverage_html - - - -# target: clean - Remove all generated files -.PHONY: clean -clean: clean-py clean-cov - find . -name '*~' -exec rm -f {} + - find . -name '*.log*' -exec rm -fr {} + - - - -# target: install - Install all Python packages specified in requirement.txt (requirements/prod.txt) -.PHONY: install -install: - ${pip} install -r requirements.txt - - - -# target: install-dev - Install all Python packages specified in requirements/* -.PHONY: install-dev -install-dev: - ${pip} install -r requirements/dev.txt - - - -# target: install-test - Install all Python packages specified in requirements/{test.txt, prod.txt} -.PHONY: install-test -install-test: - ${pip} install -r requirements/test.txt - - - -# target: install-deploy - Install all Python packages specified in requirements/{deploy.txt} and ansible galaxy collections in ansible/requirements.yml -.PHONY: install-deploy -install-deploy: - ${pip} install -r requirements/deploy.txt - cd ansible && ansible-galaxy install -r requirements.yml From a2998e2fcc74b9aceb7001060f318deefe49b20e Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:24:17 +0100 Subject: [PATCH 046/185] build: Added pubblished_ports for appservers to connect to mysql --- ansible/roles/mysql-docker/tasks/main.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/ansible/roles/mysql-docker/tasks/main.yml b/ansible/roles/mysql-docker/tasks/main.yml index 3defb663..5c91dec8 100644 --- a/ansible/roles/mysql-docker/tasks/main.yml +++ b/ansible/roles/mysql-docker/tasks/main.yml @@ -1,14 +1,3 @@ ---- -- name: Install pip3 - apt: - name: python3-pip - state: present - -- name: Install Docker SDK for Python - pip: - name: docker - state: present - - name: Start MySQL Container docker_container: name: db @@ -22,3 +11,5 @@ volumes: - mysql_data:/var/lib/mysql restart_policy: always + published_ports: + - "3306:3306" From 3064ef6dbf672f60b63f4053391d738e2d458fce Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:25:28 +0100 Subject: [PATCH 047/185] build: Moved the pip3 install and docker sdk to docker-install asnible. Is being reused for both appservers and mysql so better move the tasks to the docker-install playbook to prevent DRY. --- ansible/roles/docker-install/tasks/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ansible/roles/docker-install/tasks/main.yml b/ansible/roles/docker-install/tasks/main.yml index f46e72e4..d5055997 100644 --- a/ansible/roles/docker-install/tasks/main.yml +++ b/ansible/roles/docker-install/tasks/main.yml @@ -4,3 +4,13 @@ name: docker.io state: latest update_cache: yes + +- name: Install pip3 + apt: + name: python3-pip + state: present + +- name: Install Docker SDK for Python + pip: + name: docker + state: present From c2f84fe4a359bc91bd109009a99daaacf8ca6766 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:27:12 +0100 Subject: [PATCH 048/185] build: Added playbook for appserver for deployment. --- ansible/appserver.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 ansible/appserver.yml diff --git a/ansible/appserver.yml b/ansible/appserver.yml new file mode 100644 index 00000000..7830f715 --- /dev/null +++ b/ansible/appserver.yml @@ -0,0 +1,10 @@ +--- +- hosts: appserver + vars: + ansible_python_interpreter: /usr/bin/python3 + remote_user: deploy + become: yes + become_method: sudo + roles: + - docker-install + - appserver From 76637cb238e49caa262e52c596c6b81a62b7ada8 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 19:27:49 +0100 Subject: [PATCH 049/185] build: Added task to start the microblog docker container. Connecting to use database vm. Now using 0.2.0-prod version. --- ansible/roles/appserver/tasks/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 ansible/roles/appserver/tasks/main.yml diff --git a/ansible/roles/appserver/tasks/main.yml b/ansible/roles/appserver/tasks/main.yml new file mode 100644 index 00000000..d54ddfbe --- /dev/null +++ b/ansible/roles/appserver/tasks/main.yml @@ -0,0 +1,10 @@ +--- +- name: Start Microblog Container + docker_container: + name: microblog + image: falkendev/microblog:0.2.0-prod + env: + DATABASE_URL: "mysql+pymysql://microblog:password@{{ groups['database'][0] }}:3306/microblog" + ports: + - "5000:5000" + restart_policy: always From 3046683f0607533a9929a98e08b4d0b5cb930010 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 20:46:56 +0100 Subject: [PATCH 050/185] docs: Updated changelog to reflect the new builds and fixes --- CHANGELOG.md | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e4b93c..e8085311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- _No unreleased changes at this time._ +## [Unreleased] + +### Added + +- **CD:** Added public ssh keys for ansible - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Dbwebb:** New provisioning and terminate structure. More than halves execution - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Dbwebb:** Adds 10-first-minutes playbook - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Build:** Added users_users var with the ssh keys - [PR 17](https://github.com/FalkenDev/microblog/pull/17) +- **Build:** Added password to root - [PR 17](https://github.com/FalkenDev/microblog/pull/17) +- **Build:** Added task to install docker - [PR 18](https://github.com/FalkenDev/microblog/pull/18) +- **Build:** Added playbook docker-mysql - [PR 18](https://github.com/FalkenDev/microblog/pull/18) +- **Build:** Added task to run a mysql docker container - [PR 18](https://github.com/FalkenDev/microblog/pull/18) +- **Build:** Added new appserver as well as subdomains for each appserver - [PR 20](https://github.com/FalkenDev/microblog/pull/20) +- **Build:** Added published_ports for appservers to connect to mysql - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Build:** Added playbook for appserver for deployment - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Build:** Added task to start the microblog docker container - [PR 21](https://github.com/FalkenDev/microblog/pull/21) + +### Changed + +- **Dbwebb:** Updates ansible to new structure - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Dbwebb:** Cleans up new ansible code - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Dbwebb:** Cleans up vars file from my info - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Build:** Updated to look for azure ssh keys using users_users var for the file names - [PR 17](https://github.com/FalkenDev/microblog/pull/17) +- **Chore:** Updated .gitignore with ansible/group_vars/all.yml and Makefile - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Build:** Moved the pip3 install and docker sdk to docker-install ansible - [PR 21](https://github.com/FalkenDev/microblog/pull/21) + +### Fixed + +- **Dbwebb:** Small fixes - [PR 15](https://github.com/FalkenDev/microblog/pull/15) +- **Build:** Fixed python interpreter .venv to use just venv - [PR 17](https://github.com/FalkenDev/microblog/pull/17) + +### Removed + +- **Chore:** Stop tracking group_vars/all.yml because of personal data - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Chore:** Stop tracking Makefile because of personal data - [PR 21](https://github.com/FalkenDev/microblog/pull/21) ## [1.0.0] - 2023-11-11 -**Branch:** Push development branch to master branch to reflect kmom01 is done [PR 14 - "Development to Master (kmom01)"](https://github.com/FalkenDev/microblog/issues/14) -**Docs:** Changelog updated to reflect the new release [PR 13 - "Changelog Update"](https://github.com/FalkenDev/microblog/issues/13) +### Added / Changed + +- **Branch:** Push development branch to master branch to reflect kmom01 is done [PR 14 - "Development to Master (kmom01)"](https://github.com/FalkenDev/microblog/issues/14) +- **Docs:** Changelog updated to reflect the new release [PR 13 - "Changelog Update"](https://github.com/FalkenDev/microblog/issues/13) ## [0.2.0] - 2023-11-08 +### Added + - **Feat:** User Follow/Unfollow Functionality [PR 12 - "Implement User Follow/Unfollow Functionality"](https://github.com/FalkenDev/microblog/pull/12) ## [0.1.0] - 2023-11-06 From 8a25be9a04c7be5ef832a7c2014aa96be027587d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 20:55:45 +0100 Subject: [PATCH 051/185] Fix gitignore problem --- .gitignore | 4 ---- ansible/group_vars/all.yml | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 ansible/group_vars/all.yml diff --git a/.gitignore b/.gitignore index f0daa327..7e7712b7 100755 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,3 @@ venv.bak/ # mypy .mypy_cache/ - -# Asnible and Makefile own data -ansible/group_vars/all.yml -Makefile diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 00000000..d6caaf05 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,19 @@ +--- +# Here you can add variables that will be available for all hosts. +ansible_python_interpreter: "python3" + +region: northeurope +resource_group: # Change me +domain_name: # Change me + +admin_email: + +vmtags: + StudentId: # Change me + +pub_ssh_key_location: "" # Change me, your local ssh key! + +server_user: "deploy" +server_user_pass: "" # change me +server_user_groups: + - sudo From 89e5cd96a096dee967df3357e34b988128eafb1a Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 15 Nov 2023 20:56:42 +0100 Subject: [PATCH 052/185] Fix gitignore problem --- Makefile | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7622a96e --- /dev/null +++ b/Makefile @@ -0,0 +1,218 @@ +#!/usr/bin/env make -f +# +# Makefile for recipe static site generator +# + +# --------------------------------------------------------------------------- +# +# General setup +# + +# Set default target +.DEFAULT_GOAL := test + +# Decide if use python3 or python +ifeq (, $(@shell which python3)) + py = python3 +else + py = python +endif +# Decide if use pip3 or pip +ifeq (, $(@shell which pip3)) + pip = pip3 +else + pip = pip +endif +# Decide if use firefox or firefox.exe +ifeq (, $(@shell which firefox.exe)) + browser = firefox.exe +else + browser = firefox +endif + + +# Detect OS +OS = $(shell uname -s) + +# Defaults +ECHO = echo + +# Make adjustments based on OS +ifneq (, $(findstring CYGWIN, $(OS))) + ECHO = /bin/echo -e +endif + +# Colors and helptext +NO_COLOR = \033[0m +ACTION = \033[32;01m +OK_COLOR = \033[32;01m +ERROR_COLOR = \033[31;01m +WARN_COLOR = \033[33;01m + +# Which makefile am I in? +WHERE-AM-I = $(CURDIR)/$(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) +THIS_MAKEFILE := $(call WHERE-AM-I) + +# Echo some nice helptext based on the target comment +HELPTEXT = $(ECHO) "$(ACTION)--->" `egrep "^\# target: $(1) " $(THIS_MAKEFILE) | sed "s/\# target: $(1)[ ]*-[ ]* / /g"` "$(NO_COLOR)" + + + +# ---------------------------------------------------------------------------- +# +# Highlevel targets +# +# target: help - Displays help with targets available. +.PHONY: help +help: + @$(call HELPTEXT,$@) + @echo "Usage:" + @echo " make [target] ..." + @echo "target:" + @egrep "^# target:" Makefile | sed 's/# target: / /g' + + + +# target: add-ssh - Add ssh key to agent +.PHONY: add-ssh +add-ssh: + eval `ssh-agent -s` + ssh-add + + + +# target: info - Displays versions. +.PHONY: info +info: + @${py} --version + @${py} -m pip --version +#@virtualenv --version + + + +# target: validate - Validate code with pylint +.PHONY: validate +validate: + @/home/microblog/.venv/bin/pylint --rcfile=.pylintrc app tests + + + + +# target: validate-docker - Validate Dockerfile with hadolint +.PHONY: validate-docker +validate-docker: + @docker run --rm -i hadolint/hadolint < docker/Dockerfile_prod + @docker run --rm -i hadolint/hadolint < docker/Dockerfile_test + + + +# target: validate-ci - Validate CircleCi config with CircleCi CLI +.PHONY: validate-ci +validate-ci: + @circleci config validate + + + +# target: test-integration - Run tests in tests/integration with coverage.py +.PHONY: test-integration +test-integration: clean + @$(ECHO) "$(ACTION)---> Running all tests in tests/integration" "$(NO_COLOR)" + @${py} \ + -m coverage run --rcfile=.coveragerc \ + -m pytest -c pytest.ini tests/integration + $(MAKE) clean-py + + + +# target: test-unit - Run tests in tests/unit with coverage.py +.PHONY: test-unit +test-unit: clean + @$(ECHO) "$(ACTION)---> Running all tests in tests/unit" "$(NO_COLOR)" + @${py} \ + -m coverage run --rcfile=.coveragerc \ + -m pytest -c pytest.ini tests/unit + $(MAKE) clean-py + + + +# target: run-test test=test-file.py - Run one test file +.PHONY: run-test +run-test: + @${py} \ + -m pytest --pylint --pylint-rcfile=.pylintrc $(test) + + + +## target: exec-tests - Run all tests in tests/ with coverage.py +.PHONY: exec-tests +exec-tests: test-unit test-integration + + + +# target: test - Run tests and display code coverage +.PHONY: test +test: validate exec-tests + ${py} -m coverage report --rcfile=.coveragerc + $(MAKE) clean-cov + + + +## target: test-html - Run tests and display detailed code coverage with html +.PHONY: test-html +test-html: exec-tests + ${py} -m coverage html --rcfile=.coveragerc && ${browser} tests/coverage_html/index.html & + + + +## target: clean-py - Remove generated python files +.PHONY: clean-py +clean-py: + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + find . -name '.pytest_cache' -exec rm -fr {} + + + + +## target: clean-cov - Remove generated coverage files +.PHONY: clean-cov +clean-cov: + rm -f .coverage + rm -rf tests/coverage_html + + + +# target: clean - Remove all generated files +.PHONY: clean +clean: clean-py clean-cov + find . -name '*~' -exec rm -f {} + + find . -name '*.log*' -exec rm -fr {} + + + + +# target: install - Install all Python packages specified in requirement.txt (requirements/prod.txt) +.PHONY: install +install: + ${pip} install -r requirements.txt + + + +# target: install-dev - Install all Python packages specified in requirements/* +.PHONY: install-dev +install-dev: + ${pip} install -r requirements/dev.txt + + + +# target: install-test - Install all Python packages specified in requirements/{test.txt, prod.txt} +.PHONY: install-test +install-test: + ${pip} install -r requirements/test.txt + + + +# target: install-deploy - Install all Python packages specified in requirements/{deploy.txt} and ansible galaxy collections in ansible/requirements.yml +.PHONY: install-deploy +install-deploy: + ${pip} install -r requirements/deploy.txt + cd ansible && ansible-galaxy install -r requirements.yml From 9e75e09b7ec1c7ff2b7ba7ec8fe7db2ab8a7ea13 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 12:33:43 +0100 Subject: [PATCH 053/185] bug: Add letsencrypt and pip --- ansible/load_balancer.yml | 2 + ansible/roles/load_balancer/tasks/main.yml | 52 +++++++++------------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/ansible/load_balancer.yml b/ansible/load_balancer.yml index 86461acc..492e38dd 100644 --- a/ansible/load_balancer.yml +++ b/ansible/load_balancer.yml @@ -1,6 +1,8 @@ --- - hosts: - loadbalancer + pre_tasks: + - raw: apt-get install -y python-simplejson remote_user: deploy become: yes become_method: sudo diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index 69f863f2..9df96964 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -1,35 +1,22 @@ --- + - name: Install nginx apt: name=nginx state=latest -- name: Install certbot - pip: - name: - - certbot - - certbot-nginx - executable: pip3 - tags: - - nginx - - certbot - -- name: Register certbot - shell: | - certbot -n register --agree-tos --email - touch /etc/letsencrypt/.registered - args: - creates: /etc/letsencrypt/.registered - tags: - - nginx - - certbot +- name: Upgrade system + apt: upgrade=dist update_cache=yes -- name: 'Get certificate' - command: '/usr/local/bin/certbot -n --nginx certonly -d {{ domain_name }}' - args: - creates: '/etc/letsencrypt/live/{{ domain_name }}' - ignore_errors: true - tags: - - nginx - - certbot +- name: Install nginx + apt: name=nginx state=latest + +- name: install letsencrypt + apt: name=letsencrypt state=latest + +- name: create letsencrypt directory + file: name=/var/www/letsencrypt state=directory + +- name: Remove default nginx config + file: name=/etc/nginx/sites-enabled/default state=absent - name: Copy ngnínx.conf.j2 template: @@ -47,7 +34,12 @@ dest: /etc/nginx/sites-enabled/load-balancer.conf state: link -- name: Start nginx +- name: Create letsencrypt certificate + shell: letsencrypt certonly -n --webroot -w /var/www/letsencrypt -m jopl16@student.bth.se --agree-tos -d {{ domain_name }} + args: + creates: /etc/letsencrypt/live/{{ domain_name }} + +- name: Restart nginx service: - name: nginx - state: started \ No newline at end of file + name: nginx + state: restarted \ No newline at end of file From 4bc1997073c2a6f5fc20162e0fa050285c5115d2 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:08:42 +0100 Subject: [PATCH 054/185] bug: Update certbot script --- ansible/roles/load_balancer/tasks/main.yml | 28 ++++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index 9df96964..a761fe8f 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -1,19 +1,26 @@ --- - -- name: Install nginx - apt: name=nginx state=latest - - name: Upgrade system apt: upgrade=dist update_cache=yes - name: Install nginx apt: name=nginx state=latest -- name: install letsencrypt - apt: name=letsencrypt state=latest +# - name: Stop nginx +# service: +# name: nginx +# state: stopped -- name: create letsencrypt directory - file: name=/var/www/letsencrypt state=directory +- name: Ensure Python is installed + raw: test -e /usr/bin/python || (apt-get update -y && apt-get install -y python) + +- name: Ensure Pip is installed + raw: test -e /usr/bin/pip || (apt-get update -y && apt-get install -y python-pip) + +- name: Install certbot + apt: name=python-certbot-nginx state=latest + +- name: Get new cert if it is not already there. + shell: "certbot certonly --nginx --noninteractive --expand --agree-tos --email jopl16@student.bth.se -d {{ domain_name }} -d www.{{ domain_name }}" - name: Remove default nginx config file: name=/etc/nginx/sites-enabled/default state=absent @@ -34,11 +41,6 @@ dest: /etc/nginx/sites-enabled/load-balancer.conf state: link -- name: Create letsencrypt certificate - shell: letsencrypt certonly -n --webroot -w /var/www/letsencrypt -m jopl16@student.bth.se --agree-tos -d {{ domain_name }} - args: - creates: /etc/letsencrypt/live/{{ domain_name }} - - name: Restart nginx service: name: nginx From 0cdda697ab537ea508220bae05866b8ba602cdaa Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:37:33 +0100 Subject: [PATCH 055/185] build: loop through appservers dynamically --- .../roles/load_balancer/tasks/templates/load-balancer.conf.j2 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 index 2466b5f2..2c6221e7 100644 --- a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 +++ b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 @@ -1,7 +1,9 @@ http { upstream app-hosts { {{ lb_method }}; - server {{ groups['appserver'][0] }}:8000; + {% for appserver_item in groups['appserver'] %} + server {{ appserver_item }}:8000; + {% endfor %} } access_log /var/log/nginx/access.log; From f7d05c268bf678f981bb5229f178ae9bf271c303 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:37:53 +0100 Subject: [PATCH 056/185] build: use admin_email for cert --- ansible/roles/load_balancer/tasks/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index a761fe8f..ea5b458d 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -19,8 +19,8 @@ - name: Install certbot apt: name=python-certbot-nginx state=latest -- name: Get new cert if it is not already there. - shell: "certbot certonly --nginx --noninteractive --expand --agree-tos --email jopl16@student.bth.se -d {{ domain_name }} -d www.{{ domain_name }}" +- name: Install cert + shell: "certbot certonly --nginx --noninteractive --expand --agree-tos --email {{ admin_email }} -d {{ domain_name }} -d www.{{ domain_name }}" - name: Remove default nginx config file: name=/etc/nginx/sites-enabled/default state=absent From f20432d804a73febc5b051e893ec07d78ddb1652 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:38:13 +0100 Subject: [PATCH 057/185] bug: published ports for appserver --- ansible/roles/appserver/tasks/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ansible/roles/appserver/tasks/main.yml b/ansible/roles/appserver/tasks/main.yml index d54ddfbe..2653c423 100644 --- a/ansible/roles/appserver/tasks/main.yml +++ b/ansible/roles/appserver/tasks/main.yml @@ -6,5 +6,7 @@ env: DATABASE_URL: "mysql+pymysql://microblog:password@{{ groups['database'][0] }}:3306/microblog" ports: - - "5000:5000" + - "8000:5000" restart_policy: always + published_ports: + - "8000:8000" \ No newline at end of file From a24264f2a39d26c1baa54a6fb3f10cefee424cfb Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:43:02 +0100 Subject: [PATCH 058/185] remove comment --- ansible/roles/load_balancer/tasks/main.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index ea5b458d..4f69ed01 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -5,11 +5,6 @@ - name: Install nginx apt: name=nginx state=latest -# - name: Stop nginx -# service: -# name: nginx -# state: stopped - - name: Ensure Python is installed raw: test -e /usr/bin/python || (apt-get update -y && apt-get install -y python) From 082aca68184d6829984d5580f72c785d1733669b Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 16 Nov 2023 16:47:40 +0100 Subject: [PATCH 059/185] build: load_balancer vars --- ansible/roles/load_balancer/vars/main.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 ansible/roles/load_balancer/vars/main.yml diff --git a/ansible/roles/load_balancer/vars/main.yml b/ansible/roles/load_balancer/vars/main.yml new file mode 100644 index 00000000..a89d89e0 --- /dev/null +++ b/ansible/roles/load_balancer/vars/main.yml @@ -0,0 +1,2 @@ +--- +lb_method: least_conn From a524b29dd809deea2a5963074117704bb8941237 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sat, 18 Nov 2023 20:53:56 +0100 Subject: [PATCH 060/185] build: Workflow file for deploying release Runs deploy.yml playbook for deploying new release to servers --- .github/workflows/deploy.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ff818afc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,27 @@ +name: Rolling Update Deployment + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + pip install ansible + + - name: Run Rolling Update Playbook + run: ansible-playbook -i ansible/hosts ansible/deploy.yml -e "github_release_tag=${{ github.event.release.tag_name }}" + env: + ANSIBLE_HOST_KEY_CHECKING: False From c8db284b49ced974012a5415cd81df68831bccbe Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sat, 18 Nov 2023 21:00:34 +0100 Subject: [PATCH 061/185] build: Playbook for updating docker images Updates docker images to latest github release --- ansible/deploy.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ansible/deploy.yml diff --git a/ansible/deploy.yml b/ansible/deploy.yml new file mode 100644 index 00000000..01c18674 --- /dev/null +++ b/ansible/deploy.yml @@ -0,0 +1,47 @@ +--- +- name: Rolling update for Microblog Application + hosts: appserver + serial: 1 # Kör en server i taget + dockerhub_username: "falkendev" + github_release_tag: "0.2.0" + ansible_python_interpreter: /usr/bin/python3 + remote_user: deploy + become: yes + become_method: sudo + roles: + - docker-install + tasks: + - name: Pull the latest Microblog image based on GitHub release tag + docker_image: + name: "{{ dockerhub_username }}/microblog:{{ github_release_tag }}-prod" + source: pull + + - name: Restart Microblog Container + docker_container: + name: microblog + image: "{{ dockerhub_username }}/microblog:{{ github_release_tag }}-prod" + env: + DATABASE_URL: "mysql+pymysql://microblog:password@{{ groups['database'][0] }}:3306/microblog" + ports: + - "8000:5000" + restart_policy: always + published_ports: + - "8000:8000" + state: started + + - name: Wait for the application to start + wait_for: + port: 8000 + delay: 10 # Seconds + + - name: Verify the deployment + uri: + url: "http://{{ inventory_hostname }}:8000/" + return_content: yes + status_code: 200 + register: result + + # - name: Check if correct version is running + # assert: + # that: + # - "'expected_version_string' in result.content" # ändra sen From fc6fda30de508711c7219009accb445887c4d228 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sat, 18 Nov 2023 21:03:23 +0100 Subject: [PATCH 062/185] feat: Creates DNS A records for appservers DNS A records for appservers in order to create subdomains --- .../tasks/create_instance.yml | 118 +++++++++--------- 1 file changed, 58 insertions(+), 60 deletions(-) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index 3dca8e47..468504e9 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -1,65 +1,63 @@ --- -- name: Create public IP address - azure_rm_publicipaddress: - resource_group: "{{ resource_group }}" - allocation_method: Static - name: "{{ iname }}-ip" - tags: "{{ vmtags }}" - delegate_to: localhost - register: output_ip_address +- name: Create public IP address + azure_rm_publicipaddress: + resource_group: "{{ resource_group }}" + allocation_method: Static + name: "{{ iname }}-ip" + tags: "{{ vmtags }}" + delegate_to: localhost + register: output_ip_address -- name: Dump public IP for VM which will be created - debug: - msg: "The public IP is {{ output_ip_address.state.ip_address }}." +- name: Dump public IP for VM which will be created + debug: + msg: "The public IP is {{ output_ip_address.state.ip_address }}." -- name: Create virtual network interface card - azure_rm_networkinterface: - resource_group: "{{ resource_group }}" - name: "{{ iname }}-NIC" - virtual_network: "microblog-Vnet" - subnet: "microblog-Subnet" - ip_configurations: - - name: ipconfig1 - public_ip_address_name: "{{ iname }}-ip" - security_group: "{{ itype }}-sg" - tags: "{{ vmtags }}" +- name: Create virtual network interface card + azure_rm_networkinterface: + resource_group: "{{ resource_group }}" + name: "{{ iname }}-NIC" + virtual_network: "microblog-Vnet" + subnet: "microblog-Subnet" + ip_configurations: + - name: ipconfig1 + public_ip_address_name: "{{ iname }}-ip" + security_group: "{{ itype }}-sg" + tags: "{{ vmtags }}" -- name: Ensure "A" records for loadbalancer - azure_rm_dnsrecordset: - resource_group: "{{ resource_group }}" - relative_name: "{{ relative_name.name }}" - zone_name: "{{ domain_name }}" - record_type: A - state: present - records: - - entry: "{{ output_ip_address.state.ip_address }}" - when: iname == 'loadbalancer' - loop: "{{ relative_names }}" - loop_control: - loop_var: relative_name - delegate_to: 127.0.0.1 +- name: Ensure "A" records for loadbalancer + azure_rm_dnsrecordset: + resource_group: "{{ resource_group }}" + relative_name: "{{ relative_name.name }}" + zone_name: "{{ domain_name }}" + record_type: A + state: present + records: + - entry: "{{ output_ip_address.state.ip_address }}" + when: iname == 'loadbalancer' + loop: "{{ relative_names }}" + loop_control: + loop_var: relative_name + delegate_to: 127.0.0.1 - - -- name: Create VM - azure_rm_virtualmachine: - resource_group: "{{ resource_group }}" - name: "{{ iname }}-VM" - admin_username: "{{ root_user }}" - ssh_password_enabled: false - ssh_public_keys: - - path: "/home/{{ root_user }}/.ssh/authorized_keys" - key_data: "{{ lookup('file', pub_ssh_key_location) }}" - vm_size: Standard_B1s - network_interfaces: "{{ iname }}-NIC" - managed_disk_type: Standard_LRS - os_disk_size_gb: 30 - image: - offer: Debian-10 - publisher: Debian - sku: 10 - version: latest - tags: - StudentId: "{{ vmtags.StudentId }}" - Type: "{{ itype }}" - PublicIP: "{{ output_ip_address.state.ip_address }}" \ No newline at end of file +- name: Create VM + azure_rm_virtualmachine: + resource_group: "{{ resource_group }}" + name: "{{ iname }}-VM" + admin_username: "{{ root_user }}" + ssh_password_enabled: false + ssh_public_keys: + - path: "/home/{{ root_user }}/.ssh/authorized_keys" + key_data: "{{ lookup('file', pub_ssh_key_location) }}" + vm_size: Standard_B1s + network_interfaces: "{{ iname }}-NIC" + managed_disk_type: Standard_LRS + os_disk_size_gb: 30 + image: + offer: Debian-10 + publisher: Debian + sku: 10 + version: latest + tags: + StudentId: "{{ vmtags.StudentId }}" + Type: "{{ itype }}" + PublicIP: "{{ output_ip_address.state.ip_address }}" From 50f452bb5bc19327a9aa650d62240a54e50e40c4 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sat, 18 Nov 2023 21:06:44 +0100 Subject: [PATCH 063/185] fix: Removed subdomains Creating subdomains here was incorrect so it has been removed --- ansible/roles/provision_instances/vars/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/ansible/roles/provision_instances/vars/main.yml b/ansible/roles/provision_instances/vars/main.yml index c031fad3..58ae95de 100644 --- a/ansible/roles/provision_instances/vars/main.yml +++ b/ansible/roles/provision_instances/vars/main.yml @@ -12,5 +12,3 @@ instances: relative_names: - name: www # www. - name: "@" # so works as a URL. Otherwise only www. would work. - - name: appserver1 # appserver1. - - name: appserver2 # appserver2. From 8dd0a9e321d246d14b6b44b160a9156271561017 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sat, 18 Nov 2023 21:07:43 +0100 Subject: [PATCH 064/185] fix: Added subdomain hosts Hosts for subdomain --- ansible/hosts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ansible/hosts b/ansible/hosts index 7bf73981..d0912013 100755 --- a/ansible/hosts +++ b/ansible/hosts @@ -1,2 +1,6 @@ [local] -localhost ansible_connection=local \ No newline at end of file +localhost ansible_connection=local + +[appserver] +appserver1.taylordevops.live ansible_user=deploy +appserver2.taylordevops.live ansible_user=deploy \ No newline at end of file From 3e8fb8bdb34cc5500d189a25bb7f3a7668e96013 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sun, 19 Nov 2023 14:01:37 +0100 Subject: [PATCH 065/185] fix: Fixes to subdomain Changed configuartion to setup subdomains for appservers --- .../tasks/create_instance.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index 468504e9..9ff19eac 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -37,7 +37,21 @@ loop: "{{ relative_names }}" loop_control: loop_var: relative_name - delegate_to: 127.0.0.1 + +- name: Ensure "A" records for appserver1 and appserver2 + azure_rm_dnsrecordset: + resource_group: "{{ resource_group }}" + relative_name: "{{ item.name }}" + zone_name: "{{ domain_name }}" + record_type: A + state: present + records: + - entry: "{{ output_ip_address.state.ip_address }}" + loop: + - { name: "appserver1" } + - { name: "appserver2" } + loop_control: + loop_var: item - name: Create VM azure_rm_virtualmachine: From b12e3d89a741e09d652423abc0c2ed2fd18de24d Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Sun, 19 Nov 2023 20:28:40 +0100 Subject: [PATCH 066/185] fix: Fixed create instance for subdomains Subdomain fix --- .../tasks/create_instance.yml | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index 9ff19eac..d0931b56 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -38,20 +38,29 @@ loop_control: loop_var: relative_name -- name: Ensure "A" records for appserver1 and appserver2 +- name: Ensure "A" record for appserver1 azure_rm_dnsrecordset: resource_group: "{{ resource_group }}" - relative_name: "{{ item.name }}" + relative_name: "appserver1" zone_name: "{{ domain_name }}" record_type: A state: present records: - entry: "{{ output_ip_address.state.ip_address }}" - loop: - - { name: "appserver1" } - - { name: "appserver2" } - loop_control: - loop_var: item + delegate_to: localhost + when: iname == 'appserver1' + +- name: Ensure "A" record for appserver2 + azure_rm_dnsrecordset: + resource_group: "{{ resource_group }}" + relative_name: "appserver2" + zone_name: "{{ domain_name }}" + record_type: A + state: present + records: + - entry: "{{ output_ip_address.state.ip_address }}" + delegate_to: localhost + when: iname == 'appserver2' - name: Create VM azure_rm_virtualmachine: From c5edf7caea23996b38b8e7f1cf86ee30f64aa082 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Mon, 20 Nov 2023 13:33:17 +0100 Subject: [PATCH 067/185] feat: Added host for databas Hosts for databas for running in github action --- ansible/hosts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ansible/hosts b/ansible/hosts index d0912013..7885659b 100755 --- a/ansible/hosts +++ b/ansible/hosts @@ -3,4 +3,7 @@ localhost ansible_connection=local [appserver] appserver1.taylordevops.live ansible_user=deploy -appserver2.taylordevops.live ansible_user=deploy \ No newline at end of file +appserver2.taylordevops.live ansible_user=deploy + +[database] +database.taylordevops.live ansible_user=deploy \ No newline at end of file From 2b1a46bc6dc38c032b19a5e5c9867774e5290c3a Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Mon, 20 Nov 2023 13:34:09 +0100 Subject: [PATCH 068/185] feat: Added subdomain for databas Used in place of gather instances --- .../provision_instances/tasks/create_instance.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index d0931b56..e57e1d35 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -62,6 +62,18 @@ delegate_to: localhost when: iname == 'appserver2' +- name: Ensure "A" records for database + azure_rm_dnsrecordset: + resource_group: "{{ resource_group }}" + relative_name: database + zone_name: "{{ domain_name }}" + record_type: A + state: present + records: + - entry: "{{ output_ip_address.state.ip_address }}" + when: iname == 'database' + delegate_to: localhost + - name: Create VM azure_rm_virtualmachine: resource_group: "{{ resource_group }}" From 38f7de3aba572795ff459a4b93d1985d4ff7c4dd Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 16 Nov 2023 12:59:24 +0100 Subject: [PATCH 069/185] my vars --- ansible/group_vars/all.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index d6caaf05..b8b20dea 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -3,17 +3,17 @@ ansible_python_interpreter: "python3" region: northeurope -resource_group: # Change me -domain_name: # Change me +resource_group: DIDA-KAFA21-DV1673-H23-LP2 +domain_name: devopsbth.tech -admin_email: +admin_email: kafa21@student.bth.se vmtags: - StudentId: # Change me + StudentId: kafa21 -pub_ssh_key_location: "" # Change me, your local ssh key! +pub_ssh_key_location: "/home/falkendev/.ssh/azure" server_user: "deploy" -server_user_pass: "" # change me +server_user_pass: "devopsbth" server_user_groups: - sudo From b418ed443a9582bb792e13f944f72cfb6850bdd3 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Fri, 17 Nov 2023 00:27:40 +0100 Subject: [PATCH 070/185] My vars --- ansible/group_vars/all.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index b8b20dea..a2c5e2ce 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -13,6 +13,8 @@ vmtags: pub_ssh_key_location: "/home/falkendev/.ssh/azure" +lb_method: least_conn + server_user: "deploy" server_user_pass: "devopsbth" server_user_groups: From 583a55bde16e1b318ee59a0ecdd5c25349a08d61 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Fri, 17 Nov 2023 01:01:39 +0100 Subject: [PATCH 071/185] Revert "My vars" This reverts commit ef66c6bd265733401ba9c2beef68cd8b7617657d. --- ansible/group_vars/all.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index a2c5e2ce..b8b20dea 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -13,8 +13,6 @@ vmtags: pub_ssh_key_location: "/home/falkendev/.ssh/azure" -lb_method: least_conn - server_user: "deploy" server_user_pass: "devopsbth" server_user_groups: From 973c2e8c7dbef80d110b75d7d7c573cb03eb19b9 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Fri, 17 Nov 2023 01:02:10 +0100 Subject: [PATCH 072/185] Revert "my vars" This reverts commit 6b9a82c5c2c04e1f52977017bdbabd9017aab474. --- ansible/group_vars/all.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index b8b20dea..d6caaf05 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -3,17 +3,17 @@ ansible_python_interpreter: "python3" region: northeurope -resource_group: DIDA-KAFA21-DV1673-H23-LP2 -domain_name: devopsbth.tech +resource_group: # Change me +domain_name: # Change me -admin_email: kafa21@student.bth.se +admin_email: vmtags: - StudentId: kafa21 + StudentId: # Change me -pub_ssh_key_location: "/home/falkendev/.ssh/azure" +pub_ssh_key_location: "" # Change me, your local ssh key! server_user: "deploy" -server_user_pass: "devopsbth" +server_user_pass: "" # change me server_user_groups: - sudo From 99afd69e66d4b254fc979b17aa9023c9bbc0a39a Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 13:26:18 +0100 Subject: [PATCH 073/185] variable --- ansible/group_vars/all.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index d6caaf05..f94ee537 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -3,17 +3,19 @@ ansible_python_interpreter: "python3" region: northeurope -resource_group: # Change me -domain_name: # Change me +resource_group: DIDA-KAFA21-DV1673-H23-LP2 +domain_name: devopsbth.tech -admin_email: +admin_email: kafa21@student.bth.se vmtags: - StudentId: # Change me + StudentId: kafa21 -pub_ssh_key_location: "" # Change me, your local ssh key! +pub_ssh_key_location: "/home/falkendev/.ssh/azure.pub" + +lb_method: least_conn server_user: "deploy" -server_user_pass: "" # change me +server_user_pass: "devopsbth" server_user_groups: - sudo From 9e05baba76abd1d1f908a5d4020913077eb7e501 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Mon, 20 Nov 2023 14:42:54 +0100 Subject: [PATCH 074/185] feat: app version route --- app/main/routes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/main/routes.py b/app/main/routes.py index 4fbbf260..1156af74 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -8,6 +8,7 @@ from app.main.forms import EditProfileForm, PostForm from app.models import User, Post from app.main import bp +import os @@ -43,6 +44,13 @@ def index(): return render_template("index.html", title='Home Page', form=form, posts=posts) +@bp.route('/app_version') +def app_version(): + """ + Route for explore + """ + return {"app_version": os.environ["APP_VERSION"]} + @bp.route('/explore') @login_required def explore(): From f91668392af31ea6e3084ffd4ecc1e082241e195 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Mon, 20 Nov 2023 14:44:59 +0100 Subject: [PATCH 075/185] build: Add build args to actions for version --- .github/workflows/docker-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 08770e0e..ce7ecfb9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,6 +29,8 @@ jobs: push: true file: ./docker/Dockerfile_prod tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-prod + build-args: | + DOCKER_TAG=${{ github.event.release.tag_name }} - name: Build and push Docker image for testing uses: docker/build-push-action@v5 From b3837cdfa0a71168368aed221387ef94b7e1e3a3 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Mon, 20 Nov 2023 14:50:41 +0100 Subject: [PATCH 076/185] build: Remove release tag var --- ansible/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/deploy.yml b/ansible/deploy.yml index 01c18674..36534184 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -2,8 +2,8 @@ - name: Rolling update for Microblog Application hosts: appserver serial: 1 # Kör en server i taget + vars: dockerhub_username: "falkendev" - github_release_tag: "0.2.0" ansible_python_interpreter: /usr/bin/python3 remote_user: deploy become: yes From e1a80bce2253f1686cd589dd4cb34c11d8b2d8fe Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 14:55:05 +0100 Subject: [PATCH 077/185] Changed domain name --- ansible/hosts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/hosts b/ansible/hosts index 7885659b..25e2a1e2 100755 --- a/ansible/hosts +++ b/ansible/hosts @@ -2,8 +2,8 @@ localhost ansible_connection=local [appserver] -appserver1.taylordevops.live ansible_user=deploy -appserver2.taylordevops.live ansible_user=deploy +appserver1.devopsbth.tech ansible_user=deploy +appserver2.devopsbth.tech ansible_user=deploy [database] -database.taylordevops.live ansible_user=deploy \ No newline at end of file +database.devopsbth.tech ansible_user=deploy \ No newline at end of file From d4fb24976c077b050a95380e8ccc92096cf63db9 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:08:34 +0100 Subject: [PATCH 078/185] Added ssh key --- .github/workflows/deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff818afc..93096438 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,11 @@ jobs: with: python-version: "3.10" + - name: Set up SSH + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.azure_ssh }} + - name: Install Ansible run: | python -m pip install --upgrade pip From e3da4de7015b5f40fba58a7e19010537d7045c59 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:18:10 +0100 Subject: [PATCH 079/185] Added workflow call to wait docker before deploy --- .github/workflows/deploy.yml | 5 +++++ .github/workflows/docker-publish.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 93096438..9c6f015b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,8 +5,13 @@ on: types: [created] jobs: + docker-publish: + uses: ./.github/workflows/docker-publish.yml + deploy: + needs: docker-publish runs-on: ubuntu-latest + if: ${{ github.event_name == 'release' && github.event.action == 'created' }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 08770e0e..77327032 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,6 +1,7 @@ name: Docker CD Publish Image on: + workflow_call: release: types: [created] From 3ab00fed8d1363175e1b4b53b87831d9dc5b3b38 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:30:30 +0100 Subject: [PATCH 080/185] Fix --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 77327032..3458fafa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,7 +1,7 @@ name: Docker CD Publish Image on: - workflow_call: + workflow_call: {} release: types: [created] From 8664b9b65290d4bfc0c7e002a122fa75c185c28e Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:32:08 +0100 Subject: [PATCH 081/185] bugfix --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c6f015b..1dc76441 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,7 @@ on: jobs: docker-publish: uses: ./.github/workflows/docker-publish.yml + secrets: inherit deploy: needs: docker-publish From 949a0d79e27f9a5b819da8bd6e698dbd98171c4d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:41:39 +0100 Subject: [PATCH 082/185] app version fix --- app/main/routes.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/main/routes.py b/app/main/routes.py index 1156af74..9fd83007 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -11,7 +11,6 @@ import os - @bp.before_request def before_request(): """ @@ -23,10 +22,9 @@ def before_request(): db.session.commit() - @bp.route('/', methods=['GET', 'POST']) @bp.route('/index', methods=['GET', 'POST']) -@login_required +@login_required def index(): """ Route for index page @@ -44,12 +42,14 @@ def index(): return render_template("index.html", title='Home Page', form=form, posts=posts) + @bp.route('/app_version') def app_version(): """ Route for explore """ - return {"app_version": os.environ["APP_VERSION"]} + return {"app_version": os.environ.get('APP_VERSION', 'unknown')} + @bp.route('/explore') @login_required @@ -61,7 +61,6 @@ def explore(): return render_template('index.html', title='Explore', posts=posts) - @bp.route('/user/') @login_required def user(username): @@ -73,7 +72,6 @@ def user(username): return render_template('user.html', user=user_, posts=posts) - @bp.route('/edit_profile', methods=['GET', 'POST']) @login_required def edit_profile(): @@ -81,7 +79,7 @@ def edit_profile(): Route for editing user profile """ form = EditProfileForm(current_user.username) - if form.validate_on_submit(): #pylint: disable=no-else-return + if form.validate_on_submit(): # pylint: disable=no-else-return current_user.username = form.username.data current_user.about_me = form.about_me.data db.session.commit() @@ -93,6 +91,7 @@ def edit_profile(): return render_template('edit_profile.html', title='Edit Profile', form=form) + @bp.route('/follow/') @login_required def follow(username): @@ -111,6 +110,7 @@ def follow(username): flash(f'You are following {username}!') return redirect(url_for('main.user', username=username)) + @bp.route('/unfollow/') @login_required def unfollow(username): @@ -127,4 +127,4 @@ def unfollow(username): current_user.unfollow(user_) db.session.commit() flash(f'You are not following {username}.') - return redirect(url_for('main.user', username=username)) \ No newline at end of file + return redirect(url_for('main.user', username=username)) From 15a3d4088f25bf11ffd117ac9779b632c91d43db Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 15:50:15 +0100 Subject: [PATCH 083/185] rename change --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 88ac06fc..190167d1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -31,7 +31,7 @@ jobs: file: ./docker/Dockerfile_prod tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-prod build-args: | - DOCKER_TAG=${{ github.event.release.tag_name }} + APP_VERSION=${{ github.event.release.tag_name }} - name: Build and push Docker image for testing uses: docker/build-push-action@v5 From 2c56b39653c2fbc3ae0c03192b1d22b7d9fcf1b4 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 16:03:15 +0100 Subject: [PATCH 084/185] Trying to fix app version --- .github/workflows/docker-publish.yml | 3 +-- app/main/routes.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 190167d1..b7fef674 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -30,8 +30,7 @@ jobs: push: true file: ./docker/Dockerfile_prod tags: ${{ secrets.DOCKERHUB_USERNAME }}/microblog:${{ github.event.release.tag_name }}-prod - build-args: | - APP_VERSION=${{ github.event.release.tag_name }} + build-args: APP_VERSION=${{ github.event.release.tag_name }} - name: Build and push Docker image for testing uses: docker/build-push-action@v5 diff --git a/app/main/routes.py b/app/main/routes.py index 9fd83007..ee3f7d66 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -10,6 +10,8 @@ from app.main import bp import os +APP_VERSION = os.environ.get('APP_VERSION', 'No version set') + @bp.before_request def before_request(): @@ -46,9 +48,9 @@ def index(): @bp.route('/app_version') def app_version(): """ - Route for explore + Route for getting the current version of the application """ - return {"app_version": os.environ.get('APP_VERSION', 'unknown')} + return {"app_version": APP_VERSION} @bp.route('/explore') From 668014b4461e7ba87cbac3873a59fd5fb0ee4c06 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 16:04:42 +0100 Subject: [PATCH 085/185] Trying to fix app version --- app/main/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main/routes.py b/app/main/routes.py index ee3f7d66..4e5f9806 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -48,7 +48,7 @@ def index(): @bp.route('/app_version') def app_version(): """ - Route for getting the current version of the application + Route for explore """ return {"app_version": APP_VERSION} From 52ac6b5ca19a020f58239541dc28a0e21d5875b6 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 16:21:17 +0100 Subject: [PATCH 086/185] Test code --- app/main/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main/routes.py b/app/main/routes.py index 4e5f9806..4703bbf3 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -50,7 +50,7 @@ def app_version(): """ Route for explore """ - return {"app_version": APP_VERSION} + return {"app_version": APP_VERSION, "Kursmoment": "Kmom02"} @bp.route('/explore') From 54205ce3c842693123e909949a6944ec555f0f1b Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 16:33:16 +0100 Subject: [PATCH 087/185] Added env and arg for app version --- docker/Dockerfile_prod | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod index 5da00a69..5589e575 100644 --- a/docker/Dockerfile_prod +++ b/docker/Dockerfile_prod @@ -1,6 +1,10 @@ # syntax=docker/dockerfile:1.4 FROM python:3.8-alpine + +ARG APP_VERSION=unknown +ENV APP_VERSION=${APP_VERSION} + RUN adduser -D microblog WORKDIR /home/microblog From d1408664c818e659e2b6a842f5351c9fd4311e9d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 16:34:40 +0100 Subject: [PATCH 088/185] Removed test string --- app/main/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main/routes.py b/app/main/routes.py index 4703bbf3..4e5f9806 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -50,7 +50,7 @@ def app_version(): """ Route for explore """ - return {"app_version": APP_VERSION, "Kursmoment": "Kmom02"} + return {"app_version": APP_VERSION} @bp.route('/explore') From 637f68701f69f6e006417aa185c97f69a0956b79 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Mon, 20 Nov 2023 17:04:59 +0100 Subject: [PATCH 089/185] docs: Changelog updated to reflect the new release --- CHANGELOG.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8085311..e1133b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [Unreleased] +- _No unreleased changes at this time._ + +## [2.0.0] - 2023-11-20 + +- **Branch:** Push development branch to master branch to reflect kmom02 is done [PR 36](https://github.com/FalkenDev/microblog/pull/36) +- **Docs:** Changelog updated to reflect the new release (Kmom02) - [PR 37](https://github.com/FalkenDev/microblog/pull/37) + +## [1.1.3] - 2023-11-20 + +### Added + +- **Feat:** Added app_version env and arg in dockerfile_prod - [PR 35](https://github.com/FalkenDev/microblog/pull/35) + +## [1.1.2] - 2023-11-20 + +### Changed + +- **Test** - Test code to see if the appservers in azure updates - [PR 34](https://github.com/FalkenDev/microblog/pull/34) + +## [1.1.1] - 2023-11-20 + +### Fixed + +- **Fix:** Rename DOCKER_TAG to APP_VERSION - [PR 32](https://github.com/FalkenDev/microblog/pull/32) +- **Fix:** Trying to fix app version route - [PR 33](https://github.com/FalkenDev/microblog/pull/33) + +## [1.1.0] - 2023-11-20 ### Added @@ -23,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Build:** Added published_ports for appservers to connect to mysql - [PR 21](https://github.com/FalkenDev/microblog/pull/21) - **Build:** Added playbook for appserver for deployment - [PR 21](https://github.com/FalkenDev/microblog/pull/21) - **Build:** Added task to start the microblog docker container - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Build:** Workflow file for deploying release - [PR 24](https://github.com/FalkenDev/microblog/pull/24) +- **Feat:** Creates DNS A records for appservers - [PR 24](https://github.com/FalkenDev/microblog/pull/24) +- **Fix:** Added subdomain hosts - [PR 24](https://github.com/FalkenDev/microblog/pull/24) +- **Feat:** Added subdomain for the database - [PR 25](https://github.com/FalkenDev/microblog/pull/25) ### Changed @@ -32,11 +62,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Build:** Updated to look for azure ssh keys using users_users var for the file names - [PR 17](https://github.com/FalkenDev/microblog/pull/17) - **Chore:** Updated .gitignore with ansible/group_vars/all.yml and Makefile - [PR 21](https://github.com/FalkenDev/microblog/pull/21) - **Build:** Moved the pip3 install and docker sdk to docker-install ansible - [PR 21](https://github.com/FalkenDev/microblog/pull/21) +- **Docs:** Updated Changelog to reflect the new builds and fixes - [PR 22](https://github.com/FalkenDev/microblog/pull/22) +- **Build:** Playbook for updating docker images - [PR 24](https://github.com/FalkenDev/microblog/pull/24) ### Fixed - **Dbwebb:** Small fixes - [PR 15](https://github.com/FalkenDev/microblog/pull/15) - **Build:** Fixed python interpreter .venv to use just venv - [PR 17](https://github.com/FalkenDev/microblog/pull/17) +- **Fix:** - Gitignore fix - [PR 23](https://github.com/FalkenDev/microblog/pull/23) +- **Fix:** Fixed subdomain hosts - [PR 24](https://github.com/FalkenDev/microblog/pull/24) ### Removed From 1f2146b385f7bb3b37dd482728cac586775d1780 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 09:46:23 +0100 Subject: [PATCH 090/185] test: Added bandit test to find security holes in the code Added a script in Makefile to run the bandit. Skipping hashlib (B324) as it's false positive. Added bandit to requirments in test requirments. --- Makefile | 7 +++++++ requirements/test.txt | 1 + 2 files changed, 8 insertions(+) diff --git a/Makefile b/Makefile index 7622a96e..701206ad 100644 --- a/Makefile +++ b/Makefile @@ -216,3 +216,10 @@ install-test: install-deploy: ${pip} install -r requirements/deploy.txt cd ansible && ansible-galaxy install -r requirements.yml + + + +# target: bandit-test - Run SAST tool bandit to find security holes in the code. Skipping hashlib (B324) as it's false positive. +.PHONY: bandit-test +bandit-test: + bandit -s B324 app/*.py app/auth/*.py app/errors/*.py app/main/*.py diff --git a/requirements/test.txt b/requirements/test.txt index 9b6c8005..341d4d10 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,3 +2,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 +bandit >= 1.7.5 \ No newline at end of file From 0dcd725a7090f9f05e4d2552ef5ebe8770410c99 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 10:09:12 +0100 Subject: [PATCH 091/185] ci: Added CI Workflow to run BTD when release created and when using docker publish CD --- .github/workflows/BTD-ci.yml | 33 ++++++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 5 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/BTD-ci.yml diff --git a/.github/workflows/BTD-ci.yml b/.github/workflows/BTD-ci.yml new file mode 100644 index 00000000..a1202c59 --- /dev/null +++ b/.github/workflows/BTD-ci.yml @@ -0,0 +1,33 @@ +name: Bandit, Trivy, and Dockle + +on: + workflow_call: + release: + types: [created] + +jobs: + BTD: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install make + + - name: Run Bandit + run: bandit-test + + - name: Run Trivy + run: trivy-test + + - name: Run Dockle + run: dockle-test diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b7fef674..83b73d45 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,8 +9,11 @@ jobs: python-ci: uses: ./.github/workflows/python-ci.yml + BTD-ci: + uses: ./.github/workflows/BTD-ci.yml + build-and-push: - needs: python-ci + needs: [python-ci, BTD-ci] runs-on: ubuntu-latest if: ${{ github.event_name == 'release' && github.event.action == 'created' }} steps: From a6cac466fcdd9b760945fc973e4564ddf927a753 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 10:19:19 +0100 Subject: [PATCH 092/185] ci: Added pip install bandit --- .github/workflows/BTD-ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/BTD-ci.yml b/.github/workflows/BTD-ci.yml index a1202c59..a4631de1 100644 --- a/.github/workflows/BTD-ci.yml +++ b/.github/workflows/BTD-ci.yml @@ -22,12 +22,13 @@ jobs: run: | sudo apt-get update sudo apt-get install make + pip install bandit - name: Run Bandit run: bandit-test - - name: Run Trivy - run: trivy-test + # - name: Run Trivy + # run: trivy-test - - name: Run Dockle - run: dockle-test + # - name: Run Dockle + # run: dockle-test From 79f6a579cb38c35e5bd6268234f0586e6b18a52d Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 21 Nov 2023 11:09:46 +0100 Subject: [PATCH 093/185] refactor: Updated connection permissions Chnaged mysql source address prefix to only allow appservers to connect --- ansible/roles/security_groups/vars/main.yml | 110 ++++++++++---------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index 77cdb23a..4bb2a782 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -1,57 +1,57 @@ --- sg_groups: - - name: loadbalancer - port_rules: - - name: SSH - protocol: Tcp - destination_port_range: 22 - access: Allow - priority: 1001 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: HTTP - protocol: Tcp - destination_port_range: 80 - access: Allow - priority: 1002 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: HTTPS - protocol: Tcp - destination_port_range: 443 - access: Allow - priority: 1003 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: appserver - port_rules: - - name: APP - protocol: Tcp - destination_port_range: 22 - access: Allow - priority: 1001 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: HTTP - protocol: Tcp - destination_port_range: 8000 - access: Allow - priority: 1002 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: database - port_rules: - - name: SSH - protocol: Tcp - destination_port_range: 22 - access: Allow - priority: 1001 - direction: Inbound - source_address_prefix: '0.0.0.0/0' - - name: MYSQL - protocol: Tcp - destination_port_range: 3306 - access: Allow - priority: 1002 - direction: Inbound - source_address_prefix: '0.0.0.0/0' \ No newline at end of file + - name: loadbalancer + port_rules: + - name: SSH + protocol: Tcp + destination_port_range: 22 + access: Allow + priority: 1001 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: HTTP + protocol: Tcp + destination_port_range: 80 + access: Allow + priority: 1002 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: HTTPS + protocol: Tcp + destination_port_range: 443 + access: Allow + priority: 1003 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: appserver + port_rules: + - name: APP + protocol: Tcp + destination_port_range: 22 + access: Allow + priority: 1001 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: HTTP + protocol: Tcp + destination_port_range: 8000 + access: Allow + priority: 1002 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: database + port_rules: + - name: SSH + protocol: Tcp + destination_port_range: 22 + access: Allow + priority: 1001 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: MYSQL + protocol: Tcp + destination_port_range: 3306 + access: Allow + priority: 1002 + direction: Inbound + source_address_prefix: '{{ groups["appserver"][0] }}/32,{{ groups["appserver"][1] }}/32' From 9f519535b87d7ab671ff6ac5d2b6993cbb13c11c Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 21 Nov 2023 11:12:43 +0100 Subject: [PATCH 094/185] refactor: Moved the creation of security groups Security group creation moved to the end in order to be created after VM's --- ansible/provision_instances.yml | 61 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/ansible/provision_instances.yml b/ansible/provision_instances.yml index d797d1d8..9de20ce7 100755 --- a/ansible/provision_instances.yml +++ b/ansible/provision_instances.yml @@ -3,38 +3,33 @@ # To create vm's concurrently we can't use a regular loop, then they are create after each other and it would take a lot longer. # Here we add each VM instance we want to create as a host and include the data we need to create the host. # Then we can run the create_playbook on all hosts and it is executed concurrently. -- name: add instances to hosts - hosts: local - gather_facts: False - tasks: - - include_vars: roles/provision_instances/vars/main.yml - - include_tasks: roles/provision_instances/tasks/add_vms_as_hosts.yml - - - -- name: create security groups - hosts: local - gather_facts: False - roles: - - security_groups - - - -- name: create networks - hosts: local - gather_facts: False - tasks: - - include_tasks: roles/provision_instances/tasks/vnet.yml - collections: - - azure.azcollection - +- name: add instances to hosts + hosts: local + gather_facts: False + tasks: + - include_vars: roles/provision_instances/vars/main.yml + - include_tasks: roles/provision_instances/tasks/add_vms_as_hosts.yml + +- name: create networks + hosts: local + gather_facts: False + tasks: + - include_tasks: roles/provision_instances/tasks/vnet.yml + collections: + - azure.azcollection # Here we utilize that we added each instance to hosts. -- hosts: devops - connection: local # Keep ansible from open ssh connection - gather_facts: False - # no_log: True - collections: - - azure.azcollection - roles: - - provision_instances +- hosts: devops + connection: local # Keep ansible from open ssh connection + gather_facts: False + # no_log: True + collections: + - azure.azcollection + roles: + - provision_instances + +- name: create security groups + hosts: local + gather_facts: False + roles: + - security_groups From 4d3d1073e369c64a700e87d52bcb40b29e0c9a39 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 11:15:10 +0100 Subject: [PATCH 095/185] test: Added dockle-test in makefile --- Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Makefile b/Makefile index 701206ad..5db2804e 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,9 @@ THIS_MAKEFILE := $(call WHERE-AM-I) # Echo some nice helptext based on the target comment HELPTEXT = $(ECHO) "$(ACTION)--->" `egrep "^\# target: $(1) " $(THIS_MAKEFILE) | sed "s/\# target: $(1)[ ]*-[ ]* / /g"` "$(NO_COLOR)" +# Tag for docker image build +TAG ?= $(shell git describe --tags --abbrev=0) + # ---------------------------------------------------------------------------- @@ -223,3 +226,11 @@ install-deploy: .PHONY: bandit-test bandit-test: bandit -s B324 app/*.py app/auth/*.py app/errors/*.py app/main/*.py + + + +# target: dockle-test - Run SAST tool dockle to find security holes in the docker image. +.PHONY: dockle-test +dockle-test: + docker build -f docker/Dockerfile_prod -t microblog:$(TAG) . + dockle --ignore DKL-LI-0003 -f json microblog:$(TAG) From 9cb3dbbb94a808458c6866dffa0b60a4cec7153c Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 11:15:29 +0100 Subject: [PATCH 096/185] ci: Added dockle run --- .github/workflows/BTD-ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/BTD-ci.yml b/.github/workflows/BTD-ci.yml index a4631de1..ab52a889 100644 --- a/.github/workflows/BTD-ci.yml +++ b/.github/workflows/BTD-ci.yml @@ -23,12 +23,13 @@ jobs: sudo apt-get update sudo apt-get install make pip install bandit + curl -sfL https://raw.githubusercontent.com/goodwithtech/dockle/master/install.sh | sh -s -- -b /usr/local/bin - name: Run Bandit - run: bandit-test + run: make bandit-test # - name: Run Trivy # run: trivy-test - # - name: Run Dockle - # run: dockle-test + - name: Run Dockle + run: make dockle-test TAG=${{ github.event.release.tag_name }}-prod From af4f5d5594e3fc95a8f1be7e820abbd2607516fa Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 11:15:48 +0100 Subject: [PATCH 097/185] ci: Added docker_content_trust --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 83b73d45..f6507d84 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,6 +28,8 @@ jobs: - name: Build and push Docker image for production uses: docker/build-push-action@v5 + env: + DOCKER_CONTENT_TRUST: 1 with: context: . push: true @@ -37,6 +39,8 @@ jobs: - name: Build and push Docker image for testing uses: docker/build-push-action@v5 + env: + DOCKER_CONTENT_TRUST: 1 with: context: . push: true From fbc466b4e9db78cee237cd00bcbfaefaf09e0e8f Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 11:16:20 +0100 Subject: [PATCH 098/185] build: Added healthcheck for Dockerfile_prod --- docker/Dockerfile_prod | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod index 5589e575..37aa293f 100644 --- a/docker/Dockerfile_prod +++ b/docker/Dockerfile_prod @@ -22,9 +22,12 @@ RUN <<-EOF chown -R microblog:microblog ./ EOF +HEALTHCHECK --interval=60s --timeout=20s --start-period=5s --retries=3 \ + CMD curl --fail http://localhost:8000/ || exit 1 + ENV FLASK_APP microblog.py USER microblog -EXPOSE 5000 +EXPOSE 8000 ENTRYPOINT ["./boot.sh"] \ No newline at end of file From e4e4570ff74b9960c9ff20922dca0abee7310729 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 13:59:18 +0100 Subject: [PATCH 099/185] fix: Upgrade pip, setuptools, openssl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: Upgrade pip, setuptools, openssl to fix security issues: libcrypto3: Säkerhets fel i versioner innan 3.1.4-r0 och 3.1.4-r1. Lösning uppdatera från 3.1.3-r0 till 3.1.4-r1 libssl3: Samma fel som libcrypto3. pip: When installing a package from a Mercurial VCS URL. Uppgradera till minst 23.3 setuptools: pypa-setuptools: Regular Expression Denial of Service. Uppgradera till minst 65.5.1 --- docker/Dockerfile_prod | 71 +++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod index 37aa293f..54639f17 100644 --- a/docker/Dockerfile_prod +++ b/docker/Dockerfile_prod @@ -1,33 +1,40 @@ -# syntax=docker/dockerfile:1.4 - -FROM python:3.8-alpine - -ARG APP_VERSION=unknown -ENV APP_VERSION=${APP_VERSION} - -RUN adduser -D microblog - -WORKDIR /home/microblog - -COPY app app -COPY migrations migrations -COPY requirements requirements -COPY requirements.txt microblog.py boot.sh ./ - -RUN <<-EOF - apk add --no-cache make && \ - python -m venv .venv && \ - .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ - chmod +x boot.sh && \ - chown -R microblog:microblog ./ -EOF - -HEALTHCHECK --interval=60s --timeout=20s --start-period=5s --retries=3 \ - CMD curl --fail http://localhost:8000/ || exit 1 - -ENV FLASK_APP microblog.py - -USER microblog - -EXPOSE 8000 +# syntax=docker/dockerfile:1.4 + +FROM python:3.8-alpine + +ARG APP_VERSION=unknown +ENV APP_VERSION=${APP_VERSION} + +RUN adduser -D microblog + +WORKDIR /home/microblog + +COPY app app +COPY migrations migrations +COPY requirements requirements +COPY requirements.txt microblog.py boot.sh ./ +# Update the package manager and install necessary dependencies +RUN apk update && \ + apk upgrade && \ + apk add --no-cache gcc musl-dev linux-headers + +RUN pip install --upgrade pip setuptools + +RUN <<-EOF + apk add --no-cache make && \ + apk upgrade --no-cache && \ + python -m venv .venv && \ + .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ + chmod +x boot.sh && \ + chown -R microblog:microblog ./ +EOF + +HEALTHCHECK --interval=60s --timeout=20s --start-period=5s --retries=3 \ + CMD curl --fail http://localhost:8000/ || exit 1 + +ENV FLASK_APP microblog.py + +USER microblog + +EXPOSE 8000 ENTRYPOINT ["./boot.sh"] \ No newline at end of file From 57845f4b337a4014d87e831cf156b33e8bd746f5 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 13:59:53 +0100 Subject: [PATCH 100/185] build: Add trivy-test to run trivy against app --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 5db2804e..69e2dd03 100644 --- a/Makefile +++ b/Makefile @@ -234,3 +234,10 @@ bandit-test: dockle-test: docker build -f docker/Dockerfile_prod -t microblog:$(TAG) . dockle --ignore DKL-LI-0003 -f json microblog:$(TAG) + +# target: trivy-test +.PHONY: trivy-test +trivy-test: + docker build -f docker/Dockerfile_prod -t microblog:$(TAG) . + trivy image microblog:$(TAG) --scanners vuln,secret,config + trivy fs scanners vuln,secret,config . From 82aadf7b321e32884515f76ae6837f883fcf193b Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:01:13 +0100 Subject: [PATCH 101/185] fix: Add setuptools, pip min version Security issued fixed: pip: When installing a package from a Mercurial VCS URL. Upgrade to atleast 23.3 setuptools: pypa-setuptools: Regular Expression Denial of Service. Upgrade to atleast 65.5.1 --- requirements.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 96d9a012..4e01668a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ --r requirements/prod.txt -gunicorn >= 19.9.0 -pymysql >= 0.9.3 \ No newline at end of file +-r requirements/prod.txt +gunicorn >= 19.9.0 +pymysql >= 0.9.3 +setuptools>=65.5.1 +pip>=23.3 From 4337a1164f5d6ed54af2da79b5c50097ee13ef02 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:02:53 +0100 Subject: [PATCH 102/185] fix: Upgrade flask and werkzeug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flask: Cookie header fel. Uppgradera från 2.0.1 till minst 2.2.5 Werkzeug: high resource consumption leading to DoS. Uppgradera till minst 2.3.8 high resource usage when parsing multipart form data with many fields. Solution: Upgrade to atleast 2.2.3. 2.3.3 is latest version without breaking changes. cookie prefixed with = can shadow unprefixed cookie. Solution: Upgrade to atleast 2.2.3. Verison 2.3.8 is latest version without breaking changes. --- requirements/prod.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index d6cbd82e..5a2689bb 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,11 +1,11 @@ -Flask==2.0.1 -SQLAlchemy==1.4.50 -Flask-Bootstrap==3.3.7.1 -Flask-Login==0.5.0 -Flask-Migrate==3.1.0 -Flask-Moment==1.0.2 -Flask-SQLAlchemy==2.5.1 -Flask-WTF==0.15.1 -email_validator==1.1.3 -python-dotenv==0.19.1 -Werkzeug==2.0.1 \ No newline at end of file +Flask==2.3.3 +SQLAlchemy==1.4.50 +Flask-Bootstrap==3.3.7.1 +Flask-Login==0.5.0 +Flask-Migrate==3.1.0 +Flask-Moment==1.0.2 +Flask-SQLAlchemy==2.5.1 +Flask-WTF==0.15.1 +email_validator==1.1.3 +python-dotenv==0.19.1 +Werkzeug==2.3.8 \ No newline at end of file From e443122d512ff14130a0ae2afe32b2804b2edc48 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:12:41 +0100 Subject: [PATCH 103/185] ci: Add trivy test to BTD workflow --- .github/workflows/BTD-ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/BTD-ci.yml b/.github/workflows/BTD-ci.yml index ab52a889..06110545 100644 --- a/.github/workflows/BTD-ci.yml +++ b/.github/workflows/BTD-ci.yml @@ -24,12 +24,17 @@ jobs: sudo apt-get install make pip install bandit curl -sfL https://raw.githubusercontent.com/goodwithtech/dockle/master/install.sh | sh -s -- -b /usr/local/bin + sudo apt-get install wget apt-transport-https gnupg lsb-release + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install trivy - name: Run Bandit run: make bandit-test - # - name: Run Trivy - # run: trivy-test + - name: Run Trivy + run: make trivy-test TAG=${{ github.event.release.tag_name }}-prod - name: Run Dockle run: make dockle-test TAG=${{ github.event.release.tag_name }}-prod From 69e6c8d3ee96ff37868ddaa40080ce42c8508329 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:14:49 +0100 Subject: [PATCH 104/185] fix: Add werkzeug2.3.8 to test requirements --- requirements/test.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/test.txt b/requirements/test.txt index 341d4d10..3dbd9807 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,4 +2,5 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 -bandit >= 1.7.5 \ No newline at end of file +bandit >= 1.7.5 +Werkzeug==2.3.8 \ No newline at end of file From d229361ec8e26967ac7df815f21956e101dcec4d Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:17:21 +0100 Subject: [PATCH 105/185] fix: Upgrade flask login to 0.6.3 --- requirements/prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index 5a2689bb..300bdbdf 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,7 +1,7 @@ Flask==2.3.3 SQLAlchemy==1.4.50 Flask-Bootstrap==3.3.7.1 -Flask-Login==0.5.0 +Flask-Login==0.6.3 Flask-Migrate==3.1.0 Flask-Moment==1.0.2 Flask-SQLAlchemy==2.5.1 From 81c80dbe512bd38d5f77f9ced3da5fb377892b1a Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 14:21:43 +0100 Subject: [PATCH 106/185] fix: Downgrade flask to oldest version with security fix 2.2.5 --- requirements/prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index 300bdbdf..de809146 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,4 +1,4 @@ -Flask==2.3.3 +Flask==2.2.5 SQLAlchemy==1.4.50 Flask-Bootstrap==3.3.7.1 Flask-Login==0.6.3 From fffdbc205a8ffd7b0a7d44ee0ca48246d83dee4f Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 15:09:59 +0100 Subject: [PATCH 107/185] fix: Downgrade flask-logn 0.6.0. upgrade em Downgrade flask-logn 0.6.0. upgrade email_validation 1.3.1, downgrade werkzeug 2.2.3 --- requirements/prod.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index de809146..e6da07d9 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,11 +1,11 @@ Flask==2.2.5 SQLAlchemy==1.4.50 Flask-Bootstrap==3.3.7.1 -Flask-Login==0.6.3 +Flask-Login==0.6.0 Flask-Migrate==3.1.0 Flask-Moment==1.0.2 Flask-SQLAlchemy==2.5.1 Flask-WTF==0.15.1 -email_validator==1.1.3 +email_validator==1.3.1 python-dotenv==0.19.1 -Werkzeug==2.3.8 \ No newline at end of file +Werkzeug==2.2.3 \ No newline at end of file From 3500f4174d9e1e4442e8ed95e99ae06b9ce0cd64 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 15:11:33 +0100 Subject: [PATCH 108/185] fix: downgrade werkzeug 2.2.3 --- requirements/test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/test.txt b/requirements/test.txt index 3dbd9807..87a498b9 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,4 +3,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 bandit >= 1.7.5 -Werkzeug==2.3.8 \ No newline at end of file +Werkzeug==2.2.3 \ No newline at end of file From 5b6b578ccbca6c1dcf8a75ada04a16671dc812c8 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 15:33:53 +0100 Subject: [PATCH 109/185] fix: Upgrade flask-wtf, downgrade email_validator, downgrade werkzeud 2.2.2 Found combination of flask-wtf, email_validator and werkzeug that do not cause json decode errors and where safe_str_cmp exists --- requirements/prod.txt | 22 +++++++++++----------- requirements/test.txt | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index e6da07d9..b85a0ca1 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,11 +1,11 @@ -Flask==2.2.5 -SQLAlchemy==1.4.50 -Flask-Bootstrap==3.3.7.1 -Flask-Login==0.6.0 -Flask-Migrate==3.1.0 -Flask-Moment==1.0.2 -Flask-SQLAlchemy==2.5.1 -Flask-WTF==0.15.1 -email_validator==1.3.1 -python-dotenv==0.19.1 -Werkzeug==2.2.3 \ No newline at end of file +Flask==2.2.5 +SQLAlchemy==1.4.50 +Flask-Bootstrap==3.3.7.1 +Flask-Login==0.6.0 +Flask-Migrate==3.1.0 +Flask-Moment==1.0.2 +Flask-SQLAlchemy==2.5.1 +Flask-WTF==1.2.1 +email_validator==1.1.3 +python-dotenv==0.19.1 +Werkzeug==2.2.2 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index 87a498b9..beccf738 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,4 +3,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 bandit >= 1.7.5 -Werkzeug==2.2.3 \ No newline at end of file +Werkzeug==2.2.2 From 1f95bf157cb907102dccf2ca1a880efafadae65c Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 15:51:36 +0100 Subject: [PATCH 110/185] fix: Change from crlf to lf --- docker/Dockerfile_prod | 78 +++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod index 54639f17..e2431cd9 100644 --- a/docker/Dockerfile_prod +++ b/docker/Dockerfile_prod @@ -1,40 +1,40 @@ -# syntax=docker/dockerfile:1.4 - -FROM python:3.8-alpine - -ARG APP_VERSION=unknown -ENV APP_VERSION=${APP_VERSION} - -RUN adduser -D microblog - -WORKDIR /home/microblog - -COPY app app -COPY migrations migrations -COPY requirements requirements -COPY requirements.txt microblog.py boot.sh ./ -# Update the package manager and install necessary dependencies -RUN apk update && \ - apk upgrade && \ - apk add --no-cache gcc musl-dev linux-headers - -RUN pip install --upgrade pip setuptools - -RUN <<-EOF - apk add --no-cache make && \ - apk upgrade --no-cache && \ - python -m venv .venv && \ - .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ - chmod +x boot.sh && \ - chown -R microblog:microblog ./ -EOF - -HEALTHCHECK --interval=60s --timeout=20s --start-period=5s --retries=3 \ - CMD curl --fail http://localhost:8000/ || exit 1 - -ENV FLASK_APP microblog.py - -USER microblog - -EXPOSE 8000 +# syntax=docker/dockerfile:1.4 + +FROM python:3.8-alpine + +ARG APP_VERSION=unknown +ENV APP_VERSION=${APP_VERSION} + +RUN adduser -D microblog + +WORKDIR /home/microblog + +COPY app app +COPY migrations migrations +COPY requirements requirements +COPY requirements.txt microblog.py boot.sh ./ +# Update the package manager and install necessary dependencies +RUN apk update && \ + apk upgrade && \ + apk add --no-cache gcc musl-dev linux-headers + +RUN pip install --upgrade pip setuptools + +RUN <<-EOF + apk add --no-cache make && \ + apk upgrade --no-cache && \ + python -m venv .venv && \ + .venv/bin/pip3 install --no-cache-dir -r requirements.txt && \ + chmod +x boot.sh && \ + chown -R microblog:microblog ./ +EOF + +HEALTHCHECK --interval=60s --timeout=20s --start-period=5s --retries=3 \ + CMD curl --fail http://localhost:8000/ || exit 1 + +ENV FLASK_APP microblog.py + +USER microblog + +EXPOSE 8000 ENTRYPOINT ["./boot.sh"] \ No newline at end of file From eb42f5aa17ccfae953082b6c1e7cd460c36c9a1c Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 15:52:05 +0100 Subject: [PATCH 111/185] fix: Update werkzeug from 2.2.2 to 2.2.3 --- requirements/prod.txt | 2 +- requirements/test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index b85a0ca1..c2febf23 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -8,4 +8,4 @@ Flask-SQLAlchemy==2.5.1 Flask-WTF==1.2.1 email_validator==1.1.3 python-dotenv==0.19.1 -Werkzeug==2.2.2 \ No newline at end of file +Werkzeug==2.2.3 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index beccf738..a8f875e7 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,4 +3,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 bandit >= 1.7.5 -Werkzeug==2.2.2 +Werkzeug==2.2.3 From cbcb06d1ae71543b74c9036b33d8cfc5cff01199 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 16:01:09 +0100 Subject: [PATCH 112/185] fix: Update trivy-test make --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 69e2dd03..f8e1b185 100644 --- a/Makefile +++ b/Makefile @@ -240,4 +240,4 @@ dockle-test: trivy-test: docker build -f docker/Dockerfile_prod -t microblog:$(TAG) . trivy image microblog:$(TAG) --scanners vuln,secret,config - trivy fs scanners vuln,secret,config . + trivy fs --scanners vuln,secret,config . From a53882bf0df0cbce5fa32d257f363f3459e058dd Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 21 Nov 2023 16:08:25 +0100 Subject: [PATCH 113/185] fix: Upgrade werkzeug 2.3.8 Upgrade werkzeug to high resource consumption leading to denial of service --- requirements/prod.txt | 2 +- requirements/test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index c2febf23..366c5553 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -8,4 +8,4 @@ Flask-SQLAlchemy==2.5.1 Flask-WTF==1.2.1 email_validator==1.1.3 python-dotenv==0.19.1 -Werkzeug==2.2.3 \ No newline at end of file +Werkzeug==2.3.8 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index a8f875e7..8e9658a6 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,4 +3,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 bandit >= 1.7.5 -Werkzeug==2.2.3 +Werkzeug==2.3.8 From 1413839571bf1464e4f1ff94cad2c604be786f34 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 16:11:29 +0100 Subject: [PATCH 114/185] build: Getting the latest tag from github to the docker image tag --- ansible/roles/appserver/tasks/main.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ansible/roles/appserver/tasks/main.yml b/ansible/roles/appserver/tasks/main.yml index 2653c423..3fd356e3 100644 --- a/ansible/roles/appserver/tasks/main.yml +++ b/ansible/roles/appserver/tasks/main.yml @@ -1,12 +1,25 @@ ---- +- name: Get the latest tag from GitHub API + uri: + url: "https://api.github.com/repos/falkendev/microblog/tags" + return_content: yes + headers: + User-Agent: "Ansible GitHub" + register: github_tags + delegate_to: localhost + become: no + +- name: Set latest tag as a variable + set_fact: + latest_tag: "{{ github_tags.json[0].name }}" + - name: Start Microblog Container docker_container: name: microblog - image: falkendev/microblog:0.2.0-prod + image: "falkendev/microblog:{{ latest_tag }}-prod" env: DATABASE_URL: "mysql+pymysql://microblog:password@{{ groups['database'][0] }}:3306/microblog" ports: - "8000:5000" restart_policy: always published_ports: - - "8000:8000" \ No newline at end of file + - "8000:8000" From 453e297816ab74ca507ff76662bd197d26e50701 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 16:12:11 +0100 Subject: [PATCH 115/185] fix: Cookie No HttpOnly Flag [10010] fix: Cookie Without Secure Flag [10011] fix: Missing Anti-clickjacking Header [10020] fix: X-Content-Type-Options Header Missing [10021] fix: Strict-Transport-Security Header Not Set [10035] Added roxy_cookie_path / "/; HTTPOnly; Secure"; for ZAP fix [10010] and [10011] Added add_header X-Frame-Options "SAMEORIGIN" always; for ZAP fix [10020] Added add_header X-Content-Type-Options "nosniff" always; for ZAP fix [10021] Added add_header Strict-Transport-Security "max-age=31536000; for ZAP fix [10035] --- .../load_balancer/tasks/templates/load-balancer.conf.j2 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 index 2c6221e7..beb0f3bf 100644 --- a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 +++ b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 @@ -24,12 +24,16 @@ http { server { listen 443 ssl; server_name {{ domain_name }} www.{{ domain_name }}; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/cert.pem; ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + proxy_cookie_path / "/; HTTPOnly; Secure"; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + location / { proxy_pass http://app-hosts; } From c8f3e2c5b19a97029b86fe17dcf3d530bc945593 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 21 Nov 2023 16:18:04 +0100 Subject: [PATCH 116/185] fix: Changed download depend for dockle --- .github/workflows/BTD-ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/BTD-ci.yml b/.github/workflows/BTD-ci.yml index 06110545..7bf4eeab 100644 --- a/.github/workflows/BTD-ci.yml +++ b/.github/workflows/BTD-ci.yml @@ -23,13 +23,19 @@ jobs: sudo apt-get update sudo apt-get install make pip install bandit - curl -sfL https://raw.githubusercontent.com/goodwithtech/dockle/master/install.sh | sh -s -- -b /usr/local/bin sudo apt-get install wget apt-transport-https gnupg lsb-release wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list sudo apt-get update sudo apt-get install trivy + - name: Install Dockle + run: | + VERSION=$(curl -s https://api.github.com/repos/goodwithtech/dockle/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.deb" + sudo dpkg -i dockle.deb + rm dockle.deb + - name: Run Bandit run: make bandit-test From e8a46d0c8d973ea339569678744e579cbb79a784 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 21 Nov 2023 16:24:26 +0100 Subject: [PATCH 117/185] feat: New SSHD config SSHD config file that follows Modern Configuration --- .../roles/10-first-minutes/files/sshd_config | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ansible/roles/10-first-minutes/files/sshd_config diff --git a/ansible/roles/10-first-minutes/files/sshd_config b/ansible/roles/10-first-minutes/files/sshd_config new file mode 100644 index 00000000..d2aee8a9 --- /dev/null +++ b/ansible/roles/10-first-minutes/files/sshd_config @@ -0,0 +1,28 @@ +# Supported HostKey algorithms by order of preference. +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_ecdsa_key + +KexAlgorithms curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256 + +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,umac-128@openssh.com + +# Password based logins are disabled - only public key based logins are allowed. +AuthenticationMethods publickey + +# LogLevel VERBOSE logs user's key fingerprint on login. Needed to have a clear audit track of which key was using to log in. +LogLevel VERBOSE + +# Log sftp level file access (read/write/etc.) that would not be easily logged otherwise. +Subsystem sftp /usr/lib/openssh/sftp-server -f AUTHPRIV -l INFO + +# Root login is not allowed for auditing reasons. This is because it's difficult to track which process belongs to which root user: +# +# On Linux, user sessions are tracking using a kernel-side session id, however, this session id is not recorded by OpenSSH. +# Additionally, only tools such as systemd and auditd record the process session id. +# On other OSes, the user session id is not necessarily recorded at all kernel-side. +# Using regular users in combination with /bin/su or /usr/bin/sudo ensure a clear audit track. +PermitRootLogin No +AllowUsers deploy \ No newline at end of file From 493d74a6e603234b24dd85635a9f4a99484e9db3 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 21 Nov 2023 16:26:20 +0100 Subject: [PATCH 118/185] feat:Replace sshd Replace sshd_config with Mozilla Modern Configuration --- ansible/roles/10-first-minutes/tasks/main.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ansible/roles/10-first-minutes/tasks/main.yml b/ansible/roles/10-first-minutes/tasks/main.yml index 325a456e..116815e3 100644 --- a/ansible/roles/10-first-minutes/tasks/main.yml +++ b/ansible/roles/10-first-minutes/tasks/main.yml @@ -65,6 +65,15 @@ line: "PermitRootLogin no" notify: restart ssh +- name: Replace sshd_config with Mozilla Modern Configuration + copy: + src: ../files/sshd_config + dest: /etc/ssh/sshd_config + owner: root + group: root + mode: "0644" + notify: restart ssh + - name: flush handlers to restart SSH meta: flush_handlers # we cant do it later because after this we cant ssh as root From 08ce909ff7a7f176be74904fdaec2c2a94e6c898 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 22 Nov 2023 12:28:11 +0100 Subject: [PATCH 119/185] debug: Trying to fix security groups problem with ip --- .../provision_instances/tasks/create_instance.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index e57e1d35..65cd4e48 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -50,6 +50,11 @@ delegate_to: localhost when: iname == 'appserver1' +- name: Set fact for appserver1 IP + set_fact: + appserver1_ip: "{{ output_ip_address.state.ip_address }}" + when: iname == 'appserver1' + - name: Ensure "A" record for appserver2 azure_rm_dnsrecordset: resource_group: "{{ resource_group }}" @@ -62,6 +67,11 @@ delegate_to: localhost when: iname == 'appserver2' +- name: Set fact for appserver2 IP + set_fact: + appserver2_ip: "{{ output_ip_address.state.ip_address }}" + when: iname == 'appserver2' + - name: Ensure "A" records for database azure_rm_dnsrecordset: resource_group: "{{ resource_group }}" From 3dd77d092db7d4dbe937a998a27686cba7b4550d Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 22 Nov 2023 12:30:01 +0100 Subject: [PATCH 120/185] debug: Trying to fix security groups problem with ip --- ansible/roles/security_groups/tasks/main.yml | 28 +++++++++++--------- ansible/roles/security_groups/vars/main.yml | 11 ++++++-- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/ansible/roles/security_groups/tasks/main.yml b/ansible/roles/security_groups/tasks/main.yml index 1d09d62c..f8c183fb 100755 --- a/ansible/roles/security_groups/tasks/main.yml +++ b/ansible/roles/security_groups/tasks/main.yml @@ -1,13 +1,17 @@ --- -- name: Network Security Group - azure_rm_securitygroup: - resource_group: "{{ resource_group }}" - name: "{{ group.name }}-sg" - purge_rules: yes - rules: "{{ group.port_rules }}" - tags: "{{ vmtags }}" - state: "{{ state }}" - loop: "{{ sg_groups }}" - loop_control: - loop_var: group - delegate_to: 127.0.0.1 \ No newline at end of file +- name: Debug groups + debug: + var: groups + +- name: Network Security Group + azure_rm_securitygroup: + resource_group: "{{ resource_group }}" + name: "{{ group.name }}-sg" + purge_rules: yes + rules: "{{ group.port_rules }}" + tags: "{{ vmtags }}" + state: "{{ state }}" + loop: "{{ sg_groups }}" + loop_control: + loop_var: group + delegate_to: 127.0.0.1 diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index 4bb2a782..720c3efc 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -48,10 +48,17 @@ sg_groups: priority: 1001 direction: Inbound source_address_prefix: "0.0.0.0/0" - - name: MYSQL + - name: MYSQL-appserver1 protocol: Tcp destination_port_range: 3306 access: Allow priority: 1002 direction: Inbound - source_address_prefix: '{{ groups["appserver"][0] }}/32,{{ groups["appserver"][1] }}/32' + source_address_prefix: "{{ groups['appserver'][0] }}/32" + - name: MYSQL-appserver2 + protocol: Tcp + destination_port_range: 3306 + access: Allow + priority: 1003 + direction: Inbound + source_address_prefix: "{{ groups['appserver'][1] }}/32" From 39256e2ec9cab9a9e22381adc36fe60947c6e475 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 22 Nov 2023 22:40:45 +0100 Subject: [PATCH 121/185] fix: Added gather instances to run before security groups to get the ips of the appserver1 and appserver2 --- ansible/provision_instances.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/ansible/provision_instances.yml b/ansible/provision_instances.yml index 9de20ce7..f94cc8a2 100755 --- a/ansible/provision_instances.yml +++ b/ansible/provision_instances.yml @@ -32,4 +32,5 @@ hosts: local gather_facts: False roles: + - gather_instances - security_groups From a4af4c3644d393472976aa7fa829842cfa2b0ad1 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 22 Nov 2023 22:41:33 +0100 Subject: [PATCH 122/185] fix: Fixed the appserver ip to source adress prefix --- ansible/roles/security_groups/vars/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index 720c3efc..c4824a62 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -54,11 +54,11 @@ sg_groups: access: Allow priority: 1002 direction: Inbound - source_address_prefix: "{{ groups['appserver'][0] }}/32" + source_address_prefix: "{{ groups['appserver'][2] }}/32" - name: MYSQL-appserver2 protocol: Tcp destination_port_range: 3306 access: Allow priority: 1003 direction: Inbound - source_address_prefix: "{{ groups['appserver'][1] }}/32" + source_address_prefix: "{{ groups['appserver'][3] }}/32" From 01986eb15355ca8ad38f29151a77f6e15f1be33c Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Wed, 22 Nov 2023 23:19:47 +0100 Subject: [PATCH 123/185] fix: removed unused code --- .../provision_instances/tasks/create_instance.yml | 10 ---------- ansible/roles/security_groups/tasks/main.yml | 4 ---- 2 files changed, 14 deletions(-) diff --git a/ansible/roles/provision_instances/tasks/create_instance.yml b/ansible/roles/provision_instances/tasks/create_instance.yml index 65cd4e48..e57e1d35 100644 --- a/ansible/roles/provision_instances/tasks/create_instance.yml +++ b/ansible/roles/provision_instances/tasks/create_instance.yml @@ -50,11 +50,6 @@ delegate_to: localhost when: iname == 'appserver1' -- name: Set fact for appserver1 IP - set_fact: - appserver1_ip: "{{ output_ip_address.state.ip_address }}" - when: iname == 'appserver1' - - name: Ensure "A" record for appserver2 azure_rm_dnsrecordset: resource_group: "{{ resource_group }}" @@ -67,11 +62,6 @@ delegate_to: localhost when: iname == 'appserver2' -- name: Set fact for appserver2 IP - set_fact: - appserver2_ip: "{{ output_ip_address.state.ip_address }}" - when: iname == 'appserver2' - - name: Ensure "A" records for database azure_rm_dnsrecordset: resource_group: "{{ resource_group }}" diff --git a/ansible/roles/security_groups/tasks/main.yml b/ansible/roles/security_groups/tasks/main.yml index f8c183fb..7b9c5db3 100755 --- a/ansible/roles/security_groups/tasks/main.yml +++ b/ansible/roles/security_groups/tasks/main.yml @@ -1,8 +1,4 @@ --- -- name: Debug groups - debug: - var: groups - - name: Network Security Group azure_rm_securitygroup: resource_group: "{{ resource_group }}" From a5a68d8c78f3b86b651cb5d857f6e8800cfc6276 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 26 Nov 2023 14:26:50 +0100 Subject: [PATCH 124/185] fix: Prettier update --- ansible/10-first-minutes.yml | 14 +++---- ansible/gather_instances.yml | 14 +++---- ansible/terminate_instances.yml | 70 +++++++++++++++------------------ 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/ansible/10-first-minutes.yml b/ansible/10-first-minutes.yml index a8f50fc9..c5e6f720 100644 --- a/ansible/10-first-minutes.yml +++ b/ansible/10-first-minutes.yml @@ -1,8 +1,8 @@ --- -- hosts: - - devops - remote_user: azureuser - become: yes - become_method: sudo - roles: - - 10-first-minutes +- hosts: + - devops + remote_user: azureuser + become: yes + become_method: sudo + roles: + - 10-first-minutes diff --git a/ansible/gather_instances.yml b/ansible/gather_instances.yml index d87e92be..f10ce324 100755 --- a/ansible/gather_instances.yml +++ b/ansible/gather_instances.yml @@ -1,9 +1,9 @@ # Gather facts from remote instance and set as hosts --- -- hosts: local - connection: local - gather_facts: False - collections: - - azure.azcollection - roles: - - gather_instances +- hosts: local + connection: local + gather_facts: False + collections: + - azure.azcollection + roles: + - gather_instances diff --git a/ansible/terminate_instances.yml b/ansible/terminate_instances.yml index cc496083..9c9fe275 100755 --- a/ansible/terminate_instances.yml +++ b/ansible/terminate_instances.yml @@ -1,40 +1,34 @@ # Gather EC2 facts from remote instance --- -- hosts: local - connection: local - gather_facts: False - collections: - - azure.azcollection - roles: - - gather_instances - - - -- name: remove instance - hosts: devops - connection: local - gather_facts: False - collections: - - azure.azcollection - roles: - - terminate_instances - - - -- name: remove networks - hosts: local - gather_facts: False - tasks: - - include_tasks: roles/terminate_instances/tasks/vnet.yml - collections: - - azure.azcollection - - - -- name: remove security groups - hosts: local - gather_facts: False - vars: - state: absent - roles: - - security_groups \ No newline at end of file +- hosts: local + connection: local + gather_facts: False + collections: + - azure.azcollection + roles: + - gather_instances + +- name: remove instance + hosts: devops + connection: local + gather_facts: False + collections: + - azure.azcollection + roles: + - terminate_instances + +- name: remove networks + hosts: local + gather_facts: False + tasks: + - include_tasks: roles/terminate_instances/tasks/vnet.yml + collections: + - azure.azcollection + +- name: remove security groups + hosts: local + gather_facts: False + vars: + state: absent + roles: + - security_groups From 1291057850fc826180110f23dabc01e9d6166ebe Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 26 Nov 2023 14:27:03 +0100 Subject: [PATCH 125/185] fix: Changed to use j2 for sshd_config --- .../roles/10-first-minutes/files/{sshd_config => sshd_config.j2} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ansible/roles/10-first-minutes/files/{sshd_config => sshd_config.j2} (100%) diff --git a/ansible/roles/10-first-minutes/files/sshd_config b/ansible/roles/10-first-minutes/files/sshd_config.j2 similarity index 100% rename from ansible/roles/10-first-minutes/files/sshd_config rename to ansible/roles/10-first-minutes/files/sshd_config.j2 From 72d627a862096edce0696fa978cdc7f518ef4402 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 26 Nov 2023 14:27:57 +0100 Subject: [PATCH 126/185] fix: Prettrier --- ansible/roles/10-first-minutes/handlers/main.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ansible/roles/10-first-minutes/handlers/main.yml b/ansible/roles/10-first-minutes/handlers/main.yml index 3deff158..ed1e418b 100644 --- a/ansible/roles/10-first-minutes/handlers/main.yml +++ b/ansible/roles/10-first-minutes/handlers/main.yml @@ -1,5 +1,10 @@ --- -- name: restart ssh - service: - name: ssh - state: restarted \ No newline at end of file +- name: restart ssh + service: + name: ssh + state: restarted + +- name: restart sshd + service: + name: sshd + state: restarted From 5513ff1684bb4a997e74f3494bb3243b6af4ee10 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 26 Nov 2023 14:28:14 +0100 Subject: [PATCH 127/185] build: Fixed the 10-firt-minutes Changed order when the replace sshd_config runs. Instead of change im now deleting and adding the new config to be sure it overwrites. Outcommented flush handlers, not needed anymore bcs we restart the ssh in access. --- ansible/roles/10-first-minutes/tasks/main.yml | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/ansible/roles/10-first-minutes/tasks/main.yml b/ansible/roles/10-first-minutes/tasks/main.yml index 116815e3..d39166a2 100644 --- a/ansible/roles/10-first-minutes/tasks/main.yml +++ b/ansible/roles/10-first-minutes/tasks/main.yml @@ -51,6 +51,18 @@ state: present validate: "/usr/sbin/visudo -cf %s" # kan få fel med line "{{ server_user }} testing" +- name: Remove sshd_config + file: + path: /etc/ssh/sshd_config + state: absent + +- name: Add new sshd config + template: + src: files/sshd_config.j2 + dest: /etc/ssh/sshd_config + notify: + - restart ssh + - name: Disallow root and password ssh access lineinfile: path: /etc/ssh/sshd_config @@ -65,18 +77,8 @@ line: "PermitRootLogin no" notify: restart ssh -- name: Replace sshd_config with Mozilla Modern Configuration - copy: - src: ../files/sshd_config - dest: /etc/ssh/sshd_config - owner: root - group: root - mode: "0644" - notify: restart ssh - -- name: flush handlers to restart SSH - meta: flush_handlers # we cant do it later because after this we cant ssh as root - +#- name: flush handlers to restart SSH +# meta: flush_handlers # we cant do it later because after this we cant ssh as root - name: Only allow user to ssh lineinfile: path: /etc/ssh/sshd_config From 08bd1242c7696edd8599bb9dfe9f0e84e509497b Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Sun, 26 Nov 2023 19:10:33 +0100 Subject: [PATCH 128/185] docs: Changelog 2.1.0 and 3.0.0 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1133b3d..a853cd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _No unreleased changes at this time._ +## [3.0.0] - 2023-11-26 + +- **Fix:** Prettier update [PR 48](https://github.com/FalkenDev/microblog/pull/48) +- **Fix:** Changed to use j2 for sshd_config [PR 48](https://github.com/FalkenDev/microblog/pull/48) +- **Fix:** Prettrier [PR 48](https://github.com/FalkenDev/microblog/pull/48) +- **Build:** Fixed the 10-firt-minutes [PR 48](https://github.com/FalkenDev/microblog/pull/48) + +## [2.1.0] - 2023-11-22 + +- **Fix:** Added gather instances to run before security groups to get the ips [PR 47](https://github.com/FalkenDev/microblog/pull/47) +- **Fix:** Fixed the appserver ip to source adress prefix [PR 47](https://github.com/FalkenDev/microblog/pull/47) +- **Fix:** removed unused code [PR 47](https://github.com/FalkenDev/microblog/pull/47) +- **Feat:** replaces sshd on the loadbalancer vm [PR 46](https://github.com/FalkenDev/microblog/pull/46) +- **Feat:** new sshd config file follwing mozillas modern configuration [PR 46](https://github.com/FalkenDev/microblog/pull/46) +- **Build:** Getting the latest tag from github to the docker image tag in appserver ansible [PR 45](https://github.com/FalkenDev/microblog/pull/45) +- **Fix:** Cookie Without Secure Flag [10011] [PR 45](https://github.com/FalkenDev/microblog/pull/45) +- **Fix:** Missing Anti-clickjacking Header [10020] [PR 45](https://github.com/FalkenDev/microblog/pull/45) +- **Fix:** X-Content-Type-Options Header Missing [10021] [PR 45](https://github.com/FalkenDev/microblog/pull/45) +- **Fix:** Strict-Transport-Security Header Not Set [10035] [PR 45](https://github.com/FalkenDev/microblog/pull/45) +- **Fix:** add -- before scanners in trivy-test make command [PR 43](https://github.com/FalkenDev/microblog/pull/43) +- **Fix:** Update werkzeug to 2.2.8 [PR 44](https://github.com/FalkenDev/microblog/pull/44) +- **Fix:** changed crlf to lf on dockerfile_prod [PR 42](https://github.com/FalkenDev/microblog/pull/42) +- **Fix:** Upgrade pip, setuptools, openssl to fix security issues [PR 41](https://github.com/FalkenDev/microblog/pull/41) +- **Build:** New trivy-test make command to run docker build and trivy testing of image and repo. [PR 41](https://github.com/FalkenDev/microblog/pull/41) +- **Test:** Added dockle-test in makefile [PR 40](https://github.com/FalkenDev/microblog/pull/40) +- **CI:** Added dockle run [PR 40](https://github.com/FalkenDev/microblog/pull/40) +- **CI:** Added docker_content_trust [PR 40](https://github.com/FalkenDev/microblog/pull/40) +- **Build:** Added healthcheck for Dockerfile_prod [PR 40](https://github.com/FalkenDev/microblog/pull/40) +- **Refactor:** Updated connection permissions [PR 39](https://github.com/FalkenDev/microblog/pull/39) +- **Refactor:** Moved the creation of security groups [PR 39](https://github.com/FalkenDev/microblog/pull/39) +- **Build:** Added a script in Makefile to run the bandit. Skipping hashlib (B324) as it's false positive. [PR 38](https://github.com/FalkenDev/microblog/pull/38) +- **Build:** Added bandit to requirments in test requirments. [PR 38](https://github.com/FalkenDev/microblog/pull/38) +- **CI:** Added CI Workflow for test BTD (Bandit, Trivy and Dockle) [PR 38](https://github.com/FalkenDev/microblog/pull/38) +- **CI/CD:** Changed docker-publish CD workflow to run and pass the CI BTD (Bandit, Trivy and Dockle) before publish [PR 38](https://github.com/FalkenDev/microblog/pull/38) + ## [2.0.0] - 2023-11-20 - **Branch:** Push development branch to master branch to reflect kmom02 is done [PR 36](https://github.com/FalkenDev/microblog/pull/36) From 914c4717ab60164bb6f29f7546ae0067ad409839 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 28 Nov 2023 13:18:33 +0100 Subject: [PATCH 129/185] build: Add docker-install role to loadbalancer --- ansible/load_balancer.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ansible/load_balancer.yml b/ansible/load_balancer.yml index 492e38dd..7e92f3d8 100644 --- a/ansible/load_balancer.yml +++ b/ansible/load_balancer.yml @@ -1,10 +1,11 @@ --- - hosts: - - loadbalancer + - loadbalancer pre_tasks: - - raw: apt-get install -y python-simplejson + - raw: apt-get install -y python-simplejson remote_user: deploy become: yes become_method: sudo roles: - - load_balancer \ No newline at end of file + - docker-install + - load_balancer From 8df331f2ec6692d5c4a35814e4f76946e8497cc3 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 28 Nov 2023 13:19:08 +0100 Subject: [PATCH 130/185] build: Check cert before create, start prometheus exporter --- ansible/roles/load_balancer/tasks/main.yml | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index 4f69ed01..37ac8afe 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -14,8 +14,14 @@ - name: Install certbot apt: name=python-certbot-nginx state=latest +- name: Check if cert is already present + stat: + path: /etc/letsencrypt/live/{{ domain_name }}/cert.pem + register: cert_installed + - name: Install cert shell: "certbot certonly --nginx --noninteractive --expand --agree-tos --email {{ admin_email }} -d {{ domain_name }} -d www.{{ domain_name }}" + when: not cert_installed.stat.exists - name: Remove default nginx config file: name=/etc/nginx/sites-enabled/default state=absent @@ -39,4 +45,19 @@ - name: Restart nginx service: name: nginx - state: restarted \ No newline at end of file + state: restarted + +- name: Run the nginx-prometheus-exporter container + docker_container: + name: nginx-prometheus-exporter + image: nginx/nginx-prometheus-exporter:0.4.2 + restart_policy: always + state: started + ports: + - "9113:9113" + env: + nginx_scrape_uri: "https://{{ domain_name }}/metrics" + nginx_retries: "10" + nginx_ssl_verify: "false" + web_telemetry_path: "/prometheus" + command: "--nginx.scrape-uri=https://{{ domain_name }}/metrics --nginx.retries=10 --nginx.ssl-verify=false --web.telemetry-path=/prometheus" From 7e958203bde5ea078603246e470eb4a33b5741a7 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 28 Nov 2023 13:20:10 +0100 Subject: [PATCH 131/185] chore: Enable nginx metrics --- .../tasks/templates/load-balancer.conf.j2 | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 index beb0f3bf..fd41593f 100644 --- a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 +++ b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 @@ -1,4 +1,4 @@ -http { + http { upstream app-hosts { {{ lb_method }}; {% for appserver_item in groups['appserver'] %} @@ -13,29 +13,32 @@ http { # Notice that the upstream name and the proxy_pass need to match. server { - listen 80; - server_name {{ domain_name }} www.{{ domain_name }}; - return 301 https://$server_name$request_uri; + listen 80; + server_name {{ domain_name }} www.{{ domain_name }}; + return 301 https://$server_name$request_uri; - #location / { - # proxy_pass http://app-hosts; - #} + #location / { + # proxy_pass http://app-hosts; + #} } server { - listen 443 ssl; - server_name {{ domain_name }} www.{{ domain_name }}; + listen 443 ssl; + server_name {{ domain_name }} www.{{ domain_name }}; - ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/cert.pem; - ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/cert.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - proxy_cookie_path / "/; HTTPOnly; Secure"; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + proxy_cookie_path / "/; HTTPOnly; Secure"; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - location / { - proxy_pass http://app-hosts; - } + location / { + proxy_pass http://app-hosts; + } + location /metrics { + stub_status on; + } } } \ No newline at end of file From 10511341525a7091b0ded6afb01d9def29316a16 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:44:17 +0100 Subject: [PATCH 132/185] build: Added monitoring to the build Ansible code that installs and starts Prometheus, Grafana and Alertmanager on the monitoring vm --- ansible/monitoring.yml | 10 ++++ ansible/roles/monitoring/tasks/main.yml | 62 +++++++++++++++++++++++++ ansible/roles/monitoring/vars/main.yml | 0 3 files changed, 72 insertions(+) create mode 100644 ansible/monitoring.yml create mode 100644 ansible/roles/monitoring/tasks/main.yml create mode 100644 ansible/roles/monitoring/vars/main.yml diff --git a/ansible/monitoring.yml b/ansible/monitoring.yml new file mode 100644 index 00000000..bea0fd99 --- /dev/null +++ b/ansible/monitoring.yml @@ -0,0 +1,10 @@ +--- +- hosts: monitoring + vars: + ansible_python_interpreter: /usr/bin/python3 + remote_user: deploy + become: yes + become_method: sudo + roles: + - docker-install + - monitoring diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml new file mode 100644 index 00000000..a36402f4 --- /dev/null +++ b/ansible/roles/monitoring/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Install Docker-compose + apt: + name: docker-compose + state: latest + update_cache: yes + +- name: Remove image + docker_image: + name: prometheus + state: absent + force_absent: yes + +- name: Remove image + docker_image: + name: deploy_grafana_1 + state: absent + force_absent: yes + +- name: Remove image + docker_image: + name: deploy_alertmanager_1 + state: absent + force_absent: yes + +- name: copy docker-compose.yml to vm + ansible.builtin.copy: + src: ../files/docker-compose.yml + dest: /home/deploy/docker-compose.yml + +- name: copy Prometheus config to vm + ansible.builtin.copy: + src: ../files/prometheus.yml + dest: /home/deploy/prometheus.yml + +- name: copy Prometheus rules to vm + ansible.builtin.copy: + src: ../files/rules.yml + dest: /home/deploy/rules.yml + +- name: copy Alertmanager config to vm + ansible.builtin.copy: + src: ../files/alertmanager.yml + dest: /home/deploy/alertmanager.yml + +- name: Start Prometheus, Grafana and Alertmanager + ansible.builtin.command: + cmd: docker-compose up -d + chdir: /home/deploy + become: yes + +- name: Add Prometheus as datasource in Grafana + community.grafana.grafana_datasource: + ds_type: prometheus + ds_url: http://0.0.0.0:9090 + name: Prometheus + grafana_url: http://0.0.0.0:3000 + access: proxy + is_default: true + grafana_user: admin + grafana_password: admin + state: present diff --git a/ansible/roles/monitoring/vars/main.yml b/ansible/roles/monitoring/vars/main.yml new file mode 100644 index 00000000..e69de29b From b4bd11c7cc18dc228ee7bd01fbe3a5ee01deb9ea Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 13:44:18 +0100 Subject: [PATCH 133/185] feat: Added gunicorn config Added gunicorn config --- docker/Dockerfile_prod | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile_prod b/docker/Dockerfile_prod index e2431cd9..14c79838 100644 --- a/docker/Dockerfile_prod +++ b/docker/Dockerfile_prod @@ -13,10 +13,11 @@ COPY app app COPY migrations migrations COPY requirements requirements COPY requirements.txt microblog.py boot.sh ./ +COPY gunicorn_config.py gunicorn_config.py # Update the package manager and install necessary dependencies RUN apk update && \ - apk upgrade && \ - apk add --no-cache gcc musl-dev linux-headers + apk upgrade && \ + apk add --no-cache gcc musl-dev linux-headers RUN pip install --upgrade pip setuptools @@ -36,5 +37,7 @@ ENV FLASK_APP microblog.py USER microblog +ENV PROMETHEUS_MULTIPROC_DIR /tmp + EXPOSE 8000 ENTRYPOINT ["./boot.sh"] \ No newline at end of file From 7877cb8280a7834d2c32582afa66e5bc679c3662 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 13:46:12 +0100 Subject: [PATCH 134/185] feat: Configuration for gunicorn Python file for gunicorn configuration, methods for when ready and exit --- gunicorn_config.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 gunicorn_config.py diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 00000000..66319ae8 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,7 @@ +from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics + +def when_ready(server): + GunicornPrometheusMetrics.start_http_server_when_ready(8080) + +def child_exit(server, worker): + GunicornPrometheusMetrics.mark_process_dead_on_child_exit(worker.pid) \ No newline at end of file From c08af3ea45f132b4e1d6714f109e69040fb6a842 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:46:21 +0100 Subject: [PATCH 135/185] build: Docker-compose file to run the Prometheus, Grafana and Alertmanager --- .../roles/monitoring/files/docker-compose.yml | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 ansible/roles/monitoring/files/docker-compose.yml diff --git a/ansible/roles/monitoring/files/docker-compose.yml b/ansible/roles/monitoring/files/docker-compose.yml new file mode 100644 index 00000000..cb493c4b --- /dev/null +++ b/ansible/roles/monitoring/files/docker-compose.yml @@ -0,0 +1,69 @@ +version: "3" + +networks: + monitor: + +volumes: + prometheus-data: {} + grafana-data: {} + +services: + node-exporter: + image: prom/node-exporter:v1.2.2 + container_name: node-exporter + restart: unless-stopped + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.rootfs=/rootfs" + - "--path.sysfs=/host/sys" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + ports: + - "9100:9100" + networks: + - monitor + + prometheus: + image: prom/prometheus + container_name: prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + - ./rules.yml:/etc/prometheus/rules.yml" + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/etc/prometheus/console_libraries" + - "--web.console.templates=/etc/prometheus/consoles" + - "--storage.tsdb.retention.time=24h" + - "--web.enable-lifecycle" + networks: + - monitor + + grafana: + image: grafana/grafana + restart: unless-stopped + volumes: + - grafana-data:/var/lib/grafana + networks: + - monitor + ports: + - "3000:3000" + + alertmanager: + image: prom/alertmanager + restart: unless-stopped + ports: + - "9093:9093" + volumes: + - "./alertmanager.yml:/config/alertmanager.yml" + command: + - "--config.file=/config/alertmanager.yml" + networks: + - monitor From 796495b3912aa404f9fc0dcacb138ecd4ef0e596 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:47:04 +0100 Subject: [PATCH 136/185] config: Added alertmanager condig --- ansible/roles/monitoring/files/alertmanager.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 ansible/roles/monitoring/files/alertmanager.yml diff --git a/ansible/roles/monitoring/files/alertmanager.yml b/ansible/roles/monitoring/files/alertmanager.yml new file mode 100644 index 00000000..b1da479b --- /dev/null +++ b/ansible/roles/monitoring/files/alertmanager.yml @@ -0,0 +1,7 @@ +route: + receiver: "webhook_receiver" +receivers: + - name: "webhook_receiver" + webhook_configs: + - url: "https://webhook.site/cdc2e956-05cc-435d-b8fb-0fd7df91038e" + send_resolved: false From faf5d130834aacdec3084812e480676de6a09a08 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:47:38 +0100 Subject: [PATCH 137/185] build: Added prometheus condig for exporter --- ansible/roles/monitoring/files/prometheus.yml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ansible/roles/monitoring/files/prometheus.yml diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml new file mode 100644 index 00000000..a5b7015a --- /dev/null +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -0,0 +1,28 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "node" + static_configs: + - targets: ["node-exporter:9100"] + - job_name: "prometheus" + static_configs: + - targets: ["0.0.0.0:9090"] + - job_name: "flask" + static_configs: + - targets: ["prod:8000"] + labels: + instance: "flask" + - job_name: nginx + metrics_path: /prometheus + scrape_interval: 30s + static_configs: + - targets: ["{{ domain_name }}:9113"] + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - "rules.yml" From 635d3a768fcee451892dbe327b67cfd36e5829cd Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 13:47:41 +0100 Subject: [PATCH 138/185] feat: New requirement Adds prometheus pip package --- requirements/prod.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/prod.txt b/requirements/prod.txt index 366c5553..33550d9b 100755 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -8,4 +8,5 @@ Flask-SQLAlchemy==2.5.1 Flask-WTF==1.2.1 email_validator==1.1.3 python-dotenv==0.19.1 -Werkzeug==2.3.8 \ No newline at end of file +Werkzeug==2.3.8 +prometheus-flask-exporter==0.23.0 \ No newline at end of file From 29a497c9e32d33637ed62b6c3d42f21e706bbc8f Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:48:06 +0100 Subject: [PATCH 139/185] build: Added prometheus rules --- ansible/roles/monitoring/files/rules.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ansible/roles/monitoring/files/rules.yml diff --git a/ansible/roles/monitoring/files/rules.yml b/ansible/roles/monitoring/files/rules.yml new file mode 100644 index 00000000..cf1fa8d7 --- /dev/null +++ b/ansible/roles/monitoring/files/rules.yml @@ -0,0 +1,6 @@ +groups: + - name: More get request than 20 + rules: + - alert: Between20And25 + expr: flask_http_request_total{method="get", status="200"} > 20 and flask_http_request_total{method="get", status="200"} < 25 + for: 10s From 8798a26cd74873067ea49dafbdc4c6d23a549eb4 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 13:48:34 +0100 Subject: [PATCH 140/185] Updated port --- gunicorn_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gunicorn_config.py b/gunicorn_config.py index 66319ae8..97760921 100644 --- a/gunicorn_config.py +++ b/gunicorn_config.py @@ -1,7 +1,7 @@ from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics def when_ready(server): - GunicornPrometheusMetrics.start_http_server_when_ready(8080) + GunicornPrometheusMetrics.start_http_server_when_ready(8000) def child_exit(server, worker): GunicornPrometheusMetrics.mark_process_dead_on_child_exit(worker.pid) \ No newline at end of file From 94f107b36e8427584463d29c61dbf5a63cd54049 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:48:50 +0100 Subject: [PATCH 141/185] build: Added monitoring as a new vm --- ansible/roles/provision_instances/vars/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ansible/roles/provision_instances/vars/main.yml b/ansible/roles/provision_instances/vars/main.yml index 58ae95de..3856a168 100644 --- a/ansible/roles/provision_instances/vars/main.yml +++ b/ansible/roles/provision_instances/vars/main.yml @@ -8,6 +8,8 @@ instances: type: database - name: loadbalancer type: loadbalancer + - name: monitoring + type: monitoring relative_names: - name: www # www. From d35cd6540a406fbc33d6fdae3decef956982f66c Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 13:48:53 +0100 Subject: [PATCH 142/185] feat: updated boot.sh Runs gunicorn config --- boot.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boot.sh b/boot.sh index 20f9e032..e0057494 100644 --- a/boot.sh +++ b/boot.sh @@ -9,4 +9,4 @@ while true; do echo Upgrade command failed, retrying in 5 secs... sleep 5 done -exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app \ No newline at end of file +exec gunicorn -b :5000 --access-logfile - --error-logfile - -c gunicorn_config.py microblog:app \ No newline at end of file From e366d8f05a16be93d9e5102ea7e786a76f39ba60 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 13:49:15 +0100 Subject: [PATCH 143/185] build: Added monitoring to the security groups --- ansible/roles/security_groups/vars/main.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index c4824a62..fe7c0754 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -62,3 +62,19 @@ sg_groups: priority: 1003 direction: Inbound source_address_prefix: "{{ groups['appserver'][3] }}/32" + - name: monitoring + port_rules: + - name: APP + protocol: Tcp + destination_port_range: 22 + access: Allow + priority: 1001 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: HTTP + protocol: Tcp + destination_port_range: 8000 + access: Allow + priority: 1002 + direction: Inbound + source_address_prefix: "0.0.0.0/0" From bc833cbe0cbaf65fc5e8af69cd90a516f63a4fc0 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 28 Nov 2023 14:06:15 +0100 Subject: [PATCH 144/185] chore: Reverse proxy for grafana --- .../load_balancer/tasks/templates/load-balancer.conf.j2 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 index fd41593f..8ddae676 100644 --- a/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 +++ b/ansible/roles/load_balancer/tasks/templates/load-balancer.conf.j2 @@ -40,5 +40,13 @@ location /metrics { stub_status on; } + location /grafana/ { + proxy_pass http://{{ groups['monitoring'][0] }}:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } } } \ No newline at end of file From 8dcbe5d6145a4f5f74a588f1167dc6ed28ef2515 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 16:15:19 +0100 Subject: [PATCH 145/185] fix: Removed unused citation " --- ansible/roles/monitoring/files/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible/roles/monitoring/files/docker-compose.yml b/ansible/roles/monitoring/files/docker-compose.yml index cb493c4b..d050fd7a 100644 --- a/ansible/roles/monitoring/files/docker-compose.yml +++ b/ansible/roles/monitoring/files/docker-compose.yml @@ -35,7 +35,7 @@ services: volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus - - ./rules.yml:/etc/prometheus/rules.yml" + - ./rules.yml:/etc/prometheus/rules.yml command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" @@ -51,6 +51,7 @@ services: restart: unless-stopped volumes: - grafana-data:/var/lib/grafana + - ./grafana.ini:/etc/grafana/grafana.ini networks: - monitor ports: From 8426512119732afe286ef21553aa1c31b678c1e5 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 16:15:49 +0100 Subject: [PATCH 146/185] build: Added Grfana initials --- ansible/roles/monitoring/files/grafana.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 ansible/roles/monitoring/files/grafana.ini diff --git a/ansible/roles/monitoring/files/grafana.ini b/ansible/roles/monitoring/files/grafana.ini new file mode 100644 index 00000000..52ef2d0d --- /dev/null +++ b/ansible/roles/monitoring/files/grafana.ini @@ -0,0 +1,3 @@ +[server] +domain = devopsbth.tech +root_url = %(protocol)s://%(domain)s/grafana/ \ No newline at end of file From dd34dbbfd520b18a7d6842e83a3f1a22d96b31e5 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 16:16:21 +0100 Subject: [PATCH 147/185] build: Changed to use the vms ips --- ansible/roles/monitoring/files/prometheus.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index a5b7015a..2300613e 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -4,20 +4,22 @@ global: scrape_configs: - job_name: "node" static_configs: - - targets: ["node-exporter:9100"] + - targets: ["{{ groups['monitoring'][0] }}:9100"] - job_name: "prometheus" static_configs: - - targets: ["0.0.0.0:9090"] + - targets: ["{{ groups['monitoring'][0] }}:9090"] - job_name: "flask" static_configs: - - targets: ["prod:8000"] - labels: - instance: "flask" + - targets: + - "{{ groups['appserver'][2] }}:8000" + - "{{ groups['appserver'][3] }}:8000" + labels: + instance: "flask" - job_name: nginx metrics_path: /prometheus scrape_interval: 30s static_configs: - - targets: ["{{ domain_name }}:9113"] + - targets: ["{{ groups['loadbalancer'][0] }}:9113"] alerting: alertmanagers: From 71a50a2cca893e0b75b6cdb6573a37bdaa2460aa Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 16:17:22 +0100 Subject: [PATCH 148/185] build: Bugging --- ansible/roles/monitoring/tasks/main.yml | 46 ++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml index a36402f4..e0217a92 100644 --- a/ansible/roles/monitoring/tasks/main.yml +++ b/ansible/roles/monitoring/tasks/main.yml @@ -5,58 +5,64 @@ state: latest update_cache: yes -- name: Remove image +- name: Remove image prometheus docker_image: name: prometheus state: absent force_absent: yes -- name: Remove image +- name: Remove image deploy_grafana_1 docker_image: name: deploy_grafana_1 state: absent force_absent: yes -- name: Remove image +- name: Remove image deploy_alertmanager_1 docker_image: name: deploy_alertmanager_1 state: absent force_absent: yes +- name: Remove image node-exporter + docker_image: + name: node-exporter + state: absent + force_absent: yes + - name: copy docker-compose.yml to vm - ansible.builtin.copy: - src: ../files/docker-compose.yml + ansible.builtin.template: + src: files/docker-compose.yml dest: /home/deploy/docker-compose.yml - name: copy Prometheus config to vm - ansible.builtin.copy: - src: ../files/prometheus.yml + ansible.builtin.template: + src: files/prometheus.yml dest: /home/deploy/prometheus.yml - name: copy Prometheus rules to vm - ansible.builtin.copy: - src: ../files/rules.yml + ansible.builtin.template: + src: files/rules.yml dest: /home/deploy/rules.yml - name: copy Alertmanager config to vm - ansible.builtin.copy: - src: ../files/alertmanager.yml + ansible.builtin.template: + src: files/alertmanager.yml dest: /home/deploy/alertmanager.yml +- name: copy grafana initals to vm + ansible.builtin.template: + src: files/grafana.ini + dest: /home/deploy/grafana.ini + - name: Start Prometheus, Grafana and Alertmanager ansible.builtin.command: cmd: docker-compose up -d chdir: /home/deploy become: yes -- name: Add Prometheus as datasource in Grafana +- name: Prometheus as data source community.grafana.grafana_datasource: + name: prometheus + url: http://0.0.0.0:3000 ds_type: prometheus - ds_url: http://0.0.0.0:9090 - name: Prometheus - grafana_url: http://0.0.0.0:3000 - access: proxy - is_default: true - grafana_user: admin - grafana_password: admin - state: present + ds_url: http://prometheus:9090 From 22d973ed96835c0d71d0845d7aff23eb31c4df62 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 16:46:11 +0100 Subject: [PATCH 149/185] build: Fixed security ports --- ansible/roles/security_groups/vars/main.yml | 27 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index fe7c0754..19d8539c 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -64,17 +64,38 @@ sg_groups: source_address_prefix: "{{ groups['appserver'][3] }}/32" - name: monitoring port_rules: - - name: APP + - name: SSH protocol: Tcp destination_port_range: 22 access: Allow priority: 1001 direction: Inbound source_address_prefix: "0.0.0.0/0" - - name: HTTP + - name: prometheus protocol: Tcp - destination_port_range: 8000 + destination_port_range: 9090 access: Allow priority: 1002 direction: Inbound source_address_prefix: "0.0.0.0/0" + - name: alertmanager + protocol: Tcp + destination_port_range: 9093 + access: Allow + priority: 1003 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: grafana + protocol: Tcp + destination_port_range: 3000 + access: Allow + priority: 1004 + direction: Inbound + source_address_prefix: "0.0.0.0/0" + - name: node-exporter + protocol: Tcp + destination_port_range: 9100 + access: Allow + priority: 1005 + direction: Inbound + source_address_prefix: "0.0.0.0/0" From a410e2fc6f21338b0547e9a726046046cadb2840 Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Tue, 28 Nov 2023 17:39:18 +0100 Subject: [PATCH 150/185] feat: __init__.py Added GunicornPrometheusMetrics to app --- app/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index dc4feb1b..fff42f46 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,10 +12,11 @@ from flask_login import LoginManager from flask_moment import Moment from flask_bootstrap import Bootstrap +from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics from app.config import ProdConfig, RequestFormatter - +metrics = GunicornPrometheusMetrics.for_app_factory() db = SQLAlchemy() migrate = Migrate() login = LoginManager() @@ -33,6 +34,7 @@ def create_app(config_class=ProdConfig): app = Flask(__name__) app.config.from_object(config_class) + metrics.init_app(app) db.init_app(app) migrate.init_app(app, db) login.init_app(app) From 23528080138200b7ad17a168637077624b33327c Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 17:46:18 +0100 Subject: [PATCH 151/185] Added port for exporter in load balancer --- ansible/roles/security_groups/vars/main.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index 19d8539c..99393af3 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -23,6 +23,13 @@ sg_groups: priority: 1003 direction: Inbound source_address_prefix: "0.0.0.0/0" + - name: EXPORTER + protocol: Tcp + destination_port_range: 9113 + access: Allow + priority: 1004 + direction: Inbound + source_address_prefix: "0.0.0.0/0" - name: appserver port_rules: - name: APP From 2404e9320d38b7abbd3301ba8d93dd09b2ff2478 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 18:18:26 +0100 Subject: [PATCH 152/185] fix: Adding the PROMETHEUS_MULTIPROC_DIR to the test dockerfile --- docker/Dockerfile_test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/Dockerfile_test b/docker/Dockerfile_test index c4099c4c..6ad20bc8 100644 --- a/docker/Dockerfile_test +++ b/docker/Dockerfile_test @@ -19,4 +19,6 @@ EOF ENV PATH="/home/microblog/.venv/bin:$PATH" +ENV PROMETHEUS_MULTIPROC_DIR /tmp + ENTRYPOINT ["./run_tests.sh"] \ No newline at end of file From d156d3f44294103f09864f27048b66faad5e217a Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 18:40:00 +0100 Subject: [PATCH 153/185] fix: Fixing pytest unit --- .github/workflows/python-ci.yml | 2 ++ requirements/test.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 505ddd98..3963c127 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -31,6 +31,8 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Set Environment Variable for Prometheus + run: echo "PROMETHEUS_MULTIPROC_DIR=/tmp" >> $GITHUB_ENV - name: Run unit tests run: | pytest tests/unit diff --git a/requirements/test.txt b/requirements/test.txt index 8e9658a6..9ab867ff 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,3 +4,4 @@ pytest >= 6.1.2 coverage~=4.5.4 bandit >= 1.7.5 Werkzeug==2.3.8 +prometheus-flask-exporter==0.23.0 \ No newline at end of file From 12b2c881246da6de17afa249babea8c71109abcf Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 22:53:15 +0100 Subject: [PATCH 154/185] fix: Using internal instead of without --- gunicorn_config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gunicorn_config.py b/gunicorn_config.py index 97760921..4d190bfe 100644 --- a/gunicorn_config.py +++ b/gunicorn_config.py @@ -1,7 +1,6 @@ -from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics +from prometheus_flask_exporter.multiprocess import GunicornInternalPrometheusMetrics -def when_ready(server): - GunicornPrometheusMetrics.start_http_server_when_ready(8000) def child_exit(server, worker): - GunicornPrometheusMetrics.mark_process_dead_on_child_exit(worker.pid) \ No newline at end of file + GunicornInternalPrometheusMetrics.mark_process_dead_on_child_exit( + worker.pid) From 5d1184c33ddeb93601030309384c62d174c01647 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 22:53:38 +0100 Subject: [PATCH 155/185] fix: Using internal instead of without --- app/__init__.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index fff42f46..a37881b6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,11 +12,11 @@ from flask_login import LoginManager from flask_moment import Moment from flask_bootstrap import Bootstrap -from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics +from prometheus_flask_exporter.multiprocess import GunicornInternalPrometheusMetrics from app.config import ProdConfig, RequestFormatter -metrics = GunicornPrometheusMetrics.for_app_factory() +metrics = GunicornInternalPrometheusMetrics.for_app_factory() db = SQLAlchemy() migrate = Migrate() login = LoginManager() @@ -26,7 +26,6 @@ moment = Moment() - def create_app(config_class=ProdConfig): """ Create flask app, init addons, blueprints and setup logging @@ -40,9 +39,8 @@ def create_app(config_class=ProdConfig): login.init_app(app) moment.init_app(app) bootstrap.init_app(app) - - #pylint: disable=wrong-import-position, cyclic-import, import-outside-toplevel + # pylint: disable=wrong-import-position, cyclic-import, import-outside-toplevel from app.errors import bp as errors_bp app.register_blueprint(errors_bp) @@ -51,8 +49,7 @@ def create_app(config_class=ProdConfig): from app.main import bp as main_bp app.register_blueprint(main_bp) - #pylint: enable=wrong-import-position, cyclic-import, import-outside-toplevel - + # pylint: enable=wrong-import-position, cyclic-import, import-outside-toplevel if not app.debug and not app.testing: formatter = RequestFormatter( @@ -63,5 +60,5 @@ def create_app(config_class=ProdConfig): return app - -from app import models #pylint: disable=wrong-import-position, cyclic-import, import-outside-toplevel + +from app import models # pylint: disable=wrong-import-position, cyclic-import, import-outside-toplevel \ No newline at end of file From 912a17a23c8586a85759afc26b1f5e8bf64b73d9 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 28 Nov 2023 22:54:10 +0100 Subject: [PATCH 156/185] fix: Removed alredy existing pip installs --- requirements/test.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/requirements/test.txt b/requirements/test.txt index 9ab867ff..341d4d10 100755 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,6 +2,4 @@ pylint >= 2.15.4 pytest >= 6.1.2 coverage~=4.5.4 -bandit >= 1.7.5 -Werkzeug==2.3.8 -prometheus-flask-exporter==0.23.0 \ No newline at end of file +bandit >= 1.7.5 \ No newline at end of file From d631201e1bac8f14f7354d0c829a8fcc682b9cfd Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 30 Nov 2023 10:15:22 +0100 Subject: [PATCH 157/185] build: Add node exporter to database vm --- ansible/roles/monitoring/files/prometheus.yml | 4 ++++ ansible/roles/mysql-docker/tasks/main.yml | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index 2300613e..24540b74 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -20,6 +20,10 @@ scrape_configs: scrape_interval: 30s static_configs: - targets: ["{{ groups['loadbalancer'][0] }}:9113"] + - job_name: mysql-node + metrics_path: /mysql-node + static_configs: + - targets: ["{{ groups['database'][0]:9100 }}"] alerting: alertmanagers: diff --git a/ansible/roles/mysql-docker/tasks/main.yml b/ansible/roles/mysql-docker/tasks/main.yml index 5c91dec8..edc4c9bc 100644 --- a/ansible/roles/mysql-docker/tasks/main.yml +++ b/ansible/roles/mysql-docker/tasks/main.yml @@ -13,3 +13,20 @@ restart_policy: always published_ports: - "3306:3306" + +- name: Start Node Exporter + docker_container: + image: prom/node-exporter:v1.2.2 + name: node_exporter + published_ports: + - "9100:9100" + restart_policy: unless-stopped + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.rootfs=/rootfs" + - "--path.sysfs=/host/sys" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" From 49107780ba1b2fbad1a7eca8f0ed6f7e2e55ba47 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 10:18:27 +0100 Subject: [PATCH 158/185] feat: Added new alarms - High cpu usage and High memory --- ansible/roles/monitoring/files/rules.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ansible/roles/monitoring/files/rules.yml b/ansible/roles/monitoring/files/rules.yml index cf1fa8d7..e8b6c66b 100644 --- a/ansible/roles/monitoring/files/rules.yml +++ b/ansible/roles/monitoring/files/rules.yml @@ -4,3 +4,25 @@ groups: - alert: Between20And25 expr: flask_http_request_total{method="get", status="200"} > 20 and flask_http_request_total{method="get", status="200"} < 25 for: 10s + + - name: High cpu usage + rules: + - alert: HighCpuUsage + expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 + for: 5m + labels: + severity: critical + annotations: + summary: High CPU usage detected + description: "CPU usage is above 80% for more than 5 minutes " + + - name: High memory + rules: + - alert: HighMemoryUsage + expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 80 + for: 5m + labels: + severity: critical + annotations: + summary: High memory usage detected + description: "Memory usage is above 80% for more than 5 minutes" From 2b30601ee77f695b3ec4ab1158426c2c9069abaa Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 10:19:24 +0100 Subject: [PATCH 159/185] feat: Added flash dashboard, node and nginx --- ansible/roles/monitoring/tasks/main.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml index e0217a92..464e31a7 100644 --- a/ansible/roles/monitoring/tasks/main.yml +++ b/ansible/roles/monitoring/tasks/main.yml @@ -66,3 +66,26 @@ url: http://0.0.0.0:3000 ds_type: prometheus ds_url: http://prometheus:9090 + +- name: Flask Dashboard Grafana + community.grafana.grafana_dashboard: + grafana_url: http://0.0.0.0:3000 + grafana_user: "admin" + grafana_password: "admin" + path: https://gist.githubusercontent.com/AndreasArne/433f902f9b986c301f2b2877454a581f/raw/4898bb2013b469cf74ace82d2d5aa39e073cb069/flaskdash.json + +- name: Node exporter Dashboard Grafana + community.grafana.grafana_dashboard: + grafana_url: http://0.0.0.0:3000 + grafana_user: "admin" + grafana_password: "admin" + dashboard_id: 1860 + dashboard_revision: 33 + +- name: Nginx Dashboard Grafana + community.grafana.grafana_dashboard: + grafana_url: http://0.0.0.0:3000 + grafana_user: "admin" + grafana_password: "admin" + dashboard_id: 14900 + dashboard_revision: 2 From f38f30bcc497e1181ca709921af3b71068c39a54 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 30 Nov 2023 10:32:43 +0100 Subject: [PATCH 160/185] Changelog 3.1.0 - 3.1.1 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a853cd7c..3cf97638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _No unreleased changes at this time._ +## [3.1.1] - 2023-11-29 + +- **Fix:** Using internal instead of without [PR 58](https://github.com/FalkenDev/microblog/pull/58) +- **Fix:** Using internal instead of without [PR 58](https://github.com/FalkenDev/microblog/pull/58) +- **Fix:** Removed alredy existing pip installs [PR 58](https://github.com/FalkenDev/microblog/pull/58) + +## [3.1.0] - 2023-11-28 + +- **Fix:** Fixing pytest unit [PR 57](https://github.com/FalkenDev/microblog/pull/57) +- **Fix:** Adding the PROMETHEUS_MULTIPROC_DIR to the test dockerfile [PR 56](https://github.com/FalkenDev/microblog/pull/56) +- **Fix:** Added port for exporter in load balancer [PR 55](https://github.com/FalkenDev/microblog/pull/55) +- **Fix:** added GunicornPrometheusMetrics to app [PR 54](https://github.com/FalkenDev/microblog/pull/54) +- **Build:** Added monitoring to the build [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Docker-compose file to run the Prometheus, Grafana and Alert [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Config:** Added alertmanager condig [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Added prometheus condig for exporter [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Added prometheus rules [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Added monitoring as a new vm [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Added monitoring to the security groups [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Chore:** Reverse proxy for grafana [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Fix:** Removed unused citation " [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Added Grfana initials [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Changed to use the vms ips [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Bugging [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Build:** Fixed security ports [PR 53](https://github.com/FalkenDev/microblog/pull/53) +- **Feat:** Created Gunicorn config in dockerfile_prod [PR 52](https://github.com/FalkenDev/microblog/pull/52) +- **Feat:** Configuration file for gunicorn setup [PR 52](https://github.com/FalkenDev/microblog/pull/52) +- **Feat:** Added pip requirement for prometheus [PR 52](https://github.com/FalkenDev/microblog/pull/52) +- **Feat:** Updated boot.sh to include gunicorn config [PR 52](https://github.com/FalkenDev/microblog/pull/52) +- **Build:** Add docker-install role to loadbalancer [PR 51](https://github.com/FalkenDev/microblog/pull/51) +- **Build:** Check cert before create, start prometheus exporter [PR 51](https://github.com/FalkenDev/microblog/pull/51) +- **Chore:** Enable nginx metrics [PR 51](https://github.com/FalkenDev/microblog/pull/51) + ## [3.0.0] - 2023-11-26 - **Fix:** Prettier update [PR 48](https://github.com/FalkenDev/microblog/pull/48) From 9eab14490242efc4bd98ec0ed0368456dc04a693 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 30 Nov 2023 10:33:31 +0100 Subject: [PATCH 161/185] fix: string formatting --- ansible/roles/monitoring/files/prometheus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index 24540b74..8f79cb27 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -23,7 +23,7 @@ scrape_configs: - job_name: mysql-node metrics_path: /mysql-node static_configs: - - targets: ["{{ groups['database'][0]:9100 }}"] + - targets: ["{{ groups['database'][0] }}:9100"] alerting: alertmanagers: From 73a013f315202391499b64d806114b7a36b77287 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 30 Nov 2023 10:54:52 +0100 Subject: [PATCH 162/185] fix: mysql node metrics path --- ansible/roles/monitoring/files/prometheus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index 8f79cb27..c925de8f 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -21,7 +21,7 @@ scrape_configs: static_configs: - targets: ["{{ groups['loadbalancer'][0] }}:9113"] - job_name: mysql-node - metrics_path: /mysql-node + metrics_path: /metrics static_configs: - targets: ["{{ groups['database'][0] }}:9100"] From aca80b7e86f924ee46790c2ed46eff7142f12579 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 11:16:51 +0100 Subject: [PATCH 163/185] feat: Added port 9100 to mysql exporter --- ansible/roles/security_groups/vars/main.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ansible/roles/security_groups/vars/main.yml b/ansible/roles/security_groups/vars/main.yml index 99393af3..da07da0c 100644 --- a/ansible/roles/security_groups/vars/main.yml +++ b/ansible/roles/security_groups/vars/main.yml @@ -69,6 +69,13 @@ sg_groups: priority: 1003 direction: Inbound source_address_prefix: "{{ groups['appserver'][3] }}/32" + - name: EXPORTER-DB + protocol: Tcp + destination_port_range: 9100 + access: Allow + priority: 1004 + direction: Inbound + source_address_prefix: "0.0.0.0/0" - name: monitoring port_rules: - name: SSH From 1737be607e59b101aa039683919d37ec401cff75 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 11:17:13 +0100 Subject: [PATCH 164/185] feat: Added job mysql-node (node exporter) --- ansible/roles/monitoring/files/prometheus.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index 2300613e..613f84ec 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -5,9 +5,11 @@ scrape_configs: - job_name: "node" static_configs: - targets: ["{{ groups['monitoring'][0] }}:9100"] + - job_name: "prometheus" static_configs: - targets: ["{{ groups['monitoring'][0] }}:9090"] + - job_name: "flask" static_configs: - targets: @@ -15,12 +17,18 @@ scrape_configs: - "{{ groups['appserver'][3] }}:8000" labels: instance: "flask" + - job_name: nginx metrics_path: /prometheus scrape_interval: 30s static_configs: - targets: ["{{ groups['loadbalancer'][0] }}:9113"] + - job_name: mysql-node + metrics_path: /metrics + static_configs: + - targets: ["{{ groups['database'][0] }}:9100"] + alerting: alertmanagers: - static_configs: From fe76bc9c9e7b40a309986d3c0e48516a44a9e877 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 11:17:35 +0100 Subject: [PATCH 165/185] fix: Update timer rules --- ansible/roles/monitoring/files/rules.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ansible/roles/monitoring/files/rules.yml b/ansible/roles/monitoring/files/rules.yml index e8b6c66b..9f3cc6ce 100644 --- a/ansible/roles/monitoring/files/rules.yml +++ b/ansible/roles/monitoring/files/rules.yml @@ -8,21 +8,21 @@ groups: - name: High cpu usage rules: - alert: HighCpuUsage - expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 - for: 5m + expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100) > 80 + for: 1m labels: severity: critical annotations: summary: High CPU usage detected - description: "CPU usage is above 80% for more than 5 minutes " + description: "CPU usage is above 80% for more than 1 minutes " - name: High memory rules: - alert: HighMemoryUsage expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 80 - for: 5m + for: 1m labels: severity: critical annotations: summary: High memory usage detected - description: "Memory usage is above 80% for more than 5 minutes" + description: "Memory usage is above 80% for more than 1 minutes" From 10f681b946f274910b355607bd368371f8b6ddee Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 12:31:16 +0100 Subject: [PATCH 166/185] feat: Added dashboards to grafana --- .../roles/monitoring/files/flask_dash.json | 778 ++++++++++++++++++ .../roles/monitoring/files/nginx_dash.json | 530 ++++++++++++ 2 files changed, 1308 insertions(+) create mode 100644 ansible/roles/monitoring/files/flask_dash.json create mode 100644 ansible/roles/monitoring/files/nginx_dash.json diff --git a/ansible/roles/monitoring/files/flask_dash.json b/ansible/roles/monitoring/files/flask_dash.json new file mode 100644 index 00000000..2e94d72b --- /dev/null +++ b/ansible/roles/monitoring/files/flask_dash.json @@ -0,0 +1,778 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.0.2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "5.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "5.0.0" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Example dashboard for monitoring Flask webapps using prometheus_flask_exporter", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 6, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 4, + "w": 10, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(flask_http_request_duration_seconds_count{status=\"200\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ path }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Requests per second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 4, + "w": 6, + "x": 10, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": true, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "errors", + "color": "#c15c17" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(flask_http_request_duration_seconds_count{status!=\"200\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "errors", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Errors per second", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 0 + }, + "hiddenSeries": false, + "id": 13, + "legend": { + "avg": true, + "current": false, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "HTTP 500", + "color": "#bf1b00" + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "increase(flask_http_request_total[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "HTTP {{ status }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total requests per minute", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "decimals": null, + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 5, + "w": 10, + "x": 0, + "y": 4 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(flask_http_request_duration_seconds_sum{status=\"200\"}[1m])\n/\nrate(flask_http_request_duration_seconds_count{status=\"200\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ path }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Average response time [1m]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "s", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "description": "", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 5, + "w": 9, + "x": 10, + "y": 4 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.5, rate(flask_http_request_duration_seconds_bucket{status=\"200\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ path }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Request duration [s] - p50", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "none", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 5, + "w": 10, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "increase(flask_http_request_duration_seconds_bucket{status=\"200\",le=\"0.25\"}[1m]) \n/ ignoring (le) increase(flask_http_request_duration_seconds_count{status=\"200\"}[1m])", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ path }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Requests under 250ms", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "percentunit", + "label": null, + "logBase": 1, + "max": "1", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "prometheus", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 5, + "w": 9, + "x": 10, + "y": 9 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pluginVersion": "7.1.0", + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "histogram_quantile(0.9, rate(flask_http_request_duration_seconds_bucket{status=\"200\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ path }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Request duration [s] - p90", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "5s", + "schemaVersion": 26, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "Prometheus Flask exporter", + "uid": "_eX4mpl3", + "version": 2 +} diff --git a/ansible/roles/monitoring/files/nginx_dash.json b/ansible/roles/monitoring/files/nginx_dash.json new file mode 100644 index 00000000..e2be5e24 --- /dev/null +++ b/ansible/roles/monitoring/files/nginx_dash.json @@ -0,0 +1,530 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "5.0.0" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Official dashboard for NGINX Prometheus exporter\r\n", + "editable": true, + "gnetId": 11199, + "graphTooltip": 0, + "id": null, + "iteration": 1562682051068, + "links": [], + "panels": [ + { + "datasource": "prometheus", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 4, + "panels": [], + "title": "Status", + "type": "row" + }, + { + "datasource": "prometheus", + "cacheTimeout": null, + "colorBackground": true, + "colorPostfix": false, + "colorPrefix": false, + "colorValue": false, + "colors": ["#E02F44", "#FF9830", "#299c46"], + "decimals": null, + "description": "", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 8, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "options": {}, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "repeat": "instance", + "repeatDirection": "h", + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "nginx_up{instance=~\"$instance\"}", + "format": "time_series", + "instant": false, + "intervalFactor": 1, + "refId": "A" + } + ], + "thresholds": "1,1", + "timeFrom": null, + "timeShift": null, + "title": "NGINX Status for $instance", + "type": "singlestat", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "Down", + "value": "0" + }, + { + "op": "=", + "text": "Up", + "value": "1" + } + ], + "valueName": "current" + }, + { + "datasource": "prometheus", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 6, + "panels": [], + "title": "Metrics", + "type": "row" + }, + { + "datasource": "prometheus", + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "decimals": null, + "description": "", + "fill": 1, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 10, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "options": {}, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(nginx_connections_accepted{instance=~\"$instance\"}[5m])", + "format": "time_series", + "instant": false, + "intervalFactor": 1, + "legendFormat": "{{instance}} accepted", + "refId": "A" + }, + { + "expr": "irate(nginx_connections_handled{instance=~\"$instance\"}[5m])", + "format": "time_series", + "instant": false, + "intervalFactor": 1, + "legendFormat": "{{instance}} handled", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Processed connections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": 1, + "format": "short", + "label": "Connections (rate)", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "datasource": "prometheus", + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "decimals": 0, + "fill": 1, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 12, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "options": {}, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "nginx_connections_active{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}} active", + "refId": "A" + }, + { + "expr": "nginx_connections_reading{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}} reading", + "refId": "B" + }, + { + "expr": "nginx_connections_waiting{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}} waiting", + "refId": "C" + }, + { + "expr": "nginx_connections_writing{instance=~\"$instance\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}} writing", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Active Connections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": "Connections", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "datasource": "prometheus", + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 1, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 15 + }, + "id": 15, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "options": {}, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "irate(nginx_http_requests_total{instance=~\"$instance\"}[5m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{instance}} total requests", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total requests", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "5s", + "schemaVersion": 18, + "style": "dark", + "tags": ["nginx", "prometheus", "nginx prometheus exporter"], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "prometheus", + "definition": "label_values(nginx_up, instance)", + "hide": 0, + "includeAll": true, + "label": "", + "multi": true, + "name": "instance", + "options": [], + "query": "label_values(nginx_up, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "NGINX Dashboard", + "uid": "MsjffzSZz", + "version": 1 +} From 521748e15d363e3d1281c0aa6a79f9022a044d7b Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 12:32:04 +0100 Subject: [PATCH 167/185] fix: Small fixes, using now the local json files of dashboards instead of getting them from internet --- ansible/roles/monitoring/tasks/main.yml | 60 ++++++++++++------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml index 464e31a7..633b4595 100644 --- a/ansible/roles/monitoring/tasks/main.yml +++ b/ansible/roles/monitoring/tasks/main.yml @@ -5,30 +5,6 @@ state: latest update_cache: yes -- name: Remove image prometheus - docker_image: - name: prometheus - state: absent - force_absent: yes - -- name: Remove image deploy_grafana_1 - docker_image: - name: deploy_grafana_1 - state: absent - force_absent: yes - -- name: Remove image deploy_alertmanager_1 - docker_image: - name: deploy_alertmanager_1 - state: absent - force_absent: yes - -- name: Remove image node-exporter - docker_image: - name: node-exporter - state: absent - force_absent: yes - - name: copy docker-compose.yml to vm ansible.builtin.template: src: files/docker-compose.yml @@ -54,29 +30,48 @@ src: files/grafana.ini dest: /home/deploy/grafana.ini +- name: Copy Flask dash to vm + copy: + src: files/flask_dash.json + dest: /home/deploy/flask_dash.json + +- name: Copy Nginx dash to vm + copy: + src: files/nginx_dash.json + dest: /home/deploy/nginx_dash.json + - name: Start Prometheus, Grafana and Alertmanager ansible.builtin.command: cmd: docker-compose up -d chdir: /home/deploy become: yes +- name: Wait for a few seconds for starting grafana + pause: + seconds: 60 + - name: Prometheus as data source community.grafana.grafana_datasource: name: prometheus - url: http://0.0.0.0:3000 - ds_type: prometheus + url: "http://0.0.0.0:3000" + ds_type: "prometheus" ds_url: http://prometheus:9090 + grafana_user: "admin" + grafana_password: "admin" + state: present - name: Flask Dashboard Grafana community.grafana.grafana_dashboard: - grafana_url: http://0.0.0.0:3000 + grafana_url: "http://0.0.0.0:3000" grafana_user: "admin" grafana_password: "admin" - path: https://gist.githubusercontent.com/AndreasArne/433f902f9b986c301f2b2877454a581f/raw/4898bb2013b469cf74ace82d2d5aa39e073cb069/flaskdash.json + path: /home/deploy/flask_dash.json + overwrite: true + state: present - name: Node exporter Dashboard Grafana community.grafana.grafana_dashboard: - grafana_url: http://0.0.0.0:3000 + grafana_url: "http://0.0.0.0:3000" grafana_user: "admin" grafana_password: "admin" dashboard_id: 1860 @@ -84,8 +79,9 @@ - name: Nginx Dashboard Grafana community.grafana.grafana_dashboard: - grafana_url: http://0.0.0.0:3000 + grafana_url: "http://0.0.0.0:3000" grafana_user: "admin" grafana_password: "admin" - dashboard_id: 14900 - dashboard_revision: 2 + path: /home/deploy/nginx_dash.json + overwrite: true + state: present From da4ec2245835e7923a0d02bf57974cc4771ab0c2 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 12:33:00 +0100 Subject: [PATCH 168/185] refactor: Changed the timer to be on 10s --- ansible/roles/monitoring/files/rules.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/roles/monitoring/files/rules.yml b/ansible/roles/monitoring/files/rules.yml index 9f3cc6ce..742b39cf 100644 --- a/ansible/roles/monitoring/files/rules.yml +++ b/ansible/roles/monitoring/files/rules.yml @@ -8,8 +8,8 @@ groups: - name: High cpu usage rules: - alert: HighCpuUsage - expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100) > 80 - for: 1m + expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[10s])) * 100) > 80 + for: 10s labels: severity: critical annotations: @@ -20,7 +20,7 @@ groups: rules: - alert: HighMemoryUsage expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 80 - for: 1m + for: 10s labels: severity: critical annotations: From 3c1596623e8bf6ecf82b722c6f61977d64fb2b55 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Thu, 30 Nov 2023 12:33:41 +0100 Subject: [PATCH 169/185] refactor: typo change --- ansible/roles/monitoring/files/prometheus.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ansible/roles/monitoring/files/prometheus.yml b/ansible/roles/monitoring/files/prometheus.yml index 613f84ec..be984f2d 100644 --- a/ansible/roles/monitoring/files/prometheus.yml +++ b/ansible/roles/monitoring/files/prometheus.yml @@ -18,13 +18,12 @@ scrape_configs: labels: instance: "flask" - - job_name: nginx + - job_name: "nginx" metrics_path: /prometheus - scrape_interval: 30s static_configs: - targets: ["{{ groups['loadbalancer'][0] }}:9113"] - - job_name: mysql-node + - job_name: "mysql-node" metrics_path: /metrics static_configs: - targets: ["{{ groups['database'][0] }}:9100"] From 95f452c3a72f828188ad0d94ebeb2d596c3b47c5 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Thu, 30 Nov 2023 12:45:51 +0100 Subject: [PATCH 170/185] changelog 4-0-0 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf97638..39c8d74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _No unreleased changes at this time._ +## [4.0.0] - 2023-11-30 + +- **Feat:** Added new alarms - High cpu usage and High memory [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Feat:** Added flash dashboard, node and nginx [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Feat:** Added port 9100 to mysql exporter [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Feat:** Added job mysql-node (node exporter) [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Fix:** Update timer rules [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Feat:** Added dashboards to grafana [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Fix:** Small fixes, using now the local json files of dashboards instead [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Refactor:** Changed the timer to be on 10s [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Refactor:** typo change [PR 60](https://github.com/FalkenDev/microblog/pull/60) +- **Build:** Add node exporter to database vm [PR 59](https://github.com/FalkenDev/microblog/pull/59) + ## [3.1.1] - 2023-11-29 - **Fix:** Using internal instead of without [PR 58](https://github.com/FalkenDev/microblog/pull/58) From 0008066853e09a96f0f5decf61644af866caf0d9 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 5 Dec 2023 10:49:01 +0100 Subject: [PATCH 171/185] fix: Added task to update requests_toolbelt & urllib3 Needed to be updated for certbot install to work --- ansible/roles/load_balancer/tasks/main.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ansible/roles/load_balancer/tasks/main.yml b/ansible/roles/load_balancer/tasks/main.yml index 37ac8afe..e2d4c376 100644 --- a/ansible/roles/load_balancer/tasks/main.yml +++ b/ansible/roles/load_balancer/tasks/main.yml @@ -11,6 +11,13 @@ - name: Ensure Pip is installed raw: test -e /usr/bin/pip || (apt-get update -y && apt-get install -y python-pip) +- name: Update requests_toolbelt and urllib3 packages + pip: + name: + - requests_toolbelt + - urllib3 + state: latest + - name: Install certbot apt: name=python-certbot-nginx state=latest From 5604fa37733011bc8e2cb18c1d04103586be336a Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 13:18:51 +0100 Subject: [PATCH 172/185] cd: Add doppler to deploy workflow --- .github/workflows/deploy.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1dc76441..72d8c611 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,6 +26,9 @@ jobs: uses: webfactory/ssh-agent@v0.5.3 with: ssh-private-key: ${{ secrets.azure_ssh }} + + - name: Install doppler + uses: dopplerhq/cli-action@v2 - name: Install Ansible run: | @@ -33,6 +36,7 @@ jobs: pip install ansible - name: Run Rolling Update Playbook - run: ansible-playbook -i ansible/hosts ansible/deploy.yml -e "github_release_tag=${{ github.event.release.tag_name }}" + run: doppler run -- ansible-playbook -i ansible/hosts ansible/deploy.yml -e "github_release_tag=${{ github.event.release.tag_name }}" env: ANSIBLE_HOST_KEY_CHECKING: False + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} From 40e4f1670de12ce77b097de5ae84366050936513 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 13:19:23 +0100 Subject: [PATCH 173/185] chore: Replace vars with doppler secrets --- ansible/group_vars/all.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index f94ee537..3a4fef5d 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -3,19 +3,19 @@ ansible_python_interpreter: "python3" region: northeurope -resource_group: DIDA-KAFA21-DV1673-H23-LP2 -domain_name: devopsbth.tech +resource_group: "{{ lookup('env', 'RESOURCE_GROUP') }}" +domain_name: "{{ lookup('env', 'DOMAIN_NAME') }}" -admin_email: kafa21@student.bth.se +admin_email: "{{ lookup('env', 'ADMIN_EMAIL') }}" vmtags: - StudentId: kafa21 + StudentId: "{{ lookup('env', 'STUDENTID') }}" -pub_ssh_key_location: "/home/falkendev/.ssh/azure.pub" +pub_ssh_key_location: "{{ lookup('env', 'PUB_SSH_KEY_LOCATION') }}" lb_method: least_conn -server_user: "deploy" -server_user_pass: "devopsbth" +server_user: "{{ lookup('env', 'SERVER_USER') }}" +server_user_pass: "{{ lookup('env', 'SERVER_USER_PASS') }}" server_user_groups: - sudo From 6464a8d27f24edcfae43def52075064674948937 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 13:19:48 +0100 Subject: [PATCH 174/185] chore: Replace config vars with doppler secrets --- ansible/group_vars/devops.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/group_vars/devops.yml b/ansible/group_vars/devops.yml index 7bf4c9ec..c342fa81 100644 --- a/ansible/group_vars/devops.yml +++ b/ansible/group_vars/devops.yml @@ -1,2 +1,2 @@ -root_user: azureuser -root_password: "devopsbth" # change me +root_user: "{{ lookup('env', 'ROOT_USER') }}" +root_password: "{{ lookup('env', 'ROOT_PASSWORD') }}" # change me From ec167113901b7a36cdd1c9322fcfae8507f89e89 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 13:20:10 +0100 Subject: [PATCH 175/185] chore: Get .pub ssh keys from doppler instead of files --- ansible/roles/10-first-minutes/tasks/main.yml | 2 +- ansible/roles/10-first-minutes/vars/main.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ansible/roles/10-first-minutes/tasks/main.yml b/ansible/roles/10-first-minutes/tasks/main.yml index d39166a2..9275e3a3 100644 --- a/ansible/roles/10-first-minutes/tasks/main.yml +++ b/ansible/roles/10-first-minutes/tasks/main.yml @@ -39,7 +39,7 @@ - name: Set ssh keys for regular users from files authorized_key: user: "{{ server_user }}" - key: "{{ lookup('file', item.key) }}" + key: "{{ item.key }}" exclusive: no with_items: "{{ users_users }}" diff --git a/ansible/roles/10-first-minutes/vars/main.yml b/ansible/roles/10-first-minutes/vars/main.yml index 9f77bfd9..a4337d31 100644 --- a/ansible/roles/10-first-minutes/vars/main.yml +++ b/ansible/roles/10-first-minutes/vars/main.yml @@ -6,6 +6,6 @@ packages: - unattended-upgrades users_users: - - key: azure_james.pub - - key: azure_joel.pub - - key: azure_kasper.pub + - key: "{{ lookup('env', 'AZURE_KASPER') }}" + - key: "{{ lookup('env', 'AZURE_JOEL') }}" + - key: "{{ lookup('env', 'AZURE_JAMES') }}" From 3d2fe13b0284bf7a00d4cbfe06d931bb5cc8c314 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 13:40:13 +0100 Subject: [PATCH 176/185] chore: DOPPLER_TOKEN -> DOPPLER_TOKEN_PROD --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72d8c611..a4c4161c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,4 +39,4 @@ jobs: run: doppler run -- ansible-playbook -i ansible/hosts ansible/deploy.yml -e "github_release_tag=${{ github.event.release.tag_name }}" env: ANSIBLE_HOST_KEY_CHECKING: False - DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_PROD }} From 605eef559d88285c1586a6b403ada5924b7c79a4 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 5 Dec 2023 14:43:39 +0100 Subject: [PATCH 177/185] docs: Added images for Doppler technical analysis --- img/addIntegration.png | Bin 0 -> 6503 bytes img/dopplerGithub.png | Bin 0 -> 21013 bytes img/dopplerPricing.png | Bin 0 -> 55604 bytes img/dopplerProjects.png | Bin 0 -> 31655 bytes img/dopplerSecrets.png | Bin 0 -> 23857 bytes img/dopplerServiceToken.png | 1 + img/dopplerWorkflow.png | 1 + img/integrations.png | Bin 0 -> 4154 bytes 8 files changed, 2 insertions(+) create mode 100644 img/addIntegration.png create mode 100644 img/dopplerGithub.png create mode 100644 img/dopplerPricing.png create mode 100644 img/dopplerProjects.png create mode 100644 img/dopplerSecrets.png create mode 100644 img/dopplerServiceToken.png create mode 100644 img/dopplerWorkflow.png create mode 100644 img/integrations.png diff --git a/img/addIntegration.png b/img/addIntegration.png new file mode 100644 index 0000000000000000000000000000000000000000..0c41542f8734d68c3d4a5d9b37ea25846274352b GIT binary patch literal 6503 zcmbVRc{o(>+n=$FZN^#{MyOCiF=LD^yQxG9$yS6hwlT7los^OcW&4gHW66@J#@LIg zDa6=!22<9og>0dBzQ60e-v8g5BwAacA^cMO0001T@q+0! z_MF2W4ZI-s)yeWChCKm;uAz+qm0hqOY=O(m$kGS^c$Xru@5arR`S2GUf&c(P`o9C% z8c^y107&j%G&QolcXK6oH|B`lP|H;FQ(5y=vl=er5iZkF9+@Nej6RAu(_En98D?e< zrouf$D*Y7~>gp>|mTB4lLvf9#Qo*Un!agYYa2uR^_*>^7cvVWq zASi=y@#HZ{`=@7Hpwr2Ltvkcl$^cns?`@7}sO0L!^T5dt)kb`17o=#F^N^N2xqBoMA@MsU_3_H`+m%{+nf(gyO4P z@&;{U>lq0;T&Vp)T7%6#U8LHCt$Fx9geuA-nAU%0xNp~2MB&T~WOwO;0zb@ZYY`Gx z0T*CP3!cGOCBBKRIxjtefHFlh_Ac9%$nqL_=vXQ>gqVdhO~)}%PP4+0k^5)vifeR1Xdzjp_e zHfmSpXP~=Q#{M9|r27S{g+=QVY%6~#rGHM9Mb@&PfuCJGe@S_YBd65v|KqL7_y@X` zhuG7-DH8f)8&k)le!4%afvNR09B_yG--;WG{DKyY{4i@Bceoq%>@aAo&|ShJc-OqF zW0ImkQ#i#$hi_dHoQmK{5tq| zl(q%LmxA!|&n)(9?_;iLhi}~qb8NLh$_GvDIC)tg|b${d$!(eOX1YoKD^Am}V z-X!;Pe+f|1olSn-xnbu}r{}vj3@uO6s*V1+<%X+ylIyiN1pDpM>H2~35qBMwoPXQ; zh-)PYh%lU^4f*oLRLFH)dn2*)_+Ar?bGLfizpXcWf_v|m#2cH#DoGBe>Y`6gkyI?t z-Fm{D2?*T?!4=C?%&$AMFpIpj<1kAjaqh98XeQwv(&WSLNL#blWF{=?r~k7`jE+PI zd-;Ob3mSc}1;^-Jd2`M%*0K?LN%2yNkd$n7;cqT1NCWh8*kFIboB1?_{~QfdPZY?0 z;?>@plGhwzmwEF*?~2#ikYfv6b=MagZ|OTSCfw=1U@_e>|FiFceyn>l86eAt2`V#K zFp+P#6BqL$U%;?K!-F^aX(6a$yI<$UcS2SQU2@WEvb(=GD0jLrlY?o=wKft{d|~5! zn@!BBUUFesJ<(35gA6>!qp^aCD=h1C_<`lu;Fglx0NQ+XB?7R@FI3Q+f0s`<-xkVI zezj5I#fR4taV60O`%*fxI3TOV@=M-Q_W_9Lu7{A+Q9{ex3EW4J{h99Wb}I~1g$wGu zQug}EWYVs$Oq95nw{Uaxq?7huLVh{hj@<(dojg)}^cg5J!J@PODJgJwIZ?FzEpgX@ zg^@xWE%v(uKyW)*BeglSHr$qyPsk+&Rskqx*Y&=&x<;?uuMG;`ynK*p4^%Vvsx!K` zo~8$J_1efnMry%RPov>)9dH=q3`QHp+XxefaTSQe(64h-&!>2mz9E<&?s`p4n}6Tl zQ>o-|e(-kl-JU_1kMp$2935jgZT+(U(dCdF!0UEOf;Z?B`G>3FcUXmxamHmq9!gUZ zh5wu^GJ0Q9RwM<3MQK6b1jYwdzdR(1v0!fMA;Y~K$xeChTK%yOslWLzDH$Lwq(K@H z-tj;ISSNy`<*Otw_YE#ACR&^)`~C`_Yz;sIlKtc%v>`r4Y*%QeF#h-Ulqnyc5M9UW zjUV3_koL5t7fS(ckRmO;U@W1Kn@seCOk-k`fsGRY@z|EsACytW9!W}c%KA3m`Jb23 z0vd?ayuW`MU^3-Zv!cK*(JgcPwbMOvI8ocL1D^dtQ=P_dp=~NXNBqDJrPEaA>$sTE zbzg_HUXDs3g#&6eH7<l@ zrrezswT;QpHWc8W6|CZ(uf0vGI(`w=oq{=ZA)P-{s{7nmm2GOgPIlbE6@4ZiuxpBilaU9L91!Pr8nowa{%UM7VA`5?8ognZ=)u6Jv1w&Fe?k@@g9N@jDkLH)LlI z`n=`($@#(8(=BaufWv9au7VyTcQnYO5jm%b?`XdsOs01V`@JIvqCERUs{jbdI8EDu z09KvIDd`yYv2d>CSsi(EOn=i#t*^HoS4>wUDjsL5;Jgc^@bX$!?TW|$A(i1_$v^?u zDnn_MM5<>L>o6;il$(#fKEH4;dF|wnE!v8{Ly>pD4VRK7>?)IyzyMajW(hW`{tJo# z*DoAi^-vwFDr!Egv(BNYP~A3@M6|AKAwxWRY#Hr6VBCW(nTi#?%5l!^Z_kb=hba^U zJCaY2sI(2_NXF4&eWvop6ef)m;r`$*EfwUeUl12^p1Z?Tl<=7hxdP zNNA_`^Hj_qcc7KQtK^|oHEY>ACqQ;{C>!4{B~C~Yr)}|t@b-xGVYov0c9QXEFW65? z<0yziT=qjv=H-7B@+gaQr}J>+MB7S#=u&8@Bf98t+9v@z^YT2s$0v_ZTv#>G#%16< z9#8%$xZ!{V64i2BdI8z@A?oqd0Z_+Uo7nyopTg>8@hD&pw*!-sFcEmXCi1Ib*1FQv zo7!~jk{94q|Oia@^qPzdK%_o0v(} z4%{j=JEg?wCy2!?oeqr=6R26;BUV{QESNHMm{)F=@)-^%K8G^8U!`}E{Wy>i=Xj9+ zTTn%yjuyCI1F`!}<-j*ZWq%P}-8<=SmffTgo_}3is*%XQg1vgz&o*{Wt#4gKmm`oS zbN(v7s_)JQ90iS$0TP9>xuLp_03x>|{g$>QwWZ9#C+GP~V$z+>-?h$JzNI)J=DUq! z`ivm;MkueY6y1}_xI?|r14G0AkT{{pW&clKZ_5zeY{>FYNuzI}JsfSQ*OanLN^*@^ z-}=d#mpw<=M!&++D4QeC^>R@wIfm_vTQrxZn*|cJ^I^E_35@hE7X1_(Jpl0Cf#V1d z&beRHygdvYz?H5pUN;SPHXHT$xr34v0XlNddB=irE%~kjunO>)Fvx$QNDq?xPvKEt zf#BII|MA(med*@5e&N+?CPDC{;OD!BPgXD^j!*70fSnJBd3Ky#p>PBj5ZkrSKsnN* zmOWZJ!Pu5g1lB}x7Td-}h`56YcYIOPbZhs}yL_CJCn}73A;!fm!)mxUhr&049}fi( zL6Dblyrux6N?MqQ=?35#IpV70tObABA6R8QIzZO6Vx2HSZXOG*`4##83>rQ!fn7Ts z>zZO$ijdW7iQ)n6??~#_dqZ-DbM%%Jh+jn@6`&!A07@Yloh*-cG_&+hS|=d)MIn*U zkKo{^d|kf1j4@Hta1|S=Set9TJ74A<8J9rl=s!o8&pi5L|BYsiRwkhJ)BQ!KJc&|b z$E!byO0yWn?{e`ZdA}ava0fc08*m+lM|zBi{OnNV(RDnLWyP62&Wo_}VAc77$jRGA zz76Fx@~^rX9g!(T&NR~xS*|9FbICN6e5=dQ01re>=C^=HuwGhZhyJJ z5{&Go)swx^0-78)K@EPC!qYv=eP075cp4r0Z-iFAGm}ClOm}hUzkhoH-BkXe(TF2Q z{5-MAM@h*qZVi*)_d_Q;ju8rHT`3-up-8W1+2{xaCi2`x$RJaUY0> z&VD!VPjwLW6uL?p2y3N8tTJ7S3YDI0>gy^KWPc7%?;IH8kjdXD?Zs=D=vuy>1pEot zIWIgs-SYFf1iz6}pn#K{T78AzU>kE0!3+4PI_Z-%hfmEK)U7;jsdv_#B{G&k@n2Bj zG#1GQ5Ez?pP8*#D!&Gla7fZz@!p3?Ne0q8UZ$~4Kkdr;@tdVHAYvFDi86y3$3;C&V zZtnPm{$ydYv|hW%CihSr7Yg0l9ZqFc!!+@yxm{1?`2^V9C4rFA6k4YWT7ZsG^>xL~ zsSP+1iif%-5y3e=(Jf@nAANj1k?4&Mj95JT!_YxJ2|?$uope1Z&HK7*N|%DaD@D15 z90zuZDE`_Zf1UA)M8l;)^dTidDEoBo;Xatq63hLv zpQlqb7exKmsUS5u)5}YM_UrF$rd@LhF0SFK<8}FPwV=9j9T6TE3a5$Bvdb2?6AL5m zfihPaKwfqm$=M|;lfv_BZbyT(f?%B>2mu)>Nk^uqnCNArDov$_GZywVql`jM{@atE z4d49Kq6e8LXf6)<5Axnft?(^ULN zWR&K-FiRIZyWdjtrGSFveq+f1=4+xwabVTAWCf{SntGk-bb^?E$3~62!Gb`k3HuP! z`_bLZvF>?3N%~zPjtPFNj{Uet)Hi^WM+wD-8#0iWVuZQ)mQ-~to;r6mBN<>QErN1{ zQbpn=k!od062egt)_M1GFU2+{_L#!<-(5%=n(_Xeqk)Yh$>-o~j1v0zw5qa@EOvRZ zL#m_8bMk;xH$iMoA%utR8kH;%QsT9(# zXFsExqvt7!-IGZJ2f?N0v2?>0+|biqVj$uLiF*UiDfMr@eg+8+)2rV>~jd3Vy&78$D4!}_Q98TXYe~9@m z&Rp(>K|kTTwn02e-PLGx2J1TVn&NF3Up@hUzEFlvJUrmbyCS0&Wt#>V2Dz#KM%{h8 zRC^S=`du$M8`G|c{%q{OPXM4_7iyCOpZXrpHb`1f4XqYyU5aTER{&PZw0qR&?{pOo zYaR8T6yuiC9c&)GS`W?CeA~=!#Z>%lb)*cXM`@mEM)66|Hgj*Z)48mLPEFHCTVkF| z*nrLJY@&lK4q~-!D}>MMr@U}-EOHPM_g2Xo!1(C{DP$2=E12joW{tyWa)iS1_VC`E zh{Q}iOX`Y$!CKSoc3GO!)3FmkHKY82@5t3RTVd1?68dcO_uc}tW1YFvy_;ob4PH5D z_k;+gK z&fdNLHBvp-Yyx1@Vf%NbIGQpp$zOs_|%6`|8415&OeW>)|t!gvt8N- zzRCXuq30vW3o&J||yNp+pp!tz5gdi-Y4!Vm?fI27D2D_)zb})WyrZiBDqJ{SW#Thv@=c3%9P$1hUt_`f<>@cMhk zbVEbxtr_jp)dF=>l^!o&hX%?wvuQFP3H~Dg zy!qm*3EI&Q&mE7<#Fq;2?V8?vHg$3k$08fNnSbfNa{E7gT?Q{+`1N*s^~@FP^rqC- zB8#%Cw`oQaDo(h?v-!gNGVX$M$)@wd>klGB zY@yYI@-(A=@C6;u6w>y@*W>jTO>D2sT}+M$wT_}`3HJ(vTXwEX`6j$ zO*U(3exs~qi&sr}C^_ki>RAp~olhwD_v)*XF*Cs33wL<(t1oe*oa&YUwGFO!o{;uj z=j)=N+YF17(*H-5URkV6sZkDLnNB!yNQL^mFX-<3aQ}+61eM57<^WEiorzeM_k2l=Ee7LfZ!?~?F z?-zR}#b~#FVf&(SB8P-C)+7Ba17&`XCBwQcnhVCOottBx0O?$?wOtr)&GCitKAS(b zael7)-F)kvW(obK)s3q(yO_L_>2mlvj_-7^;Q?U&ldyWv(dM(pE8La*19R1bhWO^; zXx6Kz0lFD5Jf^2m)E#82^x4_AD3|BrnXIxN$D5BhBz{G7hSX0@Z=a5tXnMZbBzWhR zbVT&)x@|s}AV4*A!%8q`cPa7af+nAO{530Uvm5b8z|N+h1usa)olz=+&FM3$>)meo zGJPF6od+UFyf>X@_nldE!dFkgNYm=?zT4FiKMTMf0ePaJuSP4H*Rd?s(Wx`-rA0oj5`HXlQ--9 lw)|XW{lCQRk>0X?8?9NDEc4A-za3R52is&=WeSAp8^rrMG~H(t994XaY(k zfuIryAp}H9AR!wHnx3mHnu&phuDEF7Zp#+0Uvw(%x>whRS<+`fiL@AuNhurW2;W! z+;lnseCP1KZR5wr#@)I5w+HK0^ni`cv_$XvHH$!dCN%*idIPzz!#X-wz66G^=u3UN zD0qx-A9Q5kMr7&a{JEkBHr8+S-pU5F*~;EIdz@8P|NK}uc`SU*pwEBpXOF8U4NFr9)9ka*xRRSO zpD~-^r@1vtDx@u|?u;jl{^Z;3E8A_}Jnr33o|{GcfR9H}$LYIY&p1Vac0X?m{5P8Y zJHV;P%2D)2M2SMhy44Czd5dS}>bgeCnALjAi!IHgJikw_yCx<&>c);M4Ba?T_ml2W zG@-ngS~pn|x7mtYDBs-f3a>o))Z`%0xxB2lWSJt2{CYqOMjmKe6C(FrGWp4+Xko&qP9T$m&QDK~1{Gt|T5@i8d59JGiPT(^vlv40; zy^bEgroUh78o1)B!46#yu3PswTEbez_gyvv<`(qKBw0DYuzHoAst~hHoeRQlR}~>vH$26 zPU1XK^^${WU(0w5MmWa7>Xv<&uEvJ(HR^FCQPP9OD9q9L(v~3dqQGzoJyu7l;nenn-XRq(P2nc<50m*A_^ z)wu}kurSKhfS&*=cTDXqqFfVfr@C(4s$QNPTN>zhh0c?{y-UGMYCr1^4vb=vOJdx`tk&uqE%hoQz^bWjjKOFXo;#&(4Fe&^Y zc<)@@d1A`+MCMyj`lJF}lu~g?v@UWwW&nK@L}{Ld*3E+Fzio=c-*fSf@|UXQ&5v@_ zG0qm|Og!QaUAVF;s(SOoW3NWa{uTHSl^+dusU{FcuM$z}F8=~LudHjWYfC2CM~C{B zy9Pp)e4QP<9QhGgWIwW0@C?Ma3c&$6`;3M3I#8(E+2z-o3*TwuVW$kVw3L}|@dFhL zs#7`g4^l`APGLEO?buU&Xe{hFeKen;K3|p=Muxap?_m=t0d9#jFe=(AeU$hrx3$P} z67t2Qex=@hv#_!>-z)vxJNsZe4UpgUw5^-Wt#@UCc2rp8_@o3q$Ob-n68^5fPnozT zwhYSQAIO)P{hnrTJ{BRZhOWJZBV>U}a>Blpea+grCcM6G3O7Hl?O@r5+lbIA@eIO1 zDPJ5~OS-VgEgDSgKBZ!9d9%;n(h779@$mzm-tFLuO^vwa5PFySBYv?aJiESsYwc?( zozHy1ijIr1zuTuebt`-;Gc(=W<(g<;#fqO+*bR*U3z1n5^y0;p+FHg760xDY(9X^B z%F0j82tLL9gq2So^q>&QMrp0BFw@b{-rDju^EJ)2=m3qJ>{m79O`*&Ry@*6ExGAe} zfQ!a@>^wz?LDh(fSk~JI->KgIr7!k8qlJa2z>(DQjMRR-kHT#3BP(GezTJc0I=+-+ z=CJc%Qee2J9$gx1+&ApKnZX#Co|Qi{;Jjj7E3K^osW`{X|M~n+V)d@ zJKsRG5v`sDuWRN6nozwF>nM5B8eW+XzqK_&uv8ua+tXT-z|FbsTw|GkM1dC5F%wC& zVA|I)pSo!B#PV}TDbl+Ul3XEUEOrqYyX}_?1QK#+6sAd8q+2(G+jgu6SCTicOrOui z1FPNj=#D-0Dr}z#+K^A%U-p-$WyB2;d)(`^gKfj@;irzo&sbPQl}7~M75>7Ar+Wh^ zwm_6_{AirI!+fXyX!RNlx<3Pj$b#P2mgYWils1e;x!`;M`lNYb$c| zTY7_*D<#|>tD$r7Al;bRVh2}7t*{opqP!*i<4WQwVskuAtun(|c49Eztt=IbI0Hegs}db}D|@AY2*38Z;U0}JA|N;*yx#LEjI!yK zkW4NUobRmw|2Z# zvnEka9nsp05{hS8K@*CK<7G{2{A3K&QsSuXC^NsUn?4i9+^Yg(g}fdR)KnWwbfDzM6wQN=v(DoE7AF2S#bIN8aO5MRCBVlpsHT zA~zBcE{k&TQshZV9jZA!9>2Nq^t)fIadD-Cvnv_rM`l4>*-N8C1ZnM}8U^!YlwEnB zD-UPb_6Y8joN!LZ8Id1PEUoJo{v2edQKT};VI8S9pi57;h1{Ith8%2&C zR*p3xT`lTMx%OrgN)l~x%^zNY(NJKWqnlz{0o4jKx!@wsdU2K9TnWuKez28kxYM0Q zawC2oSGg5Qj~01+1!aL;Yjjhb2(K_&U1M_8jS^f|*8O7b$4z{3Hj=?`h zVfwagk$;R_I>N@r?xCqmT?8+;H;p{`6)}-F53B=AJJng1R}-o)G=r>~(U_5V=DdPq z8!kRCa#U)1)gVmLl@CukcFF!L>qvai)Z{Dm4?R{zyls}bqifHDS8t2V|AaSCkRQRH zOoA>8i>e1d^odFNX&?^M?t z-tSUsLhGrW8uNAQD<|>4wfICks621!u#>|J6~(#UV{JC3Tr)g~9|nUIV7hiqz)D#S z_zHi1FNln&W7vm4r4S)9K%S5u#04LIzA&9T06HCoQP2KWFS}9G_hsvv##Z*W*3OgU zgl%iB!D|b%xr}oPvHW97BA1rDCqf4+uN*bEXT!O+K>vtCw={1p+4GKszq3?%y!^G3 zto+9V+Sn!H6m!kf)Zeo$^lRIeJRd!N2WRSspIX-X(r&h$M!DAsvh?vN`b=B$AeU}R zhBzp&g0MKL*IiIx#emv+8V3wpkxLdfwC!t4Lp17@Y?HMaJ9j&i@E*vN+&m@7v$Zv$ zk7(%CH+*tAhe;P{%Jm{^t7e&yu&(qGKj$^_CLHa1SLR$b(gZ`d8F+~Ti0xF1fD ziQN0NB5qJy79jAoqYkFVqCw>&nma=2BCNE>?LEVgozuE^t{Tj+Et&AiLk8!%Mcc|Q ztqR`_iP*pduZk(XoP4amQyR)~XE8&L7JKap=qsTtel#Jpy}Na+RHD$qGGSW>2h%Q& z#z4{fUOOWa?hE}grCC2Q`fzJ`7+2fnsT*NNN;8U2K`a~XBr&Gzmix@sr$z&V9ZTlW zT1l{x3uDQHw4B);SGLZF*D9jHSyRClt(V^|@@`XWSeimZfDTg0kCggmdy$`Mv38>V%X zTdrG`QB!!()*G42l8RqI*&jSpQ)5ro=D9rkv#+ z+s9jnciu27UB0F@s`wPbW78>vNB`=r95rWd&`lrLE?|9-RTE(k1i9rr3X2pK!dkazZ0vu;X4J<_S z2u*~0Su(e)pUYgJwa6!)be?h0+I`!rHdS-`E<&(_%IrrwA4xT zG0=WCw#ch#CiN=U_gZz#F*Ex#MOYO7*s<@2;iCq4b9zqS)HtKTZ(zzH+=L15OyWMN zaMYj_tX$!{Et0j9fpI2`0aW5j=kVhg5sP|z(}0jMarc4VfZ~ws4YFJTk*nQcSX8Rb zk{SH%rSd1OUs0iI-;L@B>lBz_9$~-o8Y*zZdJT%Ik2<{W^4UkRIKf;I4!r_iW zn#^q=SO|#d2=npkAY)8O_(jp7H?5vpx-L8Wffnuif6{mn9XsJQeXTekL8lDL>jPTD zSN|@>osv|0VbL@D#9xg$uZP8N<;YysvAa*8*o9|J_xy~)jIeb00XART3|rZ;@M@cX zI&I=tyJXzO>-wHpv_fLt!HIgcx8~(U&=qv}%^yJ!Imzn5OGy8wH zAG35RoWS3Syvi^M=+MIxDQUUI+sn`JL^&@F)7&0FQ2+*>Bk}PCPQ6Qs&=Yur zNcmC|u@~W#8Z9d?)m7s+VuyBj$=8?69{k`|=3#asI%p`TCY5`aX?bn~Xu)DKk zvYzF0NYS6-4cnt2?SD|>!t8GjThGX`Mt&w;S}^TDI`3sC3Y=Cu^AMclr8nZo4~3^F zt*R^c`VzHW89Z}96KEzf_pTaZ!NOzoxOkS8Yl&k0NHM~8IpCGNB;WZ{^zHCXJ>Ec< zZBm{4Gw^D)o?<>J!ViV2*f(gqF-Gh%9<%skpn636Ru!NU#gc0h7KrULD0j%)GOq>u zN~N%EPWtx)M{OE4b*Qf5dHFg?vq8Z;&l7R}MQVY+^i~Fpb1YNgSBZ5O#}`Ds zsIVcjM$?D~;wUhpip!_z>`H#AjEO*r$}f3yset%n;t{1w)l`JS2#?*g=?HSsGUt~7Zo>MAyDn92u*ev8iUa}mJj~KU*&}^y?N|9RP!D?q zt6_uq6u#wN_pqPk?0p;>P;+qdO)R>RhXv3&U(3fFAMkVIq&wgyCMQ? ziC$O8wagtZ5ap%w)g9-CkC~FniFr&8;lWnK4Zf-llYjwXda1Hq-YBcJ%`$?u9$}g= zQ^RTteJrk!H~+)j-5q<9-nL0nBW?w2@eH-H+B_{HSg!;Jtd7o1iEetW_>lYbluJ15 zwm)f&RSa%Vv4~?4Zn>kRBzkGu6n6HSXJhh9zQl}1>#(y^yV`11`am>h&F+n~>Y60H zL$9znN#q+(Xba+&@CTDz&r)xK2rEuAj7PHMgr-GJtnp)#ta|URD{(S5uz}$ zGa%`z08es<+2=e}Z|MuY z@u^^aaj(O#=fFfIhfuy}?YwD*`LG5{2OP)=<2|>E{P0xSpe~5bZB~_3{}X0{O?WkC zazm!5+$f8ogR~i(I$OMBCAi|OY`c_!d8J*%E8%6e#(23x-B}hEBf=~kw%QfwuUZ0% z>O{9aR?wLl7^S@V-YiJLywA$JHaw2~@vacg*A!TUcbPyt$kdDG6vQvO&~d?DTe14Y zyK3+ua$q>R!m!A%^iu5b0^OyiW6e5eHHenM`U__^%QAT_Wa?cpq zAUhWjveyX4rEN6|@OXtmJZ)98lpu~vFBUuyg|S5lx+q1aKE+6W(D2=1(!I1oAKz;_ z&oZEv=}??G8#NQRxHu_Eeey8tCZW)(yscDnS~`hmKen&$kT zxqXu_wmQdVU`*N~&ZPc((!{iaA5BKfu;p&YJ&_G1ujZjp&-o8i5&6=#Z8wpUu&}X# z=}={d^N<=DVNJ+%f=PYRar)uX0e%rcGI4WfrtRG5O{Ylbnyp0ynHd7Ys+(8SoRQ*6 zBy?wb5FgQ+Uqp$e7U&6l+eLK8YW$PbsQkpJdIimzyRg(Vek?0uK|*K;Fm9*Uz;a%g z8i0~DpokxH@p!a->VXR+o|bZ{olVz)gQPCcw&r`~Qyqx~SB*y6+5y+OquG?)vESUSOc;PL^UZ79fR>$bIv4gfH+#=y! zFJ;B@j70R`pC8&Clw*uRFB3Jyn9Y!PZ~DFJrICl!Yp2&u#PX#0zCk}zya(X+VY{~# zDYoa%ljQxS&S}{~<*1hzn#pIxV8@)nZ4Zy+YgD{C3g;QV(5&|dC;q{p5iY$Pm=U&U zEIWw9M9G>9bUSROqi%X=-p82{{n#We22&vadkSQQ=8qsHDQ6V_pomNf_=b+N;|k!N zhXT)w(&wPIWj)cLb)FP!)!lB^1?%(*R<^z&tvt&cbV(H*YBpKhRjcVz0_;=b z)R0oX`vYAh@+kugjii=n#PqBtITM1HuUCqkFGbxj6ww4+76d)PkRO>yb!cal4Hnz6 zkTrLLlDxrfT=oZx$;)!gjA1h(inT}pvT4U-KLtE|`!M&(aSqKsxJSq6(;v9}Ya~$) zN<-Z{@?d^Xl*!}}EM5#Ay+X#k;)1VweeEt!X}KDfamS0t`zP-`9UJqQ?p#$FrEq?oW!sBsy%_}^ZkrlH(E~k|0j4)?4f&3sZq8cS6Pg+p$ zB(sRpH6elm-SiuS<_aZY=EFB(j4p4xlk6ZTuSOyN*Su(rYI%IP!t`s33xF%JDNip9 zb@Sf0%rnZVoql#glWbcpcAsLx-?~qcqJ1)2*P*}3b{F(N-<3Cdmf-cE*(|=^l9@4h zw$e7W+E^)-qf61=nzIY)`N*HXK2h{ZMds!LUaNpI_{qBJp*h?@Yzpq&Trqp^HSbqG z)$o4F>=vnH6OwKTa6SK34jTk_D}5te2T6q2_vL*0sHV^n#$WPmxx%y#N%nawId{S! zWJ%s?&K6j%>^HlN>%2;uoNjd)_q$siXG1|yNI~KeaeSAXQv6a&;>TSa;uq)a)g&G;1_dPs8EZ*E`I^c^G9R_lvRqH<@@-~3qPZFD90|qvwhMoPGtWQ zq^$qyjsm%o)zHEoiZ9Y|a%uGr%I{BR!|R;!-dUcr?yq@ap5?Wupz4}yXNWg&Iw)@V z7o+!KI^9W%tNWA0>@N_fj7^lULy*tF0o!};Dwe6f)$siElfFxY-v1y{h3}@s2y0Yy z)6?)Dk$MCH$qUY}hbgBGi}0%hn~jauGn`nnnz?9p@IV;7C*+^Oq_EDRUCGn95c zVr|mZsHzf4$@ScK%V|t&2~dTc(-l4()P}FN$6EaK9KFlnnm1=WZ~T`Y-|8K2XnG13 zo82i^e|7kW3kfd%`I%Cl(eRxj52v#_$o-1z3I|$x<|#7g7v4O)As}yTq!wazCD)tG zTXWejry=FoRLj0(IQP7!#KPe916baVa+zy-yW!jJ)qx_%5i|Z~Xjnkmi7XpVEG%Wi zC_|W6HP0v&_3a|_2iCUE2~SE@%WKd+9PR~Sf8OVdh@T->Civ1v`$U?Ow zfkfYo^4Juvj!)5;@Es#NiVZ;2*&JVA&_)OIwuOv%z z_`R`b-~R|g{<9tXe;muc*1Gqdg?qc}*Gj^(3UW*pLI!W(&CZ|I(@7Hio+_o)FR9>uSJj1$H)mBv7UUDVW{`Ro8Psnx!_@D0PXfEHL}mKmB#c|D-CV&^ z3xOR*w{sc}t{E5IbqPWa*Nu^Ho`8UfZ=3A1gzyOv9Cxu@DYNDhR+-|$l4Q6SFxS`j z)5aI-Gu)b*U|%fpImX`1>%A*-9ZKXaK3XzKVNzhKkp=UO2df&>f}g@TDlf9J%_KKQ zW9Ii4(p6y9Q6RyYIaktTHJ43|5XHsf1+)l-^4^+J9(szr!M6mx{b_HiXbEnr%u`(; zkX0U)e49;Te|zu#pvRk&UXmTiT`_1HObx8RCD&euwJ7y&zt)zZ`upoC}-x< znam~i7)&bi!O(2FAE!==s%L{SZ>GBrGQj?oymII%-|(7BYXSekkA!|{e$o}0em>op z!))*GpN#)F32zEi7z;5>t9~SvBzH|o`U-t8?pBS@`|FM6s;Q_-$Fc{*&iE&bx6zeS z1Rtgx73~((RJ!8Pbwl{SLg4wl3ZWUw4z)&J;6uj^amqqe_?s?zl<*5ne}nDt-tguc z$EN!fr91*vdbIK5FVb1H__jI3z3p%arRK#{uqh($8aNw~h9#^CM=N!zuDu=+8ru#% zpC`S#3Be2wf5qWaKiO2?Zz2v!mAhNa}DkBsLiSSZR1%3)p9Wk(aT+DUMVS& z7SI(3KxbjECX{kn>~2;Un~|>tNb4P0VL7Sw`Mz~{tmX0y=qXZhthQ5&xsk&ZW0d|W{Ai< zEIk7x-=FOaEBnn{uou3_fjfgGBr6@_6R2-TDoTa$f&muwUKL|@c1Qd>*wIRP(w0hx zqA<5@9&YQ{t-p|16^X8bJLfezW@X(k%bpA5|>W%xZ~cG}Q#Tt$97N8bYB3;Y1+=>(>_ zr$%A=vO_1zC#tPtjS*^X-zLebjCXpgfexRto>!5SH}a?$GqmIKf74r@f>4tA*S@vt zAl2KSKW^hVK;TueCV*09t5hND;l0iadZ#%Y4`|i?!v3-Uv{+F(I)-*X&5(jb-|Td; zeb+BeJW!Uo6;0|tPa%6G!n2di`)%x~gQvEJ5dK@)h2kiZ<{`MDmKN|s2u)c`Ufz(w z4=g^@>M9ZK{D5bqO*kuyS8Alj={@(kwUI|4_`>Brb5K$X+G54>EQU`@2=3RhdnNPdo06SVydbtu7E;6e41^;nn4xTgLsOluA?(&WaRDkJ^EGBD-_+W2s zD|xmI%Na;Zdfg**DG~(EFWsyOD7$fjKACz`aC8nMeE{_35>XG#0hf}zUX;{cznSsG z-UaZ00BD@Kv}3rmX=txIKU1v$Z_jEWWYMRxh8LAl0CsjnbPkWX_Pul&vn7z;ouES< z6UiVObUg@h@iT{@IyK|N%Va#%t_*tYrV??56+j;*(02j(A+}}3?1?RDFY+QRQR&Wm z&Zq7E0>CaDjpb$?b~5t1VhV`ru-DI66;?&_%U-|{0wnY3T9U(FQ*pmbQQbW9bJMr^mbOQw6cNNbw-?6dW%l;|+ z82xllfG$(gR%?R`QGoifDp#)52)8sYI#t-bG%1*cKo2a%i_Jw%J`3^QiMj_#CG8SX z*mYC%4MPm0uDg2Ex?t|*aNV%9hi@nfhVHAWa2d|YHTD+S4$rzOtN>P`@lh9S3H{}z z2e6WB%$??%+(MM}&iYVT46J;!c@LYmYmXukE~au9>lS;F_?H}cE6d;410rMSQ1JD_ zoaCm-%crrj&>;ZXj|KXUhI{ON#`++A;4ChRr(VO41Ac92aoD18HoXNzX++ZEnqWir zs-j`Gv&X1g`XQGJ3u56}lhx*~)&8&9*y5f{``q-3!aSW~2ic^OWd!FYBQnZ4>uoOk z&M9G&K%E;<>Zcftwax7()X*rqS$NmI0Eu0~CuPCy(2DNJzt6~35mC3|#nqYb)v z$pUYSz^7s%H9qYCjtgrA;vr%EB4EC_gqkpzPdua521art2;eFg7AhGX+G2B3X_4Ii z9cVF27qVBEkUvH8cWjLS5d7C~1h@UfTf5zz#thKWGIfS`#NU(vU5aRP(XzLxva5z= znb17>7e-3X(Pj^n)ff&`T}@j~mM3dgP6(S1HVH$5q?Z(uJkBWiXZ=)KvR;KzE=}2o z+;ad{mF&Z96~ne^{sffV&D9N&Q~RdgIIG@LlJ>@)7yw{gp3eu%)h;+qZpBrj7rN;T9{@=Eg@D<-1eFaC)nQR7P1qRy{z`*2oP);hc%@jhEGs_U-3Yykysl zL{(waA-Mr}PytF>tF^Wf+NqvhD5M{*IWHGEf$!@U$q$sIWsx{^?aXO__>DQQWaNhr zZfRW$rXKwbM5o0AuxCcAn-}lES<@TTfsfi^P)845`u%?)q3jVX2;ZF!v4D3u>enR3 zi>-#L&t5sIc|&ld#`epe>#MW-v+fkdG9^l;)7#;TxYM92Gs<#u*bG9Am10zNC^D67 zNw@4PHY@}*H;kB2YzBn3(iUpi*Cn)#8LebF1#K-!b_wi4^d(z zR{Z;rRWP@Wra+!M@5%v10Du#*Ylhnf*JS)QfbuW9_F2gzyy3W|AKkX~VMB|bcVTL>HG)Al4xVfD>r1t$QUldCB8*WdW(ZJ?qoIVzLi_$>%r` ztJd3^A<$+wWsxYKQ(T_18U%vh~GJ$?h`+f3@U5%5{2b{q~suk31Ui4?kFN@#JpV zJsaEI>LYcZ(~EbZ*v>a4{&i4)Uy>KLdz|NuT*R zy_IHQ4RlbY=I(8^C$U|;cIU}JwZ2jOpv`TbQRvGgr9Z069j-=y5g~nw0dc^1MlXDL z3X%5^gY*pUUiGiTuXYh02rs98&!PZ#K(tXR?B(eXums;Ds;%DpApIDp{mp>xj8Kxc zbOCJhXLrhkfOBzxHS%1zMa`NlZ{a?3@FZTi+IZHJyrj@u6V+6;9g5mhBN~Na=lo)l z@fhXs{9L_5H1t~%_IE!d-%7cgB{V&lyXdl7CV{hLs#=r<7{)(k3FH`>hiAzJD19dP zRdhQG);v%}_ZFQdo4C4;Z+!8pDn)4Tl5uRannh}p@MHfYqiB{Gv81_-6rKf2!G$AX z5)gCn6$%Mlh+qcP2aK+iX!JENBM8idt^)j&qna1xc1FFZlzOf-d=s#`WtEP{37>ZP zJhG^B>*|ZP&sxf#yK8#F$&O+>Kw8_-tMkgaUNwh!YS5JdWUu5lJ+XSr)vrGxR}2k) z`93k_>w^A)+iQ60>L#Sk`O`l0)=KZTOU)|6VDrMSt&&iOB=zhYx{m!pxCveNo4H_9 zdjPJFao=G4B5|^@y}fCVXXx0OX9$bUK6*F?Rhl?V>vdH=5F%!ZfiMWRd~j`QqSRI;Tw$WE8R~05$FLUb4Pgc+{tL~yB99Qw#UVQ& zA7LlZYG5WnXO^gVEm6tNy0_)4x)sck$!HfhGep?{gzf3L-h)HaQ6qbeFSIEn$lV^=__> zhAORzVQJ2}bb$)DO_HJ=r@zkdIjmRaQ&jy^9(u5~P+mo-*^OLVLeiMbT5k3iG2bfN zwGWE$cb!*~$FUUY8THH$g#PE~cPhq|;_me+tgo7OKYLrt4H_W1_;h(B%*l+Q=QDTH z*Qz}RejO%dMiBLHhp-o4;73*7oJNoM#6f8)N{|-9ePwmZZ$I_XTbYDXn7f{d9)!a&I&Y+ynj+%JX1@RIx<lD{2{yYB6amU`#C+B6mulXU4H9_}bG(5x2 zm;&DS)~}fCH4(IdOq`X3$0*WF_}z?uUv!*Oi{%m~do*fsyYVM?_?S)9D90B?UsO=4 zo;WBY3CY8A8IN0c3KhD(JlvUevi72Yad-6e_SNxRvJY}cdF0*Z<(lnH;3HCiNDvt{eOV_vQ>L-vDZ1TA)j)Q;)+j7^t~7q ze*4>Hy}yUR-oj;NM;qAXxUzvfuP@xzs{$dqyT5=q^*XY+e7~~Gp;uzNWQQky8@YxZ zSy2sD1}@CEal3{nn`0pKbrKsSa^Q6&F-F?fBc3UujP?}x4}5r>03Z&RS!4s#H2y4> z-}5!}o^sqH<-Z`rXPipKh&|)F?j-u{Q=n`AkTLtT_J$$dCoQh!1Hg-KkOA}c`AS81 zwYO*|$_?kGFil_wvB-)+P~B8XEc(>l^MXL~{F@#Ra6h~U^JSz5#Nd4E&%casryu}I zoRYRO>}6hPMy^D`-a*~c?4CSs{LN4W~OxEBP0Cn zkuAS{NJ;!$8W&=zt!MBlMe>9yB5Ohq=@f?c1U#dEN0dKA@ra#yaP}j6lzj7psqLCE zxnEA_M=Ci)jIe{m;#wI_3h9D}Lv6*Z|Iyia^3jB8m1=IauX-^wk8~J5EEDt*VMwwR zVXQVVS9GYgnc2SKH#Zg^9HVb&8W-C4uK)`mwRw8*DPX6od4AsWrS&+txvL2u6y~M= z>cw%ythY_`DZ#jjW|B8qX{7#mobfppE*@OIfbWt)u!hn*QVrwv>;km5(J4L_*r$Ly z3YI=7JQD>oF6*foi&LFJ+W@-CeaKl3#8OncN0FcIwYnY$e(r6qV{8ajEYhi$4oYqFLviAc=C3%m0Zh*#{i_&q6k9Hb{2Hj$JPUpTjh4Z;v zQ6z)K4c@?>yxDM+q9nHKRU<;ptwv4%3)p8YEOOUzsue8vGr1{_X^de$4TpMOr35@I ztFoNpq~Eln2^EYasGf%d4?)3c;&cK)+kQ>fsfG5&m2*Db%|Zgy$6p-BVdj5zs3BxM ze>hA~7j={9ath<=om7Vhf@|s{a?il_rzZSk{*&xLo!s)cjTLnyHH&>`JY3h1f|gcb zx&^O&8jzox6iy7)dq!e6zg3xla95-;5cc<29*lsfg2+Lv66H?IofsN0TI$B-vm5)^ zWN}JNABV`P6;Nm|+#!kabRrtal+?cn(Mu{<2t&G@4imjTS00Sv)R#reO^DF4 zdujqi!oe8z?U#SSn5&NE89m9|RkEjlGSh;abOa3E`g!B1V*dR}GIQBF0FyuIzt=uP zG;U)d?)qqTBdd-QB<$E)hTrnTgK~dZr!Z)qG=U3JPi!Is(ZUKszW-Y?|w zplH(1ja9sKg2vw~lPz=Lcmw{YnBR@;;<5XBN#fVMy<946=* z#FLk#+-uMVW#&z*YEy(D%QZ%hYZcPFexPGn4*Q~Z;1C*Es*%$Pr$(v`;B!ff*b|)t zSGPOnhW=XJ*sZp9g1@$rEDm%*(4Zz>;)oW4ydbc|mb4%N0KJUSWjDITaN1L*hvSn( z-iEG%m7w8(mIqn{HOB(ULy6;!*E`?N6`ls3dc;>+CA=Q=2a=BWk6suiPaIG{KTN-t zw0Ipm`VwL**EXUphL>kSB9WUiDEPP%=Z$#kbfB55b#A#JAVln3LXO-c>-VP>U*+gb z6TDsm*~U~k%R+)^cgadT$-@Wg3u?;B%Rz;*#GuErhv1%KcH{~dpj)=C#w4XnW{Unnvi zeSI}Wxc=T)+Ka-u)$#NjU2jK(fmz@H6a|( z^F2;%3BqEp0rUXn{eT`I0l4xu_R51CGt5}!%19p@e@W}?Ti)?OjU+TT%1Hd zQm$%%qX;trB}Up_w!)I zE*Vy;s^ftFYn|9KKOHCL10-Ff6Dt2ky%}By4+RR}|HKTOtRAg|g+QBz!&H$eUp}9` z<2v|;X-EPpK99UQb@aMDZ8rzVCN|8yP%ClLqYqa}G-k&{j+LVhe z_eii(MMEh9{~`=SV3g>`RW1}?d)sH+fBnbdxGgzQ8 z&j?h~{Nd0u33J5JxMN={J7uEx%FnUcdxyvz>5413-c{ zw$$J-*A-xqJ$js|P4=^_%KCeX*RYoljj1lHEQA6?+VOLZO_OwAmtyrb0(+Aad;X=b zN)!T!=f`~tcgO9RU6Oc4aU6yeZ|j!+|EfAL8h`(Lgz)8oJF4!X-#y_w`&d0Y%3f`T zQW{e~AKC$DVVgOugG7AE)E{W?>=WRbzCi4D<_O%cw%c_7Z<*R3_hxei3sztVr2t)G zbNq2ZTkmC}BYZ9~DId<47^N&Y`IhTGl)?YPsTQlalxL)q6{XDK-xX{ zF{CZF2ghD|?O^dyyM{p_iiRhYDtnB+1O)p(Uz-%&iOfra2)!0XWhboiW6UfmibWPogex7<&EzMsFIRB*&%8w8o&1 z<__jnwCh~8aQQZu_U02a?X2Lh$wjCO0}#_6pk)k84LybXUnNfgC2uDoSp_4^+AUu9 zyY_<~Z8hahGFFf_0y?FpSSp3-T*B%xh@XN7XS+5w8!?9~8AB87lt#+1hl$YnF|=)k zm77<89UK=|?N-^WVqdj=M&=kB+fTRHOn-ZQ>Y@&S+iuGkt;^f#?91(cOGqf#2dL$P zXUWG>;IUqoDG9!*0j5!^D5Sd?@;f6m*u_e z-fmTI0QL=XgJc@iTNyENHAE^`c4F^xS94Gpp^Ib6*M3aUW2>(;RUx`OPlKspPf}eT~AUX3{ip^@4+(D|6 z$E2=ZndF6bqnu-YN8ylzK*f2sK9^p($J|vgSL7XR`34dgLs0BirqHRMkW9 z6`JaC|Ldxiop_6i$8-2>7F5>`n82}%aSLM}F|2drsj9&HqqGH=Fkl64aeLNzNM_KM zRF^E75zsJ~5HKfj0Z{iDjcVMVRv9nc9)M|eZSvC`UF?v?x%!U+`d$ukYEOJ@RJvXQ z+CJu+(6VnOOn9(PTkNfr?`DCGN(lIJUPX-Q5qP!i`I`R-=!Oqd^Q6`6?TTW{NH`A< zwXL#!9zcooGPD&vwf&tXGkn~C#6ANB@PMB#_NDoic`XhLousipL0xNJi}pIAAre50*cVT2lk@l98R#l{<2uOwuUa! zeh^9O$&o5o`&8iZ4KSw>pxzwVVuJaZd9V^(^AV-nv%gN2oXZD?I<=QqJWr$soIsW?VId(q%$hkf;dmF;G2jB25W?wd)2ne-T30 zdHfY`{CBlH@F4FrG9|k|PvFvt%)s+mOh_r{{x6BA^dVNCpJW(pdIc?g6 z7W2-r=S{Dt(8*|JiHEUnuCh6Pgl@?Y+Iamtw>br5^&l$wQoe1H?K^eF>98@wcyk+Q01L++Am8N%pVdlyj_=Jj%!LZ}4^CDz^;-!LDng54?@2Au!f@`(@LS(9}bYD54~kHF_5G{yh2sbxLE*u-lG z)n>7?xU(LSKecK|uTSl%pMG zI{#~P4G#J(51g6y#26yknWAvgX;LR}%leTu!0iApU3cb(*a>Ld{{q@p1={{SA!T9W q-J+MXf+2$jJmLem>fl=RmH+&HzYo|79Y3`ISt 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 k=dypD|C?)8$;_vOdN*Y-~R`o`n+a literal 0 HcmV?d00001 diff --git a/img/dopplerSecrets.png b/img/dopplerSecrets.png new file mode 100644 index 0000000000000000000000000000000000000000..7fe568742a7ab5abd4c0a8e76e03c84367aebf89 GIT binary patch literal 23857 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 mo^Y#>&4}$^2z#@UL$Q3b Date: Tue, 5 Dec 2023 14:44:22 +0100 Subject: [PATCH 178/185] docs: Doppler technical analysis --- DOPPLER.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 DOPPLER.md diff --git a/DOPPLER.md b/DOPPLER.md new file mode 100644 index 00000000..72cd31c4 --- /dev/null +++ b/DOPPLER.md @@ -0,0 +1,97 @@ +# Teknisk studie om Doppler + +## Introduktion + +Om man har ett antal miljövariabler/secrets som behöver hanteras över olika webbappar, servrar och utvecklingsmiljöer, så kan Doppler hjälpa till. Doppler används för att säkert hantera information som API-nycklar, lösenord och andra konfigurationsdata. Detta skapar en enhetlig och centraliserad plats för att förvara alla sina secrets vilket ger hela teamet enkel åtkomst. + +Det är ofta att som utvecklare att jobba i olika miljöer som utveckling, testning och produktion vilket gör de till en utmaning att hantera att rätt konfiguration används i sin miljö. Även så uppdateras variablerna hela tiden under utvecklingsprocessen där utvecklarna kan bland annat lägga till env variabler, redigera eller ta bort men har glömt att informera detta till de andra utvecklarna. Detta kan skapa problem att man har fel env variabler eller har glömt sätta in någon specifik som kan göra att programmet inte funkar som det ska eller att byggprocessen inte blir rätt. + +Detta blir i stället automatiserat genom att använda sig utav Doppler vilket ser till att rätt konfiguration används överallt och minskar risken för mänskliga fel, jämfört med om man skulle göra detta manuellt. Data är dessutom väl krypterat så det är skyddat mot cyberhot. + +## Instruktioner + +### Sätta upp konto + +Det första man behöver för att kunna använda doppler är att vara student på GitHub för att få "Team" ranken. Annars kostar den 18$ per månad per användare. Det går dock att använda Developer ranken som är gratis upp till 3 personer. +![Doppler Pricing](img/dopplerPricing.png) + +### Sätta upp projektet + +När man väl har skapat ett konto så kan man sätta upp sitt projekt. Vi döper detta till microblog. I projektet så kan vi skapa enviroments. Vi får 3 defaults som är "Development", "Staging" och "Production". Vi väljer att använda de 3 defaults för att de uppfyller de enviroments som vi använder oss utav i microblogen. Vi lägger även till en ny enviroment som heter Github. Där kommer de secrets variablerna som vi vill ha in i github läggas till, mer om detta kommer att tas upp under rubriken [Integrering med Github](#integrering-med-github). + +I varje enviroment kan man skapa en branch som ärver hemligheter från rotkonfigurationen. Detta kan vara väldigt användbart om det ser olikt ut från varje användare som programmerar i projektet. Detta kan vara exempelvis att vi har olika resource groups vid publicering i azure, olika lösenord eller olika ssh nycklar. Dessa är bra att ha gömda samt att man lätt kan komma åt sin egen och blir då väldigt användbart att spara detta i Doppler. + +![Doppler Projects](img/dopplerProjects.png) + +Inuti en enviroment är platsen man skapar secrets. Detta kan allt vara från admin email, ssh och lössenord exempelvis. Dessa secrets blir till env variablar när man kör doppler kommandot som visas längre ner. + +![Doppler Projects](img/dopplerSecrets.png) + +### Doppler CLI och integrering med Microblog lokalt + +För att integrera detta Doppler projekt med Microblog behöver vi först installera doppler cli. + +- [Installera Doppler cli](https://docs.doppler.com/docs/install-cli) + +För att sedan använda det inom lokal utveckling så behöver man logga in på Doppler genon kommandot: `doppler login` i terminalen. + +När man väl har loggat in så går du in på det repot du vill koppla dopplern till i detta fallet är det microblog report och kör kommandot: `doppler setup` och välja vilket projekt och config man vill använda sig utav. I detta fallet använder vi microblog som är projekt namnet i doppler och dev som är development enviromentet. Man kan även valfrit skapa en `doppler.yaml` i roten av förvarvet, för att konfigurera för användning för lokal utveckling. + +Efter man har loggat in och valt sin miljö så kan man start upp sin applikation med hjälp utav kommandot `doppler run -- ` (exempelvis `doppler run -- npm start` ). Detta injekterar alla secrets från doppler till environment utav det programmet du startar upp. + +### Integrering med Github + +Att lätt kunna lägga till, ändra och ta bort secret variabler från de olika enviroments samt i github så är det väldigt användbart att koppla Doppler med Github. Man kan välja att ha automatisk synkronisering som ändrar direkt github secrets om något ändras i doppler eller så kan också välja att ha manuell synkronisering och då behöver man manuellt klicka på att synka för att få ner de nya uppdateringarna till github. + +Vi gör detta genom att klicka på Integrations i vänstra hörnet + +![Doppler Integrations](img/integrations.png) + +och sedan klicka på "Add Integration". + +![Doppler "Add Integrations"](img/addIntegration.png) + +Man väljer sedan vilket github repo man vill koppla Doppler projektet till och vilken enviroment man vill använda sig utav. Vi valde att använda oss utav produktions enviroment med microblog repot. När github integrationen har lagt till så kan man kolla i Secrets i github för att se om variablerna har blivit tillagda. + +Såhär kan det se ut: + +![Doppler "Add Integrations"](img/dopplerGithub.png) + +| _Här valde vi att bara sätta in DOPPLER_CONFIG, DOPPLER_ENVIROMENT, DOPPLER_PROJECT och DOPPLER_TOKEN_PROD som vi behöver i github secrets. De andra som står med som secret variabler är de som inte ska synas för någon förutom ägaren till github projektet, för att inte råka exponera lössenord exempelvis på Dockerhub i Doppler. Detta hade kunnat gjorts olikt i Doppler med att använda sig utav roller och låsa de konfidentiella sakerna som tillhör en själv men i just detta projektet kan vi tyvär inte göra detta nu pågrund av att vi tre som jobbar på projektet står som ägare på Doppler._ | + +### Integration med Github Actions + +För att kunna injektera din environment variabler i ditt ci/cd flöde på github, så behöver du först se till att ditt workflow har installerat doppler. Detta gör du genom att lägga till `uses: dopplerhq/cli-action@v2` i ditt workflow. Efter det så har du sedan tillgång till doppler cli. När du sen ska injektera dina variabler så kör du likadant som i den +lokala miljön `doppler run -- `. Till exempel såhär: + +![Doppler "Workflow"](img/dopplerWorkflow.png) + +För att du ska kunna få tillgång till dina secrets behöver du också en token eftersom du inte kan köra `doppler login` då det kräver användar input. Denna token kallas för en **"Service Token"** och går att lägga till under inställningarna för någon utav dina configs. Under fliken access kan du enkelt skapa en ny genom att klicka på **"Generate"**. Du kan låta din token vara gå ut efter en viss tid samt lägga till read/write rättigheter. När detta sen är gjort ska denna access token skickas med i environment när du kör ditt kommando. Se förra bild. + +![Doppler "Service Token"](img/dopplerServiceToken.png) + +## Relatering till Devops + +En viktig del av DevOps är automatisering och förenkling av olika processer för att öka hastigheten på pålitliga leveranser. Eftersom Doppler automatiserar processen att hantera miljövariabler och konfigurationsdata i olika sammanhang, bidrar det till denna process. Doppler erbjuder en centraliserad plats där team kan använda gemensamma miljövariabler, vilket underlättar samarbetet i teamet. Detta är en väsentlig del av DevOps. Doppler tillåter snabbare iterationer och deployments genom att göra hanteringen av miljövariabler mer effektiv, vilket driver mot DevOps-målet om CI/CD (kontinuerlig integration och kontinuerlig deployment). + +### Hantering av ENV och Konfiguration + +Dopplers roll är att förenkla hanteringen av miljövariabler (ENV) och konfigurationsdata. Eftersom Doppler centralisera variablerna så eliminera detta behov att sprida ut dem över flera filer och system. Detta skapar ett mycket enklare sätt att spåra dessa variabler. + +Traditionellt sett kan uppdatering av miljövariabler vara en trög och tidskrävande process. Doppler automatiserar och förenklar dock denna process, vilket möjliggör för utvecklare att snabbt genomföra ändringar och säkerställa att de omedelbart sprids till alla relevanta miljöer. + +### Integrationsstöd + +Doppler stödjer integration med ett stort utbud av verktyg och plattformar. På grund av detta kan man hantera miljövariabler oavsett teknisk stack vilket tillåter Doppler att passa in bland många olika DevOps-ekosystem. Detta inkludera CI/CD-verktyg, container-teknologier som kubernetes och Docker, samt en del programmeringspråkoch och ramverk. + +### Säkerhet + +Doppler bidrar till säkerheten för känsliga miljövariabler och konfigurationsdata. Centraliserad lagring minskar risken för potentiellt osäker förvaring av dessa variabler. Doppler erbjuder en säker plats med låg risk för obehörig åtkomst och dataintrång. All data som förvaras på Doppler är skyddad med stark kryptering, vilket garanterar säkerheten vid såväl lagring som överföring. Åtkomsten till olika resurser kan hanteras av organisationen, vilket säkerställer att endast auktoriserade medlemmar har tillgång till känslig information. + +## Slutsats + +Doppler är enkelt och smidigt att både använda och sätta upp. Det förenklar och snabbar upp konfiguration av secrets och environments och håller dom konsistent mellan miljöer och med bra möjlighet till personliga variabler. + +Vi tycker själva att detta var väldigt användbart att lära oss och kommer definitivt ta med detta till framtida projekt. + +== **8/10 Fiskmåsar** == From 5e805db21ef78074f2222ddbad7213b12eef4664 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 5 Dec 2023 14:44:37 +0100 Subject: [PATCH 179/185] feat: Added doppler.yaml for easy configuration on Doppler --- doppler.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 doppler.yaml diff --git a/doppler.yaml b/doppler.yaml new file mode 100644 index 00000000..ccb95e0c --- /dev/null +++ b/doppler.yaml @@ -0,0 +1,3 @@ +setup: + - project: microblog + config: dev_personal From 1bb3f193d8f0eabf144d6da087595ec32a155fa4 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Tue, 5 Dec 2023 14:45:13 +0100 Subject: [PATCH 180/185] docs: Changelog updated to reflect the new release (Kmom05) --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c8d74a..baf0a2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _No unreleased changes at this time._ +## [5.0.0] - 2023-12-05 + +- **Branch:** Push development branch to master branch to reflect kmom05 is done [PR 67](https://github.com/FalkenDev/microblog/pull/67) +- **Docs:** Changelog updated to reflect the new release (Kmom05) [PR 66](https://github.com/FalkenDev/microblog/pull/66) +- **Docs:** Doppler technical analysis/study [PR 66](https://github.com/FalkenDev/microblog/pull/66) +- **Feat:** Added doppler.yaml for easy configuration on Doppler [PR 66](https://github.com/FalkenDev/microblog/pull/66) + +## [4.1.0] - 2023-12-05 + +- **CD:** Add doppler to deploy workflow [PR 65](https://github.com/FalkenDev/microblog/pull/65) +- **Chore:** Replace vars with doppler secrets [PR 65](https://github.com/FalkenDev/microblog/pull/65) +- **Chore:** Replace config vars with doppler secrets [PR 65](https://github.com/FalkenDev/microblog/pull/65) +- **Chore:** Get .pub ssh keys from doppler instead of files [PR 65](https://github.com/FalkenDev/microblog/pull/65) +- **Fix**: Added task to update requests_toolbelt & urllib3 [PR 64](https://github.com/FalkenDev/microblog/pull/64) + ## [4.0.0] - 2023-11-30 - **Feat:** Added new alarms - High cpu usage and High memory [PR 60](https://github.com/FalkenDev/microblog/pull/60) From 615214f67a3a61572775afae272d7e64c27dedc4 Mon Sep 17 00:00:00 2001 From: Joel Funk Persson Date: Tue, 5 Dec 2023 14:51:53 +0100 Subject: [PATCH 181/185] fix: doppler images --- img/dopplerServiceToken.png | Bin 73333 -> 20824 bytes img/dopplerWorkflow.png | Bin 46024 -> 12879 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/img/dopplerServiceToken.png b/img/dopplerServiceToken.png index 46a2ce908fcc3272be0599bef3adb8f173208389..72e2f27a5d0d341e016c206491a079a65d5419bf 100644 GIT binary patch literal 20824 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 TW{!Z{7Pv9bfTJAu=*+(XN~k$l literal 73333 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 z z>6D1bw(kKCK!vKDDcM*1(~*3PLKbJ-gy`umV`O`Z#T&UyP=nN4dk6Op3i=Pn&e8@2 zs};@hv^S$+Xm|*LJ_0TpW_AiSDJRg4>ZkWQsW}pwUnGADXIj z0TBF1Kq1)%WK-!ldFT5@nh0oz&3S8 zll{?_FR8d6i|DxPi*7Y%9f8d13jOyYWE;F)lqS1?5QH$2EaEzBS;gXQay{M~t9<&jJ9 z&rW^Uu*~|#zBJ5PdV@$Dgi-w0kXrX=-jQI3Y7d1(O)EQMC zEulU4uZ*~BIw|d9X~m$%E1mtJ6XAZ!?O2tr{s9t?-dYel%yI6e^n<7uJdVK4`t@={ z00JtY0JzMmUquAb6qIrlzQ2s+95bFR>|o|7_lWwj@kzi9@ckv9LB*?!)gk_Qu8;mv zz3dnue`btJOL?09n7_FtV8w|p4in+q>h+=cbhrk>W#Zn3l|sd6Sa0n@kAMLsTFrXj z_R`Ks`jtENHO(HM@cL*DTn7Bv*`1FNj7%d|-6lNQFi0g|+10dwTeb}VstJ6Q|HJ0+dQu(SxL>|X+_h^rlm-BmV*AVg&5_u*( zNFgr&hh;m5ivLf#od~>!3jE8xgQCR#uhg34^8=2m_`1&AlXR{>f9;T$R+cK0Fb?=1 Dn4lnv literal 46024 zcmd_xU3MHvj)c(-<%2T$BXa*6>+hOJswjzdW*+*?%bXEAnGx=Az+f;Sf;IP-pSO=6 zx1S%kpZDAS<#zvZd%XMK^M76W*}uo_{p0rW*u~bop5K17?tc5Yy+SL5{r32{Jzid}Rj(O(xxK&Le*W~ow%+^o{@Pjh+vDwV`}}!3 z*MHnTyOzDjt#cUP>MVTz_G`g>y5DE6-+vr;+MU@SdGvbw=<@yj_TdbXy!Dw~_uJH+ zEj>HVx2KI(KVEJxuQw2 z(*5z~@?Wdpe%@X`Z~v`5$5H|O%W2=8^L5W>xV5#YTG?lLg-;&d?-8JMMvMV1-p{fg z)9%Iac!PL%_V51YPMhG|NGb+8_GX4_g zsC2)*eGb*U>gX8l=;-I1xSMT+xFdQz@Pv-n-QS1M_Egy8I~E!hXcjU4Cy@h_(6g`hF`wACEk^ z|LD%Xca3|q+c~1{h;Bdb9hZSe{tPiLFxVv7U?CGU|>NGCwNns04b zUXN#GZsNkHe(uS5Sr#=nD*$&-lG7vg*T*B8$4-?c#fonQSQ4pS&V*5##N9UoGevXKi4ydrp{DANE~w4)B@@I-Rj<#2cMV`(Q<{qUp{T#c8VxL`xEP6U~G z8ax?HOaHE*;vjq}lR``=Cm99cTy*WqyFkiwwNFr~P#9Q?E*Xyqk|-rFYo>OgE^ej< z8uK%{fRUxF3q5T5{<+Se3nLml@?KuhN)V$bx`fG&?_7am0UEXCULLa0sRS3%;g{F3 zys0Q}Gosj9^dP-!`b{&9DidA$nBR5rWrCn&H3b(_>(@1kN3HqV@;VgdjbkxR4e&D? zhaT^a>qgW=B>p5h|ed2F&ar%Lv_PQAl&zadRPBN~DOka7o5OQnOJ3w#L%8krb z5}lEYd=FTDwr4nXn*ft7Hl?h2wJ-(+{6JEUY{&TZ^XPyz+PY;?T;t!T5dnW6@Nc&}`;$C6KsfP40EDN&%> zxh@gX=rJrm$|_s*D<_@N#}r0l=fsBOUj^9WDMR%{F}-$Ep|PT^mG?evM$8;963INCaXHsKJY0lC@+` zfAgqQWY;l7lg_jSXV5UWwQaSC5eI`g7Q9(Ds+*i#w$rt~gCdfS0^%Jdt92 zZ>i6|4!bW?eA6hR8RN1s6|K+IvGa-6=a8BmPtY)TQLO{Pfk*|;*B0helrc<$&Zwj; zF`qtWvE{}I2STY`WEPfDeDjJ3Lx{hbA&e#)=HpT5cE1Z_e!DQMkMbCQXny#`?l#hA zbU-M*I0=}@WQnThm2gwf1>YXxC3?t_h0FrWHtP$=cL@dsbKH;nT`nbOE^Hr&+u>1g zsalXNVoz0+#BQ`z$}GxB;LFq^4RpA7vV^8^t2h%>VYVmF5E~cu3sR!GQO1)6K8^9& zXoG5VD~HL9K|}))ye1-R@y#f+XKb760oT=VKS}I68LFF*h`?xwC=L0<&te*dK^HiC zbd>uJpt10SqeRwmT5k^`1j^WXm37Ua$V@dLHi^e8=Sptub-#;6v~i^vvXMjT2)N)7 za*yJH*3 zmlC3*H`5goRWhvv7Xpts8Cy9CRnKm&v|$KyJ7){eW%C(;x}}RlL_WwMUBj0+!IkpJ zWyxt&I!_yQI@~_`GU5kOE6`-4Tk9eapF8J^4uszsX2t*$yx~Y$#_@hdzB=8Wk?I$6 zAg?^jWJ?jUf25#HBd(xOQ)^tM9^AJ{SztmRRnI>`rot#d=yy`%Vxh5m#mikQ6sPVy0nDoO5ya4GV08B=^6*WT-mb(!Rwq&3!GA8 zj#Rq5>Tqgg-Z2bxupzx~DUcSG^EpOyz@RKH`e_oF;5@hU|2)^^v0IFWj$7==jBwK>mA zIIS>~zIVkyFz0QCM{iTr#7c!707Y8$`^8yP!!!C_^K>EtmooUdsFTp zia#bnt+YIATJ8ah81-vcp)v?Xl>^$3vO|{?Y>$YoZx2V5p)O91_!tbu(f)~9C~&Im zb>c)e_1N=IE=21U`UyuwikwKcMIi=r=i`V1q4q@u9oZxdg|3j0nP=zOqkE{5)s8LT z_2ObPtmLF#Q;JsvgG_F_1^6N?t~@DMH3ZyOtR0sRty?kG?)MlAz)sm*B-4fo0q`ww zln-CmBP}f{GXGoAcyl-yebScd#uKE(dju&kg`OW~5=E0cc_}Y$mcUZj?Twmag|afR zc8*85Kq;cmm)|ybjjd9(IY3!c{NS6g*E6ap2D>p{ciJV;Lef5iC4)u~(N8eRoj}EJ z9H^gd@HR6N6lCc(gZM1kZW$rDTVV=ON{S_yT8`-1CKwXhhEgd~Di+yvPR}I^pwDr! z#hF{(m;KOoKu#A6)hfshQ^j5~l^d;>m<&eQXmFk4H{{|~zv5;~RIp5IBoPYrMWzE) zAg#fEAXh4g*G-*c5|v&Q_!K=%JdK1nyTUEbP9ZrWtS-iIi1l?1u2?{th_4s)znigP zE(_I1NM&Fa9gRW_pTTGodYb{UYk+-m|VI$>NRTeDGYLC{rxlG#5vx1-1C zJj|CpFtwo!TAu^CTPe2Uym;$~Os*Skyjzrkn$>mh`Hww~0z$_H8J-{n7eK*2QtJf^ zgWhy{0U=me7R#pOf7~w`&mnqN7xxY93m`z&U`FKv0Wl2-^0*`1!%^ChR|>@5RucRc zN3a7s@)bC#pvU?tqb8)ROuGJn^^{cHYk7$*KYtbVl+qlCtbf>fel3HN5V`9D zWwQK#x3tU!6$0k_H*40+*`)eP?#tl*ixfGFFvTpXzb^lApg&+ysn8R~x6ATEX^Ql_ zxow*|Dgd>59pD-7AATjNg>xY}@roj$l|tZp1!T@MJ6CCB+j-b3V`Z{nDw#EhX=Ar! zh2H^Nlm-j(7C-trZb))>Vv8hpBO_zS$=iCD_|(@Xnmw;+5{h-Yn1{p2#W+QWdDPam zE610MN_-PZZg((w;c|P@Pxm`o@S6eKvbP@^?T8<6h7rxOW|LXEpsZF(*9qzSCa=i5 zB4rgyo+(;N?AYrcDT?X4+JZ_I7IV7s5deFkv!(4KIEo`M_qW3dRHv&o(N;{xo(QSi zQrWWHQIZi#du2fJ%K~XDfD;wg)QHxTnhkP6ODWc_?4?J*n-D34Vh_S;votQN5+^Zx z-bUrPp}ciElB?1SVy{v%>+>27@Q5I^kXRJ{usz2&N_3%2WTW*W3i6`SC$+7mxZh!u zFP$G~5=3;mxU+L!GwO&MR&`{W$ZItTBHk8%nqO3ujHS_!YoJ#Nhl8JZ5I+$oOS z7kSa6uRud*J+Wi5KL|1&ydB+Y9it3cRUTsQdfPZU-epCh??%6`uZ5*;KHs(MtfFvMlRCP(E4^4*|W?jt|SSTE;+LwpU`6 zh|r5JzW1f1ggs*INu?yQVj0qI7-_p!BGa7etlYIhHX#!H;h7pl4cE~^ebd!^*fDsG zI9+;z2ve)C0D}2E2DZ~|iQeUBw8-|@H2xIEr0%>L2$3mFmb71Up>#iS%b%&?s9Ges zlN;=L%4}0Ywh1mgIC7(3i7LLbh2&ntaKg;r7*vKiPLtgf=r zG4yV|6oma$P)LZ2S5?bc->7(S@Gvhbfjt;VzdXoV*1AkPqC-g=Qk5i8Yk79gA7Hvt zf9+dEe0aWqJ;s*XJ&Tpo=&)q^H1TyzHd_#lcitlG=Du@DQ8mZ+E317|^I}{bbY4JG z9ut$%u23h!MU09?h7SH-H-S{&QFYG71%6Xi#tI`mW26RV!S`HTwFzsY)K zo;y(yUSSu+mS(4n#mph5@hj=>k5v|gm?o^R$wim)N(NR1uwB2*oX{)mESu(yn~SmM z1wb^=c5qzSPnCY5li~gMN|KDxEK{UDRVe@s9u15w;CvT7Q^ujbq)%L9q1Aa1(Ba#T zmgmAGqFhxKU2PEUYTH<)a<=Ol<-=i5)hs83jwi*0X>A~XXp8V&)-5vSEe8i;CeA4p zQB3kV+^TXkQIDJeWvvw!Hjs2oDZsxz%1)#_LnLm{6drU4m$0IL8ITD;MFl9h6f@Wc z&5-I-05uf9NoqfuMwy+5E@h4)YC&YC<62D(7ifp5AB?~|xeY|9=hG5%HXN?D62-Cw z%9c!B3&ZlYsyyyQc`9nP)p}kLAxeA{`wT2)GAm2*o+NN9L~`vANCHD8=bcdeRMyQM zZ9ZlamibtWLf#J-!?K7u5FU{~pVwdHh5=hzfZXwkS?ErN&O@3rXIPPTJLE)xx9GHH z-4c0`5C#+o^L4Xm$k{w;uNkSRfQ{>l{_`TBRju4B?^2UT)qR*?$*Is{Ua!et%t@N?I4_MM8I}sTX>=r& ze#ivS*>G5ku?_p!mbfG~KjvU+&;?$(xpu$TKa>M{(LxHtxGbUQD4yf|QFFvp%gg%} z92W>KQx%Fz+bcpfX&c{rV5t;gm#*$LQ^`T6ZknZWFpy;t*9<8J*?w11%q{Obz(e>6 z5?HO`fYP!l=yQuwfkm@Ha}xv_jK_Oj0OazBe#30*IGBPrmS6}vk!8Slu|q;t%Sl5t zfzV8%N5K5u8NuaPR+AF|Zwy@N^m|ay=6oGslGe{gt#Ww^l)v*IXVx?Wu49CfSDR#q zcC;X70LWq5(CO${JoTj9sK@qi7_=ydXd}7ae{91$<4zx7Up<0<{ed3JBZjB6SI>lE z2E(LWhv0H_Wg`(|stB{0-8K8XSb=m2ElPoG7*N$q0c*dMRSNN-!@8SJWh!WrrE-6i zIrlqU`A$RgC95;G|Hfvx<4Q+U(pALRhJ#DNWXA?2Wahp8b=Bgniu9+aq zr_#E>&(1j5vRE!mG^i*cyD_y7L;-Z^vtC8yZHJc4JTOue(f%U^yEEUBfZ33_$KjaN z*(kfMK@(bg^7c@KuNB1TCZaBtN+g^yE8*H1r9QA*USMUcnmE0%&nRN5Qh+i50O<-I z%`P7|+jM81)zu0MOY$qQC9lJ#8A;jMmLm2mA82J>v3q%P8n|NsOhm283KmNb)QVd2 z%7-u+Yk4z*d<=+u-ixaCX|{%gT1NS=7w$D%LAIwjCDmMk=lld&HnXCh#FXhA?7Zc_ zd+vjo2oo@sZF<)O+dmP*!D;)PTE;hCZGJ6Aol<*`h{^P3hw@Mo)LNU7GWZ-xla+;B zwNXRhX@#clqSsz{7sBjaew7rniIxh=BoOqYaV`@^2{hyNtpyb?1cJq1vaBvAK&BYo zQwC>yE*aATD2Ul?VKu4Q#L{VS>vJ|judK2j2~B08X@-XtmcWP_7F(`&Sb(;~t)dFy ztME3gw^;4?St~C%hacB4dkuDep9l7=C}mZyydcqGK;w$o=#4E{GR~yye8Qlv~ zM)6Z;g;JRJ;Ss3QAmn@!TiL`!&c#A0R4Sm;9LZ>gomYY-KTm3lr#*^(;zs z+qoyZ+13sMe62dflrO*-nlugB^L{lSPc#Zd6{*gSmj9dptN?bkoM?by;2KFt z9|Em^40k!(6h_Bmak;@)<6DO!1KtZ~{9LMAJZc{HRTvo`I^kT#F3NqWsx|HPq7ETH z*qZrDjnt01HHk9hLRK0OTO@a-s3j|lawCMY_?)0e0MP*_mjs@lIJUr%MxHy0tc0<% zPP8FcXlBvPlBHvhEw*yf7I`gQdg;Q;IzjRKe9D=Pp}CsJO9VZ0qBb27NS}OLnH3!m zEl5hXt4RWa5w1$z(w==CwSdaa{>q=!A+@1|HxO>MtZAI8rK?mRUfX%Bf@QB*G!z!8 zY}qw#>NtFDTgIq;j8RkbB0m?Qvt+rIn=1R>{qe!DbRehJ*9an^(96NGxdYeYSGna! zvU9RP3mD`Ws{~ok0b`zN50A5N*cECw@wMNRnU=H>YwJY;tV^{Bf|XRH;_!y%waO4R z7-b@us1}IzD=tgKGxR1Sr1KKX z4TTezu6e3tOBHwao*P4@U>61PqZjKCxRra03|d*;TME4qe5N8ioWMwi#TgLonLt{k zvMZP9l~^Hz^ydQ@5Fr}^!CW(+bX32|)6zcxPM>{LnZl+!eX4&yO(R^3Y^j-K~JDpjKM zf_NbPR>(u-LPw*O2QiK8v>k~TbDoqLR2`YmP+&C zR7nbs+>%usD5XfIN-|oz9H@ATCac3%R)E85#NGpg zw-By&MX0HdFtfPY9m2xm3VG5&2wHMr0Pm~GF|_rujvo&yv@efeQUILt6MA{-l>H|s zf|ouFH;qXYYv&=ZobP#j|G{DBsV(rzb5CnIo$5BywXyS|if!w`@_9m(wCwVZOd+|} z(!!}hGo?dW&i7)*K*bWy{fm1bBTtzOqlx!Ph2#OIA0sm3dA7FK+CX0~zUFXgV90|g z!V$gT4%Eza-UN$?b!oKVAT%4^vN}L=W?9J2RA_;r@A_j#HvoY2#931UhNF6-K?vOL zz_lz>N~KG7I!hrWu`w%j;1Q~E-IqFKamNY_FJtJoSt_J5fb`|FHG9?7HiPHsGNl{- z(_1kLo^xF2`bLa`EW8zG^0DGoPld&#KvAjCv)n!xNMjdMr(~8!w_GYT)>dv4`=f(! z@|3Y`mXiTAce#l?ZOJ-jE0sPZqATuoRG?c8 z9yz#&L27O4E?2R|BPQJZR!{&a1CYy%E*}TZ;0Ejlg)WbMIToU2Y%4bI`Y$K)Z_yXt z;P-tx3XMR?O8Br+MitObJZosC^ygqMbZ`1qi~s>^7!*m+>`kR%5GugUll-I<>6}b; zPnGE#qUbBNFDFSxIGjf>i>lmWM(wtmlKe`&g0cXWX=k`_BE!}!V*rTU$0nAs+)w_o zH7ySxZ%jmBO2&PKCI@x9Qn+W$u*lPMRR$W_YMdPbA-?lz9@^;K<|@$VW4EYN*wHo< z2@#0_8&Xn5aE+Qk8SC?DeHrbU=`eAGJs;Qd&K%oTqT2C9Y7_g=`R38>C`+r3ShrxX-|%xbF|!IHwgOG zeZ1!(Z`XIT?K>~4ul~0M5tfzn3Mj{`ycLa%H?nkRZef3`xf-oq;}GJ6nGnmOex*Cc zGd$&AZmcC+Bsu%@)){HrlM0Y~Z7D3Wt5yv1xW~7nUI1&p7VK(3!UD+lnAv*+5Yy?jZ@m>;KYdaxl;R>tivLg_cK6qCsP{Z zeCReBr&>{W-XUtT*!5i2267Bh)D>gOrgH;eul~w5&)xFm9`2jHv?4U=)2%$vkML=! z?Yrik_+s~FXuHFqJOXx?5ZOnpHT@iX7j$+YddJ zi-@-8mE%B=MC7_wI@4ZysK^zEc+tDmgzOr8D>|jb?p=f@neNl23iSA>A_K8lw4nM@ z-7((enndvsCF=YJ3VM2ZJ2GHd`~8Gb`#6s9`cT)`I>MCcDlBW7Ehq)X-#F6gWKf(# z96N|Xn~J;S6~P%la-c0hMXfrx?U|0AyDBg8m*)odsvsrYTb&Jvc!0KT55z2Vfs%Ci zB&J^{K%HMmf#fj1a;{-aG4w0mQ{(|MNd$Aw{J3_YbVS{EC^wIon^K)Y4 z9z|?#sn8b5TQz6%=8VI~eC2gNo@{cYo08#>g6da9UpjN8it&ae;3U8fr;c*qs%us= zehc=<7w4vkWJf*jIV*-HN$X$J1bw#1$B$s%QMuVEhnb}7+4GPwU}VdKKT@oh#Q)sk zZww5J<*^8gE1kEpL&oy@+eR7HR|}IQG1=3nWnqsoen^z0xSUi*mH?(-TJ%)1EEZL^ zLRlqsuP1G#ks_9F4Yc6$)F|otyT#Tw{s)W5wLM1HUuyQt_-B8 z{oxh}u#wrY(IR5-hQqGpOF&H+S+9Sl7WHe+Y!2QtFemiMGEkvWIf&%eDoF$5{S!kHTiMICS8=mw1a6 zkw>Va$Nv2$aJr&2SFi}x;hjpY+JHxy4?lQzaY%Nrqw_j8jO>9a3q!L-!@k%i^;i^` zWqObFT`rv*IDf_Ogh_9o@hsOis>DR8^0hFqlaE|DVMjraI2Cgxi7R~DER?4;)y_=% zAOL<*w4Lt-|5QQTja`Kjh~;N=I9aRGHeqzHf~xd99fe9>PkzAKsUD0plg+` z8o_gE#hR*$*?u-twqWC?wpb7fG`CF-T4=5YSC zp#+kA`@_%3maE!3Etc&32vq0g;MOVVQjNBwLtiV(jNoVSw@bddkMWxVb@TK0#BS)t z6YeKDUs_d12h)0!2z*=c*qB zRjEbn@&W(3MLW=i+vlUD;hGQ45*enUs1V@TT>d86mZSi?raTHr8k$YoQG)Y*6SmI+s!QkM&QC*FFoszYJ zM0W?518LAqvl?z5w~|f~BbAA)v+$#A!oCY7T*;7VuAHk{MohxKL4;Qw+&YL-lI*N3 zS{460)+1W9=r@3mPImWWTUhD5fxEX3$32sGLV$#8RM|GaDEJWAdyROr0rz?)6lMVJ z!P+A>5J_re%epJNS}0BwJq2X1fea2UsNCzL?U>8)AfzW+p<^Ed<$Xj-p4| ziQAQ72X*O;g97YEeJw`}Jqn+`R$4$uyRxzF(zbr<5OTG|l&Th>cNh>u>fD=!+_C?9 zn45wMM{}UJl7{VSt1}?oj`Mf~fP;P~IK&2CP6N8z;d!t}gZ&*Qm!iU|m8;Y>?uKMjvNx3QTmdb-6bT+)Y@vGM-o>X2(3`~MarL}kugdp#K9yoUnQBD za1KHY7pQuS_j@+T20xvHt}+lNR8{S?N&QS(0-&4v5lUnn3-l6ojXN<}b8U~D;^7`E zK-kxu>P!MMK#CMrj_qot9|8s^?;;okS0AB~A6QU}ifUS&j+2iu+Y!k}Au&X6%3+5p z-ql)2o<6<@081S*bKN0g#qQ zTqyP(QkCb4X^SLWsVH3dKU;Rmg~4#sg%3K-12P8i_P) z$OEE4W7&1wUL&;W+}|5X?6MVOhjq?$0?)@-wjkiX&L0XX)8QWlDQJ%ny0WSqbNW5f z$(EzL4BEcYPS13}Z{f2~BX_1U+!>}12u=lBlW0AMq)ne$;#z_j^sOsKEnN+Tm@WJ*5Zq&qPEL(2QklMK3B^fBO6s8it2=b@ zx*Hryp$bzps-b%fDObZa7dmVD-{mPXuM_QN8%5tSieI?f5Ksy{&`vvNs2yV{cLGpt z*Tl=P!JCKYrP93TVmpLrSRHZ|*dEO9)ms0K0 zd8f|pB8Ry_R2bdv&=Tfpn1h6FCD~q>~SFv+8lcW zbM=+;pvw#nBQTALXq%@p1|a}Kzr<@Dxi@|8Tc_5bJO64kgJq)+z0EikmbImMN!-l2 z;uKmu>TZQ{o)5TP@|C-8Y8pp$2&{@43=-vhIH_I7ry^@Abh=)H}j5nR+S#-RNKnQjP?Ko4#2tNrzn+At#6;jLC zUOgb{X|h3xX|aS<`*k>HHgo+e)Zp2f`IRHF4<_LTh`1@s$ldwX+<*&L;!hQaemh$) z66x&|VmYy_IHlZ=&?%mh8oXUSUxt#@1dYOvA-I-!XOLVf8NPs7L*m8)O_0BuW1 zlvr-&OwydsOL4c>G-^tx3%E*knnDZ?_Rh;r?Br7{(6OvTSI(W=fyDykK4_DS^GGK( zu?0ou3J1hyHrL|a#?xp%loTmDxL!$`!7y%0GMzJsTIT4KLf64zLn`B>q| z5|V4&-7CU2pBW>ct9ufXH9sOQlFbei-E4MTccxOrM?ZpU_$}f1rsO9$ozTFyjO9C# z&6_8OX&-1x);g6LS_>ZbU6)Y5Ltyarx%tUwPP9Z8`jiMh#&lSE+77l3f&M= zSXSOs6sj@Jl$f#-aXJ??QBixE+_k=~xSiT$0Bl+Z7dv4yWQvQCAsI-cMu?QJVR$)c zh*GQEW~PN%T2JZ{*`?u{qGlkITCJQf+k0)Oy@E~>toB_UtbM5G9v8Y*Z|PCqkfhWNTChk05;ST)gU%1|a_h64^VJ%x_#3g93{+{3zl* zS!h)itk&12Gst4(PCjgC9E->YrFB|nI;>@5LFtyHD`My9AAv-iOFP@;k0+)K*-3-! z7Fc~9Zl33=Mcby`#dwXYIv%w9SC1<33H<&Id}0Ft!K2vb&H(6G*#kTkS+6>3sHo%B z*+)}~F1Y9eE!k7-d0+*?xEy)%ZZF&skvJ7a?&lE!_lAMG=O*Mre$`fP_8?#UhTlRe zx}d~?mQ8Jwj_WH0Kx1zY)N%S6Xuob>tDS*Cr#-gx4=ei`8#iU=3a|b?YpbwtN*4s3 z{p~XH7OEW>WuKn@ab!oui2pe{ED8CX`ztE;-3=$0eiyjI<3PnQE%4Qs1IXy3@QP0xr>)5CmD5>GV_7(+N5s=n$t6ytOYes$7g#f+5vWKo3or10j zSC$<|s6D6)J)W?+28HpVwl^l7X>c=9l@V&0!`QMiY(rA5!gLbW4R@vgSCw^(b8W90 zO*&Ipmb%m0gu|1BS)mT+B3Zb|`v)Z^Rn&i4W%Lz6-@T&2yGX!_@}Mj#Mr$Onh@@og zPVM#|s_@mlYml4Vg=6KEjeap7iftUue3jRMcF_eBl(xfYU^=^MuGp(<$RjyP>UJhd z2aU=NDFSw-+wdJ!9n}7RDNKmShmEMxQHl`M`bzGX?Ydn3Shqy;yjERh^x{IU$#bYA z#;wRe4rnat98u3Zp0!-~Qqm`(Lr|C6u|Lpt$@H%e5vgQN`y_`}7cJO53XTe0j?o4R z9FSD2b)2EM2Kc=`(B9-YVxTKEp5y>>(H@G2LBukqVKATz)KG$(d0d!%iNSp3f`kS} zlfnYaZ0n;Ae-2;5^gEdtTkZamhG?>fq#R7Qom8vsFn%XH9 zXbUWHwhO$}j(X0?R371!@pZi@uU5!ON|%H(ooQ82eAi%rSMcG7y8+gT|61r~9fMo} ze@#E|zOn8Zt_Q9ELk<7`+u$Za(JM)MKF(kn@gqe&dQ^Au#kGFA5S9HBVIr>5h(>CK zUZEdS6ODSzY$ELb2D<;3SVYgBx`>Hnm4xAis^1dHt1cEdLz_Q3z%s_(6z$TW2Psary3QS8Pi*_dEa%FTgi%7{ArsA`Yg_bp+Mq9l#mi`;}0xFfzbnXa}=117vSqO9H<10|Bowa`A$6 zY-w8|vr>jPB<{t>>ZKqze|O$qU2YaL$L0TC)`hJ_lVNY`nN zVh$;8E?=Us|0ZJuTI(1qA(j^_)SNAu%je2VLoq@q9lfeW=QY@fdj2cG^(Rl?p%~aY zlw<~w!ZM*%OHFIMZhJe!E82*ABeI-XS{LeaNupMskLZS+nM?Y0gvgn}YyDou9EW0T z)wb7Mu8Zjy5Wf*w1ZE@t7*EI1>>`r(cBTX$E8TJNjM^tYu!PcyCZBM?i-gslA;;Wi z(2;1)WpmH7duVT28}^>Dz=Ftyh}19DYI}%HwtKtOrra{5_p=FVWmYtCHV}hSS!S)` znGgwOEd4V0Ogsu5u5fdt(1TWa6nE1??n`RfSIlBN1DM>M+GuSl&-JCU?Mk#QS#EPg z&J)v3t_`tHp&yxJv71H^aS6Wn92FkZELcXYRyHk;-Rnnvm}(5^hM==Q6`6dt@tHtE z(m&e_M5MQ`%{x`rs=ZFpuLimaey%CaB1#MO3W`v=%g5w94b->P9PsmT%x~}XIHN~) zew(P8#fcB$6K#G>XO5^ZvVuC5kssr7p{Bm;&}b)*{XC=Sj&};VzM+4l$&-k+`7k4d zYi*c8{3))c&){0yK_-`{oRAMdg^_o1UGfB6>NfsW5YaHx?vKGeHgw+pR5u)Qa4gM6 zp+%X!K;i}VA;JDTtqtSV$lkJ3Yc6oDp~Kb|rX!wf>Zifm<&MdQ514ILPIzXX*-*=~ z)|`U(Ow3?#Yq8l-4n<@!vS^$g0f@dr3+alhi(WMIm)8H{S$Bn_iV)JjSRC$1p4D>| zD``&&==88@`S?al=E1DwS}~3>1%!-sGH4SNq-$;dY{YuXdUOOpH$BPh-FiS6XM)F3 z6OpT(ttDqbZi!(S#y*S6R%G*sOM=*bqt{CAjNBGqtu`woSAn^dH2Zsc7CL#tZQnl)I)Xk)&U@yEG zDwIr*J0k3B@EaIMp%xY_#OfS?h^LwA>hY#PdD$HSBM-t`<L@=umJ zKXEWO)D`y9YfW@QDU)5r$2K>wBoN2+ioL^9o2hsq-C%oymh5h_u~r7VzJrUK zd&|xnkQDpZ$Mg`yrRZ4=Z&1=1O2A@#!jOi3(L#IVn}q|I}=#a?xWXsFBd2-PWu znowMt+WCd>MknLs%KehR{GO$QMsk~JJ!L0#sGQ_yMMLoV->Bu?tQbpr_iV`UJj`Px zy#gVoU{_sY=qG^pzQH3*m7Qf+Pnx@fVph}y#?0dETPx1%TDW%y_8Jhq$R}^?3dUhc zqUR0|W#G%2NpWl_i;PBd1RX9hYJ4eLohRh@zL}u&01w!fmLB6wmk3_ysXz-@EIuK4 z2yjqe$xP{O^8_xiKmx?_TXj}{0<8hrn}deetmV1v1CsQc>RlCO72euY!X5>bX@h9D z`W7An+l%)wiLfb^9J5rY&k@QxAehQZ+nS;!g3mjWFe>$lJlMMmY66r2^k1Dx`)qNx zg`MU#s>v$^&U@}_L@&)NL~JQMOvB;|YD%>GD=Fu&ea7H)r+Xn^^h)MzPM;}9(Ub$gUlUj*ZKf!Tn6+9-v3A7sNvcFR zsFc7{c3YV*OW6~?E_HW*vK>MCnoGq2^S~{MMdB#3qy#kD@%I9<)%l!IWvUEq>Y6B& z{^;&;iOGnx^9xRfPuC!oN=dKqtbl(l-ipQx7P=Eq886hUf>C)n4x)6rS~2zMonaw+ z1xdN|7clx=E(B^mMG^EU*&B`}PMKRLWWGtt+6 za=EL+)JIpvSF_4O$IsC}ntQ2@c``#z&%2mWWNL8!0D@T82;jhAmXTs2C=x0*zc1dV zIqNeOP-`cx)G|U-105OKpvhw{;upjo+>Mx4cY@79BYR4(Mf!$c!eeV3FgsGKT+5;o;6QUR1Qq6&XsO!^&oDn4IL<5BkJgc$yVZOu_`~N zjUKX!^J~Ce%c%K7?F17Ug9={CsPqTkUf#hbv1!rov$!iEr`!R;tps;t?e7ciE3~vh zD%8yWQwIgv)_fF22X~@;8rx}=ESA1;Q9BF@f@^~gR_vqm9%r+gmzgW9Z(Ybc96r|m zb}v;+*VVJ!wQNM|z#7VT5h<~<+aA>VmsJ6zu;S*r# zlrP~tZ77>oqgsN8t9t{dX-#jAROyRr8Yu&^uJ?pI4rQ0F3CI|y9d$LED`ygnBo0tU zmw-z|^l7gdG`5Ph3;k4crU%feD;-im_k4D*;Xl zi9|}nnLG&g)?HNJOHphy%C(|sf`Xw?xa6J{T{uqjtI8beSc$Q=g;0$Z=TU}7C3X%NbQGXg##OoMIL7)_8{>SZ zZkEtX5JAv6pW%jG#-3e2@;GOWE<{m_&TCoX>qD>R^)P9*`9>-=8EsNje&o!4{h+VA zRV(T$fB@AdW1CF3x&;+aKtxUPcaD&307bOuqi9j4H1d$ht_<3$tdtZ&x5;G%QBNVf zs16p7U*)cw{k9n~!Eed9BkB9oaJS*dQPW@xRL}Bh%+i9i^_%@XK0nu)?;!0;(Lkln<*^Dw>osa zY%8bqC*MKZ9(K)}axTC&pm4uF$L07z=(?f*k977xEmkV2Mx-1F(nH7UuXh9I#-5@R zg*1QVq#C9IGL?f&a;+sAd%}=wS1LmcM95^K!(7x@fTxG1-D~Xgw<@m`ht~JDb`YBe!_hLvaG@>BRbTU_ zUI)sSb>>LJ`%5yruWL9$UOCuW`DhV=$dGU61f8eYJ0w2RzExp+GiUM*$YRN_-kB9d z==9N}FfC|x+0yaRYCO-+%21wCrv3TA)KHAk8mnc#wRHhIiB2fJm}R!eG$INj7EjCDoo6bF0Lypj z?+ASH4xcO%IL)w+HIY6iN-Yfa(I`=bct^^;W0guoa&q7DGN<}di2@JnmvPiV!WzL! ziUfM)(4v9dv7k5R)NAr$Q$QYHfGA_rA6-LtXiHEbZqN}}HrO)Y@(?hVdxJu)m{6&@ zZjg^Ky5z)%D_p|}>-Ztt?N=D=lq1ZWpjgr|g}b8cZiFCF{Gu0FK3ythm)1kP&GAXL zJ7I7B11e+7Zrjl$*JN$;L?~8;1p;85C0nCFctXgLq$PdZI~9QIa!CULUG3&vNf1Tt z2ST|#bTgTpfC9%NBQnTqsPqjsYv~;;Z7EEJigc1_6O6hc9uHaEJ_#v=9v8u9aU6d}#8TZWBs@9w9CA~VyR6C})v0W%F=|-T3{vIF!f!rN?8DD6=8avu?`)KxqNCn7sXU0scE2ux$-}frD+&Lgn%Czi9@Gv zm~QqrkG2-G3J-Q(A@4<2u+baY3A8B|POO*2Lr>%Pab%x~1t3#4DPjE<=AHl?TdctO|#VnTFF=5H%N=Uj}Tka@f7@YTj z8KtItD&VShwbZ6OZ4oW<95Ds&;MrcL3K3+EFGE+LtI^F!x&Wj4{<^@5$n(k}>h8p| zXRF#fj*<+5QrEG%P7*Q2#MvMU7h;Eh5(ts_uxZkF6I|XPh{JPjrTPPULdq&mbw`V-L-<_6_lj6-`4NjEoW1)XGZ zyf`r3ijsJQ*XMbVc1AHqz?ELUw6!yX3hm0#War4F0wk!iW}+<62+k2%?;w`ow#RJ8 zZTM~%l@fy7RsoSVI0$E+6?-b0(a!wmB}!-EzN{1i?ZWKhry2N5&eQE*h%Dr)r?S%` z`7x&L@d+idyt+wi0=t9C0-W)gIK>FWs?Z_e^ zXvS=EBFz;~MvYpCsgKod@e~koX{NJTXnG3jmz40`!*CxZ1%)mrrP}6fWx%oPCj#qt z`r~Rw!urBTA4>|&5VBJI9aBjGa&y2@<*WkD8q{)5m&Uq&SH;?WrB{K*5{jyxp_Fd~ zZ9P4c0+m@df+IA_DQPLdy@4_mFrJ)WNMN#R=!kVLB;jm1=TkQG$@vnU-5AAsnFJVC zw~dG_UG(ACpkDNWE7VPSxtqohih!(YHTM?CP*s<+v>S@?S8bDa2^Q341H{sMvQ169 zYgpxJD zNs_V0hz^HixF3;m-iC8cOLN4@wyWIu$l>do;-e^imJC;ucgt30eR{1ClQ1HlhYX5! zJhrvtWV=O^43XNt|=Bod3BB~5pb!%9KtMn?ktWI<{5jWTv3FT-vRPU*$)!*K*`g<+uJqeDs@ z<;THF-?r51ypo6+#jF!z{@9Dn5 zBr9k4iwD1?Sk4Yka*}I=i?d`@z4{Jek0W)qHQ?eQA;py_F;vt1BDBWKvvW);t!pe) zwwzRhEhe2CVbD(yW$p|b zA=kuEYtFl=L*B23!?hSo@a8W`KEFk$kP@#rt8<2Fk8BnIeW-4h$N9KdhX$H_k%|_; zCXpF)1FYyt3XfvG!tOUtq-Paw#y3s1t89DJU2)})qKAZQ2GKY^nd)-4{2D7&O_|zo zqC!e|cQ(PM5nzsrkS`tNuq)v$`{COEZVKv31u)6deEJ=QO5tEvW|^HI{YpRa9brRT z<%qVA5>(LPI?kqaoX@VD058hgOfK0=o>m=*5eGNs!P!qoljT)I;<2Z=sbI{By+Kr5 z?Z2oPlxtO4CPJv29PHg(;DuK4AfBQlmbh1{(Y)XC%=Zb&T8PC(0Y0_fO+f`5(FzU% z1lK&2$da^?&eh^dPWJd1gK|~07msiyIt9}TteZsEA^pasNX|fl7WMT%IJY0USO2Xk zgJ(K`TIMsrUW908afDB+yBST@^4jlwUBzJ~2DBlZ&A}D*dAg~Tl<7I5bzcg6qw(@U zFnEwQm8N(k{{|5|i*JVjrJCBr*CEwJQ02%j7H@}-QCzHJD#NmI-aPj^a=66-D+0Wx zc<4rq9iE%6bbgw@rYr1V6FTN5Eh-80@i6n7*kj4X%7v8qP^fNHL?2qdekqedXJ7e3 zjXEmSGTaZs3z*SP?0iJj@jIs-fyz+d3u9AkCq$i9scOC2PluWa=C5c_tMe+{QODDH zU#GJkhDgXlIUBOWyiu(x%L!lCGxHR1f*xKC0gZ_PvnzcTXNJ2+h;!W|>dHSpQ0!4u zocso)YtA|NkRoAro)|O8hGx+MDDQV4ER`|4~L=w|e#80Wy^t*2%N{6z$-`>|PJ)e=V;I z>pTG*M3dS!T-?}OHYun~*D71j({(6)W)e#LLFhwmsWy_Qq@za4k-02ntW392Z*Y7$$$m>pBjGkGLy~BRQO=?cyb;_0I$GF@ z9+^*a`%NqzRuJMqKZPr<^4UxhN-MJRNiF13kK1I`9+$G+eAkUQoK-T?iVoTsF1LH< z#%8W10HlRbwE>(7F(2EymM5W(VCR!nD~dB7qe39J z4(TAEZClj&d(Pe9rh2Z{F}17oYX_AebS04Wes7k*R?0y0#`A2)6TW1&JPI(43boK6 z#KdT$bV!d{Y6SbapWpf+d6uS3*nuvh&qvDeTyI=B1y&%$&kc@npo*M^RUoNaH;we= zvbdVDR@pS4=u|cQzi7%m%K|=fmnMqW(MJ+xAH=P|Jhk8N*;i%~5%)W+FP3$5*VUq@ zp0E{Dr_n&GngLJHQ${B8qX6)(WrHf4dCC&sN3bx>2B^S|?BT*%|g6sBSx84a#sP)+g|R(oXSVZXQ895x)*Pidzu`GA+x z?B#G`UT6r5XaKSoXqIgxL0Bm_Up5r%IHudS{U2+TE{NqzA<*GSbmaI!#o& zziANm{iusX#8VIPfU~RI_lO|w#1-3w=6FLAfT|W?w%Yz>eXmC#r(1MYP0|nctGM!N zf1zlZ|5UzcD7awdA*7McHs^K8G8P6@*sZ~K zYUw!`Hxi_ynaDD}6pFFAj}f4paiII~%HnCLk{S|)=6Qp72a41b;Ub0Km)P=%9CXog z2uybSUV&9=8yi(g3&Xt7S{JF*3Aluh2$O3PG!B-nA|e=Nt))1c0h=z&hDOd-in3u2 z4A7@kF3oJ~7G+lNR%zuHT6Gx57iVZhB0SsJn=0o|LQ=laIpbMnI3i7|G8o(XUz0=V zDGU|I0r8X^Cd!MJ2!N>9(Y3rbANme;0h=k^S Date: Sun, 17 Dec 2023 17:22:14 +0100 Subject: [PATCH 182/185] Demo / test --- kubernetes/kasper/demo/01-deployment.yml | 21 ++++++++++++++++ kubernetes/kasper/demo/02-service.yml | 12 ++++++++++ kubernetes/kasper/demo/03-ingress.yml | 25 ++++++++++++++++++++ kubernetes/kasper/demo/04-issuer-staging.yml | 19 +++++++++++++++ kubernetes/kasper/demo/05-issuer-prod.yml | 19 +++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 kubernetes/kasper/demo/01-deployment.yml create mode 100644 kubernetes/kasper/demo/02-service.yml create mode 100644 kubernetes/kasper/demo/03-ingress.yml create mode 100644 kubernetes/kasper/demo/04-issuer-staging.yml create mode 100644 kubernetes/kasper/demo/05-issuer-prod.yml diff --git a/kubernetes/kasper/demo/01-deployment.yml b/kubernetes/kasper/demo/01-deployment.yml new file mode 100644 index 00000000..0bf7990e --- /dev/null +++ b/kubernetes/kasper/demo/01-deployment.yml @@ -0,0 +1,21 @@ +# 01-deployment.yml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kuard +spec: + selector: + matchLabels: + app: kuard + replicas: 1 + template: + metadata: + labels: + app: kuard + spec: + containers: + - image: gcr.io/kuar-demo/kuard-amd64:1 + imagePullPolicy: Always + name: kuard + ports: + - containerPort: 8080 diff --git a/kubernetes/kasper/demo/02-service.yml b/kubernetes/kasper/demo/02-service.yml new file mode 100644 index 00000000..99c22bcc --- /dev/null +++ b/kubernetes/kasper/demo/02-service.yml @@ -0,0 +1,12 @@ +# 02-service.yml +apiVersion: v1 +kind: Service +metadata: + name: kuard +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + selector: + app: kuard diff --git a/kubernetes/kasper/demo/03-ingress.yml b/kubernetes/kasper/demo/03-ingress.yml new file mode 100644 index 00000000..c2308217 --- /dev/null +++ b/kubernetes/kasper/demo/03-ingress.yml @@ -0,0 +1,25 @@ +# 03-ingress.yml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kuard + annotations: + cert-manager.io/issuer: "letsencrypt-prod" + +spec: + ingressClassName: nginx # kopplar vår ingress till den installerade nginx-ingress + tls: # sätter att vi ska bara acceptera https trafik till er domän + - hosts: + - devopsbth.tech + secretName: demo-tls # det kommer senare skapas en secret med detta namnet som innehåller certificatet för vårt domännamn. + rules: + - host: devopsbth.tech + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kuard + port: + number: 80 diff --git a/kubernetes/kasper/demo/04-issuer-staging.yml b/kubernetes/kasper/demo/04-issuer-staging.yml new file mode 100644 index 00000000..531f866a --- /dev/null +++ b/kubernetes/kasper/demo/04-issuer-staging.yml @@ -0,0 +1,19 @@ +# 04-issuer-staging.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-staging +spec: + acme: + # The ACME server URL + server: https://acme-staging-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: kafa21@student.bth.se + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-staging + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx diff --git a/kubernetes/kasper/demo/05-issuer-prod.yml b/kubernetes/kasper/demo/05-issuer-prod.yml new file mode 100644 index 00000000..55942b61 --- /dev/null +++ b/kubernetes/kasper/demo/05-issuer-prod.yml @@ -0,0 +1,19 @@ +# 05-issuer-prod.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-prod +spec: + acme: + # The ACME server URL + server: https://acme-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: kafa21@student.bth.se + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-prod + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx From 221e43777fa721fe51bb6d56a8ba095568722d67 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 17 Dec 2023 17:22:40 +0100 Subject: [PATCH 183/185] Kubernetes for setup microblog --- kubernetes/kasper/microblog/01-deployment.yml | 24 ++++++++++++++++++ kubernetes/kasper/microblog/02-service.yml | 12 +++++++++ kubernetes/kasper/microblog/03-ingress.yml | 25 +++++++++++++++++++ .../kasper/microblog/04-issuer-staging.yml | 19 ++++++++++++++ .../kasper/microblog/05-issuer-prod.yml | 19 ++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 kubernetes/kasper/microblog/01-deployment.yml create mode 100644 kubernetes/kasper/microblog/02-service.yml create mode 100644 kubernetes/kasper/microblog/03-ingress.yml create mode 100644 kubernetes/kasper/microblog/04-issuer-staging.yml create mode 100644 kubernetes/kasper/microblog/05-issuer-prod.yml diff --git a/kubernetes/kasper/microblog/01-deployment.yml b/kubernetes/kasper/microblog/01-deployment.yml new file mode 100644 index 00000000..aeefd71b --- /dev/null +++ b/kubernetes/kasper/microblog/01-deployment.yml @@ -0,0 +1,24 @@ +# 01-deployment.yml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: microblog +spec: + selector: + matchLabels: + app: microblog + replicas: 2 + template: + metadata: + labels: + app: microblog + spec: + containers: + - image: falkendev/microblog:5.0.0-prod + imagePullPolicy: Always + name: microblog + ports: + - containerPort: 5000 + env: + - name: DATABASE_URL + value: "mysql+pymysql://microblog:password@mysql/microblog" diff --git a/kubernetes/kasper/microblog/02-service.yml b/kubernetes/kasper/microblog/02-service.yml new file mode 100644 index 00000000..9dc88682 --- /dev/null +++ b/kubernetes/kasper/microblog/02-service.yml @@ -0,0 +1,12 @@ +# 02-service.yml +apiVersion: v1 +kind: Service +metadata: + name: microblog +spec: + ports: + - port: 80 + targetPort: 5000 + protocol: TCP + selector: + app: microblog diff --git a/kubernetes/kasper/microblog/03-ingress.yml b/kubernetes/kasper/microblog/03-ingress.yml new file mode 100644 index 00000000..03ca9351 --- /dev/null +++ b/kubernetes/kasper/microblog/03-ingress.yml @@ -0,0 +1,25 @@ +# 03-ingress.yml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: microblog + annotations: + cert-manager.io/issuer: "letsencrypt-prod" + +spec: + ingressClassName: nginx + tls: + - hosts: + - devopsbth.tech + secretName: demo-tls + rules: + - host: devopsbth.tech + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: microblog + port: + number: 80 diff --git a/kubernetes/kasper/microblog/04-issuer-staging.yml b/kubernetes/kasper/microblog/04-issuer-staging.yml new file mode 100644 index 00000000..531f866a --- /dev/null +++ b/kubernetes/kasper/microblog/04-issuer-staging.yml @@ -0,0 +1,19 @@ +# 04-issuer-staging.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-staging +spec: + acme: + # The ACME server URL + server: https://acme-staging-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: kafa21@student.bth.se + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-staging + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx diff --git a/kubernetes/kasper/microblog/05-issuer-prod.yml b/kubernetes/kasper/microblog/05-issuer-prod.yml new file mode 100644 index 00000000..55942b61 --- /dev/null +++ b/kubernetes/kasper/microblog/05-issuer-prod.yml @@ -0,0 +1,19 @@ +# 05-issuer-prod.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-prod +spec: + acme: + # The ACME server URL + server: https://acme-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: kafa21@student.bth.se + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-prod + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx From 8b43e1576a1c4eb4c4951fb81276f8da558163e3 Mon Sep 17 00:00:00 2001 From: FalkenDev Date: Sun, 17 Dec 2023 17:23:00 +0100 Subject: [PATCH 184/185] Kubernetes for setup mysql database and use it for microblog --- kubernetes/kasper/mysql/mysql-deployment.yml | 55 ++++++++++++++++++++ kubernetes/kasper/mysql/mysql-pv.yml | 26 +++++++++ kubernetes/kasper/mysql/mysql-secrets.yml | 9 ++++ 3 files changed, 90 insertions(+) create mode 100644 kubernetes/kasper/mysql/mysql-deployment.yml create mode 100644 kubernetes/kasper/mysql/mysql-pv.yml create mode 100644 kubernetes/kasper/mysql/mysql-secrets.yml diff --git a/kubernetes/kasper/mysql/mysql-deployment.yml b/kubernetes/kasper/mysql/mysql-deployment.yml new file mode 100644 index 00000000..0f69833d --- /dev/null +++ b/kubernetes/kasper/mysql/mysql-deployment.yml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: Service +metadata: + name: mysql +spec: + ports: + - port: 3306 + selector: + app: mysql + clusterIP: None +--- +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: mysql +spec: + selector: + matchLabels: + app: mysql + strategy: + type: Recreate + template: + metadata: + labels: + app: mysql + spec: + containers: + - image: mysql:5.7 + name: mysql + env: + # Use secret in real usage + - name: MYSQL_USER + value: microblog + - name: MYSQL_DATABASE + value: microblog + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-secrets + key: DB_PASSWORD + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-secrets + key: ROOT_PASSWORD + ports: + - containerPort: 3306 + name: mysql + volumeMounts: + - name: mysql-persistent-storage + mountPath: /var/lib/mysql + volumes: + - name: mysql-persistent-storage + persistentVolumeClaim: + claimName: mysql-pv-claim diff --git a/kubernetes/kasper/mysql/mysql-pv.yml b/kubernetes/kasper/mysql/mysql-pv.yml new file mode 100644 index 00000000..c4147712 --- /dev/null +++ b/kubernetes/kasper/mysql/mysql-pv.yml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: mysql-pv-volume + labels: + type: local +spec: + storageClassName: manual + capacity: + storage: 5Gi + accessModes: + - ReadWriteOnce + hostPath: + path: "/mnt/data" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mysql-pv-claim +spec: + storageClassName: manual + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/kubernetes/kasper/mysql/mysql-secrets.yml b/kubernetes/kasper/mysql/mysql-secrets.yml new file mode 100644 index 00000000..8edce3cf --- /dev/null +++ b/kubernetes/kasper/mysql/mysql-secrets.yml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: mysql-secrets +type: Opaque +data: + ROOT_PASSWORD: bXktc3VwZXItc2VjcmV0LXJvb3QtcGFzc3dvcmQ= + DB_PASSWORD: cGFzc3dvcmQ= From 5eadfd0061d934778e44cbf8c24440f22ad20cec Mon Sep 17 00:00:00 2001 From: JamesTTTT Date: Thu, 18 Jan 2024 10:31:26 +0100 Subject: [PATCH 185/185] kmom06 - james --- kubernetes/James/01-deployment.yml | 23 +++++++++++ kubernetes/James/02-service.yml | 11 ++++++ kubernetes/James/03-ingress.yml | 24 ++++++++++++ kubernetes/James/04-issuer-staging.yml | 19 +++++++++ kubernetes/James/05-issuer-prod.yml | 15 +++++++ kubernetes/James/mysql-deployment.yml | 54 ++++++++++++++++++++++++++ kubernetes/James/mysql-pv.yml | 26 +++++++++++++ kubernetes/James/mysql-secrets.yml | 9 +++++ 8 files changed, 181 insertions(+) create mode 100644 kubernetes/James/01-deployment.yml create mode 100644 kubernetes/James/02-service.yml create mode 100644 kubernetes/James/03-ingress.yml create mode 100644 kubernetes/James/04-issuer-staging.yml create mode 100644 kubernetes/James/05-issuer-prod.yml create mode 100644 kubernetes/James/mysql-deployment.yml create mode 100644 kubernetes/James/mysql-pv.yml create mode 100644 kubernetes/James/mysql-secrets.yml diff --git a/kubernetes/James/01-deployment.yml b/kubernetes/James/01-deployment.yml new file mode 100644 index 00000000..d4b6a412 --- /dev/null +++ b/kubernetes/James/01-deployment.yml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: microblog +spec: + selector: + matchLabels: + app: microblog + replicas: 2 + template: + metadata: + labels: + app: microblog + spec: + containers: + - image: falkendev/microblog:5.0.0-prod + imagePullPolicy: Always + name: microblog + ports: + - containerPort: 5000 + env: + - name: DATABASE_URL + value: "mysql+pymysql://microblog:micropassw@mysql/microblog" diff --git a/kubernetes/James/02-service.yml b/kubernetes/James/02-service.yml new file mode 100644 index 00000000..f1bf1cc0 --- /dev/null +++ b/kubernetes/James/02-service.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: microblog +spec: + ports: + - port: 80 + targetPort: 5000 + protocol: TCP + selector: + app: microblog diff --git a/kubernetes/James/03-ingress.yml b/kubernetes/James/03-ingress.yml new file mode 100644 index 00000000..ca0ae578 --- /dev/null +++ b/kubernetes/James/03-ingress.yml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: microblog + annotations: + cert-manager.io/issuer: "letsencrypt-prod" + +spec: + ingressClassName: nginx + tls: + - hosts: + - taylordevops.live + secretName: demo-tls + rules: + - host: taylordevops.live + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: microblog + port: + number: 80 diff --git a/kubernetes/James/04-issuer-staging.yml b/kubernetes/James/04-issuer-staging.yml new file mode 100644 index 00000000..4a361e41 --- /dev/null +++ b/kubernetes/James/04-issuer-staging.yml @@ -0,0 +1,19 @@ +# 04-issuer-staging.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-staging +spec: + acme: + # The ACME server URL + server: https://acme-staging-v02.api.letsencrypt.org/directory + # Email address used for ACME registration + email: jata20@student.bth.se + # Name of a secret used to store the ACME account private key + privateKeySecretRef: + name: letsencrypt-staging + # Enable the HTTP-01 challenge provider + solvers: + - http01: + ingress: + class: nginx diff --git a/kubernetes/James/05-issuer-prod.yml b/kubernetes/James/05-issuer-prod.yml new file mode 100644 index 00000000..3d79e84d --- /dev/null +++ b/kubernetes/James/05-issuer-prod.yml @@ -0,0 +1,15 @@ +# 05-issuer-prod.yml +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: jata20@student.bth.se + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx diff --git a/kubernetes/James/mysql-deployment.yml b/kubernetes/James/mysql-deployment.yml new file mode 100644 index 00000000..c0d6c291 --- /dev/null +++ b/kubernetes/James/mysql-deployment.yml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: Service +metadata: + name: mysql +spec: + ports: + - port: 3306 + selector: + app: mysql + clusterIP: None +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mysql +spec: + selector: + matchLabels: + app: mysql + strategy: + type: Recreate + template: + metadata: + labels: + app: mysql + spec: + containers: + - image: mysql:5.7 + name: mysql + env: + - name: MYSQL_USER + value: microblog + - name: MYSQL_DATABASE + value: microblog + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-secrets + key: DB_PASSWORD + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-secrets + key: ROOT_PASSWORD + ports: + - containerPort: 3306 + name: mysql + volumeMounts: + - name: mysql-persistent-storage + mountPath: /var/lib/mysql + volumes: + - name: mysql-persistent-storage + persistentVolumeClaim: + claimName: mysql-pv-claim diff --git a/kubernetes/James/mysql-pv.yml b/kubernetes/James/mysql-pv.yml new file mode 100644 index 00000000..c4147712 --- /dev/null +++ b/kubernetes/James/mysql-pv.yml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: mysql-pv-volume + labels: + type: local +spec: + storageClassName: manual + capacity: + storage: 5Gi + accessModes: + - ReadWriteOnce + hostPath: + path: "/mnt/data" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mysql-pv-claim +spec: + storageClassName: manual + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/kubernetes/James/mysql-secrets.yml b/kubernetes/James/mysql-secrets.yml new file mode 100644 index 00000000..d8569a83 --- /dev/null +++ b/kubernetes/James/mysql-secrets.yml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: mysql-secrets +type: Opaque +data: + ROOT_PASSWORD: bXktc3VwZXItc2VjcmV0LXJvb3QtcGFzc3dvcmQ= + DB_PASSWORD: bWljcm9wYXNzdw==