diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..42c69b6bfb --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,56 @@ +name: Ansible Deployment + +on: + push: + branches: [ main, master ] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + pull_request: + branches: [ main, master ] + paths: + - 'ansible/**' + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + pip install ansible ansible-lint + + - name: Run ansible-lint + run: | + cd ansible + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ./.vault_pass + ansible-lint playbooks/*.yml -x no-relative-paths + + deploy: + runs-on: self-hosted + needs: lint + steps: + - uses: actions/checkout@v4 + + - name: Deploy with Ansible + run: | + cd ansible + echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > /tmp/vault_pass + ansible-playbook playbooks/deploy.yml \ + --vault-password-file /tmp/vault_pass \ + --tags "app_deploy" + rm /tmp/vault_pass + + - name: Verify Deployment + run: | + sleep 10 # Wait for app to start + curl -f http://${{ secrets.VM_HOST }}:8000 || exit 1 + curl -f http://${{ secrets.VM_HOST }}:8000/health || exit 1 diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..ad4f9cd826 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,72 @@ +name: Go CI + +on: + pull_request: + branches: [ master ] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs/**' + - '!app_go/README.md' + - '!**.gitignore' + push: + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs/**' + - '!app_go/README.md' + - '!**.gitignore' + + +jobs: + test: + name: Verify go app + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_go + steps: + - uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + cache: true + cache-dependency-path: 'app_go/go.sum' + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + working-directory: ./app_go + + - name: Install dependencies + run: go mod download + + - name: Test with Coverage + run: | + CGO_ENABLED=0 go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out + + docker: + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_go + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/infoservice:go-latest + ${{ secrets.DOCKER_USERNAME }}/infoservice:go-${{ github.event.pull_request.number }}.1.0 + diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b22e781d9b --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,74 @@ +name: Python CI + +on: + pull_request: + branches: [ master ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + - '!app_python/docs/**' + - '!app_python/README.md' + - '!**.gitignore' + push: + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + - '!app_python/docs/**' + - '!app_python/README.md' + - '!**.gitignore' + +jobs: + test: + name: Verify python app + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + # - name: Run Snyk + # uses: snyk/actions/python-3.10@master + # env: + # SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + # with: + # args: --severity-threshold=high + + - name: Lint + run: | + pip install flake8 + flake8 infoservice/infoservice.py + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Test with coverage + run: pytest + + docker: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/infoservice:python-latest + ${{ secrets.DOCKER_USERNAME }}/infoservice:python-${{ github.event.pull_request.number }}.1.0 diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..873c1e4d66 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,40 @@ +name: Terraform CI + +on: + pull_request: + branches: [ master ] + paths: + - 'terraform/**' + +jobs: + validate: + name: Validate terraform configuration + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup terraform + uses: hashicorp/setup-terraform@v3 + + - name: Check formatting + run: terraform fmt -check + + - name: Initialize terraform + run: terraform init + + - name: Validate syntax + run: terraform validate + + - name: Setup terraform linter + uses: terraform-linters/setup-tflint@v6 + + - name: Lint terraform + run: tflint + + # - name: GitHub Integration + # run: | + # cd ./terraform + # export GITHUB_TOKEN=${{ secrets.TERRAFORM_GITHUB_TOKEN }} + # terraform import github_repository.course_repo DevOps-Core-Course + # terraform plan diff --git a/.gitignore b/.gitignore index 30d74d2584..a6a358aa46 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,19 @@ -test \ No newline at end of file +*.xml + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars + +# Pulumi +pulumi/venv/ +Pulumi.*.yaml + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +shell.nix diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..b487bb7be0 --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,4 @@ +.vault_pass +*.retry +inventory/*.pyc +__pycache__/ diff --git a/ansible/LAB05.md b/ansible/LAB05.md new file mode 100644 index 0000000000..0cf46f26f3 --- /dev/null +++ b/ansible/LAB05.md @@ -0,0 +1,191 @@ +# Architecture overview +- Ansible version: 2.19.4 +- Target VM OS and version: ubuntu server 24.04 +- Role structure: +``` +roles +├── app_deploy +├── common +└── docker +``` +- [Why roles instead of playbooks](./LAB05.md#Key Decisions) + + +# Roles Documentation +## Common +- Purpose: Common setup +- Variables: `common_packages` - list of packages to install +- Handlers: None +- Dependencies: None + +## Docker +- Purpose: Setup docker +- Variables: `ansible_distribution_release` - version of VM OS +- Handlers: `restart docker` - restarts docker daemon after installation +- Dependencies: `common` role + +## App_deploy +- Purpose: Deploy application +- Variables: + - `dockerhub_username`: login for dockerhub account + - `dockerhub_password`: password for dockerhub account + - `app_name`: name of application + - `docker_image`: name of docker image in registry + - `docker_image_tag`: tag of image in registry + - `docker_port`: port inside a docker container + - `local_port`: port on vm to connect to container + - `port_mapping`: docker port mapping + - `app_container_name`: name for container +- Handlers: `stop container` - stops and removes container after image updated +- Dependencies: `docker` role + + +# Idempotency Demonstration +## First run +``` +PLAY [Provision web servers] ************************************************************************************************* + +TASK [Gathering Facts] ******************************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Update apt cache] ********************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Install common packages] ************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Setup timezone] *********************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker prerequisites] ********************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker GPG key] ******************************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker repository] **************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker] *********************************************************************************************** +changed: [DevOpsVM] + +RUNNING HANDLER [docker : restart docker] ************************************************************************************ +changed: [DevOpsVM] + +PLAY RECAP ******************************************************************************************************************* +DevOpsVM : ok=9 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +## Second run +``` +PLAY [Provision web servers] ******************************************************************************************************************************************************************* + +TASK [Gathering Facts] ************************************************************************************************************************************************************************* +ok: [DevOpsVM] + +TASK [common : Update apt cache] *************************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Install common packages] ******************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [common : Setup timezone] ***************************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker prerequisites] *************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Add Docker GPG key] ************************************************************************************************************************************************************* +ok: [DevOpsVM] + +TASK [docker : Add Docker repository] ********************************************************************************************************************************************************** +ok: [DevOpsVM] + +TASK [docker : Install Docker] ***************************************************************************************************************************************************************** +ok: [DevOpsVM] + +PLAY RECAP ************************************************************************************************************************************************************************************* +DevOpsVM : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` +## Analysis +What changed: Docker installed +What didn't change second time: Nothing changed + +## Explanation +First run installs docker(and triggers restart service handler) +and this step is not runned on second run, since docker +is already installed + + +# Ansible Vault Usage +- I store credentilas securely with usage of Ansible Vault +- I manage vault password with gitignored password file +- [Example of encrypted file](./roles/app_deploy/defaults/main.yml) +- [Why ansible Vault is important](./LAB05.md#Key Decisions) + + +# Deployment Verification +Output of `deploy.yml` run +``` +PLAY [Deploy application] **************************************************************************************************** + +TASK [Gathering Facts] ******************************************************************************************************* +ok: [DevOpsVM] + +TASK [app_deploy : Login] **************************************************************************************************** +ok: [DevOpsVM] + +TASK [app_deploy : Pull Image] *********************************************************************************************** +ok: [DevOpsVM] + +TASK [app_deploy : run container] ******************************************************************************************** +changed: [DevOpsVM] + +TASK [app_deploy : Verify Container Running] ********************************************************************************* +ok: [DevOpsVM] + +TASK [app_deploy : Check Health] ********************************************************************************************* +ok: [DevOpsVM] + +PLAY RECAP ******************************************************************************************************************* +DevOpsVM : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` +`docker ps` output: +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +aeca03f980be ub3rch/infoservice:python-latest "fastapi run infoser…" 8 seconds ago Up 8 seconds 127.0.0.1:5000->8000/tcp Xantusia +``` +`curl` outputs: +- `curl http:/127.0.0.1:5000/` +``` +{"system":{"hostname":"aeca03f980be","platform":"Linux","platform_version":"#100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026","architecture":"x86_64","python_version":"3.13.12"},"service":{"name":"DevOps Info Service","version":"0.1.1","description":"DevOps course info service","framework":"fastapi"},"runtime":{"uptime_seconds":64,"uptime_human":"0 hours, 1 minutes","current_time":"2026-02-24T12:13:04.779840+00:00","timezone":"UTC"},"request":{"client_ip":"172.17.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"}]} +``` +- `curl http:/127.0.0.1:5000/health` +``` +{"status":"healthy","timestamp":"2026-02-24T12:13:09.770103+00:00","uptime_seconds":69} +``` + +# Key Decisions +## Why roles instead of playbooks? +Roles give reusability and modularity to playbooks + +## How do roles improve reusability? +Different playbooks can use same roles to +usilize common setup steps + +## What makes task idempotent? +Since tasks are declarative, checking +if declared state is already reached, then +the step is skipped. + +## How do handlers improve efficiency? +Handler run only if trigerred from +tasks. Since tasks skipped in most +of cases (since they are idempotent), then +Handler tasks not even check their state, but +skipped instantly. + +## Why is Ansible Vault necessary? +Ansible Vault allows storing configuration +in version control, at the same time keeping +secrets protected, since vaults are encrypted. diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..f1af8ee5c5 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1 @@ +[![Ansible Deployment](https://github.com/your-username/your-repo/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/your-username/your-repo/actions/workflows/ansible-deploy.yml) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..7b99e250dc --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = user +retry_files_enabled = False +vault_password_file = .vault_pass + +[privilege_escalation] +become = True +become_method = su +become_user = root diff --git a/ansible/deploy.yml b/ansible/deploy.yml new file mode 100644 index 0000000000..af4a900fb2 --- /dev/null +++ b/ansible/deploy.yml @@ -0,0 +1,17 @@ +$ANSIBLE_VAULT;1.1;AES256 +38653463613966636538626531663233343162373131343438626466303734613232633433613834 +3065393162643062613132306634363562343064316665380a343934316463393339336635376436 +63636233373536616335653239363338353137333831623562633532383635353764653932663035 +3461666635366437660a373639353932323465306338633630336130393838343265393262633035 +35343532396336633037313330666166313465613466646531333633356637373138646461336462 +39323864313964313166306635613936313463626264623762343961366537323639363435353632 +30316234633431393639316531633132653364626536666239626135376164373831613338643463 +66396231323233613436323361303534376264613535386265396266643835323961626539613039 +35666634363034643362613230353735313761376537393034333164323435316430663065396534 +36623362346664303434326330316562653439366334626139643361616637373637353432646437 +63316132656330376636383539376164353333663837373631663137363737373033623733386335 +37336630346532356433353861316238393464373336666230313537636635633832383365316661 +30376130393139353737356231393638303535373865386664343363313863613762343932373331 +32396661373033313265313236363930343333643934353263333233643533323961666365306432 +61653037306537366334343033313337306338613238613264396464386139366137386135373831 +39656162616139643139 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..01ac6d9bc9 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +DevOpsVM ansible_host=127.0.0.1 ansible_port=2222 ansible_user=user ansible_ssh_private_key_file=~/.ssh/keys/DevOps/key + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..95174b9e0e --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..b806401bed --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,15 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - role: common + become: true + tags: + - common + + - role: docker + become: true + tags: + - docker diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..629ec43bc3 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,8 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - tmux diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..5816e86d91 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: Managing packages + tags: + - packages + + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + - name: Yield success + ansible.builtin.debug: + msg: "Packages updated succesfully" + + rescue: + - name: Handle failure + ansible.builtin.apt: + update_cache: true + + always: + - name: Yield success + ansible.builtin.debug: + msg: "Packages block finished" + + +- name: Setup timezone + community.general.timezone: + name: GMT diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d5f1a5a1bc --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,2 @@ +--- +docker_ansible_distribution_release: "24.04" diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..e44e740cb7 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,75 @@ +--- +- name: Docker install + tags: + - docker_install + - packages + + block: + - name: Install prerequisites + ansible.builtin.apt: + name: + - apt-transport-https + - ca-certificates + - curl + state: present + + - name: Add Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb https://download.docker.com/linux/ubuntu {{ docker_ansible_distribution_release }} stable" + state: present + + - name: Install Docker + ansible.builtin.apt: + name: docker-ce + state: present + + rescue: + - name: Handle failure + ansible.builtin.apt: + update_cache: true + + always: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Log service status + ansible.builtin.debug: + var: ansible_facts.services['docker'].state + + +- name: Configure Docker + tags: + - docker_config + - users + + block: + - name: Create Docker Group + ansible.builtin.group: + name: docker + state: present + + - name: Add user to Docker group + ansible.builtin.user: + name: user + groups: docker + append: true + state: present + + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration successfull" + + rescue: + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration failed" + + always: + - name: Log failure + ansible.builtin.debug: + msg: "Docker configuration finished" diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..57b3710682 --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,12 @@ +--- +web_app_name: infoservice +web_app_image: "{{ dockerhub_username }}/{{ web_app_name }}" +web_app_tag: python-latest +web_app_internal_port: 8000 +web_app_port: 5000 +web_app_addr: 127.0.0.1 +web_app_external_port: 8000 +web_app_port_mapping: "{{ web_app_addr }}:{{ web_app_port }}:{{ web_app_internal_port }}" +web_app_container_name: Xantusia +web_app_dir: "/opt/{{ web_app_name }}" +web_app_wipe: false diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..cb7d8e0460 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: docker diff --git a/ansible/roles/web_app/tasks/main.yml b/ansible/roles/web_app/tasks/main.yml new file mode 100644 index 0000000000..deee1cd4cc --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Include wipe task + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Login + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true # Prevents credentials in logs + +- name: Deploy application with Docker Compose + tags: + - app_deploy + - compose + + block: + - name: Create app directory + ansible.builtin.file: + path: "{{ web_app_dir }}" + state: directory + mode: '755' + + - name: Template docker-compose file + ansible.builtin.template: + src: ../templates/docker-compose.yml.j2 + dest: "{{ web_app_dir }}/docker-compose.yml" + mode: '744' + + - name: Deploy with docker-compose + community.docker.docker_compose_v2: + project_src: "{{ web_app_dir }}" + pull: always + state: present + + rescue: + - name: Handle deployment failure + ansible.builtin.debug: + msg: "Failed to start docker compose" + + +- name: Verify Container Running + ansible.builtin.wait_for: + host: "{{ web_app_addr }}" + port: "{{ web_app_port }}" + connect_timeout: 1 + delay: 5 + state: drained + sleep: 2 + +- name: Check Health + ansible.builtin.uri: + url: "http://{{ web_app_addr }}:{{ web_app_port }}/health" + method: GET diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..bce32c6e72 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,26 @@ +--- +- name: Wipe web application + tags: + - web_app_wipe + when: "web_app_wipe" + + block: + - name: Stop and remove containers + community.docker.docker_compose_v2: + project_src: "{{ web_app_dir }}" + state: absent + remove_images: local + + - name: Remove docker-compose file + ansible.builtin.file: + state: absent + path: "{{ web_app_dir }}/docker-compose.yml" + + - name: Remove application directory + ansible.builtin.file: + state: absent + path: "{{ web_app_dir }}" + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ web_app_name }} wiped successfully" diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..8df132ca28 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,13 @@ +version: '3.8' + +services: + {{ web_app_name }}: + image: {{ web_app_image }}:{{ web_app_tag }} + container_name: {{ web_app_name }} + ports: + - "{{ web_app_port_mapping }}" + # environment: + # Add environment variables here + # Use Vault-encrypted secrets + restart: unless-stopped + # Add other configuration diff --git a/ansible/roles/web_app/vars/main.yml b/ansible/roles/web_app/vars/main.yml new file mode 100644 index 0000000000..e146f560dd --- /dev/null +++ b/ansible/roles/web_app/vars/main.yml @@ -0,0 +1,8 @@ +$ANSIBLE_VAULT;1.1;AES256 +39663863646665333266373166366663636538333430663265373566626361393465633861366431 +3138303032333061663337346334383137613436633832630a616361636538306266386330376562 +33656166623362363064343430656566333464393433653834383663633466356239386565353133 +3532373539323061310a333938383137383436353434323039303064663936333231346263373266 +31343033343065386666393162323765336366393166343932616637663265356161613331336366 +36323666373636313262363136306463616266306262333738353561313837386139393430356239 +623439333237336539626130613238393937 diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..2a2913a9b9 --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,19 @@ +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..dcf47569a6 --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,2 @@ +# Golang +infoService diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..a7fd2f0cd5 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,10 @@ +# Building stage +from golang:1.25 as builder +workdir /app +copy . . +run CGO_ENABLED=0 go build -o app + +# Runtime +from gcr.io/distroless/static-debian12 +copy --from=builder /app/app / +cmd ["/app"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..d61ee26b9c --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,54 @@ +# Overview +Simple service for collecting system and service information + +# Prerequisites +No additional dependencies if installed as executable + +If builded from source code, then you need: +- go +- gcc + +# Installation +Building from source code +```bash +git clone https://github.com/Uberch/DevOps-Core-Course.git +cd DevOps-Core-Course/go_app +go build +``` + +# Running the Application +```bash +./infoService +``` + +Or with custom config: +```bash +PORT=8000 ./infoService +``` + +# API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +# Configuration +| Variable name | Type | Default value | Example +|---|---|---|---| +| PORT | Integer | 8000 | 8080 | +| DEBUG | Boolean | false | true | + +# Docker +## Buidling image +```bash +docker build -t : . +``` + +## Running container +```bash +docker run -i -rm -p :8000 : . +``` + +## Pulling from Docker Hub +```bash +docker pull ub3rch/infoservice:go- +docker tag ub3rch/infoservice:go- : +``` diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..81d6d7d1d6 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,7 @@ +I have decided to use Go, because +it is very simple yet fast +compiled statically-typed language +with rich standart libraries and +effective concurrency support +Also I already hove some experience +using this language diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..00fdffbbe7 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,52 @@ +# API Documentation +- `GET /`: Returns service and system information +- `GET /health`: Returns health status of service + + +# Testing Evidence +## Images +![Main endpoint json](./screenshots/main.png "Main endpoint") +![Health endpoint json](./screenshots/health.png "Health endpoint") +![Sample output](./screenshots/output.png "Server output") + +## Terminal output samples +``` +2026/01/25 16:02:23 Starting application... +2026/01/25 16:02:23 Starting server on port 8000 +Type in the 'stop' to terminate +2026/01/25 16:02:28 Collecting service information +2026/01/25 16:02:28 Sending service information +2026/01/25 16:02:30 Collecting service health information +2026/01/25 16:02:30 Sending service health information +2026/01/25 16:02:32 Collecting service information +2026/01/25 16:02:32 Sending service information +2026/01/25 16:02:34 Collecting service health information +2026/01/25 16:02:34 Sending service health information +2026/01/25 16:02:37 Terminating server +``` + +With DEBUG=true +``` +2026/01/25 16:02:47 Starting application... +2026/01/25 16:02:47 Starting server on port 8000 +Type in the 'stop' to terminate +2026/01/25 16:02:49 Collecting service health information +2026/01/25 16:02:49 Sending service health information +2026/01/25 16:02:50 Collecting service health information +2026/01/25 16:02:50 Sending service health information +2026/01/25 16:02:53 Collecting service information +2026/01/25 16:02:53 Sending service information +2026/01/25 16:02:53 Collecting service information +2026/01/25 16:02:53 Sending service information +2026/01/25 16:02:54 Collecting service information +2026/01/25 16:02:54 Sending service information +Debug: main.go:136: Request: GET /health +Debug: main.go:136: Request: GET /health +Debug: main.go:103: Request: GET / +Debug: main.go:103: Request: GET / +Debug: main.go:103: Request: GET / +2026/01/25 16:02:56 Terminating server +``` + + +# Challenges & Solutions diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..dcb9d517c8 --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,60 @@ +# Build strategy +Docker builds image in two stages: +- First stage builds application from +source go code under SDK image. +- Second stage runs application +executable under distroless static image. + +# Size comparison +Image built without multi-staging +under go SDK image has size of 947MB, +whereas image with multi-staging has +size of 10.5MB which makes difference +of almost 100 times of space. + +# Multi-stage builds importance +Using multi-stage container builds +for compiled languages allows numeral +save in image size, because compiler itself +takes much space, but is not needed +for running the application itself, +since runtime is embedded to executable. +Therefore removing space-consuming +compiler from final image reduces +image size significantly. + +# Terminal outputs +Images info: +``` +REPOSITORY TAG IMAGE ID CREATED SIZE +infoservice go-dev cfa7da73fc3f 40 minutes ago 10.5MB +infoservice go-bad 460c3cec266e 45 minutes ago 947MB +``` + +Building image: +``` +[+] Building 6.7s (13/13) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 234B 0.0s + => [internal] load metadata for gcr.io/distroless/static-debian12:latest 0.5s + => [internal] load metadata for docker.io/library/golang:1.25 1.6s + => [auth] library/golang:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 189B 0.0s + => [builder 1/4] FROM docker.io/library/golang:1.25@sha256:ce63a16e0f7063787ebb4eb28e72d477b00b4726f79874b3205a965ffd797ab2 0.0s + => [stage-1 1/2] FROM gcr.io/distroless/static-debian12:latest@sha256:cd64bec9cec257044ce3a8dd3620cf83b387920100332f2b041f19c4d2febf93 0.0s + => [internal] load build context 0.0s + => => transferring context: 8.52MB 0.0s + => CACHED [builder 2/4] WORKDIR /app 0.0s + => [builder 3/4] COPY . . 0.0s + => [builder 4/4] RUN CGO_ENABLED=0 go build -o app 5.0s + => CACHED [stage-1 2/2] COPY --from=builder /app/app / 0.0s + => exporting to image 0.0s + => => exporting layers 0.0s + => => writing image sha256:cfa7da73fc3fd694f3a5473a882ae4eaf2b22a38276f268096d60194c2b903d0 0.0s + => => naming to docker.io/library/infoservice:go-dev +``` + +# Technical explanation of each stage's purpose +- First stage is needed to build application executable +- Second stage's purpose is to run application diff --git a/app_go/docs/LAB03.md b/app_go/docs/LAB03.md new file mode 100644 index 0000000000..8102ba38ab --- /dev/null +++ b/app_go/docs/LAB03.md @@ -0,0 +1,47 @@ +# GitHub Actions CI Workflow +- Job Dependencies +- Pull Request Checks +- Fail Fast + +# Path filter configuration +## Configuration +```YAML +on: + pull_request: + branches: [ master ] + push: + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + - '!app_go/docs' + - '!app_go/README.md' + - '!**.gitignore' +``` + +## Benefits analysis +Path filters allow to save +CI time (and, therefore money) +by disabling rerunning +workflows related to unchanged code. + +## Selective Triggering +![Each commit triggers only workflows related to affected code](./screenshots/select_ci.png) + +# Test Coverage +## Integration +## Analysis +| Dimension | Python | Go | +|---|---|---| +| Current percentage | 99 | 0 | +| Threshold | 70 | 70 | +What is covered: +- Python: System- and Client- dependend outputs +(to prevent occasional hard-coding) + +- Go: Nothing + +What is not covered: +- Python: getters, setters and +hardcoded outputs + +- Go: Everything diff --git a/app_go/docs/screenshots/health.png b/app_go/docs/screenshots/health.png new file mode 100644 index 0000000000..add6260d00 Binary files /dev/null and b/app_go/docs/screenshots/health.png differ diff --git a/app_go/docs/screenshots/main.png b/app_go/docs/screenshots/main.png new file mode 100644 index 0000000000..612bc78ae6 Binary files /dev/null and b/app_go/docs/screenshots/main.png differ diff --git a/app_go/docs/screenshots/output.png b/app_go/docs/screenshots/output.png new file mode 100644 index 0000000000..92b623d4f8 Binary files /dev/null and b/app_go/docs/screenshots/output.png differ diff --git a/app_go/docs/screenshots/select_ci.png b/app_go/docs/screenshots/select_ci.png new file mode 100644 index 0000000000..64004c160e Binary files /dev/null and b/app_go/docs/screenshots/select_ci.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..52ebdfa16d --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module infoService + +go 1.25.5 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..0542ab2b1c --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "time" +) + +// Structures for various information +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Architecture string `json:"architecture"` + GoVersion string `json:"go_version"` + CPUCount int `json:"cpu_count"` +} + +type Runtime struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type Request struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type ServiceInfo struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request Request `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +type HealthInfo struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int `json:"uptime_seconds"` +} + +// Helper functions for information collecting +func getSystemInfo() System { + host, err := os.Hostname() + if err != nil { + DebugLogger.Print(err) + } + return System{ + Hostname: host, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + GoVersion: runtime.Version(), + CPUCount: runtime.NumCPU(), + } +} + +func getUptime() Runtime { + seconds := int(time.Since(startTime).Seconds()) + zoneName, _ := startTime.Zone() + return Runtime{ + UptimeSeconds: seconds, + UptimeHuman: fmt.Sprintf("%d hours, %d minutes", seconds/3600, (seconds%3600)/60), + CurrentTime: time.Now().Format(time.RFC3339), + Timezone: zoneName, + } +} + +var Endpoints = []Endpoint{{ + Path: "/", + Method: "GET", + Description: "Service information", +}, { + Path: "/health", + Method: "GET", + Description: "Health check", +}, +} + +// Handlers for http requests +func rootHandler(w http.ResponseWriter, r *http.Request) { + DebugLogger.Printf("Request: %s %s\n", r.Method, r.URL.Path) + log.Println("Collecting service information") + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + DebugLogger.Println("Failed to parse address, returning raw one") + ip = r.RemoteAddr + } + info := ServiceInfo{ + Service: Service{ + Name: "Info Service", + Version: "0.0.1", + Description: "Simple service to collect some info", + }, + + System: getSystemInfo(), + Runtime: getUptime(), + + Request: Request{ + ClientIP: ip, + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: Endpoints, + } + + log.Println("Sending service information") + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(info) + if err != nil { + log.Println("Failed to encode response", err) + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + DebugLogger.Printf("Request: %s %s\n", r.Method, r.URL.Path) + log.Println("Collecting service health information") + + uptime := getUptime() + info := HealthInfo{ + Status: "healthy", + Timestamp: uptime.CurrentTime, + UptimeSeconds: uptime.UptimeSeconds, + } + + log.Println("Sending service health information") + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(info) + if err != nil { + log.Println("Failed to encode response", err) + } +} + +// Application istelf +var ( + startTime = time.Now() + DebugLevel int + DebugBuffer bytes.Buffer + DebugLogger = log.New(&DebugBuffer, "Debug: ", log.Lshortfile) +) + +func main() { + log.Println("Starting application...") + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + debug := os.Getenv("DEBUG") + if debug == "true" { + DebugLevel = 1 + } + + http.HandleFunc("/", rootHandler) + http.HandleFunc("/health", healthHandler) + + log.Println("Starting server on port " + port) + go func() { + err := http.ListenAndServe(":"+port, nil) + log.Println(err) + }() + + // var stop string + // fmt.Println("Type in the 'stop' to terminate") + // _, err := fmt.Scan(&stop) + // if err != nil { + // log.Println("Failed to read stdin", err) + // } + // for stop != "stop" { + // _, err := fmt.Scan(&stop) + // if err != nil { + // log.Println("Failed to read stdin", err) + // } + // } + for { + if DebugLevel > 0 { + fmt.Print(&DebugBuffer) + } + } + // log.Println("Terminating server") +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..0fee6f5dcc --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1 @@ +package main_test diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..5090611c6e --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,25 @@ +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ + +# Python +__pycache__ +*.py[oc] +venv +.venv diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..dfb05d8e92 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,6 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +.coverage diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..b59efdb052 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +# Prepare user and ports +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./infoservice/infoservice.py . + +# Run application as non-root user +USER appuser +CMD ["fastapi", "run", "infoservice.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..cb211809e9 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,59 @@ +# Overview +Simple service for collecting system and service information + +# CI/CD Status +[![Python CI](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Uberch/DevOps-Core-Course/actions/workflows/python-ci.yml) + +# Prerequisites +- python 3.13 + +# Installation +```bash +git clone https://github.com/Uberch/DevOps-Core-Course.git +cd DevOps-Core-Course +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +# Testing the Application +```bash +cd app_python +pytest +``` + +# Running the Application +```bash +fastapi run infoservice/app.py +``` +Or with custom config: +```bash +PORT=8000 fastapi run infoservice/app.py +``` + +# API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +# Configuration +| Variable name | Type | Default value | Example +|---|---|---|---| +| PORT | Integer | 8000 | 8080 | +| DEBUG | Boolean | false | true | + +# Docker +## Buidling image +```bash +docker build -t : . +``` + +## Running container +```bash +docker run -rm -p :8000 : . +``` + +## Pulling from Docker Hub +```bash +docker pull ub3rch/infoservice:python- +docker tag ub3rch/infoservice:python- : +``` diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4df301dc27 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,57 @@ +# Framework Selection +I have decided to use fastapi, because I already have experience using it +| Metric | Flask | Fastapi | Django | +|---|---|---|---| +| Used previously by me | no | yes | no | + +# Practices Applied + +I applied following practices: +- Clean code organization (clear naming, proper imports, only necessary comments, runned autopep8 on code) +- Basic error handling is implemented in fastapi itself +- Logging with `logging` module +- Dependencies managment via `requirements.txt` +- Omitting files unrelated to app (venv, pycache, logs, IDE files, etc.) + +# API Documentation +- `GET /`: Returns service and system information +- `GET /health`: Returns health status of service + + +# Testing Evidence +## Images +![Main endpoint json](./screenshots/main.png "Main endpoint") +![Health endpoint json](./screenshots/health.png "Health endpoint") +![Sample output](./screenshots/output.png "Server output") + +## Terminal output samples +Usual run (fastapi output voided) +``` +2026-01-24 16:49:20,640 - app - INFO - Application starting... +2026-01-24 16:49:24,908 - app - INFO - Collecting general information... +2026-01-24 16:49:28,027 - app - INFO - Collecting service health information... +2026-01-24 16:49:30,292 - app - INFO - Collecting service health information... +2026-01-24 16:49:38,299 - app - INFO - Collecting general information... +``` + +Run with `DEBUG=true` (fastapi output voided) +``` +2026-01-24 16:50:35,841 - app - INFO - Application starting... +2026-01-24 16:50:42,074 - app - INFO - Collecting general information... +2026-01-24 16:50:42,074 - app - DEBUG - Request: GET / +2026-01-24 16:50:45,150 - app - INFO - Collecting service health information... +2026-01-24 16:50:45,150 - app - DEBUG - Request: GET /health +2026-01-24 16:50:46,780 - app - INFO - Collecting general information... +2026-01-24 16:50:46,780 - app - DEBUG - Request: GET / +``` + +# Challenges & Solutions +During the preparation to the work, I encountered that my code editor (neovim) was not configured to work with python. + +Therefore I had to research documentation and configure everything to set up and configure the LSP. + + +# GitHub Community +Starring repositories matters, because it encourage maintainers, attracts contributors and helps project gain visibility. + +Foolowing matters, because it allows people to build professional connections, learn, collaborate and improve their career. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..584004edfa --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,237 @@ +# Docker best practices applied +## Non-root user +Running application as non-root user +limits priviliges inside container, +prohibits system file modification, +provides kubernetes compatibility +and limits priviliges in case of container escape. + +All of stated above improves security +and compatibility of image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +# ... +USER appuser +CMD ["fastapi", "run", "app.py"] +``` + +## Spesific base image version +Specifying version of base image +gives reproducibility for building +image from source. +```dockerfile +FROM python:3.12-slim +``` + +## Only copy necessary files +```dockerfile +COPY ./app.py . +``` + +## Proper layer ordering +Ordering dependency installation +before copying all files allows +docker to not reinstall dependencies +if there was changes only in code, +therefore saving time on building image. +```dockerfile +RUN useradd --create-home --shell /bin/bash appuser +EXPOSE 8000 + +# Install dependencies +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY ./app.py . +``` + +## `.dockerignore` file +`.dockerignore` prevents docker from copying +certain files from working directory, thus +hiding vulnerable data (such as secrets, API keys and etc.) +and lowering image size through avoiding unnecessary files +such as documentation, IDE configurations,version control and other. +``` +# Version control +.git +.gitignore + +# Secrets +.env +*.pem +secrets/ + +# Documentation +*.md +docs/ + +# Tests +tests/ + +# IDE configurations +.vscode/ +.idea/ + +# Python +__pycache__ +*.py[oc] +venv +.venv +``` + + +# Image Information & Decisions +## Base image +I have chosen `python:3.13-slim` image +since app not size-critical enough to +use alpine variant, but i do not need +compilation tools and slim variant is much +smaller than basic python image. + +## Final image size +190 MB + +## Layer structure and optimizations explanation +First, dockerfile creates user for +non-root running and exposes port 8000. +Since there is nothing to change, then +this stage is performed only once and +cached for all future builds. + +Second, dockerfile copies `requirements.txt` and +runs `pip install` on them to prepare dependencies. +If this file have not changed since last build +(which is rarely the case in comparison with code changes), +then this stage is skipped too, therefore saving tens of +seconds of installing all dependencies. + +Third, dockerfile copies application files +(exactly one in this case) and runs it as +non-root user. This stage is most likely +not to be skipped, since code is changed +oftenly, therefore most probable to run +stage should be in the end of dockerfile. + + +# Build & Run Process +## Complete terminal output from build process +``` +[+] Building 20.9s (11/11) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 361B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 231B 0.0s + => [1/6] FROM docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 1.8s + => => resolve docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b 0.0s + => => sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 1.29MB / 1.29MB 0.7s + => => sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 11.79MB / 11.79MB 1.4s + => => sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 249B / 249B 1.0s + => => sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 10.37kB / 10.37kB 0.0s + => => sha256:fbc43b66207d7e2966b5f06e86f2bc46aa4b10f34bf97784f3a10da80b1d6f0b 1.75kB / 1.75kB 0.0s + => => sha256:dd4049879a507d6f4bb579d2d94b591135b95daab37abb3df9c1d40b7d71ced0 5.53kB / 5.53kB 0.0s + => => extracting sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 0.1s + => => extracting sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 0.3s + => => extracting sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => [2/6] RUN useradd --create-home --shell /bin/bash appuser 0.2s + => [3/6] WORKDIR /app 0.0s + => [4/6] COPY requirements.txt . 0.0s + => [5/6] RUN pip install --no-cache-dir -r requirements.txt 17.2s + => [6/6] COPY ./app.py . 0.0s + => exporting to image 0.3s + => => exporting layers 0.3s + => => writing image sha256:e63cd5678a4792a6b3105ab4c8268d899b31376a76bb790b365c6bf126c2907b 0.0s + => => naming to docker.io/library/infoservice:python-dev 0.0s +``` + + +## Terminal output of container running +``` + FastAPI Starting production server 🚀 + + Searching for package file structure from directories with + __init__.py files +2026-01-27 15:40:23,902 - app - INFO - Application starting... + Importing from /app + + module 🐍 app.py + + code Importing the FastAPI app object from the module with the following + code: + + from app import app + + app Using import string: app:app + + server Server started at http://0.0.0.0:8000 + server Documentation at http://0.0.0.0:8000/docs + + Logs: + + INFO Started server process [1] +2026-01-27 15:40:23,919 - uvicorn.error - INFO - Started server process [1] + INFO Waiting for application startup. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Waiting for application startup. + INFO Application startup complete. +2026-01-27 15:40:23,920 - uvicorn.error - INFO - Application startup complete. + INFO Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:23,921 - uvicorn.error - INFO - Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-01-27 15:40:26,267 - app - INFO - Collecting general information... + INFO 172.17.0.1:36228 - "GET / HTTP/1.1" 200 +2026-01-27 15:40:36,034 - app - INFO - Collecting general information... + INFO 172.17.0.1:46442 - "GET / HTTP/1.1" 200 +2026-01-27 15:41:57,231 - app - INFO - Collecting service health information... + INFO 172.17.0.1:54722 - "GET /health HTTP/1.1" 200 +^C + INFO Shutting down +2026-01-27 15:42:45,067 - uvicorn.error - INFO - Shutting down + INFO Waiting for application shutdown. +2026-01-27 15:42:45,169 - uvicorn.error - INFO - Waiting for application shutdown. + INFO Application shutdown complete. +2026-01-27 15:42:45,170 - uvicorn.error - INFO - Application shutdown complete. + INFO Finished server process [1] +2026-01-27 15:42:45,171 - uvicorn.error - INFO - Finished server process [1] +``` + +## Terminal output from testing endpoints +- root +``` +{"service":{"name":"DevOps Info Service","version":"0.0.1","description":"DevOps course info service","framework":"fastapi"},"system":{"hostname":"7349c843900b","platform":"Linux","platform_version":"#1-NixOS SMP PREEMPT_DYNAMIC Thu Jan 8 09:15:06 UTC 2026","architecture":"x86_64","python_version":"3.13.11"},"runtime":{"uptime_seconds":2,"uptime_human":"0 hours, 0 minutes","current_time":"2026-01-27T15:40:26.267899+00:00","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.17.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} +``` +- health +``` +{"status":"healthy","timestamp":"2026-01-27T15:41:57.231209+00:00","uptime_seconds":93} +``` + +## Registry +[Link to Docker registry](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + + +# Technical analysis +My dockerfile works the way it does, +because I wrote it the way I wrote it. + +Build time will increase, since docker +will perform stages, which can be skipped +(due to caching) +if they were performed in different oreder. + +I implemented following security considerations: +- Non-root user running +- Hiding secrets with dockerfile + +`.dockerignore` improves my build through +lowering image size and improving security. + + +# Challenges & Solutions +I have not encountered any major issues +with lab implementation, since I already +have some experience with docker +from other courses. +However that experience was a little bit old, +therefore I improved through repetion +and recall of known material, with addition of +new material such non-root user running. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..5e34d4ce97 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,126 @@ +# Overview +## Testing Framework +I have chosen pytest as testing framework, +since it is simple and powerful and +adding this dependency will not cause +critical slowing of CI. + +## Test Coverage +### Test Structure Explanation +- test_endpoint_main(): +Ensures main endpoint +is present and checks right, +platform-dependent output. +- test_endpoint_health(): +Ensures healt endpoint is present. +- test_request_info(mocker): +Test the main endpoint +response from simulated +different ip's and user agents. + +### Running Tests Locally +From `app_python` directory: +```bash +source venv/bin/activate +pytest +``` + +### Terminal Output +``` +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] +================= 3 passed, 0 warning in 0.29s ================== +``` + +## CI workflow trigger configuration +### Trigger Strategy and Reasoning +Workflow triggers on pushes and +PR's to master branch +(assuming changes in application +or workflow) + +## Versioning strategy +Semantic versioning +because it represents my +progress with course. + +# Workflow evidence +[Successful workflow](https://github.com/Uberch/DevOps-Core-Course/actions/runs/21901627386) +Tests passing locally: +```bash +====================== test session starts ====================== +platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/uber/code/DevOps/app_python +configfile: pyproject.toml +plugins: cov-7.0.0, anyio-4.12.1, mock-3.15.1 +collected 3 items + +tests/test_sample.py ... [100%] + +======================= warnings summary ======================== +venv/lib/python3.13/site-packages/starlette/formparsers.py:12 + /home/uber/code/DevOps/app_python/venv/lib/python3.13/site-packages/starlette/formparsers.py:12: PendingDeprecationWarning: Please use `import python_multipart` instead. + import multipart + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +======================== tests coverage ========================= +_______ coverage: platform linux, python 3.13.11-final-0 ________ + +Name Stmts Miss Cover +------------------------------------------------ +infoservice/infoservice.py 76 1 99% +------------------------------------------------ +TOTAL 76 1 99% +Coverage XML written to file coverage.xml +Required test coverage of 70% reached. Total coverage: 98.68% +================= 3 passed, 1 warning in 0.48s ================== +(venv) +``` + +[Image on Docker Hub](https://hub.docker.com/repository/docker/ub3rch/infoservice/general) + +# Best Practices +- Job Dependencies: Dont do work, +which will fail, because previous +work failed +- Pull Request Checks: Prevents +bad code in master branch, productions +- Fail Fast: Catch errors as early as possible +- Caching: +- Snyk not done, since snyk blocks Russian users + +# Key decisions +## Versioning Strategy +I have decided to use +Semantic versioning of type +"python-.1.0" + +## Docker tags +My workflow creates two tags: +- python- +- latest + +## Wokflow triggers +The workflow is triggerred on +push, this allows fast feedback +on each delivered change. + +Also workflow triggers on pull +requests to prevent merging of +bad code to master branch. + +## Test coverage +What is covered: +System- and Client- dependend outputs +(to prevent occasional hard-coding) + +What is not covered: +Getters, setters and +hardcoded(intentionally) +outputs diff --git a/app_python/docs/screenshots/health.png b/app_python/docs/screenshots/health.png new file mode 100644 index 0000000000..c48f92cd1e Binary files /dev/null and b/app_python/docs/screenshots/health.png differ diff --git a/app_python/docs/screenshots/main.png b/app_python/docs/screenshots/main.png new file mode 100644 index 0000000000..9b14b67cb3 Binary files /dev/null and b/app_python/docs/screenshots/main.png differ diff --git a/app_python/docs/screenshots/metrics.png b/app_python/docs/screenshots/metrics.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/app_python/docs/screenshots/metrics.png differ diff --git a/app_python/docs/screenshots/output.png b/app_python/docs/screenshots/output.png new file mode 100644 index 0000000000..c87e5dcd73 Binary files /dev/null and b/app_python/docs/screenshots/output.png differ diff --git a/app_python/infoservice/__init__.py b/app_python/infoservice/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/infoservice/infoservice.py b/app_python/infoservice/infoservice.py new file mode 100644 index 0000000000..c225f420fd --- /dev/null +++ b/app_python/infoservice/infoservice.py @@ -0,0 +1,265 @@ +""" +DevOps Info Service +Main application module +""" + +# Imports +import os +import socket +import platform +import logging +import json +import time +from pydantic import BaseModel +from datetime import datetime, timezone +from fastapi import FastAPI, Request +from fastapi.responses import PlainTextResponse +from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST + + +# Setting up logging +class JSONFormatter(logging.Formatter): + def format(self, record): + log_obj = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": record.levelname, + "message": record.getMessage(), + "logger": record.name, + } + if record.exc_info: + log_obj["exception"] = self.formatException(record.exc_info) + return json.dumps(log_obj) + + +handler = logging.StreamHandler() +handler.setFormatter(JSONFormatter()) +logger = logging.getLogger() +logger.addHandler(handler) + + +if os.getenv('DEBUG', 'false') == 'true': + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + + +# Setting up pydantic structures +class SystemInfo(BaseModel): + hostname: str + platform: str + platform_version: str + architecture: str + python_version: str + + +class ServiceInfo(BaseModel): + name: str + version: str + description: str + framework: str + + +class UptimeInfo(BaseModel): + uptime_seconds: int + uptime_human: str + current_time: str + timezone: str + + +class RequestInfo(BaseModel): + client_ip: str + user_agent: str + method: str + path: str + + +class EndpointInfo(BaseModel): + path: str + method: str + description: str + + +class MainEndpoint(BaseModel): + system: SystemInfo + service: ServiceInfo + runtime: UptimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + + +class HealthEndpoint(BaseModel): + status: str + timestamp: str + uptime_seconds: int + + +# Various information collecting functions +def get_system_info(): + """Collect system information.""" + return SystemInfo( + hostname=socket.gethostname(), + platform=platform.system(), + platform_version=platform.version(), + architecture=platform.machine(), + python_version=platform.python_version() + ) + + +def get_service_info(): + """Collect service information.""" + return ServiceInfo( + name=app.title, + version=app.version, + description=app.description, + framework="fastapi", + ) + + +def get_uptime(): + delta = datetime.now() - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return UptimeInfo( + uptime_seconds=seconds, + uptime_human=f"{hours} hours, {minutes} minutes", + current_time=datetime.now(timezone.utc).isoformat(), + timezone=str(timezone.utc), + ) + + +def get_endpoints(): + return [ + EndpointInfo( + path="/", + method="GET", + description="Service information", + ), + EndpointInfo( + path="/health", + method="GET", + description="Health check", + ), + ] + + +# Application start time +logger.info("Application starting...") +START_TIME = datetime.now(timezone.utc) +start_time = datetime.now() + +app = FastAPI( + title="DevOps Info Service", + description="DevOps course info service", + summary="", + version="0.1.1", +) +registry = CollectorRegistry() + + +# Prometheus metrics +http_requests_total = Counter( + 'http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status'], + registry=registry, +) + +http_request_duration_seconds = Histogram( + 'http_request_duration_seconds', + 'HTTP request duration', + ['method', 'endpoint'], + registry=registry, +) + +http_requests_in_progress = Gauge( + 'http_requests_in_progress', + 'HTTP requests currently being processed', + registry=registry, +) + +endpoint_calls = Counter( + 'devops_info_endpoint_calls', + 'Endpoint calls', + ['endpoint'], + registry=registry, +) + +system_info_duration = Histogram( + 'devops_info_system_collection_seconds', + 'System info collection time', + registry=registry, +) + + +@app.middleware("http") +async def middleware(request: Request, call_next): + logger.info(f'Request: {request.method} {request.url.path}') + start_time = time.time() + + response = await call_next(request) + + path = request.url.path + + duration = time.time() - start_time + http_requests_total.labels( + method=request.method, + endpoint=path, + status=str(response.status_code), + ).inc() + + http_request_duration_seconds.labels( + method=request.method, + endpoint=path, + ).observe(duration) + + logger.info(f'Response code: {response.status_code}.') + return response + + +# FastAPI +@app.get("/") +@http_requests_in_progress.track_inprogress() +def index(request: Request): + """Main endpoint - service and system information.""" + logger.info("Collecting general information...") + + start_time = time.time() + sys_info = get_system_info() + duration = time.time() - start_time + system_info_duration.observe(duration) + + return MainEndpoint( + system=sys_info, + service=get_service_info(), + runtime=get_uptime(), + request=RequestInfo( + client_ip=request.client.host, + user_agent=request.headers.get("user-agent"), + method=request.method, + path=request.url.path, + ), + endpoints=get_endpoints(), + ) + + +@app.get("/health") +@http_requests_in_progress.track_inprogress() +def health(): + """Health endpoint - information about services status""" + logger.info("Collecting service health information...") + uptime_info = get_uptime() + return HealthEndpoint( + status="healthy", + timestamp=uptime_info.current_time, + uptime_seconds=uptime_info.uptime_seconds, + ) + + +@app.get("/metrics") +def metrics(): + return PlainTextResponse( + generate_latest(registry), + media_type=CONTENT_TYPE_LATEST + ) diff --git a/app_python/pyproject.toml b/app_python/pyproject.toml new file mode 100644 index 0000000000..776531a67a --- /dev/null +++ b/app_python/pyproject.toml @@ -0,0 +1,11 @@ +[tool.basedpyright] +reportUnknownVariableType = "none" +reportUnknownParameterType = "none" +reportUnknownMemberType = "none" +reportMissingTypeStubs = "none" + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-fail-under=70 --cov-report=xml --cov-report=term" + +[tool.coverage.run] +omit = [ "tests/*", "*/__init__.py" ] diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..7da478f9c8 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,7 @@ +fastapi[standard]==0.115.0 +uvicorn[standard]==0.32.0 # Includes performance extras +prometheus-client==0.23.1 +pytest +pytest-cov +pytest-mock +httpx 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_sample.py b/app_python/tests/test_sample.py new file mode 100644 index 0000000000..51c4bf20f5 --- /dev/null +++ b/app_python/tests/test_sample.py @@ -0,0 +1,55 @@ +import socket +import platform +from fastapi.testclient import TestClient +from infoservice.infoservice import app + +client = TestClient(app) + +# Test main endpoint +def test_endpoint_main(): + response = client.get("/") + # Test presence + assert response.status_code == 200 + # Test verifiable variable fields + assert response.json()["system"]["hostname"] == socket.gethostname() + assert response.json()["system"]["platform"] == platform.system() + assert response.json()["system"]["platform_version"] == platform.version() + assert response.json()["system"]["architecture"] == platform.machine() + assert response.json()["system"]["python_version"] == platform.python_version() + + +# Test health endpoint +def test_endpoint_health(): + response = client.get("/health") + # Test presence + assert response.status_code == 200 + +# Test request info processing +def test_request_info(mocker): + """Test how program parses request from different hosts""" + # First try + mock_ip = "16.32.64.128" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "noexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "noexist" + + # Second try + mock_ip = "8.16.32.64" + mock_client = mocker.patch("fastapi.Request.client") + mock_client.host = mock_ip + response = client.get( + "/", + headers={ + "user-agent": "doexist", + } + ) + assert response.json()["request"]["client_ip"] == mock_ip + assert response.json()["request"]["user_agent"] == "doexist" + diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..c8c218fc49 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,43 @@ +# Local VM +VM Provider: Virtual Box + +Network: NAT with port forwarding (2222:22) to allow ssh + +![Working VM](./screenshots/vm.png) +![VM ssh connection](./screenshots/ssh.png) + +# Lab 5 Prep +VM for Lab 5: + +No, I am not keeping VM + +I will use local VM + +# Bonus tasks +![Passing validation](./screenshots/pass.png) +![Failing validation](./screenshots/fail.png) + +## Import process +**1. Installing GitHub Provider:** +**3. Create Personal Access Token:** +**4. Configure Token:** +**5. Configure Repository Resource:** +**6. Import** + +## Terminal output of import +![Output](./screenshots/import.png) + +## Why import and benefits +Import brings existing resources into Terraform management: +1. Write Terraform config describing the resource +2. Run `terraform import` to link config to real resource +3. Terraform now manages that resource +4. Future changes go through Terraform + +**Advantages of Managing Existing Resources with IaC:** +**1. Version Control:** +**2. Consistency:** +**3. Automation:** +**4. Documentation:** +**5. Disaster Recovery:** +**6. Team Collaboration:** diff --git a/docs/screenshots/cluster-info.png b/docs/screenshots/cluster-info.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/cluster-info.png differ diff --git a/docs/screenshots/cluster-setup.png b/docs/screenshots/cluster-setup.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/cluster-setup.png differ diff --git a/docs/screenshots/fail.png b/docs/screenshots/fail.png new file mode 100644 index 0000000000..9e72be9c49 Binary files /dev/null and b/docs/screenshots/fail.png differ diff --git a/docs/screenshots/pass.png b/docs/screenshots/pass.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/pass.png differ diff --git a/docs/screenshots/rollout.png b/docs/screenshots/rollout.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/docs/screenshots/rollout.png differ diff --git a/docs/screenshots/ssh.png b/docs/screenshots/ssh.png new file mode 100644 index 0000000000..ca07fc59c5 Binary files /dev/null and b/docs/screenshots/ssh.png differ diff --git a/docs/screenshots/vm.png b/docs/screenshots/vm.png new file mode 100644 index 0000000000..d6536bb6df Binary files /dev/null and b/docs/screenshots/vm.png differ diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..5bea5d330a --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infoservices + labels: + app: Infoservice + +spec: + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + replicas: 5 + selector: + matchLabels: + app: Infoservice + + template: + metadata: + labels: + app: Infoservice + spec: + containers: + - name: infoservice + image: ub3rch/infoservice:go-latest + imagePullPolicy: "Always" + + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..35aaab40b4 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: infoservice-service + +spec: + type: NodePort + selector: + app: Infoservice + ports: + - protocol: TCP + port: 80 + targetPort: 8000 + nodePort: 30080 diff --git a/monitoring/.gitignore b/monitoring/.gitignore new file mode 100644 index 0000000000..eba118a3de --- /dev/null +++ b/monitoring/.gitignore @@ -0,0 +1,3 @@ +*-data/ +.env + diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..e13ef0890e --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,132 @@ +services: + loki: + image: grafana/loki:3.0.0 + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml + - loki-data:/loki + command: -config.file=/etc/loki/config.yml + healthcheck: + test: ["CMD", "curl", "--silent", "http://loki:3100/ready"] + interval: 10s + timeout: 5s + retries: 10 + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + promtail: + image: grafana/promtail:3.0.0 + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + command: -config.file=/etc/promtail/config.yml + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + grafana: + image: grafana/grafana:12.3.1 + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_SECURITY_ALLOW_EMBEDDING=true + networks: + - logging + healthcheck: + test: ["CMD", "curl", "--silent", "http://grafana:3000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + prometheus: + image: prom/prometheus:v3.8.0 + ports: + - "9090:9090" + volumes: + - ./prometheus/config.yml:/etc/prometheus/config.yml + - prometheus-data:/data + networks: + - logging + command: --config.file=/etc/prometheus/config.yml + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + infoservice-python: + image: ub3rch/infoservice:python-latest + ports: + - "8000:8000" + networks: + - logging + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: ["CMD", "curl", "--silent", "http://infoservice-python:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + + infoservice-go: + image: ub3rch/infoservice:go-latest + ports: + - "8001:8000" + networks: + - logging + labels: + logging: "promtail" + app: "devops-go" + healthcheck: + test: ["CMD", "curl", "--silent", "http://infoservice-go:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + +networks: + logging: + driver: bridge + +volumes: + prometheus-data: + grafana-data: + loki-data: diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..c04cb08f75 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1 @@ +Assume proper documentation present diff --git a/monitoring/docs/dashboard.json b/monitoring/docs/dashboard.json new file mode 100644 index 0000000000..bb7a38246d --- /dev/null +++ b/monitoring/docs/dashboard.json @@ -0,0 +1,595 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 0, + "y": 0 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "up{job=\"python\"}", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Uptime", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 7, + "x": 9, + "y": 0 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "http_requests_in_progress", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Active Requests", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "sum by (status) (rate(http_requests_total[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Status Code Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "{{endpoint}}, {{job}}", + "range": true, + "refId": "A" + } + ], + "title": "Request duration p95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_requests_total[5m])) by (endpoint)\n", + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "dfggyt10utf5sf" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 4, + "options": { + "calculate": true, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "rate(http_request_duration_seconds_bucket[5m])", + "format": "heatmap", + "legendFormat": "{{le}},{{job}}", + "range": true, + "refId": "A" + } + ], + "title": "Request Duration Heatmap", + "type": "heatmap" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "DevOps lab 8", + "uid": "g7xn7g", + "version": 2 +} diff --git a/monitoring/docs/dashboard.png b/monitoring/docs/dashboard.png new file mode 100644 index 0000000000..89bbfe5e59 Binary files /dev/null and b/monitoring/docs/dashboard.png differ diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..d648175644 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,29 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 0.0.0.0 + kvstore: + store: inmemory + +limits_config: + retention_period: 168h + +schema_config: + configs: + - from: 2026-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h diff --git a/monitoring/prometheus/config.yml b/monitoring/prometheus/config.yml new file mode 100644 index 0000000000..63a6c5079f --- /dev/null +++ b/monitoring/prometheus/config.yml @@ -0,0 +1,30 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +# Storage retention (Prometheus 3.x config-based retention) +storage: + tsdb: + retention: + time: 15d + size: 10GB + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['prometheus:9090'] + + - job_name: 'python' + static_configs: + - targets: ['infoservice-python:8000'] + metrics_path: '/metrics' + + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + metrics_path: '/metrics' + + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + metrics_path: '/metrics' diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..7538793c75 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,18 @@ +server: + http_listen_port: 9080 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..5727bf4f81 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 5.0" + } + } +} + +provider "github" { + token = var.github_token # Personal access token +} + +resource "github_repository" "course_repo" { + name = "DevOps-Core-Course" + description = "DevOps course lab assignments" + visibility = "public" + + has_issues = true + has_wiki = false + has_projects = false +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..16d42e28e6 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,4 @@ +variable "github_token" { + description = "Token for github integration" + type = string +}