diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..02bc05bae4 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,54 @@ +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 + ansible-lint playbooks/*.yml + + deploy: + runs-on: self-hosted + 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 }}:5000 || exit 1 + curl -f http://${{ secrets.VM_HOST }}:5000/health || exit 1 \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..a91b08ee9a --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,94 @@ +name: Python CI & Docker + +on: + push: + branches: [ main, master ] + tags: [ 'v*' ] + pull_request: + branches: [ main, master ] + +env: + APP_DIR: app_python + +jobs: + lint-and-test: + name: Lint, Test and Snyk + 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: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ${{ env.APP_DIR }}/requirements.txt + pip install -r ${{ env.APP_DIR }}/requirements-dev.txt + + - name: Lint + run: | + flake8 ${{ env.APP_DIR }} + + - name: Run tests + run: | + cd ${{ env.APP_DIR }} + pytest -q + + - name: Install Snyk + run: | + npm install -g snyk + + - name: Snyk test + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + snyk test --file=${{ env.APP_DIR }}/requirements.txt --severity-threshold=high || true + + docker-build-push: + name: Build and Push Docker Image + needs: lint-and-test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version variables + id: vars + run: | + echo "GITHUB_REF=$GITHUB_REF" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION="0.0.0-dev-${GITHUB_RUN_NUMBER}" + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + MAJOR_MINOR=$(echo $VERSION | awk -F. '{print $1"."$2}') + echo "MAJOR_MINOR=$MAJOR_MINOR" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.MAJOR_MINOR }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/.gitignore b/.gitignore index 30d74d2584..b4369438be 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -test \ No newline at end of file +test + +# Ansible +*.retry +.vault_pass +ansible/inventory/*.pyc +__pycache__/ \ No newline at end of file diff --git a/ansible/.ansible-lint.yml b/ansible/.ansible-lint.yml new file mode 100644 index 0000000000..2a8fdbfe6b --- /dev/null +++ b/ansible/.ansible-lint.yml @@ -0,0 +1,2 @@ +skip_list: + - var-naming[no-role-prefix] diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..4b7121bb3c --- /dev/null +++ b/ansible/README.md @@ -0,0 +1 @@ +[![Ansible Deployment](https://github.com/iu-capstone-ad/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/iu-capstone-ad/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) \ No newline at end of file diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..0b734cbee9 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = cirno +retry_files_enabled = False + +[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..137ed8a13e --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,275 @@ +# Lab 5 - Ansible Fundamentals + +## 1. Architecture Overview + +### Ansible version used + +After installing ansible using `apt install ansible` on Ubuntu 24.04, +the following version was installed: `ansible [core 2.16.3]` + +### Target VM OS and version + +VM is running on QEMU/KVM. VM is running Ubuntu 24.04 + +Output of `cat /etc/os-release` + +``` +PRETTY_NAME="Ubuntu 24.04.4 LTS" +NAME="Ubuntu" +VERSION_ID="24.04" +VERSION="24.04.4 LTS (Noble Numbat)" +VERSION_CODENAME=noble +ID=ubuntu +ID_LIKE=debian +... +``` + +### Role structure diagram or explanation + +Project uses the standard Ansible directory layout with `playbooks/` directory containing the playbook files, and `roles/` directory containing reusable rolws. Each role has its own directory with `tasks/`, `defaults/`, `handlers/` folders. + +### Why roles instead of monolithic playbooks? +A single playbook is hard to maintain and reuse. Splitting the playbook into roles allows for more code reuse and easier maintainability. + +## 2. Roles Documentation + +### common + +#### Purpose + +Make sure the system has an updated apt cache and install reqiored package: `python3-pip` and `curl`. + +#### Variables + +`common_packages` (default `['python3-pip','curl']`). + +#### Handlers + +none defined in this role. + +#### Dependencies + +none. It can run on a raw OS image. + +### docker + +#### Purpose + +Configure the official Docker apt repository, install docker and related packages, ensure the `docker` service is running and add the specified user to the docker group. It also installs the `python3-docker` package which is useful for Ansible Docker modules. + +#### Variables + +`docker_user` (default `cirno`), `docker_group` (`docker`), `python3_docker_package` (`python3-docker`). + +#### Handlers + +none. Service start/enable is done directly in tasks. + +#### Dependencies + +none. Although it is typically invoked after `common` in the playbook. + +### app_deploy + +#### Purpose + +Log in to Docker Hub, pull the application image, stop and remove any existing container, start a new container and verify it is healthy. + +#### Variables + +defaults include `app_name`, `app_port`, `app_container_name` and `container_restart_policy`. Additional required variables such as `dockerhub_username`, `dockerhub_password`, `docker_image` and `docker_image_tag` are supplied via encrypted group vars. + +#### Handlers + +none. The tasks manage containers directly. + +#### Dependencies + +none. It assumes Docker is already installed and running. + +## 3. Idempotency Demonstration + +### Terminal output from FIRST provision.yml run + +``` +PLAY [Provision web servers] ***************************************************************** + +TASK [Gathering Facts] *********************************************************************** +ok: [devops-lab4-vm] + +TASK [common : Update apt cache] ************************************************************* +ok: [devops-lab4-vm] + +TASK [common : Install common packages] ****************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Add Docker GPG key] *********************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Add Docker repository] ******************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Update apt cache] ************************************************************* +ok: [devops-lab4-vm] + +TASK [docker : Install Docker packages] ****************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Check Docker service running] ************************************************* +ok: [devops-lab4-vm] + +TASK [docker : Add user to docker group] ***************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Install python3-docker package] *********************************************** +ok: [devops-lab4-vm] + +PLAY RECAP *********************************************************************************** +devops-lab4-vm : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Terminal output from SECOND provision.yml run + +``` +PLAY [Provision web servers] ***************************************************************** + +TASK [Gathering Facts] *********************************************************************** +ok: [devops-lab4-vm] + +TASK [common : Update apt cache] ************************************************************* +ok: [devops-lab4-vm] + +TASK [common : Install common packages] ****************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Add Docker GPG key] *********************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Add Docker repository] ******************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Update apt cache] ************************************************************* +ok: [devops-lab4-vm] + +TASK [docker : Install Docker packages] ****************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Check Docker service running] ************************************************* +ok: [devops-lab4-vm] + +TASK [docker : Add user to docker group] ***************************************************** +ok: [devops-lab4-vm] + +TASK [docker : Install python3-docker package] *********************************************** +ok: [devops-lab4-vm] + +PLAY RECAP *********************************************************************************** +devops-lab4-vm : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Analysis: What changed first time? What didn't change second time? + +I had lost the original `provision.yml` run, so this is a rerun, so the output shows `ok` for every task and the recap reported `changed=0`. This already shows that actions are idempotent. + +When the same playbook was run a second time nothing was changed. Every task again shows `ok` and the shows `changed=0`. The packages, repository entries and user group membership were already in the required state, so Ansible skipped operations that are already done. + +### Explanation: What makes your roles idempotent? + +Idempotency is achieved by using module parameters: `state: present`, `update_cache: yes`. so that each task describes the required end-state rather than performing an unconditional change. The modules themselves are written to check the current state before acting. In addition, the roles avoid running shell commands. + +## 4. Ansible Vault Usage + +### How you store credentials securely + +The credentials are stored in the `inventory/group_vars/all.yml` file (the lab tasks suggest storing it in `group_vars/all.yml`, outside of `inventory/` but I had lots of issues with this approach and switched to storing the group vars inside the `inventory/` directory). + +### Vault password management strategy + +Password is loaded using the `--ask-vault-password` flag of the `ansible-playbook` command. + +### Example of encrypted file (show it's encrypted!) + +The file starts with AES256 and contains a string of numbers that represent encrypted file contents. + +``` +$ANSIBLE_VAULT;1.1;AES256 +303733626265626138346437633... +``` + +### Why Ansible Vault is important + +Ansible vault allows to distribute all the secrets needed for the service in the version control in a secure encrypted form. + +## 5. Deployment Verification + +### Terminal output from deploy.yml run + +``` +PLAY [Deploy application] ******************************************************************** + +TASK [Gathering Facts] *********************************************************************** +ok: [devops-lab4-vm] + +TASK [app_deploy : Docker Login] ************************************************************* +ok: [devops-lab4-vm] + +TASK [app_deploy : Pull Docker image] ******************************************************** +ok: [devops-lab4-vm] + +TASK [app_deploy : Stop container] *********************************************************** +changed: [devops-lab4-vm] + +TASK [app_deploy : Remove container] ********************************************************* +changed: [devops-lab4-vm] + +TASK [app_deploy : Run container] ************************************************************ +changed: [devops-lab4-vm] + +TASK [app_deploy : Wait for container] ******************************************************* +ok: [devops-lab4-vm] + +TASK [app_deploy : Check health] ************************************************************* +ok: [devops-lab4-vm] + +PLAY RECAP *********************************************************************************** +devops-lab4-vm : ok=8 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +### Container status + +Running `docker ps` in the vm: + +``` +cirno@devops-lab4-vm:~$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +bb033d424504 iucapstonead/devops-info-service:latest "python app.py" 6 minutes ago Up 6 minutes 0.0.0.0:5000->5000/tcp devops-app +``` + +### Health check of the application running inside the vm + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course/ansible$ curl 192.168.122.243:5000/health +{"status":"healthy","timestamp":"2026-02-26T17:10:11.970856+00:00","uptime_seconds":475} +``` + +## 6. Key Decisions + +### Why use roles instead of plain playbooks? + +Playbooks are hard to maintain and modify. Roles can be easily reused and allow to share configuration across project. + +### How do roles improve reusability? + +Each role is self-contained, therefore they can be copied into a different repository, without having to extract the relevant tasks from a monolithic file. Parameterizing the defaults means the same role can configure multiple hosts in slightly different ways. + +### What makes a task idempotent? + +A task is idempotent when running it once has the same effect as running it multiple times. The second and subsequent runs detect that the resource already exists and do nothing. In Ansible this is done using arguments `state: present`, `enabled: yes`. And not using direct shell commands. The modules check the system before making changes and report `changed` only if there was a change. + +### How do handlers improve efficiency? + +Handlers are triggered only when a task reports `changed`, which allows to ensure that actions run exactly once even if multiple tasks signal a change. + +### Why is Ansible Vault necessary? + +Credentials such as Docker Hub passwords or API keys must not be stored in plaintext in version control. Vault encrypts these variables so the repository can be shared or pushed to Git without leaking secrets. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..d8086bff14 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1 @@ +to be added \ No newline at end of file diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000000..6fb2409d9c --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,14 @@ +$ANSIBLE_VAULT;1.1;AES256 +30373362626562613834643763303635663064353739373864363232653130623262396363386639 +6635373664633530323839646163303736623736633333370a306662376365636566313662363139 +33656365656631633731393936356230636433663066656137386634623236633939653764316238 +3339313663666265370a306664643831303330656636323935343035396134336562323662663232 +64336635633662616663383034333062383533373866343633393239343561313138653738653833 +61633633633039383135333031613230366566663633636137336564343261386636323836313937 +35346435396433653833626238656465623736356533346632353765613664343561363361636362 +62636635393233393066323036663735333334313537643131616534616462303566313664353135 +65316132663666623531326539396466323463343035613039613166633964636235333830643335 +61313466393166323431313964306338366337323764616434633732656434356265356537646533 +33326461306266623266633535626364303235343539363164613535333466396566303164316134 +64303161613737336161656236313836303138303466623038613863333332616261613937313538 +3565 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..3d38776d60 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[webservers] +localhost ansible_connection=local 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..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/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..3428caceaa --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,4 @@ +--- +common_packages: + - python3-pip + - curl diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..a70c282d5e --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,33 @@ +--- +# Package installation block with error handling +- name: Install common packages + become: true + tags: + - packages + - common + 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 + + rescue: + - name: Fix apt cache and retry + ansible.builtin.apt: + update_cache: true + cache_valid_time: 0 + + - name: Retry package installation + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + always: + - name: Log package installation completion + ansible.builtin.debug: + msg: "Package installation completed for common role" diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..091ba24ed6 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,4 @@ +--- +docker_user: cirno +docker_group: docker +python3_docker_package: python3-docker diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..07aa0eb290 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..691f3dbfcc --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,83 @@ +--- +# Docker installation block with error handling and retry logic +- name: Install Docker + become: true + tags: + - docker_install + - docker + block: + - 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 {{ ansible_distribution_release }} stable" + state: present + + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + + - name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose + state: present + + rescue: + - name: Wait and retry on GPG key failure + ansible.builtin.wait_for: + timeout: 10 + + - name: Retry Docker repository add + ansible.builtin.apt_repository: + repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + state: present + + - name: Update apt cache retry + ansible.builtin.apt: + update_cache: true + + - name: Retry Docker package installation + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose + state: present + +# Docker configuration block +- name: Configure Docker + become: true + tags: + - docker_config + - docker + block: + - name: Check Docker service running + ansible.builtin.service: + name: docker + state: started + enabled: true + + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: "{{ docker_group }}" + append: true + + - name: Install python3-docker package + ansible.builtin.apt: + name: "{{ python3_docker_package }}" + state: present + + always: + - name: Ensure Docker service is enabled + ansible.builtin.service: + name: docker + enabled: true diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..47c639cbc0 --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,16 @@ +--- +# Application Configuration +app_name: devops-app +docker_image: devops-info-service +docker_tag: latest +app_port: 8000 +app_internal_port: 8000 +app_container_name: devops-app +container_restart_policy: unless-stopped + +# Docker Compose Configuration +compose_project_dir: "/opt/{{ app_name }}" +docker_compose_version: "3.8" + +# Wipe Logic Control +web_app_wipe: false diff --git a/ansible/roles/web_app/handlers/main.yml b/ansible/roles/web_app/handlers/main.yml new file mode 100644 index 0000000000..61a98f002e --- /dev/null +++ b/ansible/roles/web_app/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/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..06ea4c197b --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,5 @@ +--- +# Role dependencies +# docker role must run before web_app to ensure Docker and Docker Compose are installed +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..e9d8fb79f2 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,58 @@ +--- +# Include wipe tasks (runs first if web_app_wipe=true) +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +# Deploy application with Docker Compose +- name: Deploy application with Docker Compose + tags: + - app_deploy + - compose + block: + - name: Create application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + mode: '0755' + + - name: Template docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: '0644' + + - name: Deploy with docker-compose + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + pull: always + state: present + register: compose_result + + - name: Wait for application to start + ansible.builtin.wait_for: + host: localhost + port: "{{ app_port }}" + delay: 2 + timeout: 30 + + - name: Check application health + ansible.builtin.uri: + url: "http://localhost:{{ app_port }}/health" + method: GET + status_code: 200 + register: health_check + until: health_check.status == 200 + retries: 5 + delay: 2 + + rescue: + - name: Handle deployment failure + ansible.builtin.debug: + msg: "Deployment of {{ app_name }} failed" + + always: + - name: Log deployment completion + ansible.builtin.debug: + msg: "Application deployment task completed for {{ app_name }}" diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..553e96e146 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,27 @@ +--- +# Wipe tasks for web_app deployment cleanup +# Only runs when web_app_wipe=true AND web_app_wipe tag is specified + +- name: Wipe web application + when: web_app_wipe | bool + tags: + - web_app_wipe + block: + - name: Stop and remove Docker Compose containers + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + + - name: Remove docker-compose file + ansible.builtin.file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ 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..7da7dd1845 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,25 @@ +# Docker Compose configuration for {{ app_name }} +# Generated by Ansible +version: '{{ docker_compose_version }}' + +services: + {{ app_name }}: + image: "{{ docker_image }}:{{ docker_tag }}" + container_name: {{ app_container_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + restart: {{ container_restart_policy }} + networks: + - bridge + environment: + - ENVIRONMENT=production + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:{{ app_internal_port }}/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + bridge: + driver: bridge diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..2de2a67161 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,9 @@ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +.github/ +tests/ +docs/ +*.md diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..77ee791960 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# pytest +.pytest_cache/ +.coverage +coverage.xml \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..afe28c32d0 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app + +USER app + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..8c5711b7e1 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,82 @@ +# DevOps Info Service + +![Python CI](https://github.com/iu-capstone-ad/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) + +## Overview + +Python Flask service with endpoints for checking system information and health. + +## Prerequisites + +Python version 3.12 or higher, Flask 3.1.0. + +Project has been tested with python 3.12 and Flask 3.1.0 on Ubuntu 24.04 + +## Installation + +```bash +# clone repo +git clone https://github.com/iu-capstone-ad/DevOps-Core-Course +# cd into the app directory +cd app_python +# create and activate a new venv +python3 -m venv venv +source venv/bin/activate +# install dependencies from requirements.txt +pip install -r requirements.txt +``` + +## Running tests + +Install dev requirements and run pytest: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +pytest -q +``` + +## Running the Application + +```bash +python app.py +# or with custom config +PORT=8080 python app.py +``` + +## API Endpoints + +- `GET /` - Show system information. +- `GET /health` - Show health information (service uptime). + +## Configuration + +Environment Variables table + +| Variable | Default | Description | +|----------|-----------|--------------------------------------| +| `HOST` | `0.0.0.0` | Address for the service to listen on | +| `PORT` | `5000` | Port for the service to listen on | +| `DEBUG` | `False` | Enable Flask debug mode | + +## Docker + +### Building the image locally + +```bash +cd app_python +docker build -t iucapstonead/devops-info-service:lab02 . +``` + +### Running the container + +```bash +docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +``` + +### Pulling and running from docker hub + +```bash +docker pull iucapstonead/devops-info-service:lab02 +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..43e03ae3fb --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,146 @@ +import os +import socket +import platform +import logging +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(__name__) + +app = Flask(__name__) + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +START_TIME = datetime.now() + + +def get_uptime(): + delta = datetime.now() - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return {"seconds": seconds, "human": f"{hours} hours, {minutes} minutes"} + + +def get_platform_version(): + system = platform.system() + if system == "Linux": + return platform.freedesktop_os_release()["PRETTY_NAME"] + elif system == "Darwin": + return str(platform.mac_ver()[0]) + elif system == "Windows": + return platform.version() + return platform.release() + + +def get_system_info(): + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": get_platform_version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +def get_service_info(): + return { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + } + + +def get_runtime_info(): + uptime = get_uptime() + return { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + } + + +def get_request_info(): + return { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent", "unknown"), + "method": request.method, + "path": request.path, + } + + +def get_endpoints(): + return [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ] + + +@app.route("/") +def index(): + logger.info( + f"Request: {request.method} {request.path} from {request.remote_addr}" + ) + + return jsonify( + { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": get_endpoints(), + } + ) + + +@app.route("/health") +def health(): + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_uptime()["seconds"], + } + ) + + +@app.errorhandler(404) +def not_found(error): + return ( + jsonify( + { + "error": "Not Found", + "message": "Endpoint does not exist", + } + ), + 404, + ) + + +@app.errorhandler(500) +def internal_error(error): + logger.error(f"Internal server error: {error}") + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("Starting...") + 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..b1b07c526f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,158 @@ +# Lab 1 — Implementation Report + +## Framework Selection + +I chose Flask for this project because I am most familiar with it. + +Flask can be compared to another framework "FastAPI". It is similar to Flask in that both are minimal frameworks compared to another framework "Django". But FastAPI has more tools built-in compared to Flask, for example built-in async/await support, built-in API docs. + +| | Flask | FastAPI | Django | +|-|-------|---------|--------| +| Size | Minimal | Minimal (but more than Flask) | Many | +| Builtin async support | No | Yes | No | +| Builtin automatic API documentation | No | Yes | No | +| Release year | 2010 | 2018 | 2005 | + +## Best Practices Applied + +### Single responsibility principle, clear function names + +Single responsibility principle with clear functions names makes functions have only one purpose, that is clear from its name. + +```python +@app.route("/") +def index(): + logger.info( + f"Request: {request.method} {request.path} from {request.remote_addr}" + ) + + return jsonify( + { + "service": get_service_info(), + "system": get_system_info(), + "runtime": get_runtime_info(), + "request": get_request_info(), + "endpoints": get_endpoints(), + } + ) +``` + +This allows easy unit testing of every function, and allows for more code reuse. + +### No hardcoded values for deployment settings + +No hardcoded values for deployment settings. + +```bash +PORT=8080 HOST=0.0.0.0 DEBUG=false python app.py +``` + +This allows to change deployment settings without having to change the code and greatly simplifies deployment. + +### Pinned dependencies + +`requirements.txt` uses exact versions of packages instead of just package names. + +``` +Flask==3.1.0 +``` + +This helps to avoid errors related to different versions of packages. + +## API Documentation + +### Main endpoint + +```bash +curl -s http://localhost:5000/ | jq . +``` + +```json +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.5.0" + }, + "runtime": { + "current_time": "2026-01-28T19:17:00.960826+00:00", + "timezone": "UTC", + "uptime_human": "0 hours, 0 minutes", + "uptime_seconds": 5 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "t14-devops", + "platform": "Linux", + "platform_version": "Ubuntu 24.04.3 LTS", + "python_version": "3.12.3" + } +} +``` + +### Health endpoint + +```bash +curl -s http://localhost:5000/health | jq +``` + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T19:19:24.302719+00:00", + "uptime_seconds": 148 +} +``` + +### Non existent endpoint + +```bash +curl -s http://localhost:5000/doesnotexist | jq +``` + +```json +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +## Testing Evidence + +- ![Main endpoint](screenshots/01-main-endpoint.png) +- ![Health endpoint](screenshots/02-health-check.png) +- ![Formatted output](screenshots/03-formatted-output.png) + +## Challenges & Solutions + +### Python3 venv was not preinstalled on my system + +Python 3 venv did not come preinstalled on Ubuntu 24.04 with zfsbootmenu, so I had to install it with the following command. + +```bash +sudo apt install python3.12-venv +``` + +## GitHub Community + +Starring repositories helps attract more users who might find the project helpful or attract potential project contributors. Also starring the repository makes it more likely that the project will be added to package repositories. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..9aa22b4f19 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,134 @@ +# Lab 2 - Docker Containerization + +## Docker Best Practices Applied + +### Non-root user + +``` +RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app +``` + +Created a separate non-root user "app" in the image and switch to this user with `USER app`. This reduces the damage that can be done if the application inside the container is compromised. + +### Layer caching + +`requirements.txt` is copied and installed before copying the application code so dependency changes and app code changes create different layers and builds reuse the dependency layer when possible. + +### Only copy necessary files & Use Dockerignore + +Only `requirements.txt` and `app.py` are copied. `.dockerignore` lists unnecessary files. This reduces the image size. + +### Specific base image + +``` +FROM python:3.12-slim +``` + +The image uses `python:3.12-slim` to get a maintained python3.12 image. Using a specific major python release in the base image helps with predictability. + +## Image Information & Decisions + +I chose `python:3.12-slim` as a base image since I have previously tested the application with python 3.12 . I chose the slim version to have a smalled build size. + +Final image size is 132MB which is only 13MB over the base `python:3.12-slim` image, that is 119MB. + +The image layer structure consists of first installing the dependencies, then copying app code. This allows to reuse the dependencies, and not reinstall dependencies on every code change. + +For optimization I have used `--no-cache-dir` in the dependency installation command `pip install --no-cache-dir -r requirements.txt`. In order to not cache the dependencies and to make the resulting build smaller. + +## Build & Run Process + +### Build image + +```bash +cd app_python +docker build -t iucapstonead/devops-info-service:lab02 . +``` + +Terminal output: + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course$ cd app_python +cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ docker build -t iucapstonead/devops-info-service:lab02 . +[+] Building 29.9s (11/11) FINISHED docker:default + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 286B 0.0s + => [internal] load metadata for docker.io/library/python:3.12-slim 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 113B 0.0s + => CACHED [1/6] FROM docker.io/library/python:3.12-slim 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => [2/6] WORKDIR /app 3.7s + => [3/6] COPY requirements.txt . 0.0s + => [4/6] RUN pip install --no-cache-dir -r requirements.txt 5.1s + => [5/6] COPY app.py . 5.1s + => [6/6] RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app 15.9s + => exporting to image 0.1s + => => exporting layers 0.1s + => => writing image sha256:efac5b8d6f81148843d1b713144694aeca62ba8d6cef554d297c4410a64b6b12 0.0s + => => naming to docker.io/iucapstonead/devops-info-service:lab02 +``` + +### Running image + +```bash +docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +``` + +Terminal Output + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ docker run -p 5000:5000 iucapstonead/devops-info-service:lab02 +2026-02-04 20:24:09,443 - __main__ - INFO - Starting... + * Serving Flask app 'app' + * Debug mode: off +2026-02-04 20:24:09,446 - 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:5000 + * Running on http://172.17.0.2:5000 +2026-02-04 20:24:09,446 - werkzeug - INFO - Press CTRL+C to quit +2026-02-04 20:24:33,523 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 20:24:33,525 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 20:24:33] "GET / HTTP/1.1" 200 - +2026-02-04 20:24:42,137 - __main__ - INFO - Request: GET / from 172.17.0.1 +2026-02-04 20:24:42,138 - werkzeug - INFO - 172.17.0.1 - - [04/Feb/2026 20:24:42] "GET / HTTP/1.1" 200 - +``` + +Curl testing the main endpoint + +```bash +curl localhost:5000 +``` + +Curl output + +``` +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"172.17.0.1","method":"GET","path":"/","user_agent":"curl/8.5.0"},"runtime":{"current_time":"2026-02-04T20:24:42.137455+00:00","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":32},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":8,"hostname":"bdbdc916a90f","platform":"Linux","platform_version":"Debian GNU/Linux 13 (trixie)","python_version":"3.12.12"}} +``` + +### Docker Hub repository URL + +https://hub.docker.com/r/iucapstonead/devops-info-service + + +## Technical Analysis + +### Why Dockerfile order works + +Installing dependencies first produces a layer that is reused when only application code changes. Copying only requirements and installing prevents copying source files that would invalidate the cache. + +### If order changed + +If order was changed to be copy all application source code and the perform pip install, any change to the source code would invalidate the dependencies cache and require running pip install on every build, slowing down build times. + +### Security Considerations + +Running application as a dedicated non-root app user reduces the attack surface. Using a minimal base image reduces the attack surface. + +### Dockerfile benefits + +Excluding venv .git and other files reduces the amount of data sent to the Docker daemon, speeding up build times. + +## Challenges + +No challenges were faced during this lab. Everything was done without any issues. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..a612866d9c --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,93 @@ +# Lab 03 - Continuous Integration (CI/CD) + +## Overview + +Testing and CI are added to the Python service. The pipeline runs linting tests, unit tests, coverage tests, Snyk scan, and builds/pushes Docker images using a SemVer strategy. + +### Testing + +- Text framework - `pytest`: lightweight, fixture support and good plugin ecosystem. +- Tests are in `app_python/tests/` directory: covering `GET /`, `GET /health` endpoints and a 404 case. + +Running tests locally: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +pytest -q +``` + +### CI Trigger + +Workflow: `.github/workflows/python-ci.yml` +Runs on `push` to `main`/`master`, on `pull_request`, and on `push` of tags matching `v*`. + +### Versioning Strategy + +Chosen: Semantic Versioning. A tag `v1.2.3` will create a Docker image tagged as `1.2.3`, `1.2`, and `latest`. For non-tagged commits it uses development tag `0.0.0-dev-`. + +## Workflow Evidence + +### Workflow file + +`.github/workflows/python-ci.yml` + +### Local tests: + +``` +(venv) cirno@t14-devops:~/Documents/DevOps-Core-Course/app_python$ pytest -q +... [100%] +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.12.3-final-0 ________________ + +Name Stmts Miss Cover +---------------------------- +app.py 56 9 84% +---------------------------- +TOTAL 56 9 84% +Coverage XML written to file coverage.xml +3 passed in 0.28s +``` + +# Docker image + +https://hub.docker.com/r/iucapstonead/devops-info-service + +The image that was built and pushed by the CI/CD is `v0.3.1`. + +# Status badge + +Added to `app_python/README.md`. + +## Best Practices Implemented + +### Dependency caching + +`actions/cache` caches pip packages based on `requirements.txt` hash. This helps to reduce install time. + +### Fail-fast pipeline + +Docker build/push runs only after lint and tests succeed. + +### Security scanning + +Snyk is used in CI to check Python dependencies for known vulnerabilities. + +## Key Decisions + +### Versioning Strategy + +Semantic versioning was chosen because it allows to version with major and minor releases. Making the use of images easier. + +### Docker Tags + +For tagged releases CI creates `username/devops-info-service:1.2.3`, `username/devops-info-service:1.2`, and `username/devops-info-service:latest`. + +### Workflow Triggers + +`push` to `main`/`master`, PRs and tag pushes. + +### Test Coverage + +Unit tests cover main endpoints and a 404 path; coverage is reported via `pytest-cov` and an XML report is produced. diff --git a/app_python/docs/LAB04.md b/app_python/docs/LAB04.md new file mode 100644 index 0000000000..8bc3637d4b --- /dev/null +++ b/app_python/docs/LAB04.md @@ -0,0 +1,235 @@ +# Lab 4 - Infrastructure as Code + +I used a local VM instead of using a cloud provider. + +Instead of VirtualBox or VMWare I chose QEMU KVM with libvirt, because I am most familiar with it. + +## VM Setup + +First install all packages on the host system. + +```bash +sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virtinst +``` + +After installing, create a system image for the guest system + +```bash +sudo qemu-img create -f qcow2 /var/lib/libvirt/images/devops-lab4-vm.qcow2 10G +``` + +Output + +``` +Formatting '/var/lib/libvirt/images/devops-lab4-vm.qcow2', fmt=qcow2 cluster_size=65536 extended_l2=off compression_type=zlib size=10737418240 lazy_refcounts=off refcount_bits=16 +``` + +Check the name of the bridge interface on the host system + +```bash +ip a +``` + +Output + +``` +... +7: virbr0: mtu 1500 qdisc noqueue state DOWN group default qlen 1000 + link/ether 52:54:00:f2:98:90 brd ff:ff:ff:ff:ff:ff + inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0 + valid_lft forever preferred_lft forever +``` + +Install vm + +```bash +sudo virt-install --name devops-lab4-vm --memory 2048 --vcpus 1 --disk path=/var/lib/libvirt/images/devops-lab4-vm.qcow2,bus=virtio --network bridge=virbr0,model=virtio --os-variant ubuntu24.04 --location /var/lib/libvirt/images/ubuntu-24.04.4-live-server-amd64.iso,kernel=casper/vmlinuz,initrd=casper/initrd --graphics none --console pty,target_type=serial --extra-args 'console=ttyS0,115200n8 serial' +``` + +Parameters explanation: + +- `--name` created vm name +- `--memory` created vm ram size +- `--vcups` number of virtual cpu cores vm has access to +- `--disk` vm disk configuration + - `path=...` path to the disk image + - `bus=virtio` virtual device type used by the vm to communicate with the host +- `--network` network configuration + - `bridge=virbr0` name of the vm bridge network interface on the host we got in the previous step + - `model=virtio` virtual device type used by the vm to communicate with the host +- `--os-variant` guest os type for improved guest system compatibility +- `--cdrom` cdrom device. In this case it is the ubuntu server 24.04 install image. +- `--graphics` graphics devices. In this case it is none since we will communicate with the guest tty using serial, and then using ssh. +- `--console` set parameters for viewing the console +- `--extra-args` extra arguments in order to view the guest system tty in serial. + +After waiting for the guest to turn on, we see the following window + +``` +================================================================================ + Serial [ Help ] +================================================================================ + + As the installer is running on a serial console, it has started in basic + mode, using only the ASCII character set and black and white colours. + + If you are connecting from a terminal emulator such as gnome-terminal that + supports unicode and rich colours you can switch to "rich mode" which uses + unicode, colours and supports many languages. + + You can also connect to the installer over the network via SSH, which will + allow use of rich mode. + + + + + + + + [ Continue in rich mode > ] + [ Continue in basic mode > ] + [ View SSH instructions ] + +``` + +Install the guest vm by selecting the defaults. After some steps, on the SSH configuration screen enable Install OpenSSH server. Do not press Import SSH key as it requires to use GitHub or launchpad. The "Allow password authentication over SSH" option can not be toggled off. The keys will be imported after install. + +``` +================================================================================ + SSH configuration [ Help ] +================================================================================ + You can choose to install the OpenSSH server package to enable secure remote + access to your server. + + [X] Install OpenSSH server + + + [X] Allow password authentication over SSH + + + [ Import SSH key > ] + + AUTHORIZED KEYS + + No authorized key + + + + + [ Done ] + [ Back ] + +``` + +Reboot after the installation is complete. Add the public ssh key generated on the host to the authorized keys. + +``` +cirno@devops-lab4-vm:~$ echo 'public key' > ~/.ssh/authorized_keys +cirno@devops-lab4-vm:~$ chmod 700 ~/.ssh/ +cirno@devops-lab4-vm:~$ chmod 600 ~/.ssh/authorized_keys +cirno@devops-lab4-vm:~$ +``` + +Since you are not allowed to disable password authentication on the install screen. We now need to edit the sshd configuration file. The installed ubuntu server does not come with any visual editors, so we need to install vim and edit the sshd configuration file. + +``` +cirno@devops-lab4-vm:~$ sudo apt install vim +cirno@devops-lab4-vm:~$ vim /etc/ssh/sshd_config +``` + +Uncomment the PasswordAuthentication line and change it to no to disable password authentication. + +``` + +# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts +#HostbasedAuthentication no +# Change to yes if you don't trust ~/.ssh/known_hosts for +# HostbasedAuthentication +#IgnoreUserKnownHosts no +# Don't read the user's ~/.rhosts and ~/.shosts files +#IgnoreRhosts yes + +# To disable tunneled clear text passwords, change to no here! +PasswordAuthentication no +#PermitEmptyPasswords no + +# Change to yes to enable challenge-response passwords (beware issues with +# some PAM modules and threads) +KbdInteractiveAuthentication no + +# Kerberos options +#KerberosAuthentication no +#KerberosOrLocalPasswd yes +#KerberosTicketCleanup yes +#KerberosGetAFSToken no + + 68,0-1 50% +``` + +Save the configuration file and restart the sshd service to reload the configuration. + +``` +cirno@devops-lab4-vm:~$ sudo systemctl restart ssh.service +cirno@devops-lab4-vm:~$ +``` + +View the ip address of the vm. + +``` +cirno@devops-lab4-vm:~$ 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: enp1s0: mtu 1500 qdisc fq_codel state UP group default qlen 1000 + link/ether 52:54:00:c3:80:49 brd ff:ff:ff:ff:ff:ff + inet 192.168.122.243/24 metric 100 brd 192.168.122.255 scope global dynamic enp1s0 + valid_lft 3169sec preferred_lft 3169sec + inet6 fe80::5054:ff:fec3:8049/64 scope link + valid_lft forever preferred_lft forever +cirno@devops-lab4-vm:~$ +``` + +SSH into the vm from the host system. + +``` +cirno@t14-devops:~/Documents/DevOps-Core-Course$ ssh cirno@192.168.122.243 +The authenticity of host '192.168.122.243 (192.168.122.243)' can't be established. +ED25519 key fingerprint is SHA256:fingerprint. +This key is not known by any other names. +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '192.168.122.243' (ED25519) to the list of known hosts. +Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-100-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + +This system has been minimized by removing packages and content that are +not required on a system that users do not log into. + +To restore this content, you can run the 'unminimize' command. +cirno@devops-lab4-vm:~$ free -h + total used free shared buff/cache available +Mem: 1.9Gi 292Mi 1.4Gi 800Ki 400Mi 1.6Gi +Swap: 1.5Gi 0B 1.5Gi +cirno@devops-lab4-vm:~$ cat /etc/os-release +PRETTY_NAME="Ubuntu 24.04.4 LTS" +NAME="Ubuntu" +VERSION_ID="24.04" +VERSION="24.04.4 LTS (Noble Numbat)" +VERSION_CODENAME=noble +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +UBUNTU_CODENAME=noble +LOGO=ubuntu-logo +cirno@devops-lab4-vm:~$ +``` + +Everything works. The guest system has ssh server configured with authorization only through ssh keys. The host system is able to ssh into the guest system using the ssh key. diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..9366579ebe Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..72b92a1a23 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..1e62bcba52 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/pytest.ini b/app_python/pytest.ini new file mode 100644 index 0000000000..59393d91fd --- /dev/null +++ b/app_python/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --cov=app --cov-report=term --cov-report=xml +testpaths = tests diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..172f1bb886 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==9.0.2 +pytest-cov==7.0.0 +flake8==7.3.0 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..22ac75b399 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 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..e60ab46734 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,53 @@ +import pytest + +from app import app as flask_app + + +@pytest.fixture +def client(): + flask_app.config.update(TESTING=True) + with flask_app.test_client() as client: + yield client + + +def test_index_structure(client): + resp = client.get("/") + assert resp.status_code == 200 + data = resp.get_json() + # top-level keys + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + # service fields + svc = data["service"] + assert isinstance(svc.get("name"), str) + assert isinstance(svc.get("version"), str) + + # system fields + sys = data["system"] + assert "hostname" in sys + assert "platform" in sys + + # runtime fields + rt = data["runtime"] + assert "uptime_seconds" in rt + assert "current_time" in rt + + +def test_health_endpoint(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("status") == "healthy" + assert "timestamp" in data + assert isinstance(data.get("uptime_seconds"), int) + + +def test_404_error(client): + resp = client.get("/does/not/exist") + assert resp.status_code == 404 + data = resp.get_json() + assert data.get("error") == "Not Found"