diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..69a04601b2 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml new file mode 100644 index 0000000000..ad114efdf1 --- /dev/null +++ b/.github/workflows/python-ci.yaml @@ -0,0 +1,94 @@ +name: Python CI + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [ "master", "main", "lab03" ] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + + pull_request: + branches: [ "master" ] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: set up + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + app_python/requirements.txt + + - name: dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + + - name: snyk + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK }} + with: + args: --severity-threshold=high + + - name: Linter + run: | + pip install flake8 + flake8 app_python/ --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 app_python/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with coverage + working-directory: app_python + run: pytest --cov=. --cov-report=xml --cov-fail-under=70 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + docker: + name: docker + needs: test + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata (CalVer) + id: meta + uses: docker/metadata-action@v5 + with: + images: abrahambarrett228/devops-info-service + tags: | + type=raw,value={{date 'YYYY.MM'}} + type=raw,value={{date 'YYYY.MM'}}.${{ github.run_number }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30d74d2584..2b2a4cf446 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +venv \ No newline at end of file diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..ee2655531e --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo +become_user = root \ No newline at end of file diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..a01ef81da0 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,258 @@ +# Zavadskii Peter lab05 + +### 1. Architecture Overview + + - Ansible version used + +```bash +abraham_barrett@Abrahams-MacBook-Air ansible % ansible --version +ansible [core 2.20.3] +``` + + - Target VM OS and version + + - I have reused terraform yamls to create VM on ubuntu 24.04 using Yandex cloud. + + - Role structure diagram or explanation + + - ```ansible/``` — main project directory + - ```group_vars/all.yml``` — global variables, including those encrypted with Ansible Vault + - ```inventory/hosts.ini``` — static inventory file containing the webservers group + - ```playbooks/deploy.yml``` — playbook for deploying the application (app_deploy role) + - ```playbooks/provision.yml``` — playbook for initial server setup (common + docker roles) + - ```roles/app_deploy``` — manages containerized application deployment + - ```roles/docker``` — installs Docker and the Python SDK required for Ansible modules + - ```roles/common``` — handles basic OS configuration (apt updates, package installation, timezone settings) + + - Why roles instead of monolithic playbooks? + - Roles allow you to organize reusable code, maintain a clean structure, and keep responsibilities separate. This approach keeps individual playbooks compact while making roles simpler to test and update. + + +### 2. Roles Documentation + +There are several roles: +- app_deploy (application deployment, docker auth, pulling images) +- common (basic system configuration related to packages installation and etc.) +- docker (docker installation, adding user to a docker group, docker service starting) + +### 3. Idempotency Demonstration + +```bash +abraham_barrett@Abrahams-MacBook-Air ansible % ansible-playbook playbooks/provision.yml --ask-vault-pass -i inventory/hosts.ini\ +Vault password: + +PLAY [Provision web servers] ************************************************************************************************************************************* + +TASK [Gathering Facts] ******************************************************************************************************************************************* +ok: [abraham-vm] + +TASK [common : Update apt cache] ********************************************************************************************************************************* +ok: [abraham-vm] + +TASK [common : Install common packages] ************************************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Install dependencies for Docker repo] ************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Add Docker GPG key] ******************************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Add Docker APT repository] ************************************************************************************************************************ +[WARNING]: Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg. +[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to `True` is deprecated, top-level facts will not be auto injected after the change. This feature will be removed from ansible-core version 2.24. +Origin: /Users/abraham_barrett/Documents/devops/DevOps-Core-Course/ansible/roles/docker/defaults/main.yml:10:18 + + 8 + 9 docker_user: "{{ ansible_user | default('ubuntu') }}" +10 docker_apt_repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} sta... + ^ column 18 + +Use `ansible_facts["fact_name"]` (no `ansible_` prefix) instead. + +changed: [abraham-vm] + +TASK [docker : Install Docker packages] ************************************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Ensure Docker service is running and enabled] ***************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Add user to docker group] ************************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Install python3-docker (for Ansible docker modules)] ********************************************************************************************** +changed: [abraham-vm] + +PLAY RECAP ******************************************************************************************************************************************************* +abraham-vm : ok=10 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +abraham_barrett@Abrahams-MacBook-Air ansible % ansible-playbook playbooks/provision.yml --ask-vault-pass -i inventory/hosts.ini\ +Vault password: + +PLAY [Provision web servers] ************************************************************************************************************************************* + +TASK [Gathering Facts] ******************************************************************************************************************************************* +ok: [abraham-vm] + +TASK [common : Update apt cache] ********************************************************************************************************************************* +ok: [abraham-vm] + +TASK [common : Install common packages] ************************************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Install dependencies for Docker repo] ************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Add Docker GPG key] ******************************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Add Docker APT repository] ************************************************************************************************************************ +[WARNING]: Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg. +[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to `True` is deprecated, top-level facts will not be auto injected after the change. This feature will be removed from ansible-core version 2.24. +Origin: /Users/abraham_barrett/Documents/devops/DevOps-Core-Course/ansible/roles/docker/defaults/main.yml:10:18 + + 8 + 9 docker_user: "{{ ansible_user | default('ubuntu') }}" +10 docker_apt_repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} sta... + ^ column 18 + +Use `ansible_facts["fact_name"]` (no `ansible_` prefix) instead. + +ok: [abraham-vm] + +TASK [docker : Install Docker packages] ************************************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Ensure Docker service is running and enabled] ***************************************************************************************************** +ok: [abraham-vm] + +TASK [docker : Add user to docker group] ************************************************************************************************************************* +ok: [abraham-vm] + +TASK [docker : Install python3-docker (for Ansible docker modules)] ********************************************************************************************** +ok: [abraham-vm] + +PLAY RECAP ******************************************************************************************************************************************************* +abraham-vm : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +abraham_barrett@Abrahams-MacBook-Air ansible % +``` + +#### Analysis + +- **What changed first time?** All the packages were installed, docker was started. +- **What didn't change second time?** All tasks reported ok, since no modifications were made. + +#### Explanation: What makes your roles idempotent? +The implementation relies on declarative modules that enforce a target system state rather than executing procedural commands. For example, ```apt``` uses ```state: present``` to ensure packages are installed, ```service``` applies ```state: started``` to verify services are running, and ```file``` sets ```state: directory``` to confirm paths exist. When these tasks run again, they simply verify the current state matches the desired one and take no action if it already does. + +### 4. Ansible Vault Usage + +For secure credentials storing I am using ansible vault. + +It manages all the secrets by several ways. To my mind, I typically use flag ```--ask-vault-pass``` + +This is an encrypted file +```bash +$ANSIBLE_VAULT;1.1;AES256 +38333465613265343763613963613837643462326132343534643735386431336434613866663432 +3866313165333638356666383664643264663463623666620a656265373261373464333864633133 +30383938653633363466323331323333386339353639396638646430333963653635636633323933 +6431336339333231370a636537336538313964636438636463323633333134616535323037386134 +63343135333137313535303761623266323330306330386433633163643735623430346664386464 +37363164363663353361663131653839623261643662613234336362613766613739646532376134 +37353862316533313333303366636237383764393136653566303731353135373138386130356362 +31336362666263373536306633373766366664626231663438663233643665613533613035356230 +31346534653035376464303434616137343865316336653162303566353662363839363131346537 +61386365313164343834386537373231373934373336633037653262323162363530646432643235 +63643231663765626130386230643533656166323135373333366163633866333238626535353030 +31303065363237396537326534626462313330626164336436643661383561363139376635646661 +36373332363763643530396539323465326431366161666265656231616561363433356339306666 +36623862323830303935626338316265346664333430666636633061653735383463613966613730 +33313861333461366336626665643937393566353136376262656132393535646532656263313465 +34643662316435386630653934333332633764336330656233663339346435366461646263306137 +32626230383933386635616234653263623830373734343136313661303336623436 +``` + +Ansible vault is important for secure credentials usage, not to share the access to you private servers to everyone, who has an opportunity to execute ansible commands and watch command history, to obtain the password if it was used previously directly in the command line. + +#### Vault creation +```bash +abraham_barrett@Abrahams-MacBook-Air ansible % ansible-vault create group_vars/all.yml +New Vault password: +Confirm New Vault password: +[WARNING]: group_vars does not exist, creating... +abraham_barrett@Abrahams-MacBook-Air ansible % +``` + +### 5. Deployment Verification + +#### Application deployment +```bash +abraham_barrett@Abrahams-MacBook-Air ansible % ansible-playbook playbooks/deploy.yml --ask-vault-pass -i inventory/hosts.ini\ +Vault password: + +PLAY [Deploy application] **************************************************************************************************************************************** + +TASK [Gathering Facts] ******************************************************************************************************************************************* +ok: [abraham-vm] + +TASK [app_deploy : restart app container] ************************************************************************************************************************ +changed: [abraham-vm] + +PLAY RECAP ******************************************************************************************************************************************************* +abraham-vm : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + +#### Checking if my application works +```bash +abraham_barrett@Abrahams-MacBook-Air ansible % ssh ubuntu@158.160.53.7 +Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-100-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Thu Feb 26 18:57:43 UTC 2026 + + System load: 0.0 Processes: 105 + Usage of /: 34.9% of 9.04GB Users logged in: 0 + Memory usage: 32% IPv4 address for eth0: 10.128.0.5 + Swap usage: 0% + + * Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s + just raised the bar for easy, resilient and secure K8s cluster deployment. + + https://ubuntu.com/engage/secure-kubernetes-at-the-edge + +Expanded Security Maintenance for Applications is not enabled. + +17 updates can be applied immediately. +15 of these updates are standard security updates. +To see these additional updates run: apt list --upgradable + +Enable ESM Apps to receive additional future security updates. +See https://ubuntu.com/esm or run: sudo pro status + + +Last login: Thu Feb 26 18:57:43 2026 from 188.130.155.187 +ubuntu@fhmecnpbrr42gql1lldp:~$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +a953274639ac abrahambarrett228/devops-info-service:latest "python app.py" 3 hours ago Up 3 hours 5000/tcp devops-info-service +ubuntu@fhmecnpbrr42gql1lldp:~$ PID=$(docker inspect -f '{{.State.Pid}}' a953274639ac) +ubuntu@fhmecnpbrr42gql1lldp:~$ sudo nsenter -t $PID -n curl http://localhost:5000 +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"a953274639ac","platform_name":"Linux","architecture":"x86_64","python_version":"3.13.12"},"runtime":{"seconds":9974,"human":"2 hours, 46 minutes"},"request":{"client_ip":"127.0.0.1","user_agent":"curl/8.5.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]}ubuntu@fhmecnpbrr42gql1lldp:~$ +``` +### 6. Key Decisions + + - Why use roles instead of plain playbooks? Roles create a well-organized framework that promotes code reuse and clear separation of responsibilities (like common configuration, Docker setup, and application deployment). Once a role is defined, it can be applied consistently across various playbooks and projects. + - How do roles improve reusability? A single role—for Docker installation or basic system setup—can be incorporated into multiple playbooks and applied to different host groups without copying and pasting tasks. + - What makes a task idempotent? A task achieves idempotency when it uses modules that declare a target state (such as package present, service started, or directory exists) rather than executing imperative commands. Running the task again leaves the system unchanged if the desired state is already in place. + - How do handlers improve efficiency? Handlers execute only once at the conclusion of a play when notified by tasks that made actual changes. This prevents redundant operations—like restarting a service multiple times when a single restart would suffice. + - Why is Ansible Vault necessary? Ansible Vault enables secure storage of sensitive data like passwords and access tokens directly in the repository. This keeps credentials encrypted and prevents accidental exposure through logs or version control history. + +### 7. Challenges (Optional) + - \ No newline at end of file diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..cce7b6c9c9 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,18 @@ +$ANSIBLE_VAULT;1.1;AES256 +38333465613265343763613963613837643462326132343534643735386431336434613866663432 +3866313165333638356666383664643264663463623666620a656265373261373464333864633133 +30383938653633363466323331323333386339353639396638646430333963653635636633323933 +6431336339333231370a636537336538313964636438636463323633333134616535323037386134 +63343135333137313535303761623266323330306330386433633163643735623430346664386464 +37363164363663353361663131653839623261643662613234336362613766613739646532376134 +37353862316533313333303366636237383764393136653566303731353135373138386130356362 +31336362666263373536306633373766366664626231663438663233643665613533613035356230 +31346534653035376464303434616137343865316336653162303566353662363839363131346537 +61386365313164343834386537373231373934373336633037653262323162363530646432643235 +63643231663765626130386230643533656166323135373333366163633866333238626535353030 +31303065363237396537326534626462313330626164336436643661383561363139376635646661 +36373332363763643530396539323465326431366161666265656231616561363433356339306666 +36623862323830303935626338316265346664333430666636633061653735383463613966613730 +33313861333461366336626665643937393566353136376262656132393535646532656263313465 +34643662316435386630653934333332633764336330656233663339346435366461646263306137 +32626230383933386635616234653263623830373734343136313661303336623436 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..37e8f65392 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,6 @@ +[webservers] +abraham-vm ansible_host=158.160.53.7 ansible_user=ubuntu + +[webservers:vars] +ansible_ssh_private_key_file=~/.ssh/id_rsa +ansible_python_interpreter=/usr/bin/python3 \ No newline at end of file diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..66ee4902f6 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy application + hosts: webservers + become: yes + vars_files: + - ../group_vars/all.yml + + roles: + - app_deploy \ No newline at end of file diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..17d437513f --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: yes + + roles: + - common + - docker \ No newline at end of file diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..98ad3f13dd --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,3 @@ +--- +- import_playbook: provision.yml +- import_playbook: deploy.yml \ No newline at end of file diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..b2dcdb2904 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,4 @@ +--- +app_port: 5000 +restart_policy: unless-stopped +app_env: {} \ No newline at end of file diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..10e95d3202 --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + restart: true \ No newline at end of file diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..10e95d3202 --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,7 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + restart: true \ No newline at end of file diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..45340d3ca0 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,9 @@ +--- +common_packages: + - python3-pip + - curl + - git + - ca-certificates + - gnupg + - unzip + - jq \ No newline at end of file diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..028bfb9562 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,10 @@ +--- +- name: Update apt cache + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + +- name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present \ No newline at end of file diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..85144eb727 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,11 @@ +--- +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_user: "{{ ansible_user | default('ubuntu') }}" +docker_apt_repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" +docker_gpg_url: "https://download.docker.com/linux/ubuntu/gpg" \ No newline at end of file diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..ad85b66150 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + service: + name: docker + state: restarted \ No newline at end of file diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..4b54b306e3 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,44 @@ +--- +- name: Install dependencies for Docker repo + apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: yes + +- name: Add Docker GPG key + apt_key: + url: "{{ docker_gpg_url }}" + state: present + +- name: Add Docker APT repository + apt_repository: + repo: "{{ docker_apt_repo }}" + state: present + filename: docker + +- name: Install Docker packages + apt: + name: "{{ docker_packages }}" + state: present + update_cache: yes + notify: restart docker + +- name: Ensure Docker service is running and enabled + service: + name: docker + state: started + enabled: yes + +- name: Add user to docker group + user: + name: "{{ docker_user }}" + groups: docker + append: yes + +- name: Install python3-docker (for Ansible docker modules) + apt: + name: python3-docker + state: present \ No newline at end of file diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..71fd1af1d9 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +venv/ +.venv/ +env/ + +.git/ +.vscode/ +.idea/ + +*.log +pytest_cache/ +.coverage +htmlcov/ + +docs/ +tests/ \ No newline at end of file diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..4de420a8f7 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..e4fe9ff1dd --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ARG PORT=5000 + +WORKDIR /app/app_python + +COPY requirements.txt . +COPY app.py . + +RUN \ + pip install --no-cache-dir -r requirements.txt && \ + useradd --create-home --shell /usr/sbin/nologin appuser &&\ + chown -R appuser /app/app_python + +USER appuser + +EXPOSE ${PORT} + +CMD [ "python", "app.py" ] \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..85df6f9248 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,117 @@ +# Python Web Server + +## Overview + +This web server provides information about itself and its environment. + +## Prerequisites + +- Python 3.14.0 +- pip +- git +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application + +You can create `.env` file storing all enviroment values. To run application simply type: + +```bash +python app.py +``` + +## Docker Support + +### Building from Source + +Create a Docker image locally from the application code: + +```bash +docker build -t : . +``` + +### Container Execution + +Run the application in an isolated container environment: + +```bash +docker run --rm -p : --name : +``` + + +### Using Pre-built Images + +The service is also available on Docker Hub for immediate deployment: + +```bash +# Download the published image +docker pull abrahambarrett228/lab02 + +# Run the downloaded image +docker run --rm -p 5000:5000 abrahambarrett228/lab02 +``` + +### Container Management + +Basic operations for container lifecycle management: + +```bash +# List running containers +docker ps + +# Stop a running container +docker stop + +# View container logs +docker logs + +# Interactive shell access +docker exec -it /bin/sh +``` + +## API Endpoints + +- `GET /` - service data +- `GET /health` - Health check + +## Configuration + +Supported enviroment values: + +- `HOST` - address of the application +- `PORT` - port of the application +- `DEBUG` - `[true/false]` do/don't enable debug features + + +## Testing + +### Running Tests + +Install dependencies +```bash +pip install -r requirements.txt +``` + +Run tests +```bash +pytest +``` + +Run with coverage (pytest-cov should be installed) +```bash +pytest --cov=app --cov-report=term +``` + +### Test Structure + +Tests are located in `app_python/tests/` directory. + + +- `app_python/tests/test_error_endpoint.py` - tests related to errors +- `app_python/tests/test_health_endpoint.py` tests related to health endpoint +- `app_python/tests/test_mainpage_endpoint.py` tests related to the root endpoint \ No newline at end of file diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..2492b62e48 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,208 @@ +import fastapi +from datetime import datetime, timezone +import uvicorn +import logging +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + + +app = fastapi.FastAPI() + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('app.log') + ] +) +logger = logging.getLogger(__name__) + +start_time = None + + +def get_uptime(): + global start_time + if start_time is None: + start_time = datetime.now() + delta = datetime.now() - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_system(): + import platform + import socket + + return { + "hostname": socket.gethostname(), + "platform_name": platform.system(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + } + + +@app.middleware("http") +async def log_requests(request: fastapi.Request, call_next): + start_time_middleware = datetime.now() + + logger.info(f"Request started: {request.method} {request.url.path}") + logger.debug(f"Headers: {dict(request.headers)}") + + try: + response = await call_next(request) + process_time = ( + datetime.now() - start_time_middleware).total_seconds() * 1000 + + logger.info( + f"Request completed: {request.method} {request.url.path} " + f"- Status: {response.status_code} " + f"- Time: {process_time:.2f}ms" + ) + + return response + + except Exception as ex: + logger.error( + f"Request failed: {request.method} {request.url.path} - Error: {str(ex)}") + raise + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler( + request: fastapi.Request, + exc: StarletteHTTPException): + if exc.status_code == 404: + logger.warning( + f"404 Not Found: {request.method} {request.url.path} " + f"from {request.client.host if request.client else 'Unknown'}" + ) + + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": ( + f"The requested URL {request.url.path} " + f"was not found on this server." + ), + "status_code": 404, + "timestamp": datetime.now( + timezone.utc).isoformat(), + }) + + logger.error(f"HTTP Error {exc.status_code}: {str(exc.detail)}") + return JSONResponse( + status_code=exc.status_code, + content={ + "error": exc.__class__.__name__, + "message": str(exc.detail), + "status_code": exc.status_code, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: fastapi.Request, + exc: RequestValidationError): + logger.warning( + f"Validation error: {exc.errors()} " + f"for request {request.method} {request.url.path}" + ) + return JSONResponse( + status_code=422, + content={ + "error": "Validation Error", + "message": "Invalid request parameters", + "details": exc.errors(), + "status_code": 422, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: fastapi.Request, exc: Exception): + logger.error( + f"Unhandled exception: {str(exc)} " + f"for request {request.method} {request.url.path}", + exc_info=True + ) + + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred. Please try again later.", + "status_code": 500, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.get("/") +def main_page(request: fastapi.Request): + logger.debug(f'Request: {request.method} {request.url.path}') + time = get_uptime() + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": get_system(), + "runtime": time, + "request": { + "client_ip": request.client.host, + "user_agent": request.headers.get("user-agent"), + "method": request.method, + "path": request.url.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + } + + +@app.get("/health") +def health(): + logger.debug("Health check requested") + return { + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + } + + +def start(): + import os + global start_time + + HOST = os.getenv('HOST', '0.0.0.0') + PORT = int(os.getenv("PORT", "5000")) + DEBUG = os.getenv("DEBUG", 'False').lower() == "true" + start_time = datetime.now() + + logger.info(f"Starting application on {HOST}:{PORT}") + logger.info(f"Debug mode: {DEBUG}") + + uvicorn.run( + app, + host=HOST, + port=PORT, + log_config=None + ) + + +if __name__ == "__main__": + start() diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..8627e666f6 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,210 @@ +# Lab 01 + +## Chosing Web Framework + +### Choice: FastAPI + +Reasons: + +- High Performance +- Automatic Documentation ( Auto-generates OpenAPI/Swagger documentation) +- Data Validation - Built-in validation via Pydantic + + +### Comparison table on a five-point scale +| Criteria | FastApi | Flask | Django | +|-------------|----------|-----------|-----------| +|Performance | 5 | 3 | 3 | +|Documentation| 5 | 3 | 4 | +| Simplicity | 5 | 4 | 3 | +| Security | 5 | 3 | 4 | + +## Best Practices Applied + +- Clear function names like `get_uptime()` and `get_system()` - increase code readability + +Example: + +```python +def get_uptime(): + delta = datetime.now() - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_system(): + import platform + import socket + + return { + "hostname": socket.gethostname(), + "platform_name": platform.system(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + } +``` + +- Logging makes debugging easier + +Example: + +```python +@app.middleware("http") +async def log_requests(request: fastapi.Request, call_next): + start_time_middleware = datetime.now() + + logger.info(f"Request started: {request.method} {request.url.path}") + logger.debug(f"Headers: {dict(request.headers)}") + logger.debug( + f"Client IP: { + request.client.host if request.client else 'Unknown'}") + + try: + response = await call_next(request) + process_time = ( + datetime.now() - start_time_middleware).total_seconds() * 1000 + + logger.info( + f"Request completed: {request.method} {request.url.path} " + f"- Status: {response.status_code} " + f"- Time: {process_time:.2f}ms" + ) + + return response + + except Exception as ex: + logger.error( + f"Request failed: {request.method} {request.url.path} - Error: {str(ex)}") + raise +``` + +- Following PEP8 - increases code readability + +Example: + +```bash +autopep8 --in-place --aggressive --aggressive app_python/app.py + ``` + +- Error handling simplifies the process of understanding the cause of a malfunction in the code when making incorrect requests + +Example: + +```python +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: fastapi.Request, + exc: RequestValidationError): + logger.warning( + f"Validation error: { + exc.errors()} for request { + request.method} { + request.url.path}") + + return JSONResponse( + status_code=422, + content={ + "error": "Validation Error", + "message": "Invalid request parameters", + "details": exc.errors(), + "status_code": 422, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) +``` + +## API Documentation + +API documentation can be found on `http://[HOST]:[PORT]/docs` (defaults to ) + +***Request*** + +`curl http://localhost:5000/` + +***Response*** +```bash +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "Abrahams-Air", + "platform_name": "Darwin", + "architecture": "arm64", + "python_version": "3.14.0" + }, + "runtime": { + "seconds": 7, + "human": "0 hours, 0 minutes" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.4.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] +} +``` +***Request*** + +`curl http://localhost:5000/health` + +***Response*** + +```bash +{ + "status": "healthy", + "timestamp": "2026-01-26T19:34:59.627298+00:00", + "uptime_seconds": 14 +} +``` +## Testing Evidence + +`/` endpoint: + +![/](./screenshots/Screenshot1.png) + +`/health` endpoint: + +![/health](./screenshots/Screenshot2.png) + +some terminal output: + +![output](./screenshots/Screenshot3.png) + +## GitHub Community + +Starring repositories is a simple yet powerful way to support open-source projects — it helps maintainers gauge popularity, increases project visibility for new contributors, and creates a personal bookmarking system for discovering useful tools. Following developers on GitHub fosters professional growth by exposing you to diverse coding styles and project management approaches, while also building connections that can lead to collaboration opportunities in team environments. + + +**Actions Required:** +1. **Star** the course repository [Done] +2. **Star** the [simple-container-com/api](https://github.com/simple-container-com/api) project — a promising open-source tool for container management [Done] +3. **Follow** your professor and TAs on GitHub: [Done] + - Professor: [@Cre-eD](https://github.com/Cre-eD) + - TA: [@marat-biriushev](https://github.com/marat-biriushev) + - TA: [@pierrepicaud](https://github.com/pierrepicaud) +4. **Follow** at least 3 classmates from the course + - https://github.com/asqarslanov + - https://github.com/FunnyFoXD + - https://github.com/Woolfer0097 \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..ee6621d50f --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,108 @@ +# Lab 2 Submission — Docker Containerization + + +This application is fully containerized for easy and consistent deployment. + +Build the Image Locally + +To build the Docker image from the source code and the provided Dockerfile, use the docker build command with appropriate tags. + +Run a Container + +To run the application inside a container from the built image, use the docker run command, ensuring you map the container's exposed port to a port on your host machine. + +Pull from Docker Hub + +The pre-built image is also available on Docker Hub. You can pull it directly using docker pull with the correct repository name and tag, and then run it as described above. + +## Docker Best Practices Applied + +- Using a non-root user (USER appuser): + + - Why it matters: Running containers as root is a significant security risk. If an attacker compromises the application, they gain root privileges in the container, which can be leveraged for further attacks (container escape, host system compromise). Creating and switching to a dedicated, unprivileged user (appuser) minimizes the potential impact of a security breach. + +```docker +RUN useradd --create-home --shell /usr/sbin/nologin appuser +USER appuser +``` + +- Layer Caching & Ordering: + + - Why it matters: Docker caches the result of each layer. By copying only requirements.txt first and installing dependencies before copying the entire application code (app.py), we optimize the build cache. Changes to app.py will not trigger a re-install of all Python packages, leading to much faster rebuilds. + +```docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +``` + +- Using .dockerignore: + + - Why it matters: The .dockerignore file prevents unnecessary files (like local virtual environments venv/, IDE config .vscode/, cache __pycache__/, or secrets) from being sent to the Docker daemon during COPY. This results in a smaller build context, faster builds, and a more secure image by avoiding accidental inclusion of sensitive data. + +- --no-cache-dir with pip: + + - Why it matters: This flag tells pip not to store the download cache. Since the installed packages are persisted in the Docker image layer, keeping the cache is redundant and only increases the final image size. + +```docker +RUN pip install --no-cache-dir -r requirements.txt +``` + +## Image Information & Decisions + +- Base Image: python:3.13-slim + +Justification: The slim variant provides a balance. It contains the essential Python runtime and common system libraries needed for most Python apps but strips out unnecessary extra packages (like common documentation) found in the default python:3.13 image. This leads to a significantly smaller and more secure image compared to the full alpine version, which might require additional steps to compile some Python dependencies. +- Final Image Size: 49 MB + +Assessment: This is a reasonable size for a Python application. The bulk comes from the python:slim base layer. Further reduction could be explored using multi-stage builds if the project had complex compilation steps, but for this simple Flask app, slim offers the best trade-off between size and ease of use. +- Layer Structure & Optimization: + +The Dockerfile is structured to leverage caching. The most stable dependencies (Python package list from requirements.txt) are copied and installed first. The more frequently changed application code (app.py) is copied in the final COPY instruction. This ensures that code changes don't invalidate the cached pip install layer, speeding up development cycles. + +## Build & Run Process + +1. Build command: + +```bash +docker build -t lab02:latest . +``` + + +2. Command for container running: + +```bash +docker run --rm -it -p 5001:5000 lab02 +``` + + +3. Docker Hub Repository URL : +``` +https://hub.docker.com/repository/docker/abrahambarrett228/lab02/general +``` + + P.S. Terminal output showing the successful push: + + ``` +abraham_barrett@Abrahams-MacBook-Air app_python % docker push abrahambarrett228/lab02:latest +The push refers to repository [docker.io/abrahambarrett228/lab02] +fe9a90620d58: Pushed +a6866fe8c3d2: Pushed +97fc85b49690: Pushed +ee8758c3eb7d: Pushed +4fa8698484f1: Pushed +3ea009573b47: Pushed +6fb77c4bfd96: Pushed +399cf5dc7b8b: Pushed +3de5fa5034b2: Pushed +latest: digest: sha256:ff4a7b2b082f8fa68caa395f865e938ed30671d377439092b6ecefe3b2873007 size: 856 + ``` +However, there were no difficult strategy of tagging images since for now I need to create only one push. However, in future, I may recreate a strategy with creating versioning (based on major/minor updates) + +## Technical Analysis + +- Dockerfile Logic: The file follows a logical flow: define the environment (FROM), set up the workspace (WORKDIR), install system-level dependencies if any (none here), install Python dependencies, set up security (non-root user), copy application code, declare runtime configuration (EXPOSE, CMD). This order maximizes layer cache efficiency and security. + +- Changing Layer Order: If we copied app.py before running pip install, every single change to the application code would force Docker to invalidate the cache for the pip install layer and all subsequent layers. This would result in re-downloading and re-installing all dependencies on every build, making the development process much slower. +- Security Considerations: The key security measure is running the process as a non-root user (appuser). Additionally, using an official, minimal base image (slim) reduces the attack surface by including fewer pre-installed packages that could contain vulnerabilities. The .dockerignore file is also a security feature, preventing local secrets from being baked into the image. +- .dockerignore Improvement: Beyond security, .dockerignore drastically speeds up the build process, especially for projects with large directories like node_modules/ or .git/. The Docker daemon doesn't have to process these files, leading to quicker context upload and layer creation. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..d4f19f09af --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,108 @@ +# DevOps Info Service — CI/CD Pipeline Documentation + +## 1. Overview + +### Testing Framework: pytest + +### Why pytest? + +- Simple and intuitive syntax with minimal boilerplate code +- Powerful fixture system for test setup/teardown +- Excellent plugin ecosystem (pytest-cov for coverage, etc.) +- Industry standard for Python testing +- Better assertion introspection than unittest + + +### Test Coverage: + +| Endpoint | Tests | What's Tested | +|----------|-------|---------------| +| `GET /` | 7 tests | Status code, response structure, service info, system info, runtime progression, request info, endpoints list | +| `GET /health` | 2 tests | Status code, response structure (status, uptime, timestamp) | +| `404 Handler` | 1 test | Error response format and status code | + +![alt text](screenshots/image4.png) + +## CI Workflow Configuration + +### Triggers: + +- Push: master, main, lab03 branches +- Pull Request: master branch +- Path filters: Only runs when app_python/ or workflow files change +### Why these triggers? + +- Running on lab03 branch enables testing during development +- PR checks prevent merging broken code +- Path filters optimize resource usage — no need to run Python CI when only Go/Rust code changes +- Versioning Strategy: Calendar Versioning (CalVer) + +### Format: YYYY.MM.build-number (e.g., 2025.03.42) + +#### Rationale: + +This is a service, not a library — users don't need SemVer's breaking change semantics +CalVer provides immediate context about when the image was built +Dates are human-readable and eliminate version number debates +Perfect for continuous deployment workflow +Combined with build number ensures uniqueness +### Docker Tags: + +- abrahambarrett228/devops-info-service:2025.03 — Monthly release track +- abrahambarrett228/devops-info-service:2025.03.42 — Specific build +- abrahambarrett228/devops-info-service:latest — Latest stable build + + +2. Workflow Evidence + + Successful GitHub Actions Run + + Local Tests Passing + + ![alt text](screenshots/image4.png) + + Docker Hub Image + + + + 3. CI Best Practices Implemented + + | Practice | Implementation | Why It Matters | +|:---------|:---------------|:---------------| +| **1. Dependency Caching** | `actions/setup-python` with `cache: pip` | — Saves time per run by reusing pip cache | +| **2. Fail Fast** | `needs: test` in docker job | Prevents publishing broken images — if tests fail, Docker build never starts | +| **3. Conditional Deployment** | `if: github.ref == 'refs/heads/master'` | Only push Docker images from protected branches — prevents spam from feature branches | +| **4. Concurrency Control** | `concurrency.cancel-in-progress: true` | Cancels outdated workflow runs — saves resources on force-pushes | +| **5. Path Filtering** | `on.push.paths: ['app_python/**']` | CI runs only when relevant files change — saves 100% of runtime when editing docs | +| **6. Security Scanning** | Snyk with high severity threshold | 🔒 Catches vulnerable dependencies before production | +| **7. Coverage Threshold** | `--cov-fail-under=70` | Enforces minimum test coverage — prevents coverage regression | + + + +4. Key Decisions + +### Versioning Strategy: CalVer + +Why not SemVer? This is a web service, not a library. Users don't pin versions in requirements.txt — they pull the latest Docker image. CalVer tells me immediately when an image was built. The date format 2025.03 is instantly recognizable and eliminates debates about "is this a major or minor change?" For continuous deployment, dates make more sense than arbitrary version numbers. +Docker Tags + +CI generates three tags: latest, monthly version (2025.03), and specific build (2025.03.42). latest is for convenience, monthly tags provide stable release tracks, and build-specific tags enable rollbacks. The build number from GitHub Actions guarantees uniqueness even if multiple builds happen on the same day. + +### Test Coverage: + +Coverage is enforced at 70% but consistently runs at 75%. + +What's covered: + +All endpoint response structures and status codes +Dynamic behavior (uptime progression, timestamp formatting) +Error handling (404 responses) +Service metadata validation + + + +### Workflow Triggers + +I chose to run on both push to lab03/main and PRs to master. Running on development branches lets me catch issues early without creating PRs. PR checks act as a quality gate — no broken code reaches master. Path filters prevent wasting resources when I update only the Go app or documentation. +Test Coverage Strategy + diff --git a/app_python/docs/screenshots/Screenshot1.png b/app_python/docs/screenshots/Screenshot1.png new file mode 100644 index 0000000000..78b88acbf5 Binary files /dev/null and b/app_python/docs/screenshots/Screenshot1.png differ diff --git a/app_python/docs/screenshots/Screenshot2.png b/app_python/docs/screenshots/Screenshot2.png new file mode 100644 index 0000000000..afb6008cae Binary files /dev/null and b/app_python/docs/screenshots/Screenshot2.png differ diff --git a/app_python/docs/screenshots/Screenshot3.png b/app_python/docs/screenshots/Screenshot3.png new file mode 100644 index 0000000000..5f1e37f575 Binary files /dev/null and b/app_python/docs/screenshots/Screenshot3.png differ diff --git a/app_python/docs/screenshots/image4.png b/app_python/docs/screenshots/image4.png new file mode 100644 index 0000000000..feda8a3eb2 Binary files /dev/null and b/app_python/docs/screenshots/image4.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..45f5671a41 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,27 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +certifi==2026.1.4 +click==8.3.1 +coverage==7.13.4 +fastapi==0.128.0 +flake8==7.3.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +mccabe==0.7.0 +packaging==26.0 +pluggy==1.6.0 +pycodestyle==2.14.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +pyflakes==3.4.0 +Pygments==2.19.2 +pytest==9.0.2 +pytest-cov==7.0.0 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.40.0 \ No newline at end of file diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_error_endpoint.py b/app_python/tests/test_error_endpoint.py new file mode 100644 index 0000000000..e0bdcf059d --- /dev/null +++ b/app_python/tests/test_error_endpoint.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_error_handler(): + response = client.get("/non-existent-endpoint") + assert (response.json())["error"] == "Not Found" + assert response.status_code == 404 diff --git a/app_python/tests/test_health_endpoint.py b/app_python/tests/test_health_endpoint.py new file mode 100644 index 0000000000..6bd41e41da --- /dev/null +++ b/app_python/tests/test_health_endpoint.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_status_code(): + response = client.get("/health") + assert response.status_code == 200 + + +def test_response_structure(): + data = client.get("/health").json() + + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert "timestamp" in data + + timestamp = data["timestamp"] + try: + parsed_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + assert parsed_time.tzinfo is not None + except (ValueError, AttributeError): + assert False diff --git a/app_python/tests/test_mainpage_endpoint.py b/app_python/tests/test_mainpage_endpoint.py new file mode 100644 index 0000000000..022b5a4ca9 --- /dev/null +++ b/app_python/tests/test_mainpage_endpoint.py @@ -0,0 +1,95 @@ +import re + +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_status_code(): + response = client.get("/") + assert response.status_code == 200 + + +def test_response_structure(): + response = client.get("/") + assert all(key in response.json() for key in ["service", "system", "runtime", "request", "endpoints"]) + + +def test_service_structure(): + data = client.get("/").json()["service"] + assert isinstance(data["version"], str) + assert data["name"] == "devops-info-service" + assert data["framework"] == "FastAPI" + + +def test_system_structure(): + response = client.get("/") + data = response.json() + system = data["system"] + expected_fields = {"hostname", "platform_name", "architecture", "python_version"} + assert expected_fields.issubset(system.keys()) + + assert isinstance(system["hostname"], str) + assert isinstance(system["platform_name"], str) + assert isinstance(system["architecture"], str) + assert isinstance(system["python_version"], str) + + assert re.match(r'^\d+\.\d+\.\d+', system["python_version"]) + + +def test_runtime_structure(): + response = client.get("/") + assert response.status_code == 200 + + data = response.json() + + assert "runtime" in data + + runtime = data["runtime"] + + assert "seconds" in runtime + assert "human" in runtime + + assert isinstance(runtime["seconds"], int) + assert isinstance(runtime["human"], str) + + assert runtime["seconds"] >= 0 + + human = runtime["human"] + assert "h" in human or "m" in human or "s" in human + assert any(char.isdigit() for char in human) + + +def test_request_info_structure(): + response = client.get("/") + data = response.json() + request_info = data["request"] + expected_fields = {"client_ip", "user_agent", "method", "path"} + + assert expected_fields.issubset(request_info.keys()) + + assert request_info["method"] == "GET" + assert request_info["path"] == "/" + assert isinstance(request_info["client_ip"], str) + assert request_info["user_agent"] is None or isinstance(request_info["user_agent"], str) + + +def test_endpoints_list(): + response = client.get("/") + data = response.json() + endpoints = data["endpoints"] + assert isinstance(endpoints, list) + assert len(endpoints) >= 2 + + for endpoint in endpoints: + assert "path" in endpoint + assert "method" in endpoint + assert "description" in endpoint + assert isinstance(endpoint["path"], str) + assert isinstance(endpoint["method"], str) + assert isinstance(endpoint["description"], str) + + endpoint_paths = {(e["path"], e["method"]) for e in endpoints} + assert ("/", "GET") in endpoint_paths + assert ("/health", "GET") in endpoint_paths diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..983ac2c361 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,7 @@ +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars +*.pem +*.key +*.json \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..52b00398dc --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/yandex-cloud/yandex" { + version = "0.187.0" + constraints = "~> 0.80" + hashes = [ + "h1:+uf4EBRLDwNYIvZsGK/ZUzN3sGzJaXcUngyYSIJoyyQ=", + "zh:0fabcedc99430bc72df9ea2643f05f06772d929d9e62694dccc8e4ddc02b5399", + "zh:192ee529b2eccaff39c550634ddb999e5849283c31dfa9aa08a35aaecce56763", + "zh:2743c80268e91ec940c5916fb8d8f7b6e782eab9df60d6be9648f13e3ab4157b", + "zh:32fc3bcf5925ff66e03a146f308d9e0681123108b4dd1d1067a03e813e30b207", + "zh:4420ab8be98a300bd39fa74c24a786e9fe3972554bbec036649b47e07cacda9a", + "zh:58d1c1158026469dfa05c7e566a800b1868b10408bc78a38322529da39726ddb", + "zh:7ccbcb94870a95ad8eada75bfaebe882f0f6e71f746c5e672d3bb6f83a6006db", + "zh:8df34a8997ca47c89da098f327fb413f4b69af7d3e44c43183c219db9f9e88e3", + "zh:97d9e22f969693029986db7a7f41ab4e5e893ed6e1b02bf1a1b49f490c4c7f65", + "zh:b07efc6ef8d4b207a66a7cd574542fd75785ce94e957a65636281248bea302cb", + "zh:c8dcd66a172de2dbd0b992ce0a39e2523de64ffb853082e260f4a5f734b5a638", + "zh:d5c1af4eba76c9d234f21449010982e53e24a4edb501b1092b5c04dff0e20004", + "zh:fc71319af81d7ed79415b9ce0f34454c546a3e99a42e65a2731128538d87db53", + ] +} diff --git a/terraform/docs/LAB04.md b/terraform/docs/LAB04.md new file mode 100644 index 0000000000..5d585198d9 --- /dev/null +++ b/terraform/docs/LAB04.md @@ -0,0 +1,312 @@ +# Lab 04 . Terraform / Pulumi + +## 1. Cloud Provider & Infrastructure + +- As a provider, I have chosen Yandex Cloud since this service has a free period, it does not force me to use proxy servers to connect. +- As an instance, I have created a VM with ubuntu (1 cpu and 10 GB of HDD since it is enough for my lightweight application) +- As a region I have chosen "ru-central1-a". It is an alias related to the Russian region, that increases the availability of my service. +- Total cost equals to 0$, since I have used grant for the first usage (free period). +- I have created several instances : + + - Service account that manages all the credentials + - Network and subnetwork for traffic management + - DNS record for some purposes related to amdin usage + - VM itself + +## 2. Terraform Implementation + +- Terraform version = v1.9.0 +- Project Structure Explanation + + The Terraform project was structured using separate files for clarity and maintainability: + - ```main.tf``` contains core infrastructure resources (network, security group, VM). + - ```providers.tf``` contains provider data + - ```output.tf``` contains all the data that I will obtain in output after VM creation. + - ```variables.tf``` defines all input variables. + - ```terraform.tfvars``` stores sensitive and environment-specific values. +This structure follows Terraform best practices and improves readability. +- Key Configuration Decisions + - The default VPC network and subnet were reused via data sources to avoid unnecessary resource creation. + - SSH access was restricted using a security group with a specific allowed IP address. + - The SSH public key was injected into VM metadata using the file() function to avoid hardcoding credentials. +- Challenges Encountered + + - As for me, I have spent a lot of time on creating and configuring ssh connections since it was unclear for me that I can generate rsa keys myself instead of using those, that I obtained when creating service account. +- Terminal output from key commands: (I had a lot of troubles when creating terraform configs, so in my logs, I document not the first try and ```terraform plan``` and ```terraform apply``` only checks that on server I have the latest version) +``` +- terraform init +``` +```bash +Initializing the backend... + +Initializing provider plugins... +- Finding yandex-cloud/yandex versions matching "~> 0.100"... +- Installing yandex-cloud/yandex v0.187.0... +- Installed yandex-cloud/yandex v0.187.0 (self-signed, key ID E40F590B50BB8E40) + +Terraform has created a lock file .terraform.lock.hcl to record the provider selections it made above. + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. All Terraform commands +should now work. +``` + - terraform plan (sanitized, no secrets) +```bash +abraham_barrett@Abrahams-MacBook-Air terraform % terraform plan +var.ssh_public_key + SSH public key + + Enter a value: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJAD33+mhaWrq126MfUmhWC86Dd95xQ9mvGXPC+6Xmex lab04-yc + +data.yandex_compute_image.ubuntu: Reading... +data.yandex_vpc_network.default: Reading... +data.yandex_vpc_subnet.default: Reading... +data.yandex_compute_image.ubuntu: Read complete after 0s [id=fd8q1krrgc5pncjckeht] +data.yandex_vpc_subnet.default: Read complete after 1s [id=e9bu69enu1ev2gcl79nl] +data.yandex_vpc_network.default: Read complete after 1s [id=enp93sgg09cutu4hr44s] +yandex_vpc_security_group.lab_sg: Refreshing state... [id=enpqgrneh2kl2doh8u9b] +yandex_compute_instance.lab_vm: Refreshing state... [id=fhmd4hk9b586v11h6dqu] + +No changes. Your infrastructure matches the configuration. + +Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. + +``` + - terraform apply +```bash +abraham_barrett@Abrahams-MacBook-Air terraform % terraform apply +var.ssh_public_key + SSH public key + + Enter a value: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJAD33+mhaWrq126MfUmhWC86Dd95xQ9mvGXPC+6Xmex lab04-yc + +data.yandex_vpc_network.default: Reading... +data.yandex_compute_image.ubuntu: Reading... +data.yandex_vpc_subnet.default: Reading... +data.yandex_compute_image.ubuntu: Read complete after 1s [id=fd8q1krrgc5pncjckeht] +data.yandex_vpc_subnet.default: Read complete after 1s [id=e9bu69enu1ev2gcl79nl] +data.yandex_vpc_network.default: Read complete after 1s [id=enp93sgg09cutu4hr44s] +yandex_vpc_security_group.lab_sg: Refreshing state... [id=enpqgrneh2kl2doh8u9b] +yandex_compute_instance.lab_vm: Refreshing state... [id=fhmd4hk9b586v11h6dqu] + +No changes. Your infrastructure matches the configuration. + +Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. + +Apply complete! Resources: 0 added, 0 changed, 0 destroyed. + +Outputs: + +public_ip = "89.169.154.155" +abraham_barrett@Abrahams-MacBook-Air terraform % +``` + - ssh connection +```bash +abraham_barrett@Abrahams-MacBook-Air terraform % ssh ubuntu@89.169.154.155 +The authenticity of host '89.169.154.155 (89.169.154.155)' can't be established. +ED25519 key fingerprint is SHA256:IzmvYr+LtTIZ/v1vQHW+xMbba8AS9lq/VZRTkTtPvdo. +This key is not known by any other names. +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '89.169.154.155' (ED25519) to the list of known hosts. +Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-100-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Thu Feb 19 17:54:26 UTC 2026 + + System load: 0.23 Processes: 101 + Usage of /: 22.1% of 9.04GB Users logged in: 0 + Memory usage: 19% IPv4 address for eth0: 10.128.0.30 + Swap usage: 0% + + +Expanded Security Maintenance for Applications is not enabled. + +0 updates can be applied immediately. + +Enable ESM Apps to receive additional future security updates. +See https://ubuntu.com/esm or run: sudo pro status + + + +The programs included with the Ubuntu system are free software; +the exact distribution terms for each program are described in the +individual files in /usr/share/doc/*/copyright. + +Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by +applicable law. + +To run a command as administrator (user "root"), use "sudo ". +See "man sudo_root" for details. + +ubuntu@fhmd4hk9b586v11h6dqu:~$ ls -la +total 28 +drwxr-x--- 4 ubuntu ubuntu 4096 Feb 19 17:54 . +drwxr-xr-x 3 root root 4096 Feb 19 17:53 .. +-rw-r--r-- 1 ubuntu ubuntu 220 Mar 31 2024 .bash_logout +-rw-r--r-- 1 ubuntu ubuntu 3771 Mar 31 2024 .bashrc +drwx------ 2 ubuntu ubuntu 4096 Feb 19 17:54 .cache +-rw-r--r-- 1 ubuntu ubuntu 807 Mar 31 2024 .profile +drwx------ 2 ubuntu ubuntu 4096 Feb 19 17:53 .ssh +ubuntu@fhmd4hk9b586v11h6dqu:~$ exit +logout +Connection to 89.169.154.155 closed. +``` + +## 3. Pulumi Implementation + +- Pulumi version = v3.222.0 and language = Python +- How Code Differs from Terraform + - Unlike Terraform’s declarative HCL, Pulumi uses imperative programming constructs. Infrastructure is defined using Python code, allowing the use of variables, functions, and conditionals. This makes the code more flexible but also more complex. +- Advantages Discovered + - Pulumi allows reuse of programming language features such as loops and abstractions, which reduces code duplication. Debugging is easier because standard language tools and error messages can be used. Infrastructure logic feels more natural in complex scenarios. +- Challenges Encountered + - In fact I did not have such difficulties as I had in terraform, so it was pretty simple for me to cope with this task. +- Terminal output from: +- pulumi preview +```bash +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % pulumi preview +Previewing update (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/Abraham14711-org/lab04-yandex/dev/previews/5ce2812b-d968-4556-9de8-85240fc90075 + + Type Name Plan + + pulumi:pulumi:Stack lab04-yandex-dev create + + ├─ yandex:index:VpcSecurityGroup dev-sg create + + └─ yandex:index:ComputeInstance dev-instance create + +Outputs: + connect_ssh: [unknown] + instance_id: [unknown] + private_ip : [unknown] + public_ip : [unknown] + +Resources: + + 3 to create + +``` +- pulumi up +```bash +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % pulumi up +Previewing update (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/Abraham14711-org/lab04-yandex/dev/previews/30427911-9793-4fbd-a7b4-1bad983bb3ca + + Type Name Plan + + pulumi:pulumi:Stack lab04-yandex-dev create + + ├─ yandex:index:VpcSecurityGroup dev-sg create + + └─ yandex:index:ComputeInstance dev-instance create + +Outputs: + connect_ssh: [unknown] + instance_id: [unknown] + private_ip : [unknown] + public_ip : [unknown] + +Resources: + + 3 to create + +Do you want to perform this update? yes +Updating (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/Abraham14711-org/lab04-yandex/dev/updates/1 + + Type Name Status + + pulumi:pulumi:Stack lab04-yandex-dev created (49s) + + ├─ yandex:index:VpcSecurityGroup dev-sg created (3s) + + └─ yandex:index:ComputeInstance dev-instance created (41s) + +Outputs: + connect_ssh: "ssh ubuntu@93.77.190.237" + instance_id: "fhmr6hjcij7196o3pfa7" + private_ip : "10.128.0.7" + public_ip : "93.77.190.237" + +Resources: + + 3 created + +Duration: 50s +``` +- ssh connection +```bash +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % ssh ubuntu@93.77.190.237 +ssh: connect to host 93.77.190.237 port 22: Connection refused +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % ssh ubuntu@93.77.190.237 +ssh: connect to host 93.77.190.237 port 22: Connection refused +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % ssh ubuntu@93.77.190.237 +The authenticity of host '93.77.190.237 (93.77.190.237)' can't be established. +ED25519 key fingerprint is SHA256:xW/f6/1kHU+W3D7G7uc1QY7cDPHwDCgCiYaf2DzMW+M. +This key is not known by any other names. +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '93.77.190.237' (ED25519) to the list of known hosts. +Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-100-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Thu Feb 19 18:20:55 UTC 2026 + + System load: 0.55 Processes: 104 + Usage of /: 22.1% of 9.04GB Users logged in: 0 + Memory usage: 20% IPv4 address for eth0: 10.128.0.7 + Swap usage: 0% + + +Expanded Security Maintenance for Applications is not enabled. + +0 updates can be applied immediately. + +Enable ESM Apps to receive additional future security updates. +See https://ubuntu.com/esm or run: sudo pro status + + + +The programs included with the Ubuntu system are free software; +the exact distribution terms for each program are described in the +individual files in /usr/share/doc/*/copyright. + +Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by +applicable law. + +To run a command as administrator (user "root"), use "sudo ". +See "man sudo_root" for details. + +ubuntu@fhmr6hjcij7196o3pfa7:~$ exit +logout +Connection to 93.77.190.237 closed. +(venv) abraham_barrett@Abrahams-MacBook-Air pulumi % +``` + +## 4. Terraform vs Pulumi Comparison +### Ease of Learning +Terraform was easier to learn initially because of its declarative HCL syntax and a large number of simple tutorials. The basic concepts such as providers, resources, and variables are straightforward and well structured. Pulumi has a steeper learning curve since it requires knowledge of a programming language (e.g., Python), which adds additional complexity for beginners. +### Code Readability +Pulumi felt more readable and flexible because infrastructure is described using a general-purpose programming language. Loops, conditions, and abstractions are expressed naturally in code. Terraform configuration can become repetitive and verbose when infrastructure grows more complex. +### Debugging +Debugging was easier in Pulumi because standard programming tools (prints, debuggers, stack traces) can be used. In Terraform, debugging mostly relies on reading plan output and error messages, which can be less intuitive when errors are indirect or related to state. +### Documentation +Terraform has more mature documentation and a larger ecosystem of examples due to its long presence on the market. Most common use cases are well documented and easy to find. Pulumi documentation is clear but has fewer real-world examples, especially for specific cloud providers. +### Use Case +Terraform is well suited for standard, declarative infrastructure and teams that want a simple and widely adopted IaC tool. Pulumi is preferable when infrastructure logic is complex, requires conditions or reuse, or when a team wants to use familiar programming languages to manage infrastructure. + +## 5. Lab 5 Preparation & Cleanup + +VM for Lab 5: + +Are you keeping your VM for Lab 5? (Yes/No) - Yes +If yes: Which VM (Terraform or Pulumi created)? -Terraform +If no: What will you use for Lab 5? (Local VM/Will recreate cloud VM) +Cleanup Status: + +If keeping VM for Lab 5: Show VM is still running and accessible - Yes, The following screenshots show that the VM is accessible. (Yes, I have created a new one with another IP.) + +![alt text](image.png) + +![alt text](image-1.png) + +![alt text](image-2.png) diff --git a/terraform/docs/image-1.png b/terraform/docs/image-1.png new file mode 100644 index 0000000000..2d81d50e9d Binary files /dev/null and b/terraform/docs/image-1.png differ diff --git a/terraform/docs/image-2.png b/terraform/docs/image-2.png new file mode 100644 index 0000000000..a32376a0df Binary files /dev/null and b/terraform/docs/image-2.png differ diff --git a/terraform/docs/image.png b/terraform/docs/image.png new file mode 100644 index 0000000000..68db3b6fa6 Binary files /dev/null and b/terraform/docs/image.png differ diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..e2a6358938 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,70 @@ +data "yandex_vpc_network" "default" { + name = "default" +} + +data "yandex_vpc_subnet" "default" { + name = "default-${var.zone}" +} + +data "yandex_compute_image" "ubuntu" { + family = "ubuntu-2404-lts-oslogin" +} + + +resource "yandex_vpc_security_group" "lab_sg" { + name = "lab-sg" + network_id = data.yandex_vpc_network.default.id + + ingress { + protocol = "TCP" + port = 22 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "yandex_compute_instance" "lab_vm" { + name = "lab-vm" + + platform_id = "standard-v2" + + resources { + cores = 2 + core_fraction = 20 + memory = 1 + } + + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = 10 + } + } + + network_interface { + subnet_id = data.yandex_vpc_subnet.default.id + nat = true + security_group_ids = [yandex_vpc_security_group.lab_sg.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${file(var.ssh_public_key_path)}" + ssh_public_key = "${file(var.ssh_public_key_path)}" +} +} \ No newline at end of file diff --git a/terraform/output.tf b/terraform/output.tf new file mode 100644 index 0000000000..7c95e67606 --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,3 @@ +output "public_ip" { + value = yandex_compute_instance.lab_vm.network_interface[0].nat_ip_address +} \ No newline at end of file diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000000..fc74a7770d --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + } + } +} + +provider "yandex" { + token = var.iam_token + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.zone +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..8c9e80a0ee --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,28 @@ +variable "cloud_id" { + description = "Yandex Cloud ID" +} + +variable "folder_id" { + description = "Yandex Folder ID" +} + +variable "zone" { + default = "ru-central1-a" +} + +variable "iam_token" { + description = "IAM token" + sensitive = true +} + +variable "ssh_user" { + default = "ubuntu" +} + +variable "ssh_public_key" { + description = "SSH public key" +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key file" +}