diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..e9a6ae3460 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,69 @@ +name: Python CI/CD + +on: + push: + branches: [ "lab03" ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ "main" ] + paths: + - 'app_python/**' + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-build: + 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" + cache: 'pip' + + - name: Install dependencies + run: | + pip install -r app_python/requirements.txt + pip install pytest pytest-cov flake8 + + - name: Run linter + run: flake8 app_python + continue-on-error: true + + - name: Run tests + run: pytest app_python/tests --cov=app_python --cov-report=xml + + - name: Install Snyk + run: npm install -g snyk + + - name: Run Snyk (warn only) + run: snyk test --file=app_python/requirements.txt --severity-threshold=high + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + continue-on-error: true + + - name: Generate version (CalVer) + run: echo "VERSION=$(date +'%Y.%m.%d')" >> $GITHUB_ENV + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v6 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/devops-core-app-python:${{ env.VERSION }} + ${{ secrets.DOCKER_USERNAME }}/devops-core-app-python:latest diff --git a/README.md b/README.md index 371d51f456..480f60c609 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs) [![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative) [![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap) +[![Python CI](https://github.com/ph1larmon1a/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/ph1larmon1a/DevOps-Core-Course/actions/workflows/python-ci.yml) Master **production-grade DevOps practices** through hands-on labs. Build, containerize, deploy, monitor, and scale applications using industry-standard tools. diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..d598fd6580 --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,8 @@ +# Ansible +*.retry +.vault_pass +__pycache__/ +ansible/inventory/*.pyc + +# OS +.DS_Store diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..4b8ad4b3ca --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,36 @@ +# Lab05 — Ansible Fundamentals (Generated Project) + +## Quickstart + +From `ansible/`: + +```bash +# Install collections +ansible-galaxy collection install -r requirements.yml + +# Test connectivity (static inventory) +ansible all -m ping + +# Provision +ansible-playbook playbooks/provision.yml + +# Create Vault (use template as a guide) +# ansible-vault create group_vars/all.yml +# or copy example then encrypt: +# cp group_vars/all.yml.example group_vars/all.yml +# ansible-vault encrypt group_vars/all.yml + +# Deploy +ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` + +## Bonus — AWS Dynamic Inventory + +1) Ensure AWS creds are available (env vars, AWS CLI profile, or instance role) +2) Edit `inventory/aws_ec2.yml` region/filter as needed +3) Run: + +```bash +ansible-inventory -i inventory/aws_ec2.yml --graph +ansible -i inventory/aws_ec2.yml all -m ping +``` diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..b29a5fe70c --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,14 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False +stdout_callback = ansible.builtin.default +result_format = yaml +interpreter_python = auto_silent + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..a754e05384 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,307 @@ +# LAB05 — Ansible Fundamentals (Submission) + +## 1. Architecture Overview + +**Ansible version (control node):** +```bash +$ ansible --version +ansible [core 2.20.2] + config file = /Users/philarmonia/Documents/current_course/CBS-02/DevOps/DevOps-Core-Course/ansible/ansible.cfg + configured module search path = ['/Users/philarmonia/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules'] + ansible python module location = /opt/homebrew/Cellar/ansible/13.3.0/libexec/lib/python3.14/site-packages/ansible + ansible collection location = /Users/philarmonia/.ansible/collections:/usr/share/ansible/collections + executable location = /opt/homebrew/bin/ansible + python version = 3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 16.0.0 (clang-1600.0.26.6)] (/opt/homebrew/Cellar/ansible/13.3.0/libexec/bin/python) + jinja version = 3.1.6 + pyyaml version = 6.0.3 (with libyaml v0.2.5) +``` + +**Target VM:** +- Cloud: AWS +- Public IP: `98.80.214.147` +- OS: Ubuntu (22.04 or 24.04) — paste `lsb_release -a` output: + +**Role-based structure (why roles):** +- Roles keep tasks modular, reusable, and easier to test/maintain compared to a single monolithic playbook. + +**Project tree (high level):** +- `roles/common` — baseline packages + optional timezone +- `roles/docker` — installs Docker Engine, enables service, docker group, installs `python3-docker` +- `roles/app_deploy` — logs in to Docker Hub via Vault, pulls image, runs container, health checks + +## 2. Roles Documentation + +### Role: common +**Purpose:** baseline server setup (apt cache + essential tools). +**Variables (defaults):** +- `common_packages` (list) +- `common_set_timezone` (bool) +- `common_timezone` (string) +**Handlers:** none +**Dependencies:** none + +### Role: docker +**Purpose:** install Docker Engine from Docker’s official apt repo, enable/start service, add user(s) to docker group. +**Variables (defaults):** +- `docker_users` (list) +- `docker_packages` (list) +**Handlers:** +- `restart docker` +**Dependencies:** none (but pairs well with `common`) + +### Role: app_deploy +**Purpose:** deploy container image `s1mphonia/devops-core-app-python` on port 5000 and verify `/health`. +**Variables (defaults):** +- `docker_image`, `docker_image_tag` +- `app_port`, `app_container_name` +- `app_restart_policy` +- `app_env` (dict) +**Handlers:** +- `restart app container` +**Dependencies:** Docker must exist (run provision first) + +## 3. Idempotency Demonstration (Provisioning) + +Run from `ansible/`: + +```bash +ansible-playbook playbooks/provision.yml +``` + +```text +PLAY [Provision web servers] ***************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************** +ok: [aws_vm] + +TASK [common : Update apt cache] ************************************************************************************************* +ok: [aws_vm] + +TASK [common : Install common packages] ****************************************************************************************** +ok: [aws_vm] + +TASK [common : Set timezone (optional)] ****************************************************************************************** +ok: [aws_vm] + +TASK [docker : Install prerequisites for Docker repository] ********************************************************************** +ok: [aws_vm] + +TASK [docker : Ensure /etc/apt/keyrings exists] ********************************************************************************** +ok: [aws_vm] + +TASK [docker : Remove legacy Docker repo list if present] ************************************************************************ +changed: [aws_vm] + +TASK [docker : Remove legacy Docker keyring if present] ************************************************************************** +ok: [aws_vm] + +TASK [docker : Download Docker GPG key (ascii)] ********************************************************************************** +ok: [aws_vm] + +TASK [docker : Dearmor Docker GPG key into keyring] ****************************************************************************** +ok: [aws_vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************** +changed: [aws_vm] + +TASK [docker : Update apt cache after adding Docker repo] ************************************************************************ +changed: [aws_vm] + +TASK [docker : Install Docker packages] ****************************************************************************************** +changed: [aws_vm] + +TASK [docker : Ensure Docker service is enabled and running] ********************************************************************* +ok: [aws_vm] + +TASK [docker : Add users to docker group] **************************************************************************************** +changed: [aws_vm] => (item=ubuntu) + +TASK [docker : Install Python Docker SDK for Ansible docker modules] ************************************************************* +ok: [aws_vm] + +RUNNING HANDLER [docker : restart docker] **************************************************************************************** +changed: [aws_vm] + +PLAY RECAP *********************************************************************************************************************** +aws_vm : ok=17 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +Run again: + +```bash +ansible-playbook playbooks/provision.yml +``` + +```text +PLAY [Provision web servers] ***************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************** +ok: [aws_vm] + +TASK [common : Update apt cache] ************************************************************************************************* +ok: [aws_vm] + +TASK [common : Install common packages] ****************************************************************************************** +ok: [aws_vm] + +TASK [common : Set timezone (optional)] ****************************************************************************************** +ok: [aws_vm] + +TASK [docker : Install prerequisites for Docker repository] ********************************************************************** +ok: [aws_vm] + +TASK [docker : Ensure /etc/apt/keyrings exists] ********************************************************************************** +ok: [aws_vm] + +TASK [docker : Remove legacy Docker repo list if present] ************************************************************************ +changed: [aws_vm] + +TASK [docker : Remove legacy Docker keyring if present] ************************************************************************** +ok: [aws_vm] + +TASK [docker : Download Docker GPG key (ascii)] ********************************************************************************** +ok: [aws_vm] + +TASK [docker : Dearmor Docker GPG key into keyring] ****************************************************************************** +ok: [aws_vm] + +TASK [docker : Add Docker apt repository] **************************************************************************************** +changed: [aws_vm] + +TASK [docker : Update apt cache after adding Docker repo] ************************************************************************ +changed: [aws_vm] + +TASK [docker : Install Docker packages] ****************************************************************************************** +ok: [aws_vm] + +TASK [docker : Ensure Docker service is enabled and running] ********************************************************************* +ok: [aws_vm] + +TASK [docker : Add users to docker group] **************************************************************************************** +ok: [aws_vm] => (item=ubuntu) + +TASK [docker : Install Python Docker SDK for Ansible docker modules] ************************************************************* +ok: [aws_vm] + +PLAY RECAP *********************************************************************************************************************** +aws_vm : ok=16 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +**Analysis (brief):** +- First run shows many tasks as `changed` because packages/repos/services were configured for the first time. +- Second run should be mostly `ok`, proving idempotency (no unnecessary changes). + +## 4. Ansible Vault Usage + +**How secrets are stored:** +- Docker Hub credentials are stored in `group_vars/all.yml` encrypted with `ansible-vault`. + +**Vault commands used:** +```bash +ansible-vault create group_vars/all.yml +ansible-vault view group_vars/all.yml +``` + +```text +Vault password: +dockerhub_username: "s1mphonia" +dockerhub_password: + +app_name: devops-core-app-python +docker_image: "s1mphonia/devops-core-app-python" +docker_image_tag: latest +app_port: 5000 +app_container_name: "{{ app_name }}" +``` + +**Password strategy:** +- Use `--ask-vault-pass` or a `.vault_pass` file (chmod 600, add to `.gitignore`). + +## 5. Deployment Verification + +Deploy: + +```bash +ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` + +Paste terminal output from deploy: + +```text +PLAY [Deploy application] ******************************************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Ensure python3-docker is installed (required by docker modules)] ********************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Login to Docker Hub (uses vaulted credentials)] *************************************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Pull application image] *************************************************************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Run application container (recreate if needed)] *************************************************************************************************** +changed: [aws_vm] + +TASK [app_deploy : Wait for app port to be reachable] **************************************************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Check /health endpoint] *************************************************************************************************************************** +ok: [aws_vm] + +TASK [app_deploy : Show health response] ***************************************************************************************************************************** +ok: [aws_vm] => { + "health.content": "{\"status\":\"healthy\",\"timestamp\":\"2026-02-26T20:12:16.584997+00:00\",\"uptime_seconds\":11}\n" +} + +RUNNING HANDLER [app_deploy : restart app container] ***************************************************************************************************************** +changed: [aws_vm] + +PLAY RECAP *********************************************************************************************************************************************************** +aws_vm : ok=9 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +Verify container status: + +```bash +ansible webservers -a "docker ps" +``` + +Paste output: + +```text +aws_vm | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +c1c0be526b78 s1mphonia/devops-core-app-python:latest "python app.py" About a minute ago Up 37 seconds 0.0.0.0:5000->8000/tcp devops-core-app-python +``` + +Verify health endpoint: + +```bash +curl http://98.80.214.147:5000/health +curl http://98.80.214.147:5000/ +``` + +Paste outputs: + +```text +{"status":"healthy","timestamp":"2026-02-26T20:13:24.872887+00:00","uptime_seconds":52} +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"80.91.223.132","method":"GET","path":"/","user_agent":"curl/8.4.0"},"runtime":{"current_time":"2026-02-26T20:13:31.023096+00:00","timezone":"UTC","uptime_human":"0 minutes","uptime_seconds":58},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":2,"hostname":"c1c0be526b78","platform":"Linux","platform_version":"#7~24.04.1-Ubuntu SMP Thu Jan 22 21:04:49 UTC 2026","python_version":"3.13.12"}} +``` + +## 6. Key Decisions (2–3 sentences each) + +- **Why use roles instead of plain playbooks?** +- **How do roles improve reusability?** +- **What makes a task idempotent?** +- **How do handlers improve efficiency?** +- **Why is Ansible Vault necessary?** + +## 7. Challenges (Optional) + +- diff --git a/ansible/inventory/aws_ec2.yml b/ansible/inventory/aws_ec2.yml new file mode 100644 index 0000000000..b62a7cb63e --- /dev/null +++ b/ansible/inventory/aws_ec2.yml @@ -0,0 +1,26 @@ +--- +plugin: amazon.aws.aws_ec2 + +# Set region via env var AWS_REGION if you prefer, or edit this list. +regions: + - "{{ lookup('env', 'AWS_REGION') | default('us-east-1', true) }}" + +# Only running instances +filters: + instance-state-name: running + +# Compose connection vars +compose: + ansible_host: public_ip_address + ansible_user: ubuntu + +# Group by tags and other metadata +keyed_groups: + - key: tags.Name + prefix: name + - key: tags.Environment + prefix: env + +# Put everything in a convenient group +groups: + webservers: true diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000000..ac67025b5a --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,18 @@ +$ANSIBLE_VAULT;1.1;AES256 +62663730643239663661316331616462636136333265316163323431356333313538353762356565 +3538643932393738323964363534646263393839393133360a336538363239393364626232633331 +61313764396331373539346239363433653462396262313436393434613261346330653366343931 +3437663336306364300a636533343339666262336362643439666264653665626137323763623032 +37613337373564343136653731313733633366326265353162363738326231343433636635623530 +39383834303365346663323663383665366162353764353831653461663637643731316430343565 +65376637376232366364313135336466653463643531613039386361306564353633396633306637 +63373638306264323862396566616637653366333961623461303735333538383730653033333463 +66363538316433396330346463303431633031646536343437326431333363656533636332366466 +39326638393130626662356135663263383161653632636139376166646332633530653564363932 +64636262373866653733383137313733383639393937663035336565393936666434666464643234 +65626536656338336334373637393136663039626537623238616264636332633433363262373965 +63626237306466656333343835343333616463343565346262626462323161656334383366323433 +61653561616166356535623939623065323633663234346635336265346661616237646139303431 +64313933396534613563623662373930616532323865373033303937616262306463323465663863 +34336463613531313736303830353061626330336138316538323938366361343235343038663462 +34653934643531306337363432636631393333623933383930616530333632366235 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..b26cc52783 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +aws_vm ansible_host=98.80.214.147 ansible_user=ubuntu ansible_ssh_private_key_file=~/Downloads/labsuser-3.pem + +[all: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..533bf902e0 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + roles: + - app_deploy diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..7cc2e6678d --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - common + - docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..139c08f693 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,3 @@ +--- +- import_playbook: provision.yml +- import_playbook: deploy.yml diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..b1d01ce243 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.docker + - name: amazon.aws diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..50aa0f4a19 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,12 @@ +--- +# Application configuration +app_name: devops-core-app-python +docker_image: "s1mphonia/devops-core-app-python" +docker_image_tag: "latest" + +app_port: 5000 +app_container_name: "{{ app_name }}" +app_restart_policy: unless-stopped + +# Optional environment variables passed into container +app_env: {} diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..1fc3fba48b --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: true diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..059c219f80 --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,51 @@ +--- +- name: Ensure python3-docker is installed (required by docker modules) + ansible.builtin.apt: + name: python3-docker + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Login to Docker Hub (uses vaulted credentials) + community.docker.docker_login: + registry_url: https://index.docker.io/v1/ + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + no_log: true + +- name: Pull application image + community.docker.docker_image: + name: "{{ docker_image }}" + tag: "{{ docker_image_tag }}" + source: pull + +- name: Run application container (recreate if needed) + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + recreate: true + restart_policy: "{{ app_restart_policy }}" + published_ports: + - "{{ app_port }}:8000" + env: "{{ app_env }}" + notify: restart app container + +- name: Wait for app port to be reachable + ansible.builtin.wait_for: + host: "127.0.0.1" + port: "{{ app_port }}" + delay: 2 + timeout: 60 + +- name: Check /health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/health" + method: GET + return_content: true + status_code: 200 + register: health + +- name: Show health response + ansible.builtin.debug: + var: health.content diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..102a57bdba --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# Essential packages present on most servers +common_packages: + - ca-certificates + - curl + - git + - vim + - htop + - unzip + - jq + - python3-pip + - apt-transport-https + - gnupg + - lsb-release + +common_timezone: "Europe/Moscow" +common_set_timezone: true diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..b450656cab --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- 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: Set timezone (optional) + community.general.timezone: + name: "{{ common_timezone }}" + when: common_set_timezone | bool diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..862417a9fc --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,10 @@ +--- +docker_users: + - ubuntu + +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin \ 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..c923140c95 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + ansible.builtin.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..b82462603a --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,79 @@ +--- +- name: Install prerequisites for Docker repository + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Ensure /etc/apt/keyrings exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Remove legacy Docker repo list if present + ansible.builtin.file: + path: /etc/apt/sources.list.d/docker.list + state: absent + +- name: Remove legacy Docker keyring if present + ansible.builtin.file: + path: /etc/apt/keyrings/docker.asc + state: absent + +- name: Download Docker GPG key (ascii) + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /tmp/docker.asc + mode: "0644" + force: true + +- name: Dearmor Docker GPG key into keyring + ansible.builtin.command: > + gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.asc + args: + creates: /etc/apt/keyrings/docker.gpg + +- name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] + https://download.docker.com/linux/ubuntu + {{ ansible_facts['distribution_release'] }} stable + state: present + filename: docker + register: docker_repo + +- name: Update apt cache after adding Docker repo + ansible.builtin.apt: + update_cache: true + when: docker_repo.changed + +- name: Install Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + notify: restart docker + +- name: Ensure Docker service is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: true + +- name: Add users to docker group + ansible.builtin.user: + name: "{{ item }}" + groups: docker + append: true + loop: "{{ docker_users }}" + +- name: Install Python Docker SDK for Ansible docker modules + ansible.builtin.apt: + name: python3-docker + state: present \ No newline at end of file diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..222676562a --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,10 @@ +.env + +.git/ +.gitignore + +.DS_Store + +tests/ +docs/ +*.md diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..47551569a4 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: builder +FROM golang:1.22 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o app . + +# Stage 2: runtime +FROM alpine:3.20 + +WORKDIR /app +COPY --from=builder /src/app . + + +EXPOSE 8080 +CMD ["./app"] \ No newline at end of file diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..7cd4bbcd64 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,29 @@ +# DevOps Info Service (Go) + +## Build +```bash +go build -o devops-info-service main.go +``` +## Run +```bash +./devops-info-service +``` +## Custom port: +```bash +PORT=9090 ./devops-info-service +``` +## Endpoints +* `GET /` +* `GET /health` + +## Docker + +### Build locally +docker build -t go-app . + +### Run container +docker run --rm -p 8000:8000 go-app + +### Pull from Docker Hub +docker pull s1mphonia/devops-core-course-python-app:latest +docker run --rm -p 8000:8000 s1mphonia/devops-core-course-python-app \ No newline at end of file diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..b7741bf26f --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,3 @@ +# Why Go? + +I chose Go because it compiles into a small standalone binary, starts fast, and is ideal for multi-stage Docker builds. \ No newline at end of file diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..a510b460c6 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,20 @@ +# LAB01 — DevOps Info Service (Go) + +## Build & Run +```bash +go build -o devops-info-service main.go +./devops-info-service +``` +## Test +```bash +curl http://127.0.0.1:8080/ +curl http://127.0.0.1:8080/health +``` +## Binary size comparison +After building, compare: +```bash +ls -lh devops-info-service +``` +Screenshots go in: + +`app_go/docs/screenshots/` \ No newline at end of file diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..765b1d7aae --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,177 @@ +# LAB02 — Multi-Stage Docker Build (Go) + +## Overview +In this lab, I containerized my compiled Go application using a **multi-stage Docker build**. +The goal was to keep the final image small, secure, and production-ready by separating: + +- **Stage 1 (Builder):** compile the Go binary +- **Stage 2 (Runtime):** run only the compiled binary in a minimal environment + +--- + +## 1. Multi-Stage Build Strategy + +### Why Multi-Stage? +Go apps require a compiler to build, but the compiler is **not needed at runtime**. + +Multi-stage builds solve this by: +- Using a full Go SDK image only for building +- Copying only the final binary into a lightweight runtime image + +This results in: +- Smaller final image size +- Fewer dependencies included +- Reduced attack surface + +--- + +## 2. Dockerfile Used + +File: `app_go/Dockerfile` + +```dockerfile +# Stage 1: builder +FROM golang:1.22 AS builder + +WORKDIR /src +COPY . . +RUN go build -o app . + +# Stage 2: runtime +FROM alpine:3.20 + +WORKDIR /app +COPY --from=builder /src/app . + +EXPOSE 8080 +CMD ["./app"] +``` + +## 3. Explanation of Each Stage +### Stage 1 — Builder +**Base image:** `golang:1.22` \ +This stage: + +* Includes the Go toolchain and build dependencies +* Compiles the source code into a binary using: +```bash +go build -o app . +``` +Output: +* A compiled executable named `app` +### Stage 2 — Runtime +**Base image:** `alpine:3.20` +This stage: + +* Is very small compared to the builder stage +* Does not contain Go, compilers, or build tools +* Copies only the compiled binary from the builder stage: +```dockerfile +COPY --from=builder /src/app . +``` +This is the final image that gets shipped. +## 4. Build & Run Process +### Build the image +```bash +$ docker build -t s1mphonia/app-go:latest app_go/ + +[+] Building 5.6s (15/15) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 303B 0.0s + => [internal] load metadata for docker.io/library/golang:1.22 1.3s + => [internal] load metadata for docker.io/library/alpine:3.20 1.4s + => [internal] load .dockerignore 0.0s + => => transferring context: 93B 0.0s + => [builder 1/6] FROM docker.io/library/golang:1.22@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 0.0s + => [internal] load build context 0.0s + => => transferring context: 391B 0.0s + => [stage-1 1/3] FROM docker.io/library/alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4fb03805 0.0s + => CACHED [builder 2/6] WORKDIR /src 0.0s + => CACHED [builder 3/6] COPY go.mod ./ 0.0s + => CACHED [builder 4/6] RUN go mod download 0.0s + => [builder 5/6] COPY . . 0.0s + => [builder 6/6] RUN CGO_ENABLED=0 GOOS=linux go build -o app . 4.1s + => CACHED [stage-1 2/3] WORKDIR /app 0.0s + => [stage-1 3/3] COPY --from=builder /src/app . 0.0s + => exporting to image 0.0s + => => exporting layers 0.0s + => => writing image sha256:2554aeaad0e03931e31aa4f135a48c2425c059551ef59ecb1f9cc1947f2c1704 0.0s + => => naming to docker.io/library/app-go 0.0s + +View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/d8yvtnkuft1iz4zqnpq5r861i +``` + +### Run the container +```bash +$ docker run --rm -p 8080:8080 app-go +nothing here xD +``` + +### Test the endpoint +```bash +$ curl http://localhost:8080/health +{"status":"healthy","timestamp":"2026-02-03T17:15:30.665305925Z","uptime_seconds":4} +``` +## 5. Image Size Comparison +### To compare image sizes: +```bash +$ docker images +Example output: +REPOSITORY TAG IMAGE ID SIZE +app-go latest 2554aeaad0e0 2 minutes ago 15.8MB +python-app latest 562eddae025a 43 minutes ago 148MB +``` +### Size analysis +* The builder stage uses a large Go SDK image (~900MB) +* The final runtime image is small (~10–20MB typical) +* The final image contains only: + * the Go binary + * minimal Alpine runtime dependencies +This is a major improvement over shipping the full Go SDK. +## 6. Why Multi-Stage Builds Matter +### Benefits +* Smaller images +* Faster pulls/deployments +* Less storage usage +* Reduced attack surface +* Cleaner runtime environment +### What happens without multi-stage? +If I used the Go SDK image as the runtime image: +* The final image would be huge +* It would include build tools unnecessarily +* More packages = more vulnerabilities +## 7. Security Considerations +### Reduced attack surface +A smaller image means: +* fewer libraries installed +* fewer tools available to an attacker +* fewer vulnerabilities overall +### Minimal runtime environment +By using Alpine and only copying the binary: +* the container has fewer moving parts +* runtime is more predictable +## 8. Challenges & Solutions +### Challenge 1: `exec ./app: no such file or directory` +**Problem:** +When I started the container, Docker returned: + +```bash +exec ./app: no such file or directory +``` +**Cause:** +This happened because the Go binary was not built in a way that matched the runtime environment. \ +Common reasons include: +* The binary was compiled for the wrong OS/architecture +* The binary required dynamic linking (CGO) that wasn’t available in the minimal runtime image + +**Solution:** +I fixed this by compiling a Linux static binary in the builder stage: +```dockerfile +RUN CGO_ENABLED=0 GOOS=linux go build -o app . +``` +This ensured the executable could run correctly inside the Alpine runtime container. + +## What I learned +* Multi-stage builds are the standard way to ship compiled apps +* The final runtime image should contain only what is needed to run +* Image size impacts deployment speed and security \ No newline at end of file diff --git a/app_go/docs/screenshots/task3-curl-query.png b/app_go/docs/screenshots/task3-curl-query.png new file mode 100644 index 0000000000..ab3f4426f3 Binary files /dev/null and b/app_go/docs/screenshots/task3-curl-query.png differ diff --git a/app_go/docs/screenshots/task3-healthcheck.png b/app_go/docs/screenshots/task3-healthcheck.png new file mode 100644 index 0000000000..282f249cc0 Binary files /dev/null and b/app_go/docs/screenshots/task3-healthcheck.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..307ce0d1c5 --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.21 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..6f3d4aa104 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/json" + "net" + "net/http" + "os" + "runtime" + "time" +) + +var startTime = time.Now().UTC() + +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` +} + +type RuntimeInfo struct { + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type RequestInfo 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 MainResponse struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime RuntimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +func getUptime() (int64, string) { + seconds := int64(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + return seconds, formatUptime(hours, minutes) +} + +func formatUptime(hours int64, minutes int64) string { + if hours == 0 { + return "0 hours, " + itoa(minutes) + " minutes" + } + return itoa(hours) + " hours, " + itoa(minutes) + " minutes" +} + +func itoa(v int64) string { + return fmtInt(v) +} + +func fmtInt(v int64) string { + // minimal integer conversion without extra imports + if v == 0 { + return "0" + } + sign := "" + if v < 0 { + sign = "-" + v = -v + } + buf := make([]byte, 0, 20) + for v > 0 { + d := v % 10 + buf = append([]byte{byte('0' + d)}, buf...) + v /= 10 + } + return sign + string(buf) +} + +func getHostname() string { + host, err := os.Hostname() + if err != nil { + return "unknown" + } + return host +} + +func getClientIP(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + return xff + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func mainHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, uptimeHuman := getUptime() + + resp := MainResponse{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: getHostname(), + Platform: runtime.GOOS, + PlatformVersion: "unknown", + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + }, + Runtime: RuntimeInfo{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: time.Now().UTC().Format(time.RFC3339Nano), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: getClientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + writeJSON(w, 200, resp) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, _ := getUptime() + + writeJSON(w, 200, map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "uptime_seconds": uptimeSeconds, + }) +} + +func main() { + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + _ = http.ListenAndServe(":"+port, nil) +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..b192950a86 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +.env +.venv/ +venv/ + +.git/ +.gitignore + +.DS_Store + +pytest_cache/ +.mypy_cache/ + +tests/ +docs/ +*.md diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..2fc41b390b --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +.pytest_cache/ +.coverage + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..4336eb488e --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +USER appuser + +EXPOSE 8000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..9938813156 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,42 @@ +# DevOps Info Service (Python) + +## Overview +A simple DevOps info service that exposes system/runtime details and a health endpoint. + +## Prerequisites +- Python 3.11+ +- pip + +## Installation +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Testing + +### How to run tests locally: +```bash +pip install -r app_python/requirements.txt +pip install -r app_python/requirements-dev.txt +pytest -v +``` + +## Docker + +### Build locally +```bash +docker build -t python-app . +``` + +### Run container +```bash +docker run --rm -p 8000:8000 python-app +``` + +### Pull from Docker Hub +```bash +docker pull s1mphonia/devops-core-course-python-app:latest +docker run --rm -p 8000:8000 s1mphonia/devops-core-course-python-app +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..fc955487bc --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,159 @@ +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("devops-info-service") + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +app = Flask(__name__) + +# App start time (for uptime) +START_TIME = datetime.now(timezone.utc) + + +def get_uptime(): + """Return uptime in seconds and human format.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + + human_parts = [] + if hours == 1: + human_parts.append("1 hour") + elif hours > 1: + human_parts.append(f"{hours} hours") + + if minutes == 1: + human_parts.append("1 minute") + else: + human_parts.append(f"{minutes} minutes") + + human = ", ".join(human_parts) if human_parts else "0 minutes" + + return {"seconds": seconds, "human": human} + + +def get_system_info(): + """Collect system information.""" + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +def get_service_info(): + """Service metadata.""" + return { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + } + + +def get_request_info(): + """Request metadata.""" + return { + "client_ip": request.headers.get("X-Forwarded-For", request.remote_addr), + "user_agent": request.headers.get("User-Agent", "unknown"), + "method": request.method, + "path": request.path, + } + + +def get_runtime_info(): + """Runtime metadata.""" + uptime = get_uptime() + return { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + } + + +@app.route("/", methods=["GET"]) +def index(): + """Main endpoint - service and system information.""" + logger.info("Request received: %s %s", request.method, request.path) + + response = { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + return jsonify(response), 200 + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check endpoint.""" + logger.info("Health check: %s %s", request.method, request.path) + + uptime = get_uptime() + return ( + jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime["seconds"], + } + ), + 200, + ) + + +@app.errorhandler(404) +def not_found(error): + """Handle 404 errors.""" + return ( + jsonify( + { + "error": "Not Found", + "message": "Endpoint does not exist", + } + ), + 404, + ) + + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 errors.""" + logger.exception("Internal server error: %s", error) + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("Starting DevOps Info Service on %s:%s (debug=%s)", HOST, PORT, DEBUG) + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..2438d9741f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,65 @@ +# LAB01 — DevOps Info Service (Python) + +## 1. Framework Selection +I chose **Flask** because: +- lightweight and simple for a small service +- fast to implement +- minimal dependencies + +### Comparison Table + +| Framework | Pros | Cons | +|----------|------|------| +| Flask | Simple, lightweight, beginner-friendly | Less built-in features | +| FastAPI | Modern, fast, auto docs | More concepts (Pydantic/async) | +| Django | Full-featured, ORM included | Too heavy for this small service | + +--- + +## 2. Best Practices Applied + +### Clean Code Organization +- functions separated by responsibility (`get_system_info`, `get_runtime_info`, etc.) +- clear naming + docstrings +- grouped imports + +### Error Handling +- custom handlers for `404` and `500` returning JSON errors + +### Logging +- configured with timestamps and log level +- logs every request to `/` and `/health` + +### Dependencies +- pinned exact version in `requirements.txt` + +--- + +## 3. API Documentation + +### Test main endpoint +```bash +curl http://127.0.0.1:5000/ +``` +### Pretty print: +```bash +curl -s http://127.0.0.1:5000/ | jq +``` +### Test health endpoint +```bash +curl http://127.0.0.1:5000/health +``` +## 4. Testing Evidence +Screenshots are saved in: + +`app_python/docs/screenshots/` + +## 5. Challenges & Solutions + +* **Challenge:** extracting correct client IP behind proxies +* **Solution:** used `X-Forwarded-For` header fallback to `request.remote_addr` + +## 6. GitHub Community +Starring repositories helps bookmark useful tools and increases visibility/support for open source maintainers. + +Following developers (professor, TAs, classmates) helps discover new work, learn collaboration habits, and stay connected for future projects. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..da26f43c3f --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,119 @@ +# Lab 02 — Docker Containerization + +## 1. Docker Best Practices Applied + +### Non-root user +I created a dedicated user (`appuser`) and switched to it using `USER appuser`. +This improves security by preventing root-level access inside the container. + +### Layer caching (dependency-first copy) +I copied `requirements.txt` before copying the full application source: +- Faster rebuilds when code changes +- Dependencies only reinstall when requirements change + +### Small base image +I used `python:3.13-slim` to reduce image size while keeping compatibility. + +### .dockerignore +I excluded dev-only files like venv, git metadata, caches, tests, and docs. +This reduces build context size and speeds up builds. + +--- + +## 2. Image Information & Decisions + +### Base image choice +**python:3.13-slim** +- Small footprint +- Official and maintained +- Good for production containers + +### Image size +Final image size: **148MB** + +### Layer structure +1. Base image +2. Create non-root user +3. Copy requirements + install dependencies +4. Copy app source +5. Run app as non-root user + +--- + +## 3. Build & Run Process + +### Build output +```bash +$ docker build -t python-app app_python/ + +[+] Building 3.8s (15/15) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 354B 0.0s + => resolve image config for docker-image://docker.io/docker/dockerfile:1 2.4s + => [auth] docker/dockerfile:pull token for registry-1.docker.io 0.0s + => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.2s + => [auth] library/python:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 166B 0.0s + => [1/6] FROM docker.io/library/python:3.13-slim@sha256:2b9c9803c6a287cafa0a8c917211dddd23dcd2016f049690ee5219f5d3f1636e 0.0s + => [internal] load build context 0.0s + => => transferring context: 125B 0.0s + => CACHED [2/6] RUN useradd --create-home --shell /bin/bash appuser 0.0s + => CACHED [3/6] WORKDIR /app 0.0s + => CACHED [4/6] COPY requirements.txt . 0.0s + => CACHED [5/6] RUN pip install --no-cache-dir -r requirements.txt 0.0s + => CACHED [6/6] COPY . . 0.0s + => exporting to image 0.0s + => => exporting layers 0.0s + => => writing image sha256:562eddae025a7af9be7c6f1c0d7e889deedbbb4d30a9b4b8f99c0ff60ee8d4f4 0.0s + => => naming to docker.io/library/python-app 0.0s + +View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/zskjg8fybjffvn8sygiy67fs9 +``` +### Run container +```bash +$ docker run --rm -p 8000:8000 python-app + + * Serving Flask app 'app' + * Debug mode: off +2026-02-03 16:46:03,334 - devops-info-service - INFO - Starting DevOps Info Service on 0.0.0.0:8000 (debug=False) +2026-02-03 16:46:03,340 - werkzeug - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:8000 + * Running on http://172.17.0.2:8000 +2026-02-03 16:46:03,340 - werkzeug - INFO - Press CTRL+C to quit + +``` +### Test endpoints +```bash +$ curl http://localhost:8000/health +{"status":"healthy","timestamp":"2026-02-03T16:47:14.761583+00:00","uptime_seconds":20} +``` +### Docker Hub +Repository URL: +https://hub.docker.com/r/s1mphonia/devops-core-course-python-app +## 4. Technical Analysis +### Why the Dockerfile works +* Dependencies installed once (cached) +* Code copied after dependencies +* Runs safely as non-root +### What if layer order changed? +If I copied the full source before installing dependencies, Docker would reinstall packages every time I changed code → slower builds. +### Security considerations +* Non-root user reduces risk of container breakout damage +* Slim image reduces attack surface +### How .dockerignore helps +* Smaller build context +* Faster builds +* Prevents secrets/dev junk from being baked into the image +## 5. Challenges & Solutions +### Example: port mismatch +**Issue:** app didn’t respond on expected port \ +**Fix:** updated docker run -p mapping and confirmed correct port. + +What I learned: + +* Docker caching depends heavily on layer order +* Non-root containers are essential for production safety + diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..2d77da5d7f --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,82 @@ +# Lab 03 — Continuous Integration (CI/CD) + +## 1. Testing + +### Testing framework + +**Choice:** `pytest` + +**Why:** I chose `pytest` because it has a clean, readable assertion style, excellent fixture support for setting up a Flask test client, and strong ecosystem plugins like `pytest-cov` for coverage reporting. It also produces clear failure output, which helps debugging. + +### Test structure explanation: +Tests are located in `app_python/tests/`: +* `test_app.py` defines reusable fixtures and contains endpoint tests + * `client` for normal endpoint tests + * `client_no_propagate` to validate 500 error handler responses as JSON + * `GET /` validates JSON structure, required fields, types, and headers behavior (e.g., `X-Forwarded-For`) + * `GET /health` validates the health payload and timestamp format + * Error cases include `404` for unknown routes and `500` by forcing an internal exception via monkeypatching + +### Terminal output showing tests passing: +```bash +$ pytest -v +======================================================================== test session starts ========================================================================= +platform darwin -- Python 3.13.3, pytest-9.0.2, pluggy-1.6.0 -- /Users/philarmonia/Documents/current_course/CBS-02/DevOps/DevOps-Core-Course/app_python/venv/bin/python3.13 +cachedir: .pytest_cache +rootdir: /Users/philarmonia/Documents/current_course/CBS-02/DevOps/DevOps-Core-Course/app_python +plugins: cov-7.0.0 +collected 5 items + +tests/test_app.py::test_get_root_returns_expected_json_structure PASSED [ 20%] +tests/test_app.py::test_root_uses_x_forwarded_for_as_client_ip PASSED [ 40%] +tests/test_app.py::test_get_health_returns_expected_payload PASSED [ 60%] +tests/test_app.py::test_404_returns_json_error_payload PASSED [ 80%] +tests/test_app.py::test_500_returns_json_error_payload_when_exception_occurs PASSED [100%] + +========================================================================= 5 passed in 0.12s ========================================================================== +``` + +## 2. CI Workflow (GitHub Actions) + +### Workflow trigger strategy and reasoning: + +The workflow runs on: +* `push` to lab03 branch +* `pull_request` to catch issues before merging \ + Path filters are used to avoid unnecessary runs when files outside `app_python/` change. + +### Why I chose specific actions from the marketplace: +* `actions/checkout@v4` — standard, reliable way to fetch repo content in CI +* `actions/setup-python@v5` — official Python setup action, supports dependency caching +* `docker/login-action@v3` — secure login to Docker Hub using GitHub Secrets +* `docker/build-push-action@v6` — best-practice build + push with BuildKit + +### Docker tagging strategy: (choose what you actually implemented) +I used **CalVer** tags plus `latest`: +* `username/app:YYYY.MM.DD` (example: `2026.02.11`) for traceable builds +* `username/app:latest` for the most recent stable image \ + This provides at least two tags per build: a versioned tag + latest. + +### Link to successful workflow run: +* GitHub Actions run: https://github.com/ph1larmon1a/DevOps-Core-Course/actions/runs/21921286148 + +## 3. CI Best Practices, Security & Performance + +### Caching implementation and speed improvement metrics: +Dependency caching is enabled using `actions/setup-python` with pip caching. + +I compared workflow runtime before/after caching: +* Before caching: 10s +* After caching: 4s \ + This improvement occurs because pip packages are restored from cache instead of re-downloaded each run. + +### CI best practices applied (at least 3) + why they matter: +1. **Path filters** — avoids running Python CI when unrelated files change (faster, cheaper CI) +2. **Concurrency / cancel-in-progress** — prevents wasting CI minutes on outdated commits +3. **Separate concerns** — tests/lint always run, Docker push restricted (optional) to main branch to avoid pushing images for feature branches +4. **Fail-fast** — pipeline stops immediately if lint/tests fail, preventing broken builds and wasted steps + +### Snyk integration results and vulnerability handling: +I integrated Snyk scanning to detect dependency vulnerabilities during CI using `SNYK_TOKEN` stored in GitHub Secrets. +* Severity threshold: high (CI fails only for high/critical issues) +* Result: ![alt text](screenshots/lab03-snyk-result.png) \ No newline at end of file diff --git a/app_python/docs/screenshots/lab03-snyk-result.png b/app_python/docs/screenshots/lab03-snyk-result.png new file mode 100644 index 0000000000..7d43c36ae5 Binary files /dev/null and b/app_python/docs/screenshots/lab03-snyk-result.png differ diff --git a/app_python/docs/screenshots/task1-curl-query.png b/app_python/docs/screenshots/task1-curl-query.png new file mode 100644 index 0000000000..dd545f0b35 Binary files /dev/null and b/app_python/docs/screenshots/task1-curl-query.png differ diff --git a/app_python/docs/screenshots/task1-healthcheck.png b/app_python/docs/screenshots/task1-healthcheck.png new file mode 100644 index 0000000000..dec100bb43 Binary files /dev/null and b/app_python/docs/screenshots/task1-healthcheck.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..954cd471fa --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.0 +pytest +pytest-cov \ 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_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..8e6f64a332 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,151 @@ +import re +import pytest +import app as app_module # app.py +from app import app as flask_app + + +@pytest.fixture +def client(): + # Standard test client setup + flask_app.config.update( + TESTING=True, + ) + with flask_app.test_client() as client: + yield client + + +@pytest.fixture +def client_no_propagate(): + """ + Flask in TESTING mode often propagates exceptions instead of invoking error handlers. + This fixture ensures our 500 handler is actually returned as JSON. + """ + flask_app.config.update( + TESTING=True, + PROPAGATE_EXCEPTIONS=False, + ) + with flask_app.test_client() as client: + yield client +@pytest.fixture +def client(): + # Standard test client setup + flask_app.config.update( + TESTING=True, + ) + with flask_app.test_client() as client: + yield client + + +@pytest.fixture +def client_no_propagate(): + """ + Flask in TESTING mode often propagates exceptions instead of invoking error handlers. + This fixture ensures our 500 handler is actually returned as JSON. + """ + flask_app.config.update( + TESTING=True, + PROPAGATE_EXCEPTIONS=False, + ) + with flask_app.test_client() as client: + yield client +ISO_8601_UTC_REGEX = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+00:00$" +) + + +def test_get_root_returns_expected_json_structure(client): + resp = client.get("/", headers={"User-Agent": "pytest-agent"}) + assert resp.status_code == 200 + assert resp.is_json + + data = resp.get_json() + + # Top-level keys + for key in ("service", "system", "runtime", "request", "endpoints"): + assert key in data + + # service block + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["framework"] == "Flask" + assert isinstance(service["version"], str) + assert isinstance(service["description"], str) + + # system block (types + presence) + system = data["system"] + assert isinstance(system["hostname"], str) and system["hostname"] + assert isinstance(system["platform"], str) and system["platform"] + assert isinstance(system["platform_version"], str) + assert isinstance(system["architecture"], str) and system["architecture"] + assert isinstance(system["cpu_count"], int) and system["cpu_count"] >= 1 + assert isinstance(system["python_version"], str) and system["python_version"] + + # runtime block + runtime = data["runtime"] + assert isinstance(runtime["uptime_seconds"], int) and runtime["uptime_seconds"] >= 0 + assert isinstance(runtime["uptime_human"], str) and runtime["uptime_human"] + assert isinstance(runtime["current_time"], str) + assert ISO_8601_UTC_REGEX.match(runtime["current_time"]) + assert runtime["timezone"] == "UTC" + + # request block + req = data["request"] + assert req["method"] == "GET" + assert req["path"] == "/" + assert isinstance(req["client_ip"], str) and req["client_ip"] + assert req["user_agent"] == "pytest-agent" + + # endpoints list contains both entries + endpoints = data["endpoints"] + assert isinstance(endpoints, list) + paths = {(e["path"], e["method"]) for e in endpoints} + assert ("/", "GET") in paths + assert ("/health", "GET") in paths + + +def test_root_uses_x_forwarded_for_as_client_ip(client): + resp = client.get( + "/", + headers={ + "X-Forwarded-For": "203.0.113.10", + "User-Agent": "pytest-agent", + }, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["request"]["client_ip"] == "203.0.113.10" + + +def test_get_health_returns_expected_payload(client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.is_json + + data = resp.get_json() + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) and data["uptime_seconds"] >= 0 + assert isinstance(data["timestamp"], str) + assert ISO_8601_UTC_REGEX.match(data["timestamp"]) + + +def test_404_returns_json_error_payload(client): + resp = client.get("/does-not-exist") + assert resp.status_code == 404 + assert resp.is_json + + data = resp.get_json() + assert data["error"] == "Not Found" + assert data["message"] == "Endpoint does not exist" + + +def test_500_returns_json_error_payload_when_exception_occurs(client_no_propagate, monkeypatch): + # Force an exception during GET / by breaking get_system_info + monkeypatch.setattr(app_module, "get_system_info", lambda: 1 / 0) + + resp = client_no_propagate.get("/") + assert resp.status_code == 500 + assert resp.is_json + + data = resp.get_json() + assert data["error"] == "Internal Server Error" + assert data["message"] == "An unexpected error occurred" diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..12804683c8 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,20 @@ +# Terraform +.terraform/ +.terraform.lock.hcl + +# State (NEVER COMMIT) +terraform.tfstate +terraform.tfstate.* + +# tfvars often contain secrets / sensitive values +terraform.tfvars + +# Crash logs +crash.log +crash.*.log + +# Local overrides +override.tf +override.tf.json +*_override.tf +*_override.tf.json \ No newline at end of file diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..0b22926a42 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,83 @@ +# LAB04 — Infrastructure as Code (Local VM Alternative) + +## 1. Cloud Provider & Infrastructure +I used an existing VDS (public VPS) instead of provisioning a new VM in a cloud provider. +This follows the "Local VM Alternative" option from the lab. + +- VM type: VDS (already provisioned by hosting provider) +- Public IP: 46.8.64.5 +- SSH access: key-based, no password +- Planned usage for Lab 5: YES (keep this VM for Ansible) + +## 2. VM Setup Proof +### OS +``` +root@server-xx5cre:~# uname -a +Linux server-xx5cre 6.8.0-35-generic #35-Ubuntu SMP PREEMPT_DYNAMIC Mon May 20 15:51:52 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux +root@server-xx5cre:~# lsb_release -a || cat /etc/os-release +No LSB modules are available. +Distributor ID: Ubuntu +Description: Ubuntu 24.04 LTS +Release: 24.04 +Codename: noble +``` + +### Network / IP +``` +root@server-xx5cre:~# ip a +1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 + link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 + inet 127.0.0.1/8 scope host lo + valid_lft forever preferred_lft forever + inet6 ::1/128 scope host noprefixroute + valid_lft forever preferred_lft forever +2: eth0: mtu 1500 qdisc fq state UP group default qlen 1000 + link/ether fa:16:3e:3b:58:97 brd ff:ff:ff:ff:ff:ff + altname enp0s3 + altname ens3 + inet 46.8.64.5/24 metric 100 brd 46.8.64.255 scope global dynamic eth0 + valid_lft 34686sec preferred_lft 34686sec +root@server-xx5cre:~# +``` +### SSH access proof +``` +philarmonia@MacBook-Air-Aleksei-2 ~ % ssh root@46.8.64.5 "whoami && hostname && uptime" +root +server-xx5cre + 20:16:15 up 8:31, 2 users, load average: 0.00, 0.00, 0.00 +philarmonia@MacBook-Air-Aleksei-2 ~ % +``` +## 3. Firewall / Required Ports +``` +root@server-xx5cre:~# ufw status verbose +Status: active +Logging: on (low) +Default: deny (incoming), allow (outgoing), deny (routed) +New profiles: skip + +To Action From +-- ------ ---- +22/tcp ALLOW IN Anywhere +80/tcp ALLOW IN Anywhere +5000/tcp ALLOW IN Anywhere +22/tcp (v6) ALLOW IN Anywhere (v6) +80/tcp (v6) ALLOW IN Anywhere (v6) +5000/tcp (v6) ALLOW IN Anywhere (v6) +``` + +## 4. Terraform Implementation +Not applicable in this variant because the VM was already created (Local VM Alternative). +Instead, infrastructure readiness is ensured by configuring SSH access and firewall rules on the VDS. +## 5. Pulumi Implementation +Not applicable in this variant because no cloud infrastructure was created/destroyed. +The existing VDS is used as the target VM. +## 6. Terraform vs Pulumi Comparison +Terraform and Pulumi are IaC tools for provisioning resources via cloud APIs. +In this lab variant, provisioning was not performed because an existing VM was used. +Main conceptual difference: +Terraform: declarative HCL approach +Pulumi: imperative approach using real programming languages +## 7. Lab 5 Preparation & Cleanup +Keeping VM for Lab 5: YES +VM details: 46.8.64.5, SSH key-based access +Cleanup: no cloud resources were created, so no destroy step was needed diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..8d36673ed3 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,73 @@ +provider "aws" { + region = var.region +} + +data "aws_vpc" "default" { + default = true +} + +data "aws_subnets" "default_vpc" { + filter { + name = "vpc-id" + values = [data.aws_vpc.default.id] + } +} + +data "aws_ssm_parameter" "ubuntu_2404_ami" { + name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id" +} + +resource "aws_security_group" "devops_sg" { + name = "devops-sg" + vpc_id = data.aws_vpc.default.id + + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.ssh_cidr] + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "App port 5000" + from_port = 5000 + to_port = 5000 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "devops-sg" } +} + +resource "aws_instance" "devops" { + ami = data.aws_ssm_parameter.ubuntu_2404_ami.value + instance_type = var.instance_type + subnet_id = tolist(data.aws_subnets.default_vpc.ids)[1] + vpc_security_group_ids = [aws_security_group.devops_sg.id] + associate_public_ip_address = true + key_name = var.key_name + + root_block_device { + volume_size = 16 + volume_type = "gp2" + } + + tags = { Name = var.name } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..17829da9ae --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "public_ipv4" { + description = "Public IPv4 address of the devops instance" + value = aws_instance.devops.public_ip +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..b25daa52b8 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,23 @@ +variable "region" { + type = string + default = "us-east-1" +} + +variable "name" { + type = string + default = "devops" +} + +variable "instance_type" { + type = string + default = "t2.large" +} + +variable "key_name" { + type = string +} + +variable "ssh_cidr" { + type = string + default = "0.0.0.0/0" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000000..22df935f25 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.4.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +}