diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..832d744858 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +max-complexity = 10 +exclude = .*,*/__pycache__ diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..b0432547d8 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,107 @@ +name: ansible-deploy + +on: + push: + branches: + - main + - master + paths: + - "ansible/**" + - "!ansible/docs/**" + - ".github/workflows/ansible-deploy.yml" + pull_request: + branches: + - main + - master + paths: + - "ansible/**" + - "!ansible/docs/**" + - ".github/workflows/ansible-deploy.yml" + +jobs: + lint: + name: ansible-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible toolchain + run: | + python -m pip install --upgrade pip + pip install ansible-core ansible-lint + ansible-galaxy collection install -r ansible/requirements.yml + + - name: Run ansible-lint + run: | + cd ansible + ansible-lint playbooks/*.yml roles + + deploy: + name: deploy + if: github.event_name == 'push' + needs: lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible and dependencies + run: | + python -m pip install --upgrade pip + pip install ansible-core + ansible-galaxy collection install -r ansible/requirements.yml + + - name: Configure SSH key + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + VM_HOST: ${{ secrets.VM_HOST }} + run: | + mkdir -p ~/.ssh + printf "%s\n" "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$VM_HOST" >> ~/.ssh/known_hosts + + - name: Prepare inventory and vault password + env: + VM_HOST: ${{ secrets.VM_HOST }} + VM_USER: ${{ secrets.VM_USER }} + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + cat > ansible/inventory/ci_hosts.ini < /tmp/vault_pass + chmod 600 /tmp/vault_pass + + - name: Deploy with Ansible + env: + ANSIBLE_LOCAL_TEMP: /tmp/ansible-local-tmp + ANSIBLE_REMOTE_TEMP: /tmp/ansible-local-tmp + ANSIBLE_HOST_KEY_CHECKING: "false" + run: | + mkdir -p /tmp/ansible-local-tmp + cd ansible + ansible-playbook playbooks/deploy.yml \ + -i inventory/ci_hosts.ini \ + --vault-password-file /tmp/vault_pass + + - name: Verify deployment + env: + VM_HOST: ${{ secrets.VM_HOST }} + APP_PORT: ${{ secrets.APP_PORT || '5000' }} + run: | + sleep 10 + curl --fail "http://$VM_HOST:$APP_PORT/" + curl --fail "http://$VM_HOST:$APP_PORT/health" diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..ded1b7aea4 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,34 @@ +name: python-ci +on: + push: + paths: + - app_python/** + - .github/workflows/python-ci.yml + +jobs: + lint: + permissions: write-all + strategy: + fail-fast: false + matrix: + python-version: [3.14] + poetry-version: [2.3.2] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - uses: snok/install-poetry@v1 + with: + version: ${{ matrix.poetry-version }} + - name: Install Dependencies + run: poetry install --no-root + - name: run flake8 + run: poetry run flake8 src tests + - name: run pytest + run: poetry run pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30d74d2584..a8e781524a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,29 @@ -test \ No newline at end of file +test + +# Terraform state and local cache +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl + +# Terraform variable files (often contain secrets) +terraform.tfvars +*.tfvars +*.tfvars.json + +# Pulumi secrets/state +pulumi/venv/ +pulumi/.venv/ +Pulumi.*.yaml + +# Cloud credentials and keys +*.pem +*.key +*.json +credentials + +# Ansible +*.retry +.vault_pass +ansible/inventory/*.pyc +__pycache__/ diff --git a/.vault_pass b/.vault_pass new file mode 100644 index 0000000000..293d405bf8 --- /dev/null +++ b/.vault_pass @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +printf '%s\n' 'lab05-local-pass' diff --git a/README.md b/README.md index 371d51f456..672277c8fe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # DevOps Engineering: Core Practices +[![Ansible Deploy](https://github.com/nonamecorn/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/nonamecorn/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) [![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) @@ -38,8 +39,8 @@ Master **production-grade DevOps practices** through hands-on labs. Build, conta | 15 | 15 | StatefulSets | Persistent Storage, Headless Services | | 16 | 16 | Cluster Monitoring | Kube-Prometheus, Init Containers | | β€” | **Exam Alternative Labs** | | | -| 17 | 17 | Edge Deployment | Fly.io, Global Distribution | -| 18 | 18 | Decentralized Storage | 4EVERLAND, IPFS, Web3 | +| 17 | 17 | Edge Deployment | Cloudflare Workers, Global Edge | +| 18 | 18 | Reproducible Builds | Nix, Deterministic Builds, Flakes | --- @@ -60,8 +61,8 @@ Don't want to take the exam? Complete **both** bonus labs: | Lab | Topic | Points | |-----|-------|--------| -| **Lab 17** | Fly.io Edge Deployment | 20 pts | -| **Lab 18** | 4EVERLAND & IPFS | 20 pts | +| **Lab 17** | Cloudflare Workers Edge Deployment | 20 pts | +| **Lab 18** | Reproducible Builds with Nix | 20 pts | **Requirements:** - Complete both labs (17 + 18 = 40 pts, replaces exam) @@ -142,7 +143,7 @@ Each lab is worth **10 points** (main tasks) + **2.5 points** (bonus). - StatefulSets, Monitoring **Exam Alternative (Labs 17-18)** -- Fly.io, 4EVERLAND/IPFS +- Cloudflare Workers, Nix Reproducible Builds diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_compose_v2.cpython-314.pyc b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_compose_v2.cpython-314.pyc new file mode 100644 index 0000000000..9e3d371717 Binary files /dev/null and b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_compose_v2.cpython-314.pyc differ diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_container.cpython-314.pyc b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_container.cpython-314.pyc new file mode 100644 index 0000000000..2d438d55b5 Binary files /dev/null and b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_container.cpython-314.pyc differ diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_login.cpython-314.pyc b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_login.cpython-314.pyc new file mode 100644 index 0000000000..552ea7d9c1 Binary files /dev/null and b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/__pycache__/docker_login.cpython-314.pyc differ diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_compose_v2.py b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_compose_v2.py new file mode 100644 index 0000000000..6eacfa0897 --- /dev/null +++ b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_compose_v2.py @@ -0,0 +1,32 @@ +# This is a mocked Ansible module generated by ansible-lint +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = ''' +module: community.docker.docker_compose_v2 + +short_description: Mocked +version_added: "1.0.0" +description: Mocked + +author: + - ansible-lint (@nobody) +''' +EXAMPLES = '''mocked''' +RETURN = '''mocked''' + + +def main(): + result = dict( + changed=False, + original_message='', + message='') + + module = AnsibleModule( + argument_spec=dict(), + supports_check_mode=True, + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_container.py b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_container.py new file mode 100644 index 0000000000..72aea9ec0e --- /dev/null +++ b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_container.py @@ -0,0 +1,32 @@ +# This is a mocked Ansible module generated by ansible-lint +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = ''' +module: community.docker.docker_container + +short_description: Mocked +version_added: "1.0.0" +description: Mocked + +author: + - ansible-lint (@nobody) +''' +EXAMPLES = '''mocked''' +RETURN = '''mocked''' + + +def main(): + result = dict( + changed=False, + original_message='', + message='') + + module = AnsibleModule( + argument_spec=dict(), + supports_check_mode=True, + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_login.py b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_login.py new file mode 100644 index 0000000000..8700fd02b4 --- /dev/null +++ b/ansible/.ansible/collections/ansible_collections/community/docker/plugins/modules/docker_login.py @@ -0,0 +1,32 @@ +# This is a mocked Ansible module generated by ansible-lint +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = ''' +module: community.docker.docker_login + +short_description: Mocked +version_added: "1.0.0" +description: Mocked + +author: + - ansible-lint (@nobody) +''' +EXAMPLES = '''mocked''' +RETURN = '''mocked''' + + +def main(): + result = dict( + changed=False, + original_message='', + message='') + + module = AnsibleModule( + argument_spec=dict(), + supports_check_mode=True, + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..0ddcbf1672 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..496f7077fc --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,304 @@ +# LAB05 Report - Ansible Fundamentals + +## 1. Architecture Overview + +- Ansible version: `ansible-core 2.16.3` +- Target host mode: Local VM Alternative (WSL2 Ubuntu host) +- Target connection: SSH to `127.0.0.1` with inventory group `webservers` +- Role-based layout: + - `roles/common`: baseline OS packages + - `roles/docker`: Docker engine and runtime prerequisites + - `roles/app_deploy`: containerized app deployment and health check + +Why roles instead of one large playbook: +- Roles separate concerns and make task sets reusable. +- Defaults/tasks/handlers are easier to test and evolve independently. +- The playbooks stay short and orchestration-focused. + +## 2. Roles Documentation + +### common role + +- Purpose: + - Updates apt cache. + - Installs common packages (`python3-pip`, `curl`, `git`, `vim`, `htop`). +- Variables: + - `common_packages` in `roles/common/defaults/main.yml`. +- Handlers: + - None. +- Dependencies: + - None. + +### docker role + +- Purpose: + - Adds Docker apt key/repository. + - Installs Docker engine packages. + - Enables and starts Docker service. + - Adds selected user to `docker` group. + - Installs `python3-docker`. +- Variables: + - `docker_user` + - `docker_packages` +- Handlers: + - `restart docker` +- Dependencies: + - None (but commonly run after `common` role). + +### app_deploy role + +- Purpose: + - Validates required deployment credentials. + - Logs in to Docker Hub. + - Pulls application image. + - Recreates container with required port mapping and restart policy. + - Waits for service readiness and checks `/health`. +- Variables: + - `dockerhub_username` (vaulted) + - `dockerhub_password` (vaulted) + - `app_name` + - `docker_image` + - `docker_image_tag` + - `app_port` + - `app_container_name` + - `app_restart_policy` + - `app_env` + - `app_healthcheck_url` +- Handlers: + - `restart app container` +- Dependencies: + - Requires Docker to be installed/running. + +## 3. Idempotency Demonstration + +### First run: provision + +Command: + +```bash +cd ansible +export ANSIBLE_CONFIG=$PWD/ansible.cfg +ansible-playbook playbooks/provision.yml -K +``` + +Output (paste your terminal output): + +``` + +PLAY [Provision web servers] ***************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************** +ok: [wsl] + +TASK [common : Update apt cache] ************************************************************************************************************* +ok: [wsl] + +TASK [common : Install common packages] ****************************************************************************************************** +ok: [wsl] + +TASK [docker : Install Docker apt prerequisites] ********************************************************************************************* +ok: [wsl] + +TASK [docker : Ensure apt keyrings directory exists] ***************************************************************************************** +ok: [wsl] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************** +ok: [wsl] + +TASK [docker : Add Docker repository] ******************************************************************************************************** +ok: [wsl] + +TASK [docker : Install Docker packages] ****************************************************************************************************** +ok: [wsl] + +TASK [docker : Ensure Docker service is enabled and running] ********************************************************************************* +ok: [wsl] + +TASK [docker : Add user to docker group] ***************************************************************************************************** +ok: [wsl] + +TASK [docker : Install python Docker bindings] *********************************************************************************************** +ok: [wsl] + +PLAY RECAP *********************************************************************************************************************************** +wsl : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +### Second run: provision + +Command: + +```bash +ansible-playbook playbooks/provision.yml -K +``` + +Output (paste your terminal output): + +```PLAY [Provision web servers] ***************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************** +ok: [wsl] + +TASK [common : Update apt cache] ************************************************************************************************************* +ok: [wsl] + +TASK [common : Install common packages] ****************************************************************************************************** +ok: [wsl] + +TASK [docker : Install Docker apt prerequisites] ********************************************************************************************* +ok: [wsl] + +TASK [docker : Ensure apt keyrings directory exists] ***************************************************************************************** +ok: [wsl] + +TASK [docker : Add Docker GPG key] *********************************************************************************************************** +ok: [wsl] + +TASK [docker : Add Docker repository] ******************************************************************************************************** +ok: [wsl] + +TASK [docker : Install Docker packages] ****************************************************************************************************** +ok: [wsl] + +TASK [docker : Ensure Docker service is enabled and running] ********************************************************************************* +ok: [wsl] + +TASK [docker : Add user to docker group] ***************************************************************************************************** +ok: [wsl] + +TASK [docker : Install python Docker bindings] *********************************************************************************************** +ok: [wsl] + +PLAY RECAP *********************************************************************************************************************************** +wsl : ok=11 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + +Analysis: +- First run should show multiple `changed` tasks while converging the system. +- Second run should show mostly/all `ok` and ideally `changed=0`. +- This demonstrates idempotency: same desired state, no repeated unnecessary changes. + +## 4. Ansible Vault Usage + +- Sensitive credentials are stored in `ansible/group_vars/all.yml` and encrypted with Ansible Vault. +- Vault password file strategy for local lab: + - Use local `.vault_pass` file. + - Keep it out of Git via `.gitignore`. +- Why Vault is important: + - Prevents plain-text credential leaks in repository history. + - Allows safe sharing of infrastructure code without exposing secrets. + +Encrypted file evidence: + +```bash +head -n 5 ansible/group_vars/all.yml +``` + +Expected prefix: + +```text +$ANSIBLE_VAULT;1.1;AES256 +... +``` + +## 5. Deployment Verification + +Run deployment: + +```bash +cd ansible +export ANSIBLE_CONFIG=$PWD/ansible.cfg +ansible-playbook playbooks/deploy.yml -K --vault-password-file ../.vault_pass +``` + +Verify container and health: + +```bash +ansible webservers -a "docker ps" -K +curl http://127.0.0.1:5000/health +curl http://127.0.0.1:5000/ +``` + +Deployment output: + +```BECOME password: + +PLAY [Deploy application] ******************************************************************************************************************** + +TASK [Gathering Facts] *********************************************************************************************************************** +ok: [wsl] + +TASK [app_deploy : Validate required deployment variables] *********************************************************************************** +ok: [wsl] => { + "changed": false, + "msg": "All assertions passed" +} + +TASK [app_deploy : Log in to Docker Hub] ***************************************************************************************************** +ok: [wsl] + +TASK [app_deploy : Pull application image] *************************************************************************************************** +ok: [wsl] + +TASK [app_deploy : Remove old application container if present] ****************************************************************************** +changed: [wsl] + +TASK [app_deploy : Run application container] ************************************************************************************************ +changed: [wsl] + +TASK [app_deploy : Wait for application port] ************************************************************************************************ +ok: [wsl] + +TASK [app_deploy : Verify health endpoint] *************************************************************************************************** +ok: [wsl] + +PLAY RECAP *********************************************************************************************************************************** +wsl : ok=8 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +BECOME password: +wsl | CHANGED | rc=0 >> +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +5cebf003dc03 nonamecorn/myapp:latest "python app.py" 7 seconds ago Up 7 seconds 0.0.0.0:5000->5000/tcp myapp +``` + +Container status 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-03-05T15:02:11.615472.000Z","human":"0 hours, 3 minutes","seconds":202,"timezone":"UTC"},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","hostname":"5cebf003dc03","platform":"Linux","python_version":"3.14.3"}} +``` + +Health endpoint output: + +``` +{"status":"healthy","timestamp":"2026-03-05T14:58:55.407763+00:00","uptime_seconds":6} +``` + +## 6. Key Decisions + +Why use roles instead of plain playbooks: +- Roles isolate concerns and keep orchestration readable. +- They support reuse across environments and future labs. + +How roles improve reusability: +- Variables/defaults make behavior configurable without rewriting tasks. +- Roles can be shared or composed in multiple playbooks. + +What makes a task idempotent: +- Module-driven desired state (`state: present`, `state: started`) avoids repeated changes. +- Re-running converges to the same result. + +How handlers improve efficiency: +- Handlers run only when notified by a changed task. +- Services restart only when needed, reducing unnecessary disruptions. + +Why Ansible Vault is necessary: +- Credentials must not be committed in plain text. +- Vault keeps secrets encrypted while still usable in automation. + +## 7. Challenges + +- Running from `/mnt/c/...` causes Ansible to ignore local `ansible.cfg` unless `ANSIBLE_CONFIG` is explicitly set. +- SSH key setup was required for local `andre@127.0.0.1` login. +- Global `become=True` requires sudo password (`-K`) unless passwordless sudo is configured. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..a9b0a516ea --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,258 @@ +# LAB06 Report - Advanced Ansible and CI/CD + +## 1. Overview + +Lab 6 is completed with the following outcomes: + +- Refactored roles with `block`/`rescue`/`always` patterns. +- Added role and task tags for selective execution. +- Migrated app deployment from `docker_container` to Docker Compose template + module. +- Added `web_app` role dependency on `docker`. +- Implemented safe wipe flow using double-gating (`web_app_wipe` variable + `web_app_wipe` tag). +- Added GitHub Actions workflow for `ansible-lint` and deployment. +- Added workflow status badge to root `README.md`. + +Modified key files: + +- `ansible/roles/common/tasks/main.yml` +- `ansible/roles/docker/tasks/main.yml` +- `ansible/roles/web_app/tasks/main.yml` +- `ansible/roles/web_app/tasks/wipe.yml` +- `ansible/roles/web_app/templates/docker-compose.yml.j2` +- `ansible/roles/web_app/meta/main.yml` +- `ansible/playbooks/provision.yml` +- `ansible/playbooks/deploy.yml` +- `.github/workflows/ansible-deploy.yml` + +## 2. Blocks and Tags + +### common role + +- `packages` block: + - apt update and package installation are grouped in one block. + - `rescue` retries apt with `apt-get update --fix-missing`. + - `always` writes completion log to `/tmp/ansible-common-packages.log`. +- `users` block: + - user creation and group assignment are grouped. + - `always` writes completion log to `/tmp/ansible-common-users.log`. + +Tags used: + +- `common` at role/task level +- `packages` +- `users` + +### docker role + +- `docker_install` block: + - prerequisites, key/repo setup, package install, Python Docker bindings. + - `rescue` waits 10s, refreshes apt cache, retries GPG key download. + - `always` ensures Docker service is enabled and started. +- `docker_config` block: + - user group membership for Docker access. + +Tags used: + +- `docker` at role/task level +- `docker_install` +- `docker_config` + +### Tag listing evidence + +`ansible-playbook playbooks/provision.yml --list-tags`: + +```text +TASK TAGS: [common, docker, docker_config, docker_install, packages, users] +``` + +`ansible-playbook playbooks/deploy.yml --list-tags`: + +```text +TASK TAGS: [app_deploy, compose, docker, docker_config, docker_install, web_app, web_app_wipe] +``` + +## 3. Docker Compose Migration + +### Before + +- Role used: + - `community.docker.docker_container` + - direct container recreation flow + +### After + +- Role now uses Compose: + - creates `compose_project_dir` + - renders `docker-compose.yml` from Jinja template + - deploys using `community.docker.docker_compose_v2` + +Template file: + +- `ansible/roles/web_app/templates/docker-compose.yml.j2` + +Supported variables: + +- `app_name` +- `docker_image` +- `docker_tag` +- `app_port` +- `app_internal_port` +- `app_env` +- `app_restart_policy` +- `docker_compose_version` + +### Role dependency + +`ansible/roles/web_app/meta/main.yml` includes: + +- dependency on role `docker` + +This guarantees Docker setup before Compose deployment when `web_app` runs. + +## 4. Wipe Logic + +Implemented in: + +- `ansible/roles/web_app/tasks/wipe.yml` +- included from `ansible/roles/web_app/tasks/main.yml` + +Behavior: + +- runs only when: + - `web_app_wipe | bool == true` + - and `--tags web_app_wipe` is selected +- performs: + - `docker_compose_v2 state: absent` + - remove compose file + - remove project directory + - completion message + +Defaults: + +- `web_app_wipe: false` +- `web_app_wipe_remove_images: false` + +Why this is safe: + +- Tag alone is not enough. +- Variable alone is not enough (if running wipe-only flow). +- Operator must opt in explicitly. + +## 5. CI/CD Integration + +Added workflow: + +- `.github/workflows/ansible-deploy.yml` + +Pipeline: + +1. `lint` job: + - installs `ansible-core`, `ansible-lint` + - installs `community.docker` collection from `ansible/requirements.yml` + - runs `ansible-lint` on playbooks and roles +2. `deploy` job (`push` only): + - sets up SSH from `SSH_PRIVATE_KEY` + - builds CI inventory from `VM_HOST` + `VM_USER` + - writes vault pass from `ANSIBLE_VAULT_PASSWORD` + - runs `ansible-playbook playbooks/deploy.yml` + - verifies `/` and `/health` with `curl` + +Path filters: + +- triggers on `ansible/**` and workflow changes +- excludes `ansible/docs/**` + +Status badge added: + +- root `README.md` includes `ansible-deploy` badge. + +## 6. Testing Results + +### Completed local checks + +Commands run: + +```bash +cd ansible +ANSIBLE_ROLES_PATH=roles ansible-playbook -i inventory/hosts.ini playbooks/provision.yml --syntax-check +ANSIBLE_ROLES_PATH=roles ansible-playbook -i inventory/hosts.ini playbooks/provision.yml --list-tags +ANSIBLE_ROLES_PATH=roles ansible-playbook -i inventory/hosts.ini playbooks/deploy.yml --syntax-check +ANSIBLE_ROLES_PATH=roles ansible-playbook -i inventory/hosts.ini playbooks/deploy.yml --list-tags +``` + +Result: + +- both playbooks pass syntax check. +- expected tags are visible for selective execution. + +Environment note: + +- repository directory is world-writable in this environment, so `ansible.cfg` is ignored by Ansible and direct env overrides were used. + +### Recommended runtime checks on VM + +```bash +ansible-playbook playbooks/provision.yml --tags docker +ansible-playbook playbooks/deploy.yml +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe +ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" +docker ps +curl http://127.0.0.1:5000/health +``` + +## 7. Challenges and Solutions + +- Challenge: legacy deploy playbook still referenced `app_deploy`. + - Solution: migrated playbook to `web_app` and added role dependency metadata. +- Challenge: deployment path still used `docker_container`. + - Solution: switched to Compose template + `docker_compose_v2`. +- Challenge: dangerous wipe scenarios. + - Solution: implemented variable + tag double-gating. +- Challenge: CI should not run on docs-only changes. + - Solution: added workflow path filters with docs exclusion. + +## 8. Research Answers + +### Blocks/Tags + +1. What happens if rescue also fails? + - The play fails after rescue failure; `always` still runs. +2. Can blocks be nested? + - Yes. Nested blocks are valid and inherit directives unless overridden. +3. How do tags inherit in blocks? + - Tags on block propagate to child tasks; task-level tags add/override as needed. + +### Docker Compose + +1. `restart: always` vs `unless-stopped`: + - `always`: restarts even after manual stop and daemon restart. + - `unless-stopped`: restarts unless explicitly stopped by operator. +2. Compose networks vs default bridge: + - Compose creates project-scoped networks with service-name DNS. + - Default bridge is global and less isolated for multi-app stacks. +3. Can Vault vars be used in templates? + - Yes. Vaulted variables resolve normally in Jinja templates at runtime. + +### Wipe Logic + +1. Why variable + tag? + - Defense in depth: two independent opt-ins reduce accidental destructive actions. +2. Difference from `never` tag: + - `never` blocks normal execution unless directly targeted, but does not encode intent via runtime variable. +3. Why wipe before deploy? + - Enables clean reinstall flow (`wipe -> deploy`) in one run. +4. Clean reinstall vs rolling update: + - Reinstall for drift/corruption cleanup; rolling update for lower downtime. +5. How to extend wipe for images/volumes? + - add `remove_images: all` and `remove_volumes: true` (with additional safety flags). + +### CI/CD + +1. Security implications of SSH keys in GitHub Secrets: + - Safer than plaintext in repo, but compromise of repo/admin access exposes secrets. +2. Staging -> production pipeline: + - separate jobs/environments with approval gates and environment-specific inventories. +3. Rollbacks: + - pin image tags, keep previous tag metadata, add manual `rollback` workflow input. +4. Why self-hosted runner can improve security: + - no inbound SSH key distribution to third-party runner; deployment executes inside controlled network boundary. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..851912f01d --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,22 @@ +$ANSIBLE_VAULT;1.1;AES256 +32366630626237666339646163613339323364626534303835376161623663636133386363623333 +3136353239336433343733613337383865303565613662310a636562323630613432376362386465 +63636532326335646339643132643365626366386662363839613363666630656636613432343133 +3637366431333937390a626237373438663435326238663863323965316261326434346666663036 +62333435663665633662316362353638626234306136333264633737336264363334306166313461 +37383966666161363030633863333865333930376561323934386164376330383662646334323639 +31343661383932656337376333626266316562343961653238353762313233656166363934613361 +32363331313264643965306135313663653739346561313731346534653735356232643536646563 +37373430373062353332363835326465643931356364363430373866313362393431383630663365 +66633838633038346336373638616537656665383736333731386239613933343961333136346436 +65356435313862316135326531653535313736643464633235353332353232663030636262623964 +64373535643538313231386635373662653330616566353839386266343563653462316462656635 +64363765393534383061353266393639303066626563356434313162306266313664346466356566 +34626366323031366139316438663432366631626533313236393137633866663861373162646137 +35646234303137653963383039303731666337393966373162636433313432643233353033336563 +30633236643562613566396435656666666566373565373063393733666231303963343235333539 +39373832313438383164643565333934356565356134323862623636633861363837646639313739 +34376461383739373439353863313763653832363164636164333330663438346636306465636332 +63303166353238616135313166386634333934393334383837613637646439616334646335306639 +66303663613730373130313032616462656530323034623739306239663831396562373662663964 +3136 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..af00a60609 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[webservers] +wsl ansible_host=127.0.0.1 ansible_user=andre diff --git a/ansible/playbooks/deploy-monitoring.yml b/ansible/playbooks/deploy-monitoring.yml new file mode 100644 index 0000000000..6f107d588b --- /dev/null +++ b/ansible/playbooks/deploy-monitoring.yml @@ -0,0 +1,13 @@ +--- +- name: Deploy monitoring stack + hosts: webservers + become: true + pre_tasks: + - name: Load deployment variables from vault file + ansible.builtin.include_vars: + file: ../group_vars/all.yml + no_log: true + roles: + - role: monitoring + tags: + - monitoring diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..7a4e0d38ca --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,13 @@ +--- +- name: Deploy application + hosts: webservers + become: true + pre_tasks: + - name: Load deployment variables from vault file + ansible.builtin.include_vars: + file: ../group_vars/all.yml + no_log: true + roles: + - role: web_app + tags: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..54961d85fc --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,11 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + roles: + - role: common + tags: + - common + - role: docker + tags: + - docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..2262983808 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,6 @@ +--- +- name: Provision web servers + import_playbook: provision.yml + +- name: Deploy application stack + import_playbook: deploy.yml diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..660f775816 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: community.docker diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..707b02f9ba --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,13 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + +common_managed_users: + - name: "{{ ansible_user | default('ubuntu') }}" + groups: + - sudo + shell: /bin/bash diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..bda9e2e508 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Install baseline packages + become: true + tags: + - common + - packages + block: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Retry apt cache refresh + ansible.builtin.apt: + update_cache: true + cache_valid_time: 0 + + - name: Retry package installation after apt recovery + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + update_cache: true + + always: + - name: Record package block completion + ansible.builtin.lineinfile: + path: /tmp/ansible-common-packages.log + create: true + mode: "0644" + line: "{{ ansible_date_time.iso8601 }} common/packages completed on {{ inventory_hostname }}" + +- name: Manage local users + become: true + tags: + - common + - users + block: + - name: Ensure managed users exist + ansible.builtin.user: + name: "{{ item.name }}" + state: present + shell: "{{ item.shell | default('/bin/bash') }}" + groups: "{{ item.groups | default(omit) }}" + append: true + create_home: true + loop: "{{ common_managed_users }}" + loop_control: + label: "{{ item.name }}" + + always: + - name: Record user block completion + ansible.builtin.lineinfile: + path: /tmp/ansible-common-users.log + create: true + mode: "0644" + line: "{{ ansible_date_time.iso8601 }} common/users completed on {{ inventory_hostname }}" diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..445d4f2cde --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,8 @@ +--- +docker_user: "{{ ansible_user | default('ubuntu') }}" +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin 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..476d399570 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,78 @@ +--- +- name: Install Docker engine + become: true + tags: + - docker + - docker_install + block: + - name: Install Docker apt prerequisites + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + + - name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + - name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + state: present + + - name: Install Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + notify: Restart docker + + - name: Install python Docker bindings + ansible.builtin.apt: + name: python3-docker + state: present + + rescue: + - name: Wait before Docker apt retry + ansible.builtin.wait_for: + timeout: 10 + + - name: Retry apt cache update after key/repo issue + ansible.builtin.apt: + update_cache: true + + - name: Retry Docker GPG key download + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + + always: + - name: Ensure Docker service is enabled and started + ansible.builtin.service: + name: docker + enabled: true + state: started + +- name: Configure Docker access + become: true + tags: + - docker + - docker_config + block: + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: true diff --git a/ansible/roles/monitoring/defaults/main.yml b/ansible/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000000..a3233b49e7 --- /dev/null +++ b/ansible/roles/monitoring/defaults/main.yml @@ -0,0 +1,76 @@ +--- +monitoring_compose_project_dir: /opt/monitoring + +monitoring_loki_version: 3.0.0 +monitoring_promtail_version: 3.0.0 +monitoring_prometheus_version: 3.9.0 +monitoring_grafana_version: 12.3.1 + +monitoring_loki_port: 3100 +monitoring_promtail_port: 9080 +monitoring_prometheus_port: 9090 +monitoring_grafana_port: 3000 + +monitoring_loki_retention_period: 168h +monitoring_loki_schema_version: v13 +monitoring_prometheus_retention_days: 15 +monitoring_prometheus_retention_size: 10GB +monitoring_prometheus_scrape_interval: 15s + +monitoring_grafana_admin_user: admin +monitoring_grafana_admin_password: change-me-now +monitoring_grafana_anonymous_enabled: false + +monitoring_prometheus_targets: + - job: prometheus + targets: + - "localhost:9090" + - job: app + path: /metrics + targets: + - "host.docker.internal:5000" + - job: loki + path: /metrics + targets: + - "loki:3100" + - job: grafana + path: /metrics + targets: + - "grafana:3000" + +monitoring_prometheus_resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + +monitoring_loki_resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + +monitoring_promtail_resources: + limits: + cpus: "0.50" + memory: 256M + reservations: + cpus: "0.10" + memory: 128M + +monitoring_grafana_resources: + limits: + cpus: "0.50" + memory: 512M + reservations: + cpus: "0.10" + memory: 128M + +monitoring_deploy_no_log: true + +monitoring_wipe: false +monitoring_wipe_remove_images: false diff --git a/ansible/roles/monitoring/meta/main.yml b/ansible/roles/monitoring/meta/main.yml new file mode 100644 index 0000000000..cb7d8e0460 --- /dev/null +++ b/ansible/roles/monitoring/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: docker diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml new file mode 100644 index 0000000000..ce8e3cc86b --- /dev/null +++ b/ansible/roles/monitoring/tasks/main.yml @@ -0,0 +1,143 @@ +--- +- name: Include monitoring wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - monitoring_wipe + +- name: Deploy monitoring stack with Docker Compose + tags: + - monitoring_deploy + - compose + block: + - name: Ensure monitoring directory structure exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ monitoring_compose_project_dir }}" + - "{{ monitoring_compose_project_dir }}/loki" + - "{{ monitoring_compose_project_dir }}/prometheus" + - "{{ monitoring_compose_project_dir }}/promtail" + - "{{ monitoring_compose_project_dir }}/grafana" + - "{{ monitoring_compose_project_dir }}/grafana/dashboards" + - "{{ monitoring_compose_project_dir }}/grafana/provisioning" + - "{{ monitoring_compose_project_dir }}/grafana/provisioning/datasources" + - "{{ monitoring_compose_project_dir }}/grafana/provisioning/dashboards" + + - name: Render Loki configuration + ansible.builtin.template: + src: loki-config.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/loki/config.yml" + mode: "0644" + + - name: Render Prometheus configuration + ansible.builtin.template: + src: prometheus.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/prometheus/prometheus.yml" + mode: "0644" + + - name: Render Promtail configuration + ansible.builtin.template: + src: promtail-config.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/promtail/config.yml" + mode: "0644" + + - name: Render Grafana datasource provisioning + ansible.builtin.template: + src: grafana-datasource.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/grafana/provisioning/datasources/datasources.yml" + mode: "0644" + + - name: Render Grafana dashboard provisioning + ansible.builtin.template: + src: grafana-dashboards.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/grafana/provisioning/dashboards/dashboards.yml" + mode: "0644" + + - name: Copy Grafana dashboard JSON files + ansible.builtin.copy: + src: "{{ item }}" + dest: "{{ monitoring_compose_project_dir }}/grafana/dashboards/{{ item }}" + mode: "0644" + loop: + - grafana-app-dashboard.json + - grafana-logs-dashboard.json + + - name: Render monitoring docker compose manifest + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ monitoring_compose_project_dir }}/docker-compose.yml" + mode: "0640" + no_log: "{{ monitoring_deploy_no_log }}" + + - name: Deploy monitoring stack via Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ monitoring_compose_project_dir }}" + state: present + pull: always + + - name: Wait for Loki HTTP port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_loki_port }}" + delay: 2 + timeout: 120 + + - name: Wait for Promtail HTTP port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_promtail_port }}" + delay: 2 + timeout: 120 + + - name: Wait for Prometheus HTTP port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_prometheus_port }}" + delay: 2 + timeout: 120 + + - name: Wait for Grafana HTTP port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ monitoring_grafana_port }}" + delay: 2 + timeout: 120 + + - name: Verify Loki readiness endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_loki_port }}/ready" + method: GET + status_code: 200 + register: monitoring_loki_ready + changed_when: false + + - name: Verify Promtail readiness endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_promtail_port }}/ready" + method: GET + status_code: 200 + register: monitoring_promtail_ready + changed_when: false + + - name: Verify Prometheus health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_prometheus_port }}/-/healthy" + method: GET + status_code: 200 + register: monitoring_prometheus_ready + changed_when: false + + - name: Verify Grafana health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ monitoring_grafana_port }}/api/health" + method: GET + status_code: 200 + register: monitoring_grafana_ready + changed_when: false + + rescue: + - name: Report monitoring deployment failure context + ansible.builtin.debug: + msg: "Monitoring deployment failed for project {{ monitoring_compose_project_dir }}." diff --git a/ansible/roles/monitoring/tasks/wipe.yml b/ansible/roles/monitoring/tasks/wipe.yml new file mode 100644 index 0000000000..97a7bedeb8 --- /dev/null +++ b/ansible/roles/monitoring/tasks/wipe.yml @@ -0,0 +1,27 @@ +--- +- name: Wipe monitoring deployment + when: monitoring_wipe | bool + tags: + - monitoring_wipe + block: + - name: Check if monitoring compose manifest exists + ansible.builtin.stat: + path: "{{ monitoring_compose_project_dir }}/docker-compose.yml" + register: monitoring_compose_manifest + + - name: Stop and remove monitoring stack + community.docker.docker_compose_v2: + project_src: "{{ monitoring_compose_project_dir }}" + state: absent + remove_orphans: true + remove_images: "{{ 'all' if monitoring_wipe_remove_images | bool else omit }}" + when: monitoring_compose_manifest.stat.exists + + - name: Remove monitoring compose project directory + ansible.builtin.file: + path: "{{ monitoring_compose_project_dir }}" + state: absent + + - name: Confirm monitoring wipe completion + ansible.builtin.debug: + msg: "Monitoring stack wiped successfully from {{ monitoring_compose_project_dir }}" diff --git a/ansible/roles/monitoring/templates/docker-compose.yml.j2 b/ansible/roles/monitoring/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..0e812540cf --- /dev/null +++ b/ansible/roles/monitoring/templates/docker-compose.yml.j2 @@ -0,0 +1,153 @@ +services: + loki: + image: "grafana/loki:{{ monitoring_loki_version }}" + command: -config.file=/etc/loki/config.yml + restart: unless-stopped + ports: + - "{{ monitoring_loki_port }}:{{ monitoring_loki_port }}" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + labels: + logging: "promtail" + app: "devops-loki" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:{{ monitoring_loki_port }}/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "{{ monitoring_loki_resources.limits.cpus }}" + memory: "{{ monitoring_loki_resources.limits.memory }}" + reservations: + cpus: "{{ monitoring_loki_resources.reservations.cpus }}" + memory: "{{ monitoring_loki_resources.reservations.memory }}" + networks: + - logging + + prometheus: + image: "prom/prometheus:v{{ monitoring_prometheus_version }}" + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time={{ monitoring_prometheus_retention_days }}d + - --storage.tsdb.retention.size={{ monitoring_prometheus_retention_size }} + restart: unless-stopped + ports: + - "{{ monitoring_prometheus_port }}:{{ monitoring_prometheus_port }}" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + extra_hosts: + - "host.docker.internal:host-gateway" + labels: + logging: "promtail" + app: "devops-prometheus" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:{{ monitoring_prometheus_port }}/-/healthy || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "{{ monitoring_prometheus_resources.limits.cpus }}" + memory: "{{ monitoring_prometheus_resources.limits.memory }}" + reservations: + cpus: "{{ monitoring_prometheus_resources.reservations.cpus }}" + memory: "{{ monitoring_prometheus_resources.reservations.memory }}" + networks: + - logging + + promtail: + image: "grafana/promtail:{{ monitoring_promtail_version }}" + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped + ports: + - "{{ monitoring_promtail_port }}:{{ monitoring_promtail_port }}" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-positions:/tmp + labels: + logging: "promtail" + app: "devops-promtail" + depends_on: + loki: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "bash -lc 'exec 3<>/dev/tcp/127.0.0.1/{{ monitoring_promtail_port }} && printf \"GET /ready HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q \"200\" <&3'", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "{{ monitoring_promtail_resources.limits.cpus }}" + memory: "{{ monitoring_promtail_resources.limits.memory }}" + reservations: + cpus: "{{ monitoring_promtail_resources.reservations.cpus }}" + memory: "{{ monitoring_promtail_resources.reservations.memory }}" + networks: + - logging + + grafana: + image: "grafana/grafana:{{ monitoring_grafana_version }}" + restart: unless-stopped + ports: + - "{{ monitoring_grafana_port }}:{{ monitoring_grafana_port }}" + environment: + GF_AUTH_ANONYMOUS_ENABLED: "{{ monitoring_grafana_anonymous_enabled | lower }}" + GF_SECURITY_ADMIN_USER: "{{ monitoring_grafana_admin_user }}" + GF_SECURITY_ADMIN_PASSWORD: "{{ monitoring_grafana_admin_password }}" + GF_SECURITY_ALLOW_EMBEDDING: "false" + GF_METRICS_ENABLED: "true" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + labels: + logging: "promtail" + app: "devops-grafana" + depends_on: + loki: + condition: service_healthy + prometheus: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:{{ monitoring_grafana_port }}/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: "{{ monitoring_grafana_resources.limits.cpus }}" + memory: "{{ monitoring_grafana_resources.limits.memory }}" + reservations: + cpus: "{{ monitoring_grafana_resources.reservations.cpus }}" + memory: "{{ monitoring_grafana_resources.reservations.memory }}" + networks: + - logging + +networks: + logging: + name: logging + +volumes: + prometheus-data: + loki-data: + grafana-data: + promtail-positions: diff --git a/ansible/roles/monitoring/templates/grafana-dashboards.yml.j2 b/ansible/roles/monitoring/templates/grafana-dashboards.yml.j2 new file mode 100644 index 0000000000..b6e32b4c2f --- /dev/null +++ b/ansible/roles/monitoring/templates/grafana-dashboards.yml.j2 @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: default + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 b/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 new file mode 100644 index 0000000000..c1e76d24d3 --- /dev/null +++ b/ansible/roles/monitoring/templates/grafana-datasource.yml.j2 @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:{{ monitoring_loki_port }} + isDefault: false + editable: true + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:{{ monitoring_prometheus_port }} + isDefault: true + editable: true diff --git a/ansible/roles/monitoring/templates/loki-config.yml.j2 b/ansible/roles/monitoring/templates/loki-config.yml.j2 new file mode 100644 index 0000000000..21f630dfff --- /dev/null +++ b/ansible/roles/monitoring/templates/loki-config.yml.j2 @@ -0,0 +1,39 @@ +auth_enabled: false + +server: + http_listen_port: {{ monitoring_loki_port }} + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: {{ monitoring_loki_schema_version }} + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + delete_request_store: filesystem + +limits_config: + retention_period: {{ monitoring_loki_retention_period }} diff --git a/ansible/roles/monitoring/templates/prometheus.yml.j2 b/ansible/roles/monitoring/templates/prometheus.yml.j2 new file mode 100644 index 0000000000..b3efbe83a2 --- /dev/null +++ b/ansible/roles/monitoring/templates/prometheus.yml.j2 @@ -0,0 +1,16 @@ +global: + scrape_interval: {{ monitoring_prometheus_scrape_interval }} + evaluation_interval: {{ monitoring_prometheus_scrape_interval }} + +scrape_configs: +{% for target in monitoring_prometheus_targets %} + - job_name: {{ target.job | quote }} +{% if target.path is defined %} + metrics_path: {{ target.path | quote }} +{% endif %} + static_configs: + - targets: +{% for entry in target.targets %} + - {{ entry | quote }} +{% endfor %} +{% endfor %} diff --git a/ansible/roles/monitoring/templates/promtail-config.yml.j2 b/ansible/roles/monitoring/templates/promtail-config.yml.j2 new file mode 100644 index 0000000000..721154b0ae --- /dev/null +++ b/ansible/roles/monitoring/templates/promtail-config.yml.j2 @@ -0,0 +1,33 @@ +server: + http_listen_port: {{ monitoring_promtail_port }} + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:{{ monitoring_loki_port }}/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: + - logging=promtail + pipeline_stages: + - docker: {} + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: /(.*) + target_label: container + - source_labels: [__meta_docker_container_label_app] + target_label: app + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: service + - source_labels: [__meta_docker_container_id] + target_label: container_id + - target_label: job + replacement: docker diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..1216327e0d --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,24 @@ +--- +web_app_name: myapp + +# Image settings +web_app_docker_image: "{{ (dockerhub_username | default('nonamecorn')) ~ '/' ~ web_app_name }}" +web_app_docker_tag: latest + +# Runtime ports +web_app_port: 5000 +web_app_internal_port: 5000 + +# Docker Compose settings +web_app_compose_project_dir: "/opt/{{ web_app_name }}" +web_app_restart_policy: unless-stopped +web_app_env: + PORT: "{{ web_app_internal_port }}" +web_app_healthcheck_url: "http://127.0.0.1:{{ web_app_port }}/health" + +# Security/logging behavior +web_app_deploy_no_log: true + +# Wipe logic (double-gated with var + tag) +web_app_wipe: false +web_app_wipe_remove_images: 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..be42e2c0ac --- /dev/null +++ b/ansible/roles/web_app/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Restart web application stack + ansible.builtin.command: docker compose -f {{ compose_project_dir | default(web_app_compose_project_dir) }}/docker-compose.yml restart + changed_when: 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..cb7d8e0460 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: docker diff --git a/ansible/roles/web_app/tasks/main.yml b/ansible/roles/web_app/tasks/main.yml new file mode 100644 index 0000000000..6b3534cc52 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,64 @@ +--- +- name: Include wipe tasks + ansible.builtin.include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy application with Docker Compose + tags: + - app_deploy + - compose + block: + - name: Log in to Docker Hub when credentials are provided + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + when: + - dockerhub_username is defined + - dockerhub_password is defined + - dockerhub_username | length > 0 + - dockerhub_password | length > 0 + no_log: "{{ app_deploy_no_log | default(web_app_deploy_no_log) }}" + + - name: Ensure compose project directory exists + ansible.builtin.file: + path: "{{ compose_project_dir | default(web_app_compose_project_dir) }}" + state: directory + mode: "0755" + + - name: Render docker-compose manifest + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir | default(web_app_compose_project_dir) }}/docker-compose.yml" + mode: "0644" + + - name: Remove conflicting standalone container if present + community.docker.docker_container: + name: "{{ app_name | default(web_app_name) }}" + state: absent + + - name: Deploy stack via Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir | default(web_app_compose_project_dir) }}" + state: present + pull: always + + - name: Wait for application port + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ app_port | default(web_app_port) }}" + delay: 2 + timeout: 90 + + - name: Verify health endpoint + ansible.builtin.uri: + url: "{{ app_healthcheck_url | default(web_app_healthcheck_url) }}" + method: GET + status_code: 200 + register: web_app_healthcheck + changed_when: false + + rescue: + - name: Report deployment failure context + ansible.builtin.debug: + msg: "Compose deployment failed for {{ app_name | default(web_app_name) }} (project: {{ compose_project_dir | default(web_app_compose_project_dir) }})." diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..147400f5b6 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,32 @@ +--- +- name: Wipe web application deployment + when: web_app_wipe | bool + tags: + - web_app_wipe + block: + - name: Check if compose manifest exists + ansible.builtin.stat: + path: "{{ compose_project_dir | default(web_app_compose_project_dir) }}/docker-compose.yml" + register: web_app_compose_manifest + + - name: Stop and remove compose resources + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir | default(web_app_compose_project_dir) }}" + state: absent + remove_orphans: true + remove_images: "{{ 'all' if web_app_wipe_remove_images | bool else omit }}" + when: web_app_compose_manifest.stat.exists + + - name: Remove docker-compose manifest + ansible.builtin.file: + path: "{{ compose_project_dir | default(web_app_compose_project_dir) }}/docker-compose.yml" + state: absent + + - name: Remove compose project directory + ansible.builtin.file: + path: "{{ compose_project_dir | default(web_app_compose_project_dir) }}" + state: absent + + - name: Confirm wipe completion + ansible.builtin.debug: + msg: "Application {{ app_name | default(web_app_name) }} wiped successfully" diff --git a/ansible/roles/web_app/templates/docker-compose.yml.j2 b/ansible/roles/web_app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000000..8564362186 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,26 @@ +{% set effective_name = app_name | default(web_app_name) %} +{% set effective_image = docker_image | default(web_app_docker_image) %} +{% set effective_tag = docker_tag | default(docker_image_tag | default(web_app_docker_tag)) %} +{% set effective_port = app_port | default(web_app_port) %} +{% set effective_internal_port = app_internal_port | default(web_app_internal_port) %} +{% set effective_restart_policy = app_restart_policy | default(web_app_restart_policy) %} +{% set effective_env = app_env | default(web_app_env) %} +services: + {{ effective_name }}: + image: "{{ effective_image }}:{{ effective_tag }}" + container_name: "{{ effective_name }}" + restart: "{{ effective_restart_policy }}" + ports: + - "{{ effective_port }}:{{ effective_internal_port }}" +{% if effective_env | length > 0 %} + environment: +{% for env_key, env_value in effective_env.items() %} + {{ env_key }}: "{{ env_value }}" +{% endfor %} +{% endif %} + networks: + - app_net + +networks: + app_net: + name: "{{ effective_name }}_net" diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..757b921e27 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,6 @@ +* +!requirements.txt +!src/ +!src/** +!app.py +!requirements.txt diff --git a/app_python/.gitattributes b/app_python/.gitattributes new file mode 100644 index 0000000000..d48c7988d0 --- /dev/null +++ b/app_python/.gitattributes @@ -0,0 +1,128 @@ +# Common settings that generally should always be used with your language specific settings + +# Auto detect text files and perform LF normalization +* text=auto + +# +# The above will handle all files NOT found below +# + +# Documents +*.bibtex text diff=bibtex +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.md text diff=markdown +*.mdx text diff=markdown +*.tex text diff=tex +*.adoc text +*.textile text +*.mustache text +*.csv text eol=crlf +*.tab text +*.tsv text +*.txt text +*.sql text +*.epub diff=astextplain + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as text by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.eps binary + +# Scripts +*.bash text eol=lf +*.fish text eol=lf +*.ksh text eol=lf +*.sh text eol=lf +*.zsh text eol=lf +# These are explicitly windows files and should use crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Serialisation +*.json text +*.toml text +*.xml text +*.yaml text +*.yml text + +# Archives +*.7z binary +*.bz binary +*.bz2 binary +*.bzip2 binary +*.gz binary +*.lz binary +*.lzma binary +*.rar binary +*.tar binary +*.taz binary +*.tbz binary +*.tbz2 binary +*.tgz binary +*.tlz binary +*.txz binary +*.xz binary +*.Z binary +*.zip binary +*.zst binary + +# Text files where line endings should be preserved +*.patch -text + +# +# Exclude files from exporting +# + +.gitattributes export-ignore +.gitignore export-ignore +.gitkeep export-ignore + +# Basic .gitattributes for a python repo. + +# Source files +# ============ +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.pyz text diff=python +*.pyi text diff=python + +# Binary files +# ============ +*.db binary +*.p binary +*.pkl binary +*.pickle binary +*.pyc binary export-ignore +*.pyo binary export-ignore +*.pyd binary + +# Jupyter notebook +*.ipynb text eol=lf + +# Note: .db, .p, and .pkl files are associated +# with the python modules ``pickle``, ``dbm.*``, +# ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` +# (among others). diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..e15106e38f --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..2849050983 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.14-slim + +ENV PYTHONUNBUFFERED=1 + +RUN useradd --create-home --shell /bin/sh --uid 10001 appuser +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +COPY src ./src +EXPOSE 5000 +USER appuser +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..971cbf0b6c --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,14 @@ +Overview - What the service does +Prerequisites - Python version, dependencies +Installation +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +Running the Application +python app.py +# Or with custom config +PORT=8080 python app.py +API Endpoints +GET / - Service and system information +GET /health - Health check +Configuration - Environment variables table diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..5937d239c8 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,8 @@ +"""Application entrypoint for local and container execution.""" + +from src.app import HOST, PORT, app, logger + + +if __name__ == "__main__": + logger.info("application_starting", extra={"host": HOST, "port": PORT}) + app.run(host=HOST, port=PORT) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4647c4a7f1 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,14 @@ +I've decided to use Flask because its the first and easiest option. + +Best Practices Applied +List practices with code examples +Explain importance of each +API Documentation +Request/response examples +Testing commands +Testing Evidence +Screenshots showing endpoints work +Terminal output +Challenges & Solutions +Problems encountered +How you solved them \ No newline at end of file diff --git a/app_python/poetry.lock b/app_python/poetry.lock new file mode 100644 index 0000000000..5dac3af27e --- /dev/null +++ b/app_python/poetry.lock @@ -0,0 +1,552 @@ +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + +[[package]] +name = "flask" +version = "3.1.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, + {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, +] + +[package.dependencies] +blinker = ">=1.9.0" +click = ">=8.1.3" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99"}, + {file = "prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce"}, +] + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "werkzeug" +version = "3.1.5" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, + {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, +] + +[package.dependencies] +markupsafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14" +content-hash = "157250c7856693a8741df0032ff85ed0c3516bd6d2b85a8f53c521945ea39395" diff --git a/app_python/pyproject.toml b/app_python/pyproject.toml new file mode 100644 index 0000000000..0425bc7c5d --- /dev/null +++ b/app_python/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "app-python" +version = "0.1.0" +description = "Yes" +authors = [ + {name = "corndev",email = "128768882+nonamecorn@users.noreply.github.com"} +] +license = {text = "No"} +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "flask (==3.1.2)", + "prometheus-client (==0.23.1)", + "requests (==2.32.5)", + "pytest (>=9.0.2,<10.0.0)", + "flake8 (>=7.3.0,<8.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..fe0fe5e4ee --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,14 @@ +blinker==1.9.0 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +Flask==3.1.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +prometheus-client==0.23.1 +requests==2.32.5 +urllib3==2.6.3 +Werkzeug==3.1.5 diff --git a/app_python/src/__pycache__/app.cpython-312.pyc b/app_python/src/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000..8b29295315 Binary files /dev/null and b/app_python/src/__pycache__/app.cpython-312.pyc differ diff --git a/app_python/src/app.py b/app_python/src/app.py new file mode 100644 index 0000000000..e16e274575 --- /dev/null +++ b/app_python/src/app.py @@ -0,0 +1,227 @@ +""" +DevOps Info Service +Main application module +""" + +import os +import socket +import platform +import logging +from time import perf_counter +from datetime import datetime, timezone +from flask import Flask, Response, g, jsonify, request +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + + +HTTP_REQUESTS_TOTAL = Counter( + "http_requests_total", + "Total HTTP requests processed by the application", + ["method", "endpoint", "status_code"], +) +HTTP_REQUEST_DURATION_SECONDS = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint", "status_code"], +) +HTTP_REQUESTS_IN_PROGRESS = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed", + ["method", "endpoint"], +) +DEVOPS_INFO_ENDPOINT_CALLS_TOTAL = Counter( + "devops_info_endpoint_calls_total", + "Total application endpoint calls grouped by endpoint", + ["endpoint"], +) +DEVOPS_INFO_SYSTEM_INFO_COLLECTION_SECONDS = Histogram( + "devops_info_system_info_collection_seconds", + "Time spent collecting system information", +) + + +# Configuration +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) + +# Application start time +START_TIME = datetime.now(timezone.utc) + + +def normalize_endpoint(): + """Normalize endpoint labels to keep metric cardinality low.""" + if request.url_rule and request.url_rule.rule: + return request.url_rule.rule + if request.path == "/metrics": + return "/metrics" + return "unmatched" + + +@app.before_request +def before_request_metrics(): + endpoint = normalize_endpoint() + g.metrics_endpoint = endpoint + g.metrics_start_time = perf_counter() + g.metrics_tracked = endpoint != "/metrics" + g.metrics_gauge_decremented = False + + if g.metrics_tracked: + HTTP_REQUESTS_IN_PROGRESS.labels( + method=request.method, endpoint=endpoint + ).inc() + + +@app.after_request +def after_request_metrics(response): + if g.get("metrics_tracked", False): + duration = perf_counter() - g.metrics_start_time + status_code = str(response.status_code) + HTTP_REQUESTS_TOTAL.labels( + method=request.method, + endpoint=g.metrics_endpoint, + status_code=status_code, + ).inc() + HTTP_REQUEST_DURATION_SECONDS.labels( + method=request.method, + endpoint=g.metrics_endpoint, + status_code=status_code, + ).observe(duration) + HTTP_REQUESTS_IN_PROGRESS.labels( + method=request.method, endpoint=g.metrics_endpoint + ).dec() + g.metrics_gauge_decremented = True + + return response + + +@app.teardown_request +def teardown_request_metrics(error): + if g.get("metrics_tracked", False) and not g.get("metrics_gauge_decremented", False): + HTTP_REQUESTS_IN_PROGRESS.labels( + method=request.method, endpoint=g.metrics_endpoint + ).dec() + g.metrics_gauge_decremented = True + + +def get_system_info(): + """Collect system information.""" + start = perf_counter() + try: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + } + finally: + DEVOPS_INFO_SYSTEM_INFO_COLLECTION_SECONDS.observe(perf_counter() - start) + + +def get_request(): + return { + "client_ip": request.remote_addr, # Client IP + "user_agent": request.headers.get("User-Agent"), # User agent + "method": request.method, # HTTP method + "path": request.path, # Request path + } + + +def get_service(): + service = { + "name": "devops-info-service", + "version": os.getenv("DEVOPS_SERVICE_VERSION", "1.0.0"), + "description": "DevOps course info service", + "framework": "Flask", + } + release_track = os.getenv("DEVOPS_RELEASE_TRACK") + release_color = os.getenv("DEVOPS_RELEASE_COLOR") + + if release_track: + service["release_track"] = release_track + if release_color: + service["release_color"] = release_color + + return service + + +@app.route("/") +def index(): + logger.debug(f"Request: {request.method} {request.path}") + """Main endpoint - service and system information.""" + DEVOPS_INFO_ENDPOINT_CALLS_TOTAL.labels(endpoint="/").inc() + return { + "service": get_service(), + "system": get_system_info(), + "request": get_request(), + "runtime": get_uptime(), + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + {"path": "/metrics", "method": "GET", "description": "Prometheus metrics"}, + ], + } + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + now_utc = datetime.now(timezone.utc).isoformat() + # Example output: '2026-01-28T19:10:00.123456+00:00' + # Replace the +00:00 with Z + iso_format_zulu = now_utc.replace("+00:00", ".000Z") + return { + "seconds": seconds, + "human": f"{hours} hours, {minutes} minutes", + "current_time": iso_format_zulu, + "timezone": "UTC", + } + + +@app.route("/health") +def health(): + logger.debug(f"Request: {request.method} {request.path}") + DEVOPS_INFO_ENDPOINT_CALLS_TOTAL.labels(endpoint="/health").inc() + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_uptime()["seconds"], + } + ) + + +@app.route("/metrics") +def metrics(): + DEVOPS_INFO_ENDPOINT_CALLS_TOTAL.labels(endpoint="/metrics").inc() + return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) + + +@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): + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("application_starting", extra={"host": HOST, "port": PORT}) + app.run(host=HOST, port=PORT) 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..9fa25b075d --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,178 @@ +from src.app import app +import pytest +import re + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +# ---------------------------- +# GET / Tests +# ---------------------------- + + +def test_index_success_status_code(client): + response = client.get("/") + assert response.status_code == 200 + assert response.is_json + + +def test_index_json_structure(client): + response = client.get("/") + data = response.get_json() + + # Top-level keys + assert "service" in data + assert "system" in data + assert "request" in data + assert "runtime" in data + assert "endpoints" in data + + +def test_index_service_fields(client): + data = client.get("/").get_json() + service = data["service"] + + assert service["name"] == "devops-info-service" + assert service["version"] == "1.0.0" + assert service["framework"] == "Flask" + assert isinstance(service["description"], str) + + +def test_index_service_release_metadata_overrides(client, monkeypatch): + monkeypatch.setenv("DEVOPS_SERVICE_VERSION", "2.0.0-canary") + monkeypatch.setenv("DEVOPS_RELEASE_TRACK", "canary") + monkeypatch.setenv("DEVOPS_RELEASE_COLOR", "green") + + service = client.get("/").get_json()["service"] + + assert service["version"] == "2.0.0-canary" + assert service["release_track"] == "canary" + assert service["release_color"] == "green" + + +def test_index_system_fields(client): + data = client.get("/").get_json() + system = data["system"] + + assert "hostname" in system + assert "platform" in system + assert "architecture" in system + assert "python_version" in system + + assert isinstance(system["hostname"], str) + assert isinstance(system["platform"], str) + assert isinstance(system["architecture"], str) + assert isinstance(system["python_version"], str) + + +def test_index_request_fields(client): + response = client.get("/", headers={"User-Agent": "pytest-agent"}) + data = response.get_json() + request_info = data["request"] + + assert request_info["method"] == "GET" + assert request_info["path"] == "/" + assert request_info["user_agent"] == "pytest-agent" + assert request_info["client_ip"] is not None + + +def test_index_runtime_fields(client): + data = client.get("/").get_json() + runtime = data["runtime"] + + assert "seconds" in runtime + assert "human" in runtime + assert "current_time" in runtime + assert runtime["timezone"] == "UTC" + + assert isinstance(runtime["seconds"], int) + assert runtime["seconds"] >= 0 + + # Validate Zulu timestamp format + assert runtime["current_time"].endswith("Z") + + +def test_index_endpoints_list(client): + data = client.get("/").get_json() + endpoints = data["endpoints"] + + assert isinstance(endpoints, list) + assert any(e["path"] == "/" for e in endpoints) + assert any(e["path"] == "/health" for e in endpoints) + assert any(e["path"] == "/metrics" for e in endpoints) + + +# ---------------------------- +# GET /health Tests +# ---------------------------- + + +def test_health_success(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.is_json + + +def test_health_response_structure(client): + data = client.get("/health").get_json() + + assert data["status"] == "healthy" + assert "timestamp" in data + assert "uptime_seconds" in data + + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +def test_health_timestamp_format(client): + data = client.get("/health").get_json() + timestamp = data["timestamp"] + + # ISO 8601 basic validation + iso_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" + assert re.match(iso_regex, timestamp) + + +def test_metrics_success(client): + client.get("/") + client.get("/health") + + response = client.get("/metrics") + + assert response.status_code == 200 + assert response.mimetype.startswith("text/plain") + + +def test_metrics_contains_http_and_app_specific_metrics(client): + client.get("/") + client.get("/health") + client.get("/non-existent") + + metrics_output = client.get("/metrics").get_data(as_text=True) + + assert "http_requests_total" in metrics_output + assert "http_request_duration_seconds" in metrics_output + assert "http_requests_in_progress" in metrics_output + assert "devops_info_endpoint_calls_total" in metrics_output + assert "devops_info_system_info_collection_seconds" in metrics_output + assert 'endpoint="/"' in metrics_output + assert 'endpoint="/health"' in metrics_output + + +# ---------------------------- +# Error Handling Tests +# ---------------------------- + + +def test_404_error(client): + response = client.get("/non-existent") + assert response.status_code == 404 + + data = response.get_json() + assert data["error"] == "Not Found" + assert data["message"] == "Endpoint does not exist" diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..8f2b2f2e17 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,147 @@ +# LAB04 Report (Local VM Alternative - WSL) + +## 1. Cloud Provider and Infrastructure + +- Selected mode: Local VM Alternative using WSL2 (no cloud provider). +- Rationale: local mode is explicitly allowed in `labs/lab04.md`; this lab run focuses on IaC workflow basics and host access preparation for Lab 5. +- Host OS: Ubuntu 24.04.4 LTS on WSL2. +- Kernel: `Linux ZIGOTTA 6.6.87.2-microsoft-standard-WSL2`. +- Region/zone: not applicable in local mode. +- Total cost: $0. +- Resources used: + - WSL2 Ubuntu instance (manual local VM alternative) + - OpenSSH server (`openssh-server`) + - SSH authentication and localhost SSH access + +### Manual local VM creation/setup evidence + +```bash +sudo apt update +sudo apt install -y openssh-server +mkdir -p ~/.ssh +chmod 700 ~/.ssh +cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +sudo service ssh start +``` + +## 2. Terraform Implementation + +- Terraform version: `Terraform v1.14.6` +- Project structure: + - `terraform/main.tf` + - `terraform/variables.tf` + - `terraform/terraform.tfvars.example` + - `terraform/README.md` +- Key decision: in local mode, cloud provider resources are intentionally not provisioned; Terraform configuration is kept minimal and valid. +- Challenges: + - Cloud provider setup was intentionally skipped due to Local VM Alternative. + - Main risk was documenting local-mode decisions clearly to avoid mismatch with cloud-only expectations. + +### Command outputs + +```bash +terraform init +``` + +```text +Initializing the backend... +Initializing provider plugins... + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. All Terraform commands +should now work. + +If you ever set or change modules or backend configuration for Terraform, +rerun this command to reinitialize your working directory. If you forget, other +commands will detect it and remind you to do so if necessary. +``` + +```bash +terraform validate +``` + +```text +Success! The configuration is valid. +``` + +### Local host access proof + +```bash +sudo service ssh status +ssh $USER@localhost +``` + +```text +ssh.service - OpenBSD Secure Shell server + Loaded: loaded (/usr/lib/systemd/system/ssh.service; disabled; preset: enabled) + Active: active (running) since Wed 2026-02-25 23:43:00 MSK; 9min ago +TriggeredBy: ● ssh.socket + Docs: man:sshd(8) + man:sshd_config(5) + Process: 7480 ExecStartPre=/usr/sbin/sshd -t (code=exited, status=0/SUCCESS) + Main PID: 7482 (sshd) + Tasks: 1 (limit: 9081) + Memory: 1.2M (peak: 19.7M) + CPU: 73ms + CGroup: /system.slice/ssh.service + └─7482 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups" + +Feb 25 23:43:00 ZIGOTTA systemd[1]: Starting ssh.service - OpenBSD Secure Shell server... +Feb 25 23:43:00 ZIGOTTA sshd[7482]: Server listening on 0.0.0.0 port 22. +Feb 25 23:43:00 ZIGOTTA sshd[7482]: Server listening on :: port 22. +Feb 25 23:43:00 ZIGOTTA systemd[1]: Started ssh.service - OpenBSD Secure Shell server. +Feb 25 23:43:20 ZIGOTTA sshd[7490]: Accepted password for andre from 127.0.0.1 port 52888 ssh2 +Feb 25 23:43:20 ZIGOTTA sshd[7490]: pam_unix(sshd:session): session opened for user andre(uid=1000) by andre(uid=0) +``` + +## 3. Pulumi Implementation + +- Pulumi status: skipped in execution for this lab run. +- Why skipped: Local VM Alternative in `labs/lab04.md` allows skipping Pulumi cloud recreation for Task 2. +- Validation note: `pulumi` CLI was not installed in this environment at the time of this report. +- Challenges: + - Without running Pulumi commands, comparison is based on Terraform hands-on plus Pulumi documentation review. + +### Command outputs + +```bash +pulumi version +``` + +```text +pulumi not installed +``` + +## 4. Terraform vs Pulumi Comparison + +### Ease of Learning + +Terraform felt easier to start because HCL is focused only on infrastructure and the command flow is straightforward. For this lab, `init` and `validate` were enough to establish a working baseline quickly. Pulumi likely has a steeper start because it requires language/project runtime setup in addition to infrastructure concepts. + +### Code Readability + +Terraform is concise for simple infrastructure declarations and has a predictable structure (`main.tf`, `variables.tf`, `outputs.tf`). Pulumi can be more expressive because it uses general-purpose languages, which helps with reuse and abstractions. For beginners, Terraform files are usually easier to scan, while Pulumi readability depends on coding style. + +### Debugging + +Terraform debugging is clear for syntax and validation stages (`terraform validate`) and plan/apply errors are generally direct. Pulumi should benefit from normal language tooling (IDE, linters, debuggers), which is an advantage in complex logic. In this report, Pulumi debugging observations are inferred because no Pulumi run was executed. + +### Documentation + +Terraform has a larger ecosystem and more examples across providers, which makes finding fixes faster. Pulumi documentation is structured and good, but community examples are still fewer compared to Terraform. For local VM alternative work, Terraform docs were sufficient for completing the required baseline. + +### Use Case + +Terraform is a better default for standard declarative infrastructure and team workflows centered on plans and predictable state changes. Pulumi is stronger when infrastructure requires complex conditional logic, code reuse, or integration with software engineering patterns. In this lab context, Terraform was the practical fit for minimal local-mode validation. + +## 5. Lab 5 Preparation and Cleanup + +- Keeping VM for Lab 5: Yes. +- VM/host kept: WSL Ubuntu host with running SSH service. +- SSH access method: `$USER@localhost`. +- Cleanup status: + - Cloud resources: none created. + - Local SSH service: enabled and accessible. diff --git a/edge-api/.dev.vars.example b/edge-api/.dev.vars.example new file mode 100644 index 0000000000..f139381baa --- /dev/null +++ b/edge-api/.dev.vars.example @@ -0,0 +1,2 @@ +API_TOKEN="replace-with-local-dev-token" +ADMIN_EMAIL="student@example.com" diff --git a/edge-api/.gitignore b/edge-api/.gitignore new file mode 100644 index 0000000000..78b4aa29df --- /dev/null +++ b/edge-api/.gitignore @@ -0,0 +1,9 @@ +.dev.vars +.dev.vars.* +.env +.env.* +node_modules/ +.wrangler/ + +!.dev.vars.example +!.env.example diff --git a/edge-api/WORKERS.md b/edge-api/WORKERS.md new file mode 100644 index 0000000000..2564b4106f --- /dev/null +++ b/edge-api/WORKERS.md @@ -0,0 +1,261 @@ +# Lab 17 β€” Cloudflare Workers Edge Deployment + +This document records the implementation and verification steps for Lab 17. The Worker project lives in [`edge-api/`](./), and the lab rubric remains in [`labs/lab17.md`](../labs/lab17.md). + +## Deployment Summary + +- Worker name: `edge-api` +- Runtime: Cloudflare Workers (TypeScript, ES modules) +- Public URL: `https://edge-api.andreygamer366.workers.dev` +- Account verification: completed separately with `npx wrangler whoami` +- Current deployed app version: `1.0.1` +- Current Cloudflare version ID: `af1bf96c-9fe7-4841-b910-9bfc2963a2cb` +- Main routes: + +| Route | Method | Purpose | +|------|--------|---------| +| `/` | `GET` | Service metadata, runtime info, and endpoint inventory | +| `/health` | `GET` | Basic health response with UTC timestamp | +| `/edge` | `GET` | Cloudflare edge metadata from `request.cf` | +| `/counter` | `GET` | Workers KV persistence demo | +| `/admin` | `GET` | Secret-backed route using `x-api-token` | + +## Configuration Used + +### Plaintext vars in `wrangler.jsonc` + +```jsonc +"vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core Course", + "APP_VERSION": "1.0.1" +} +``` + +Why plaintext vars are acceptable here: +- They hold app metadata and course configuration, not secrets. +- Cloudflare environment variables are visible configuration, so they are not suitable for passwords or tokens. + +### Required secrets + +Create these secrets before testing `/admin` or deploying: + +```bash +npx wrangler secret put API_TOKEN +npx wrangler secret put ADMIN_EMAIL +``` + +Local development example: + +```dotenv +API_TOKEN="replace-with-local-dev-token" +ADMIN_EMAIL="student@example.com" +``` + +Do not commit the real `.dev.vars` or `.env` files. + +### Workers KV binding + +Create the namespace and then replace the placeholder ID in [`wrangler.jsonc`](./wrangler.jsonc): + +```bash +npx wrangler kv namespace create SETTINGS +``` + +The namespace created for this lab is: + +```text +SETTINGS => cb5b72b2ab72416a9cf6f6f79841a348 +``` + +Expected binding block: + +```jsonc +"kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "" + } +] +``` + +Note for local development: +- `wrangler dev` uses local KV by default. +- If you want local code to hit the deployed namespace, add `"remote": true` to the KV binding after the namespace exists. + +### Observability + +Workers Logs is enabled in [`wrangler.jsonc`](./wrangler.jsonc): + +```jsonc +"observability": { + "enabled": true, + "head_sampling_rate": 1 +} +``` + +## Implementation Notes + +The Worker intentionally mirrors the shape of the earlier Python course service while adapting to the Workers runtime: + +- [`src/index.ts`](./src/index.ts) returns service metadata from `/`, similar to the existing Flask app. +- `/health` provides a minimal health contract suitable for public checks. +- `/edge` exposes request metadata such as `colo`, `country`, `city`, `asn`, `httpProtocol`, and `tlsVersion`. +- `/counter` persists a `visits` key in Workers KV and includes an explicit note that KV is eventually consistent. +- `/admin` validates `x-api-token` against `API_TOKEN` and returns only a masked form of `ADMIN_EMAIL`. +- A safe `console.log()` statement records path, method, and non-sensitive edge metadata for observability. + +## Verification Commands + +### Local development + +```bash +cd edge-api +npm install +npx wrangler dev +``` + +In another terminal: + +```bash +curl http://127.0.0.1:8787/ +curl http://127.0.0.1:8787/health +curl http://127.0.0.1:8787/edge +curl http://127.0.0.1:8787/counter +curl -i http://127.0.0.1:8787/admin +curl -i -H "x-api-token: replace-with-local-dev-token" http://127.0.0.1:8787/admin +``` + +### Deployment + +```bash +cd edge-api +npx wrangler deploy +``` + +After deployment, test the public Worker: + +```bash +curl https://..workers.dev/health +curl https://..workers.dev/edge +curl https://..workers.dev/counter +curl -i -H "x-api-token: " https://..workers.dev/admin +``` + +### Deployments and rollback + +Create at least two code deployments, then inspect history: + +```bash +npx wrangler deployments list +``` + +Rollback options: + +```bash +npx wrangler rollback +npx wrangler rollback +``` + +Deployment history completed during this lab: + +```text +Initial code deployment: 74c3f1f4-7769-403b-ac87-6ab89fc43b12 +Second code deployment: d5aeb9a2-2703-4c9f-97ee-aaeb1ae22d2a +Rollback demo: deployed 74c3f1f4-7769-403b-ac87-6ab89fc43b12 with message "Lab 17 rollback demo" +Restored latest code: af1bf96c-9fe7-4841-b910-9bfc2963a2cb +``` + +## Evidence + +### Example `/edge` response + +```json +{ + "runtime": "cloudflare-workers", + "metadata": { + "colo": "HEL", + "country": "FI", + "city": "Vantaa", + "asn": 13335, + "httpProtocol": "HTTP/2", + "tlsVersion": "TLSv1.3" + }, + "note": "request.cf metadata is only populated by the Workers runtime, so verify this endpoint against the deployed workers.dev URL." +} +``` + +### Persistence check + +```text +Before second code deploy: visits = 1 +After second code deploy: visits = 2 +After rollback + restore: visits = 3 +Conclusion: KV data persisted across code deployments and rollback operations because the namespace binding remained attached. +``` + +### Logs / metrics + +Capture either: +- `npx wrangler tail` output showing the route log line, or +- the Worker Observability dashboard showing requests/logs. + +```text +Example tail log: +GET https://edge-api.andreygamer366.workers.dev/health - Ok @ 5/15/2026, 6:59:48 AM + (log) request { path: '/health', method: 'GET', colo: 'HEL', country: 'FI' } +``` + +### Required screenshots + +Do not automate screenshots with Wrangler or headless tooling. + +Take these manually and place them in [`screenshots/`](./screenshots/): +- `worker-overview.png` showing the Worker overview and `workers.dev` URL +- `observability.png` showing logs or metrics after exercising the Worker +- `deployments.png` showing at least two deployments or a rollback entry + +## Kubernetes vs Cloudflare Workers Comparison + +| Aspect | Kubernetes | Cloudflare Workers | +|--------|------------|--------------------| +| Setup complexity | Cluster, ingress, manifests, secrets, and ongoing ops | Much lighter for small APIs; project + deploy + bindings | +| Deployment speed | Usually slower due to image build, push, scheduling, and rollout | Very fast upload and global publish | +| Global distribution | Usually requires multi-region cluster design or provider features | Built into the platform by default | +| Cost for small apps | Can be expensive or operationally heavy | Often cheaper and simpler for low-traffic APIs | +| State / persistence | Flexible but explicit: volumes, DBs, operators, services | Bindings-based; KV, D1, R2, Durable Objects | +| Control / flexibility | Full container/runtime/network control | Less control; code must fit the Workers runtime model | +| Best use case | Long-running services, custom runtimes, complex networking | Edge APIs, lightweight request handling, globally distributed logic | + +## When To Use Each + +Prefer Kubernetes when: +- You need full container control or a custom runtime. +- You run stateful or long-lived services. +- You need advanced networking, sidecars, or operator-based integrations. + +Prefer Workers when: +- You need a lightweight public API at the edge. +- You want fast deployment with minimal platform management. +- Your workload fits request/response execution and binding-based state. + +Recommendation: +- For this lab and similar lightweight APIs, Workers is the faster and simpler fit. +- For larger service platforms or container-native systems, Kubernetes remains the more flexible choice. + +## Reflection + +What felt easier than Kubernetes: +- No container image build, registry push, or cluster scheduling was required. +- Public access through `workers.dev` is much faster to get running. +- Secrets, vars, logs, and KV are integrated into the platform workflow. + +What felt more constrained: +- The runtime is not a Docker host, so the app had to be rewritten for the Workers execution model. +- Storage and networking options are platform-specific and more opinionated. +- Some request metadata such as `request.cf` only appears in the real runtime, not every local preview path. + +What changed because Workers is not a Docker host: +- The API became a single request handler instead of a traditional process listening on a port. +- State moved from filesystem/container assumptions to a platform binding (`SETTINGS`). +- Operational tasks shifted from cluster resources to Wrangler commands and dashboard views. diff --git a/edge-api/screenshots/.gitkeep b/edge-api/screenshots/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/edge-api/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/edge-api/src/index.ts b/edge-api/src/index.ts new file mode 100644 index 0000000000..787e56a1a7 --- /dev/null +++ b/edge-api/src/index.ts @@ -0,0 +1,266 @@ +interface KVBinding { + get(key: string): Promise; + put(key: string, value: string): Promise; +} + +export interface Env { + APP_NAME: string; + COURSE_NAME: string; + APP_VERSION: string; + API_TOKEN: string; + ADMIN_EMAIL: string; + SETTINGS: KVBinding; +} + +interface RequestCfMetadata { + asn?: number; + city?: string | null; + colo?: string; + country?: string; + httpProtocol?: string; + tlsVersion?: string; +} + +type CloudflareRequest = Request & { + cf?: RequestCfMetadata; +}; + +type JsonRecord = Record; + +const COUNTER_KEY = "visits"; + +const ROUTES = [ + { + path: "/", + method: "GET", + description: "Course-service style metadata for the Worker deployment" + }, + { + path: "/health", + method: "GET", + description: "Health check with UTC timestamp" + }, + { + path: "/edge", + method: "GET", + description: "Cloudflare edge request metadata from request.cf" + }, + { + path: "/counter", + method: "GET", + description: "KV-backed visit counter for persistence validation" + }, + { + path: "/admin", + method: "GET", + description: "Secret-backed route protected by x-api-token" + } +]; + +function json(data: JsonRecord, init: ResponseInit = {}): Response { + return Response.json(data, { + status: init.status ?? 200, + headers: init.headers + }); +} + +function notFound(path: string): Response { + return json( + { + error: "Not Found", + message: "Endpoint does not exist", + path + }, + { status: 404 } + ); +} + +function methodNotAllowed(method: string, path: string): Response { + return json( + { + error: "Method Not Allowed", + message: "Only GET is supported by this lab Worker", + method, + path + }, + { status: 405 } + ); +} + +function unauthorized(path: string): Response { + return json( + { + error: "Unauthorized", + message: "Provide a valid x-api-token header", + path + }, + { status: 401 } + ); +} + +function configurationError(message: string): Response { + return json( + { + error: "Configuration Error", + message + }, + { status: 500 } + ); +} + +function utcTimestamp(): string { + return new Date().toISOString(); +} + +function maskEmail(email: string): string { + const [localPart, domain] = email.split("@"); + + if (!localPart || !domain) { + return "invalid-email-format"; + } + + if (localPart.length === 1) { + return `*@${domain}`; + } + + if (localPart.length === 2) { + return `${localPart[0]}*@${domain}`; + } + + return `${localPart.slice(0, 2)}***${localPart.slice(-1)}@${domain}`; +} + +function getRequestInfo(request: Request): JsonRecord { + return { + method: request.method, + path: new URL(request.url).pathname, + userAgent: request.headers.get("user-agent") ?? "unknown", + cfRay: request.headers.get("cf-ray") ?? null + }; +} + +function getRuntimeInfo(request: CloudflareRequest): JsonRecord { + return { + runtime: "cloudflare-workers", + timestamp: utcTimestamp(), + colo: request.cf?.colo ?? null, + country: request.cf?.country ?? null + }; +} + +async function handleCounter(env: Env): Promise { + const raw = await env.SETTINGS.get(COUNTER_KEY); + const current = Number(raw ?? "0"); + const visits = Number.isFinite(current) ? current + 1 : 1; + + await env.SETTINGS.put(COUNTER_KEY, String(visits)); + + return json({ + counterKey: COUNTER_KEY, + visits, + storage: "workers-kv", + persistence: "survives Worker redeploys as long as the KV namespace stays bound", + note: "Workers KV is eventually consistent, so this counter is a persistence demo rather than a strong-consistency design." + }); +} + +function handleIndex(request: CloudflareRequest, env: Env): Response { + return json({ + service: { + name: env.APP_NAME, + version: env.APP_VERSION, + description: "Cloudflare Workers port of the DevOps course info service", + course: env.COURSE_NAME, + runtime: "cloudflare-workers" + }, + request: getRequestInfo(request), + runtime: getRuntimeInfo(request), + configuration: { + plaintextVars: ["APP_NAME", "COURSE_NAME", "APP_VERSION"], + secretBindings: ["API_TOKEN", "ADMIN_EMAIL"], + kvBinding: "SETTINGS", + observability: "enabled in wrangler.jsonc" + }, + endpoints: ROUTES + }); +} + +function handleHealth(request: CloudflareRequest): Response { + return json({ + status: "healthy", + timestamp: utcTimestamp(), + runtime: "cloudflare-workers", + request: { + path: new URL(request.url).pathname, + colo: request.cf?.colo ?? null + } + }); +} + +function handleEdge(request: CloudflareRequest): Response { + return json({ + runtime: "cloudflare-workers", + metadata: { + colo: request.cf?.colo ?? null, + country: request.cf?.country ?? null, + city: request.cf?.city ?? null, + asn: request.cf?.asn ?? null, + httpProtocol: request.cf?.httpProtocol ?? null, + tlsVersion: request.cf?.tlsVersion ?? null + }, + note: "request.cf metadata is only populated by the Workers runtime, so verify this endpoint against the deployed workers.dev URL." + }); +} + +function handleAdmin(request: Request, env: Env): Response { + if (!env.API_TOKEN || !env.ADMIN_EMAIL) { + return configurationError( + "Missing required secrets. Set API_TOKEN and ADMIN_EMAIL before testing /admin." + ); + } + + const providedToken = request.headers.get("x-api-token"); + + if (!providedToken || providedToken !== env.API_TOKEN) { + return unauthorized(new URL(request.url).pathname); + } + + return json({ + status: "authorized", + message: "Secret-backed route verified without exposing raw secret values", + adminEmailMasked: maskEmail(env.ADMIN_EMAIL) + }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const cfRequest = request as CloudflareRequest; + + console.log("request", { + path: url.pathname, + method: request.method, + colo: cfRequest.cf?.colo ?? null, + country: cfRequest.cf?.country ?? null + }); + + if (request.method !== "GET") { + return methodNotAllowed(request.method, url.pathname); + } + + switch (url.pathname) { + case "/": + return handleIndex(cfRequest, env); + case "/health": + return handleHealth(cfRequest); + case "/edge": + return handleEdge(cfRequest); + case "/counter": + return handleCounter(env); + case "/admin": + return handleAdmin(request, env); + default: + return notFound(url.pathname); + } + } +}; diff --git a/edge-api/wrangler.jsonc b/edge-api/wrangler.jsonc new file mode 100644 index 0000000000..46ff811eb4 --- /dev/null +++ b/edge-api/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "edge-api", + "main": "src/index.ts", + "compatibility_date": "2026-05-15", + "workers_dev": true, + "observability": { + "enabled": true, + "head_sampling_rate": 1 + }, + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "DevOps Core Course", + "APP_VERSION": "1.0.1" + }, + "secrets": { + "required": ["API_TOKEN", "ADMIN_EMAIL"] + }, + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "cb5b72b2ab72416a9cf6f6f79841a348" + } + ] +} diff --git a/k8s/HELM.md b/k8s/HELM.md new file mode 100644 index 0000000000..1bac805b8e --- /dev/null +++ b/k8s/HELM.md @@ -0,0 +1,451 @@ +# Lab 10 - Helm Package Manager + +## 1. Chart Overview + +### Helm Fundamentals + +Helm is valuable here because it turns the static Lab 9 manifests into a reusable package with: + +- templated configuration instead of hardcoded values +- release history for upgrade and rollback +- environment-specific overrides via values files +- lifecycle hooks for validation and smoke testing + +### Helm Setup Evidence + +Helm is installed from the apt repository and the active binary resolves to `/usr/sbin/helm`. + +`helm version`: + +```text +version.BuildInfo{Version:"v3.20.0", GitCommit:"b2e4314fa0f229a1de7b4c981273f61d69ee5a59", GitTreeState:"clean", GoVersion:"go1.25.6"} +``` + +Repository setup: + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +``` + +Repository exploration: + +```text +NAME CHART VERSION APP VERSION DESCRIPTION +prometheus-community/prometheus 28.15.0 v3.11.0 Prometheus is a monitoring system and time series database. +``` + +`helm show chart prometheus-community/prometheus` excerpt: + +```text +apiVersion: v2 +name: prometheus +description: Prometheus is a monitoring system and time series database. +type: application +version: 28.15.0 +appVersion: v3.11.0 +``` + +### Chart Structure + +Chart location: + +```text +k8s/devops-info-service/ +β”œβ”€β”€ Chart.yaml +β”œβ”€β”€ values.yaml +β”œβ”€β”€ values-dev.yaml +β”œβ”€β”€ values-prod.yaml +└── templates/ + β”œβ”€β”€ _helpers.tpl + β”œβ”€β”€ deployment.yaml + β”œβ”€β”€ service.yaml + β”œβ”€β”€ NOTES.txt + └── hooks/ + β”œβ”€β”€ pre-install-job.yaml + └── post-install-job.yaml +``` + +Key template files: + +- `templates/deployment.yaml`: main Flask Deployment with probes, resources, rolling update strategy, and non-root security settings +- `templates/service.yaml`: Service template supporting `NodePort` and `LoadBalancer` +- `templates/_helpers.tpl`: reusable naming and labels helpers +- `templates/hooks/pre-install-job.yaml`: validates basic values before install +- `templates/hooks/post-install-job.yaml`: smoke-tests `/health` after install +- `templates/NOTES.txt`: post-install operator hints + +### Values Organization Strategy + +The values structure mirrors the Kubernetes manifest concerns: + +- `image.*` for repository, tag, and pull policy +- `service.*` for type and port exposure +- `resources.*` for requests and limits +- `livenessProbe` and `readinessProbe` kept configurable, never removed +- `hooks.*` for lifecycle behavior +- `deploymentStrategy`, `minReadySeconds`, and `revisionHistoryLimit` for rollout behavior + +## 2. Configuration Guide + +### Important Values + +- `replicaCount`: number of pod replicas +- `image.repository` / `image.tag`: container image source +- `containerPort`: container listening port +- `service.type`: `NodePort` for local access, `LoadBalancer` for production-style exposure +- `service.nodePort`: fixed local NodePort for dev install +- `resources.requests` / `resources.limits`: scheduler and runtime resource boundaries +- `livenessProbe` / `readinessProbe`: health-check timings and paths +- `hooks.enabled`: enables lifecycle Jobs + +### Default Values + +The chart default in `values.yaml` matches the Lab 9 baseline: + +- `replicaCount: 3` +- image `devops-info-service:lab09` +- `NodePort` service +- resource requests `100m / 128Mi` +- resource limits `250m / 256Mi` + +### Environment-Specific Values + +`values-dev.yaml`: + +- `replicaCount: 1` +- `NodePort` service on `30081` +- lighter resources: `50m / 64Mi` requests and `100m / 128Mi` limits +- faster probe startup timings + +`values-prod.yaml`: + +- `replicaCount: 3` +- `LoadBalancer` service +- stronger resources: `150m / 192Mi` requests and `500m / 512Mi` limits +- production-style probe timings + +### Example Commands + +Development install: + +```bash +helm install lab10-devops k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml --wait --wait-for-jobs --debug +``` + +Production-style upgrade: + +```bash +helm upgrade lab10-devops k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml --wait --debug +``` + +Render without installing: + +```bash +helm template lab10-devops k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml +``` + +## 3. Hook Implementation + +### Implemented Hooks + +#### Pre-install Hook + +File: `templates/hooks/pre-install-job.yaml` + +Purpose: + +- validate critical values before installation starts +- fail early if `replicaCount` or `containerPort` is invalid + +Behavior: + +- hook type: `pre-install` +- weight: `-5` +- delete policy: `before-hook-creation,hook-succeeded` + +#### Post-install Hook + +File: `templates/hooks/post-install-job.yaml` + +Purpose: + +- run a basic smoke test against `http://:80/health` +- verify the application actually responds after install + +Behavior: + +- hook type: `post-install` +- weight: `5` +- delete policy: `before-hook-creation,hook-succeeded` + +### Execution Order + +1. Pre-install validation job runs first because of weight `-5` +2. Helm creates the Service and Deployment +3. Helm waits for the workload to become ready +4. Post-install smoke test runs last because of weight `5` + +### Hook Evidence + +`kubectl get jobs` snapshots during install: + +```text +--- snapshot 11 --- +NAME STATUS COMPLETIONS DURATION AGE +lab10-devops-devops-info-service-pre-install Running 0/1 0s 0s + +--- snapshot 42 --- +NAME STATUS COMPLETIONS DURATION AGE +lab10-devops-devops-info-service-post-install Running 0/1 0s 0s +``` + +`kubectl describe job lab10-devops-devops-info-service-pre-install` excerpt: + +```text +Annotations: helm.sh/hook: pre-install + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + helm.sh/hook-weight: -5 +Image: busybox:1.36 +Command: + echo "Validating Helm values before install" + test 1 -ge 1 + test 5000 -ge 1 +``` + +`kubectl describe job lab10-devops-devops-info-service-post-install` excerpt: + +```text +Annotations: helm.sh/hook: post-install + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + helm.sh/hook-weight: 5 +Image: busybox:1.36 +Command: + RESPONSE="$(wget -qO- http://lab10-devops-devops-info-service:80/health)" +``` + +Deletion policy verification: + +```text +$ kubectl get jobs +No resources found in default namespace. +``` + +That confirms the hook Jobs were cleaned up after successful execution. + +## 4. Installation Evidence + +### Helm List After Dev Install + +```text +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +lab10-devops default 1 2026-04-02 23:04:43.723913299 +0300 MSK deployed devops-info-service-0.1.0 1.0.0 +``` + +### `kubectl get all` After Dev Install + +```text +NAME READY STATUS RESTARTS AGE +pod/lab10-devops-devops-info-service-6dfddc876-26xv4 1/1 Running 0 47s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/lab10-devops-devops-info-service NodePort 10.101.159.210 80:30081/TCP 47s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/lab10-devops-devops-info-service 1/1 1 1 47s +``` + +### Application Accessibility Verification + +`minikube service lab10-devops-devops-info-service --url`: + +```text +http://127.0.0.1:33097 +Because you are using a Docker driver on linux, the terminal needs to be open to run it. +``` + +`curl` output: + +```text +curl -s http://127.0.0.1:33097/health +{"status":"healthy","timestamp":"2026-04-02T20:06:12.183364+00:00","uptime_seconds":57} +``` + +### Environment Deployment Differences + +Dev install state: + +```text +replicas: 1 +service type: NodePort +service port: 80:30081/TCP +resources: + requests: cpu 50m / memory 64Mi + limits: cpu 100m / memory 128Mi +``` + +Prod upgrade state: + +```text +Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable +Service type: LoadBalancer +EXTERNAL-IP: +Requests: + cpu: 150m + memory: 192Mi +Limits: + cpu: 500m + memory: 512Mi +``` + +Minikube note: + +- `LoadBalancer` changed successfully on the resource +- external IP remained `` locally because Minikube was not running `minikube tunnel` + +## 5. Operations + +### Install + +```bash +helm install lab10-devops k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml --wait --wait-for-jobs --debug +``` + +Result: + +```text +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +``` + +### Upgrade + +```bash +helm upgrade lab10-devops k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml --wait --debug +``` + +Result: + +```text +Release "lab10-devops" has been upgraded. Happy Helming! +STATUS: deployed +REVISION: 2 +DESCRIPTION: Upgrade complete +``` + +### Rollback + +```bash +helm rollback lab10-devops 1 --wait --debug +``` + +Result: + +```text +Rollback was a success! Happy Helming! +``` + +Release history after rollback: + +```text +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Thu Apr 2 23:04:43 2026 superseded devops-info-service-0.1.0 1.0.0 Install complete +2 Thu Apr 2 23:06:17 2026 superseded devops-info-service-0.1.0 1.0.0 Upgrade complete +3 Thu Apr 2 23:07:03 2026 deployed devops-info-service-0.1.0 1.0.0 Rollback to 1 +``` + +### Uninstall + +```bash +helm uninstall lab10-devops +``` + +Result: + +```text +release "lab10-devops" uninstalled +``` + +Cleanup verification: + +```text +$ helm list -A +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION + +$ kubectl get deploy,svc,pods -l app.kubernetes.io/instance=lab10-devops +No resources found in default namespace. +``` + +## 6. Testing and Validation + +### `helm lint` + +```text +==> Linting k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +### `helm template` + +Development render verified: + +- `replicas: 1` +- `type: NodePort` +- `nodePort: 30081` +- lighter resource profile + +Production render verified: + +- `replicas: 3` +- `type: LoadBalancer` +- heavier resource profile + +### `helm install --dry-run --debug` + +Dry-run confirmed: + +- user-supplied values from `values-dev.yaml` +- computed values merged correctly with defaults +- hooks rendered with expected annotations +- deployment and service manifests matched the chart configuration + +Key dry-run excerpts: + +```text +STATUS: pending-install +DESCRIPTION: Dry run complete +``` + +```text +HOOKS: +- pre-install job with weight "-5" +- post-install job with weight "5" +``` + +```text +MANIFEST: +- NodePort service on 30081 +- Deployment replicas: 1 +``` + +### Application Validation + +- The dev release became reachable through `minikube service` +- `curl /health` returned a healthy JSON payload +- Production upgrade completed successfully with `3/3` available replicas +- Rollback restored the dev release shape and then uninstall cleaned it up + +## 7. Summary + +Lab 10 is implemented as a reusable Helm chart that preserves the Lab 9 behavior while adding: + +- parameterized values +- environment-specific overrides +- install-time validation hooks +- a post-install smoke test +- tested install, upgrade, rollback, and uninstall workflows + +The chart passed `helm lint`, rendered correctly for dev and prod, installed successfully on Minikube, and was validated through a full release lifecycle. diff --git a/k8s/MONITORING.md b/k8s/MONITORING.md new file mode 100644 index 0000000000..0a6304d5c3 --- /dev/null +++ b/k8s/MONITORING.md @@ -0,0 +1,284 @@ +# Lab 16 - Kubernetes Monitoring & Init Containers + +This lab was completed and verified on **May 9, 2026** with: + +- Minikube `v1.38.1` +- Kubernetes `v1.35.1` +- Helm `v3.20.0` +- `kube-prometheus-stack` chart `65.8.1` + +I also used **Playwright** to capture the dashboard and UI screenshots stored in [`k8s/screenshots/lab16`](./screenshots/lab16/). + +## 1. Stack Components + +- **Prometheus Operator**: manages Prometheus-related custom resources and keeps StatefulSets, configs, and secrets in sync with the desired state. +- **Prometheus**: scrapes metrics from Kubernetes targets and stores them as time-series data for queries and dashboards. +- **Alertmanager**: receives firing alerts from Prometheus, groups them, and shows their status in a single UI. +- **Grafana**: visualizes the collected metrics through the prebuilt Kubernetes dashboards. +- **kube-state-metrics**: exposes cluster object state like pod, workload, PVC, and namespace metadata as Prometheus metrics. +- **node-exporter**: exposes host-level CPU, memory, filesystem, and network metrics from the Minikube node. + +## 2. What I Added + +For this lab I extended the repo with: + +- `k8s/devops-info-service/templates/statefulset.yaml` +- `k8s/devops-info-service/templates/headless-service.yaml` +- `k8s/devops-info-service/templates/servicemonitor.yaml` +- `k8s/devops-info-service/values-monitoring.yaml` +- `k8s/lab16/init-containers-demo.yaml` + +That let me deploy: + +- a **StatefulSet-based** version of the app for workload monitoring +- a **ServiceMonitor** for the bonus Prometheus scrape task +- a separate **init-container demo** that waits for a service and downloads a file into a shared volume before nginx starts + +## 3. Installation Evidence + +Monitoring stack install: + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm upgrade --install monitoring prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --create-namespace \ + --version 65.8.1 \ + --wait --timeout 20m +``` + +Application and init-demo install: + +```bash +docker build -t devops-info-service:lab09 ./app_python +minikube image load devops-info-service:lab09 + +helm upgrade --install lab16-monitoring k8s/devops-info-service \ + -f k8s/devops-info-service/values-monitoring.yaml \ + --wait --wait-for-jobs --timeout 10m + +kubectl apply -f k8s/lab16/init-containers-demo.yaml +kubectl rollout status deployment/lab16-init-source --timeout=180s +kubectl rollout status deployment/lab16-init-demo --timeout=180s +``` + +`kubectl get po,svc -n monitoring`: + +```text +NAME READY STATUS RESTARTS AGE +pod/alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running 0 17m +pod/monitoring-grafana-69db76f9b4-l5rdh 3/3 Running 0 18m +pod/monitoring-kube-prometheus-operator-d5dbb45f9-29f7m 1/1 Running 0 18m +pod/monitoring-kube-state-metrics-75c9d8f7c7-pd2nq 1/1 Running 0 18m +pod/monitoring-prometheus-node-exporter-k4td9 1/1 Running 0 18m +pod/prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running 0 17m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/alertmanager-operated ClusterIP None 9093/TCP,9094/TCP,9094/UDP 17m +service/monitoring-grafana ClusterIP 10.99.81.137 80/TCP 18m +service/monitoring-kube-prometheus-alertmanager ClusterIP 10.99.24.246 9093/TCP,8080/TCP 18m +service/monitoring-kube-prometheus-operator ClusterIP 10.99.175.124 443/TCP 18m +service/monitoring-kube-prometheus-prometheus ClusterIP 10.109.42.79 9090/TCP,8080/TCP 18m +service/monitoring-kube-state-metrics ClusterIP 10.102.250.3 8080/TCP 18m +service/monitoring-prometheus-node-exporter ClusterIP 10.100.43.210 9100/TCP 18m +service/prometheus-operated ClusterIP None 9090/TCP 17m +``` + +StatefulSet and ServiceMonitor verification: + +```text +NAME READY AGE +statefulset.apps/lab16-monitoring-devops-info-service 3/3 14m + +NAME READY STATUS RESTARTS AGE +pod/lab16-init-demo-7c49969894-fkqmc 1/1 Running 0 11m +pod/lab16-init-source-c4f9ff8bf-wvprg 1/1 Running 0 13m +pod/lab16-monitoring-devops-info-service-0 1/1 Running 0 14m +pod/lab16-monitoring-devops-info-service-1 1/1 Running 0 13m +pod/lab16-monitoring-devops-info-service-2 1/1 Running 0 13m + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +persistentvolumeclaim/data-lab16-monitoring-devops-info-service-0 Bound pvc-3de741f4-a881-4622-a80e-2f8d1de75e32 128Mi RWO standard 14m +persistentvolumeclaim/data-lab16-monitoring-devops-info-service-1 Bound pvc-3f7823d5-96cd-4270-a9de-fb5f0f3af7ca 128Mi RWO standard 13m +persistentvolumeclaim/data-lab16-monitoring-devops-info-service-2 Bound pvc-8553d3f7-fb3a-48e6-b63d-bf84c1e6d8fc 128Mi RWO standard 13m + +NAME AGE +servicemonitor.monitoring.coreos.com/lab16-monitoring-devops-info-service 14m +``` + +## 4. Dashboard Answers + +All metrics below were collected after installing the stack, deploying the StatefulSet release, and generating sample traffic on **May 9, 2026**. + +### 4.1 Pod Resources - StatefulSet CPU and Memory + +StatefulSet pod usage from Prometheus: + +```text +lab16-monitoring-devops-info-service-0: 0.82 mCPU, 27.11 MiB +lab16-monitoring-devops-info-service-1: 0.95 mCPU, 26.73 MiB +lab16-monitoring-devops-info-service-2: 0.84 mCPU, 26.50 MiB +``` + +Screenshot: + +![Workload dashboard](./screenshots/lab16/workload-dashboard.png) + +### 4.2 Namespace Analysis - Most and Least CPU in `default` + +Current CPU ranking in the `default` namespace: + +```text +Most CPU: lab16-monitoring-devops-info-service-1 -> 0.95 mCPU +Then: lab16-monitoring-devops-info-service-2 -> 0.84 mCPU +Then: lab16-monitoring-devops-info-service-0 -> 0.82 mCPU +Then: lab16-init-demo-7c49969894-fkqmc -> 0.09 mCPU +Least CPU: lab16-init-source-c4f9ff8bf-wvprg -> 0.00 mCPU +``` + +Screenshot: + +![Namespace CPU panel](./screenshots/lab16/namespace-cpu-panel.png) + +### 4.3 Node Metrics - Memory Usage and CPU Cores + +For the Minikube node: + +```text +Memory used: 59.08% +Memory used: 4476.85 MiB (~4694 MB) +CPU cores: 16 +``` + +Screenshot: + +![Node dashboard](./screenshots/lab16/node-dashboard.png) + +### 4.4 Kubelet - Managed Pods and Containers + +Kubelet metrics showed: + +```text +Running pods: 38 +Running containers: 77 +``` + +Screenshot: + +![Kubelet dashboard](./screenshots/lab16/kubelet-dashboard.png) + +### 4.5 Network / Traffic for Pods in `default` + +In this local Minikube run, the stock Grafana pod-bandwidth panels backed by `container_network_*` stayed empty, so I used the **application traffic actually scraped by Prometheus** as the practical traffic view for the default-namespace app pods. + +15-minute request totals from `devops_info_endpoint_calls_total`: + +```text +/health traffic: + lab16-monitoring-devops-info-service-2 -> 530.15 requests + lab16-monitoring-devops-info-service-1 -> 523.49 requests + lab16-monitoring-devops-info-service-0 -> 499.15 requests + +/ traffic: + lab16-monitoring-devops-info-service-0 -> 370.92 requests + lab16-monitoring-devops-info-service-1 -> 220.82 requests + lab16-monitoring-devops-info-service-2 -> 53.10 requests +``` + +That matched the synthetic traffic I sent to create a measurable difference between the three StatefulSet pods. + +Screenshot: + +![Prometheus traffic query](./screenshots/lab16/prometheus-traffic-query.png) + +### 4.6 Alerts - Active Alert Count in Alertmanager + +At the time of capture there were **8 active alerts**: + +```text +TargetDown (kube-scheduler, warning) +KubeSchedulerDown (critical) +etcdInsufficientMembers (critical) +etcdMembersDown (critical) +TargetDown (kube-controller-manager, warning) +KubeControllerManagerDown (critical) +Watchdog (none) +TargetDown (kube-etcd, warning) +``` + +These are expected in this single-node Minikube setup because some control-plane targets are not exposed the same way they would be in a full multi-node cluster. + +Screenshot: + +![Alertmanager alerts](./screenshots/lab16/alertmanager-alerts.png) + +## 5. Init Containers + +Manifest file: + +- `k8s/lab16/init-containers-demo.yaml` + +What it demonstrates: + +- `wait-for-source` waits until `lab16-init-source.default.svc.cluster.local` resolves and the HTTP endpoint responds +- `download-page` uses `wget` to download `/index.html` into a shared `emptyDir` +- the main nginx container serves the downloaded file from the same shared volume + +Init-container log proof: + +```text +Connecting to lab16-init-source (10.100.149.205:80) +saving to '/work-dir/index.html' +index.html 100% |********************************| 150 0:00:00 ETA +'/work-dir/index.html' saved +``` + +Main-container proof: + +```html + + +

Lab 16 init container demo

+

This file was fetched by an init container before nginx started.

+ + +``` + +Screenshot: + +![Init demo page](./screenshots/lab16/init-demo-page.png) + +## 6. Bonus - Custom Metrics and ServiceMonitor + +The app already exposes `/metrics` through `prometheus-client`, and for this lab I added a ServiceMonitor template so Prometheus can scrape the workload automatically. + +ServiceMonitor resource: + +- `k8s/devops-info-service/templates/servicemonitor.yaml` + +Lab-specific values file: + +- `k8s/devops-info-service/values-monitoring.yaml` + +Prometheus target health: + +```text +lab16-monitoring-devops-info-service-0 -> up=1 +lab16-monitoring-devops-info-service-1 -> up=1 +lab16-monitoring-devops-info-service-2 -> up=1 +``` + +Screenshot: + +![Prometheus ServiceMonitor targets](./screenshots/lab16/prometheus-targets-servicemonitor.png) + +## 7. Summary + +Lab 16 is complete with: + +- Kube-Prometheus installed and verified +- Grafana and Alertmanager evidence captured with Playwright screenshots +- StatefulSet workload monitored through Prometheus/Grafana +- Init container download and wait-for-service patterns implemented +- `/metrics` scraping verified through a ServiceMonitor diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..5dfc92ba7b --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,378 @@ +# Lab 09 - Kubernetes Fundamentals + +## 1. Architecture Overview + +I used **Minikube** with the **Docker driver** because this repository already uses Docker, the setup works well on local WSL-based development machines, and `minikube service` makes NodePort access straightforward without needing a cloud load balancer. + +Current deployment shape: + +```text +Client (curl/browser) + -> minikube service devops-info-service --url + -> Service/devops-info-service (NodePort 80 -> 30080) + -> Deployment/devops-info-service + -> 3 Flask Pods listening on port 5000 +``` + +Resource and rollout strategy: + +- `replicas: 3` in the declarative manifest +- CPU request/limit: `100m` / `250m` +- Memory request/limit: `128Mi` / `256Mi` +- Rolling update strategy: `maxSurge: 1`, `maxUnavailable: 0` +- `minReadySeconds: 5` to avoid marking pods available too early +- Pod security: non-root numeric UID/GID `10001`, dropped capabilities, `RuntimeDefault` seccomp + +## 2. Cluster Setup Evidence + +Tooling used: + +- `kubectl v1.35.3` +- `minikube v1.38.1` +- Docker driver with Kubernetes `v1.35.1` + +Cluster startup: + +```bash +minikube start --driver=docker +``` + +`kubectl cluster-info`: + +```text +Kubernetes control plane is running at https://127.0.0.1:32771 +CoreDNS is running at https://127.0.0.1:32771/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy +``` + +`kubectl get nodes -o wide`: + +```text +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 12s v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.6.87.2-microsoft-standard-WSL2 docker://29.2.1 +``` + +## 3. Manifest Files + +### `k8s/deployment.yml` + +This manifest creates the main application Deployment: + +- Uses the local image `devops-info-service:lab09` +- Sets `imagePullPolicy: IfNotPresent` for local Minikube use +- Starts with `3` replicas +- Exposes container port `5000` +- Configures readiness and liveness probes against `GET /health` +- Adds requests and limits for CPU and memory +- Uses a rolling update strategy suitable for zero-downtime local demos +- Adds basic hardening with `runAsNonRoot`, explicit UID/GID, dropped capabilities, and `seccompProfile` + +### `k8s/service.yml` + +This manifest exposes the Deployment through a NodePort service: + +- Service name: `devops-info-service` +- Service port: `80` +- Target port: named container port `http` (`5000`) +- NodePort: `30080` +- Selector: `app=devops-info-service` + +### Supporting Changes + +I also fixed two image/runtime issues that blocked Kubernetes deployment: + +- Added [`app_python/app.py`](../app_python/app.py) as the top-level entrypoint used by Docker and local runs +- Updated [`app_python/Dockerfile`](../app_python/Dockerfile) to: + - create a numeric non-root user (`UID 10001`) + - use `/bin/sh` instead of invalid `sh` + - copy only the runtime files required by the container + +## 4. Deployment Evidence + +Build, load, and apply: + +```bash +docker build -t devops-info-service:lab09 ./app_python +minikube image load devops-info-service:lab09 +kubectl apply -f k8s/deployment.yml -f k8s/service.yml +kubectl rollout status deployment/devops-info-service --timeout=180s +``` + +Final `kubectl get all`: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-service-5cc99549bf-h6b6s 1/1 Running 0 102s +pod/devops-info-service-5cc99549bf-jf2qc 1/1 Running 0 113s +pod/devops-info-service-5cc99549bf-q5ggz 1/1 Running 0 2m8s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-service NodePort 10.99.84.30 80:30080/TCP 6m21s +service/kubernetes ClusterIP 10.96.0.1 443/TCP 8m11s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-service 3/3 3 3 6m21s +``` + +Final `kubectl get pods,svc -o wide`: + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-info-service-5cc99549bf-h6b6s 1/1 Running 0 102s 10.244.0.18 minikube +pod/devops-info-service-5cc99549bf-jf2qc 1/1 Running 0 113s 10.244.0.17 minikube +pod/devops-info-service-5cc99549bf-q5ggz 1/1 Running 0 2m8s 10.244.0.16 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-info-service NodePort 10.99.84.30 80:30080/TCP 6m21s app=devops-info-service +``` + +`kubectl describe deployment devops-info-service` excerpt: + +```text +Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 5 +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Image: devops-info-service:lab09 +Port: 5000/TCP (http) +Limits: + cpu: 250m + memory: 256Mi +Requests: + cpu: 100m + memory: 128Mi +Liveness: http-get http://:http/health delay=15s timeout=2s period=10s +Readiness: http-get http://:http/health delay=5s timeout=2s period=5s +``` + +Service access: + +```bash +minikube service devops-info-service --url +``` + +Sample output from this run: + +```text +http://127.0.0.1:36269 +! Because you are using a Docker driver on linux, the terminal needs to be open to run it. +``` + +`curl` verification: + +```text +curl -s http://127.0.0.1:36269/ +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"10.244.0.1","method":"GET","path":"/","user_agent":"curl/8.5.0"},"runtime":{"current_time":"2026-04-02T19:41:59.439442.000Z","human":"0 hours, 1 minutes","seconds":67,"timezone":"UTC"},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","hostname":"devops-info-service-5cc99549bf-cbvrr","platform":"Linux","python_version":"3.14.3"}} + +curl -s http://127.0.0.1:36269/health +{"status":"healthy","timestamp":"2026-04-02T19:41:59.439872+00:00","uptime_seconds":67} +``` + +## 5. Operations Performed + +### Scaling to 5 Replicas + +Commands: + +```bash +kubectl scale deployment/devops-info-service --replicas=5 +kubectl wait --for=condition=available deployment/devops-info-service --timeout=180s +kubectl get deployment devops-info-service +kubectl get pods -l app=devops-info-service -o wide +``` + +Output: + +```text +deployment.apps/devops-info-service scaled +deployment.apps/devops-info-service condition met + +NAME READY UP-TO-DATE AVAILABLE AGE +devops-info-service 5/5 5 5 2m59s +``` + +```text +NAME READY STATUS RESTARTS AGE IP NODE +devops-info-service-5cc99549bf-cbvrr 1/1 Running 0 104s 10.244.0.6 minikube +devops-info-service-5cc99549bf-crslz 1/1 Running 0 77s 10.244.0.8 minikube +devops-info-service-5cc99549bf-gx8cv 1/1 Running 0 14s 10.244.0.10 minikube +devops-info-service-5cc99549bf-lhklq 1/1 Running 0 89s 10.244.0.7 minikube +devops-info-service-5cc99549bf-p26x8 1/1 Running 0 14s 10.244.0.9 minikube +``` + +### Rolling Update Demo + +I triggered a rollout by changing a configuration value instead of changing the image tag: + +```bash +kubectl set env deployment/devops-info-service RELEASE_TRACK=rollout-demo +kubectl rollout status deployment/devops-info-service --timeout=180s +kubectl rollout history deployment/devops-info-service +``` + +Key output: + +```text +deployment.apps/devops-info-service env updated +deployment "devops-info-service" successfully rolled out +``` + +Rollout history after the update: + +```text +deployment.apps/devops-info-service +REVISION CHANGE-CAUSE +1 +2 +3 +``` + +ReplicaSet view during the update: + +```text +NAME DESIRED CURRENT READY AGE +devops-info-service-5cc99549bf 0 0 0 2m56s +devops-info-service-65c6fcf67b 5 5 5 68s +devops-info-service-bf6495c7f 0 0 0 4m11s +``` + +### Zero-Downtime Check + +While the rolling update was running, I repeatedly queried `/health` through the service tunnel: + +```bash +for i in $(seq 1 12); do curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:36269/health; sleep 1; done +``` + +Output: + +```text +200 +200 +200 +200 +200 +200 +200 +200 +200 +200 +200 +200 +``` + +All requests returned `200`, which matches the deployment strategy of `maxUnavailable: 0`. + +### Rollback Demo + +Commands: + +```bash +kubectl rollout undo deployment/devops-info-service +kubectl rollout status deployment/devops-info-service --timeout=180s +kubectl rollout history deployment/devops-info-service +``` + +Output: + +```text +deployment.apps/devops-info-service rolled back +deployment "devops-info-service" successfully rolled out +``` + +Rollout history after rollback: + +```text +deployment.apps/devops-info-service +REVISION CHANGE-CAUSE +1 +3 +4 +``` + +I then returned the live cluster to the declarative manifest state: + +```bash +kubectl apply -f k8s/deployment.yml +kubectl get deployment devops-info-service +``` + +```text +deployment.apps/devops-info-service configured + +NAME READY UP-TO-DATE AVAILABLE AGE +devops-info-service 3/3 3 3 5m32s +``` + +## 6. Production Considerations + +### Health Checks + +- **Readiness probe** uses `/health` to keep new pods out of service until they are actually responding. +- **Liveness probe** uses the same endpoint to restart unhealthy containers automatically. +- For this small Flask app, `/health` is enough. In a larger service, I would separate readiness from liveness and include downstream dependency checks only where appropriate. + +### Resource Limits Rationale + +- `100m` CPU and `128Mi` memory requests are enough for a small Flask API on a local cluster. +- `250m` CPU and `256Mi` memory limits give headroom while still preventing one pod from consuming excessive node resources. +- These values are conservative defaults for a lab cluster; real values should come from load testing and production telemetry. + +### What I Would Improve for Production + +- Use a dedicated namespace such as `devops-info` +- Push immutable images to a real registry and reference versioned tags instead of a local Minikube image +- Add `startupProbe` and separate readiness/liveness behavior if startup becomes slower +- Add Horizontal Pod Autoscaler and PodDisruptionBudget +- Add Ingress or Gateway API with TLS +- Add ConfigMaps and Secrets for configuration instead of hardcoding all runtime values +- Add NetworkPolicies and image scanning in CI + +### Monitoring and Observability Strategy + +- Keep application logs on stdout/stderr for container-native log collection +- Expose `/metrics` and scrape it with Prometheus +- Add Grafana dashboards for request rate, latency, restart count, and probe failures +- Watch Kubernetes events and ReplicaSet history during rollouts for fast debugging + +## 7. Challenges and Solutions + +### 1. Dockerfile Build Failure + +Initial build error: + +```text +useradd: invalid shell 'sh' +``` + +Fix: + +- Changed the Dockerfile to use `/bin/sh` + +### 2. `CreateContainerConfigError` on First Deployment + +Initial Kubernetes error: + +```text +Error: container has runAsNonRoot and image has non-numeric user (appuser), cannot verify user is non-root +``` + +Fix: + +- Rebuilt the image with numeric user `UID 10001` +- Set `runAsUser: 10001` and `runAsGroup: 10001` in the Deployment + +### 3. Local Python Test Runner Not Installed + +The host shell did not have `pytest` installed, so I validated the lab with: + +- successful Docker image builds +- successful Minikube deployment and rollout +- live `curl` checks through the Kubernetes service +- scale, update, and rollback verification via `kubectl` + +## 8. What I Learned + +- Kubernetes requires image/user configuration to match pod security expectations, especially with `runAsNonRoot` +- Rolling updates are easy to demonstrate with config changes, not just image changes +- `kubectl describe`, events, and ReplicaSet history are the fastest tools for debugging failed pod startups +- Declarative manifests are the source of truth, but imperative commands are very useful for day-two operations like scaling and rollback diff --git a/k8s/ROLLOUTS.md b/k8s/ROLLOUTS.md new file mode 100644 index 0000000000..90f07291e0 --- /dev/null +++ b/k8s/ROLLOUTS.md @@ -0,0 +1,466 @@ +# Lab 14 - Progressive Delivery with Argo Rollouts + +## 1. What I Changed + +I converted the Helm workload from a standard `Deployment` to an Argo `Rollout` and added strategy-specific resources: + +- `k8s/devops-info-service/templates/rollout.yaml` +- `k8s/devops-info-service/templates/preview-service.yaml` +- `k8s/devops-info-service/templates/analysis-template.yaml` +- `k8s/devops-info-service/values-canary*.yaml` +- `k8s/devops-info-service/values-bluegreen*.yaml` + +I also added lightweight release metadata to the Flask app so active/preview revisions are visible in `/` responses: + +- `DEVOPS_SERVICE_VERSION` +- `DEVOPS_RELEASE_TRACK` +- `DEVOPS_RELEASE_COLOR` + +All live verification and screenshots below were captured on **May 7, 2026**. + +## 2. Argo Rollouts Setup + +### Installation + +Controller and dashboard: + +```bash +kubectl create namespace argo-rollouts --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/dashboard-install.yaml +kubectl wait --for=condition=available deployment/argo-rollouts -n argo-rollouts --timeout=240s +kubectl wait --for=condition=available deployment/argo-rollouts-dashboard -n argo-rollouts --timeout=240s +``` + +CLI plugin: + +```bash +mkdir -p "$HOME/.local/bin" +curl -sSL -o "$HOME/.local/bin/kubectl-argo-rollouts" \ + https://github.com/argoproj/argo-rollouts/releases/latest/download/kubectl-argo-rollouts-linux-amd64 +chmod +x "$HOME/.local/bin/kubectl-argo-rollouts" +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts version +``` + +Verification: + +```text +kubectl-argo-rollouts: v1.9.0+838d4e7 + BuildDate: 2026-03-20T21:08:11Z + Platform: linux/amd64 +``` + +```text +NAME READY STATUS AGE +argo-rollouts-5f64f8d68-r4ccj 1/1 Running 26s +argo-rollouts-dashboard-755bbc64c-ntvnr 1/1 Running 24s +``` + +Dashboard access: + +```bash +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 3100:3100 +``` + +For the automated screenshots I used: + +```bash +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts dashboard --port 3101 +``` + +That local dashboard path rendered more reliably in headless Chromium while keeping the in-cluster dashboard installed and reachable. + +### Rollout vs Deployment + +Key differences between my old Helm `Deployment` and the new `Rollout`: + +- `kind` changed from `Deployment` to `Rollout` +- `spec.strategy` now supports `canary` and `blueGreen` +- canary uses explicit `steps` with weights, pauses, and analysis +- blue-green uses `activeService` and `previewService` +- rollout controller rewrites service selectors with `rollouts-pod-template-hash` +- `AnalysisTemplate` enables automatic abort/rollback decisions + +The pod template, probes, resources, service account, secrets, and service wiring stayed the same. + +## 3. Canary Deployment + +### Strategy Configuration + +I used release `lab14-canary` in namespace `lab14-canary` with `k8s/devops-info-service/values-canary.yaml`. + +Canary flow in the chart: + +```yaml +strategy: + canary: + maxSurge: 1 + maxUnavailable: 0 + steps: + - setWeight: 20 + - pause: {} + - analysis: + templates: + - templateName: -success-rate + - setWeight: 40 + - pause: { duration: 30s } + - setWeight: 60 + - pause: { duration: 30s } + - setWeight: 80 + - pause: { duration: 30s } + - setWeight: 100 +``` + +The bonus `AnalysisTemplate` checks: + +```yaml +provider: + web: + url: http://..svc.cluster.local:80/health + jsonPath: "{$.status}" +successCondition: "result == 'healthy'" +``` + +### Baseline Install + +```bash +helm upgrade --install lab14-canary k8s/devops-info-service \ + -n lab14-canary --create-namespace \ + -f k8s/devops-info-service/values-canary.yaml +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts get rollout lab14-canary-devops-info-service -n lab14-canary +``` + +Baseline response: + +```text +"release_track":"canary","version":"1.0.0-canary-v1" +``` + +### Manual Promotion at 20% + +Upgrade: + +```bash +helm upgrade lab14-canary k8s/devops-info-service \ + -n lab14-canary \ + -f k8s/devops-info-service/values-canary-v2.yaml +``` + +Paused state before promotion: + +```text +Status: Paused +Message: CanaryPauseStep +Step: 1/10 +SetWeight: 20 +ActualWeight: 20 +``` + +Dashboard evidence: + +![Canary 20 percent pause](./screenshots/lab14/canary-paused-dashboard.png) + +Promote: + +```bash +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts promote lab14-canary-devops-info-service -n lab14-canary +``` + +After the manual gate, the rollout ran the analysis step and advanced into the timed stages automatically. One captured intermediate state: + +```text +Status: Paused +Message: CanaryPauseStep +Step: 4/10 +SetWeight: 40 +ActualWeight: 40 +``` + +![Canary 40 percent pause](./screenshots/lab14/canary-40-paused-dashboard.png) + +Successful analysis evidence: + +```text +AnalysisRun Successful +``` + +### Manual Abort / Rollback + +I aborted the rollout from the 40% pause: + +```bash +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts abort lab14-canary-devops-info-service -n lab14-canary +``` + +Immediate result: + +```text +Status: Degraded +Message: RolloutAborted: Rollout aborted update to revision 2 +SetWeight: 0 +ActualWeight: 40 +``` + +After the stable ReplicaSet finished scaling back: + +```text +SetWeight: 0 +ActualWeight: 0 +Images: devops-info-service:lab09 (stable) +Desired: 5 +Updated: 0 +Ready: 5 +``` + +Dashboard evidence: + +![Canary aborted](./screenshots/lab14/canary-aborted-dashboard.png) + +### Bonus: Automated Analysis Failure + +For the bonus test I deployed `values-canary-fail.yaml`, which intentionally pointed the analysis URL at `/does-not-exist`: + +```yaml +analysis: + web: + path: /does-not-exist +``` + +Flow: + +```bash +helm upgrade lab14-canary k8s/devops-info-service \ + -n lab14-canary \ + -f k8s/devops-info-service/values-canary-fail.yaml +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts promote lab14-canary-devops-info-service -n lab14-canary +``` + +Observed failure: + +```text +Status: Degraded +Message: RolloutAborted: Rollout aborted update to revision 3: + Step-based analysis phase error/failed: + Metric "health-check" assessed Error due to consecutiveErrors (5) + ... received non 2xx response code: 404 +``` + +```text +AnalysisRun Error +``` + +Dashboard evidence: + +![Canary analysis failure](./screenshots/lab14/canary-analysis-failed-dashboard.png) + +After collecting evidence I restored the canary namespace to a healthy baseline: + +```bash +helm upgrade lab14-canary k8s/devops-info-service \ + -n lab14-canary \ + -f k8s/devops-info-service/values-canary.yaml +``` + +## 4. Blue-Green Deployment + +### Strategy Configuration + +I used release `lab14-bluegreen` in namespace `lab14-bluegreen` with `k8s/devops-info-service/values-bluegreen.yaml`. + +Blue-green strategy in the chart: + +```yaml +strategy: + blueGreen: + activeService: + previewService: -preview + autoPromotionEnabled: false + scaleDownDelaySeconds: 30 +``` + +This creates two services: + +- active: `lab14-bluegreen-devops-info-service` +- preview: `lab14-bluegreen-devops-info-service-preview` + +### Initial Blue Release + +```bash +helm upgrade --install lab14-bluegreen k8s/devops-info-service \ + -n lab14-bluegreen --create-namespace \ + -f k8s/devops-info-service/values-bluegreen.yaml +``` + +Initial response from both services: + +```text +"release_color":"blue","version":"1.0.0-blue" +``` + +### Green Preview Before Promotion + +Upgrade to the green revision: + +```bash +helm upgrade lab14-bluegreen k8s/devops-info-service \ + -n lab14-bluegreen \ + -f k8s/devops-info-service/values-bluegreen-v2.yaml +``` + +Rollout state: + +```text +Status: Paused +Message: BlueGreenPause +Images: devops-info-service:lab09 (active, preview, stable) +``` + +Dashboard evidence: + +![Blue green pause](./screenshots/lab14/bluegreen-paused-dashboard.png) + +I refreshed the service forwards after the selector change and verified the split: + +```text +ACTIVE -> "release_color":"blue","version":"1.0.0-blue" +PREVIEW -> "release_color":"green","version":"1.1.0-green" +``` + +Screenshots: + +![Blue active](./screenshots/lab14/bluegreen-active-blue.png) + +![Green preview](./screenshots/lab14/bluegreen-preview-green.png) + +### Promotion to Active + +```bash +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts promote lab14-bluegreen-devops-info-service -n lab14-bluegreen +``` + +After promotion: + +```text +Status: Healthy +Revision 2: stable, active +``` + +Active service now returned: + +```text +"release_color":"green","version":"1.1.0-green" +``` + +Screenshot: + +![Green active](./screenshots/lab14/bluegreen-active-green.png) + +### Instant Rollback + +Because `autoPromotionEnabled: false`, the rollback flow is: + +1. `undo` creates the previous revision as preview +2. `promote` switches active traffic back in one operation + +Commands: + +```bash +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts undo lab14-bluegreen-devops-info-service -n lab14-bluegreen +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts promote lab14-bluegreen-devops-info-service -n lab14-bluegreen +``` + +Final rollback state: + +```text +Status: Healthy +Revision 3: stable, active +``` + +Active service after rollback: + +```text +"release_color":"blue","version":"1.0.0-blue" +``` + +Screenshots: + +![Blue green rollback dashboard](./screenshots/lab14/bluegreen-rollback-dashboard.png) + +![Blue active after rollback](./screenshots/lab14/bluegreen-active-blue-rollback.png) + +## 5. Strategy Comparison + +| Topic | Canary | Blue-Green | +|---|---|---| +| Traffic movement | Gradual by weight | One service switch | +| Risk profile | Lower blast radius | Fast rollback after preview is validated | +| Resource cost | Lower | Higher during overlap | +| Verification style | Real production subset | Dedicated preview environment | +| Best for | User-facing changes that need progressive confidence | Releases needing crisp cutover and rollback | + +### Pros and Cons + +Canary pros: + +- gradual exposure +- easy to combine with analysis +- safer when behavior risk is unknown + +Canary cons: + +- slower to complete +- rollback is not a single instant switch +- harder to reason about mixed live traffic + +Blue-green pros: + +- preview is isolated and easy to compare +- promotion is operationally simple +- rollback is fast once the previous revision is ready + +Blue-green cons: + +- needs duplicate capacity during rollout +- with `autoPromotionEnabled: false`, both forward promotion and rollback require confirmation +- old client-side port-forwards can stay attached to the old pod, so I had to restart port-forwards after selector changes + +### Recommendation + +- Use **canary** for production changes where behavioral confidence matters more than speed. +- Use **blue-green** when you need a clear preview environment and a near-instant cutover/rollback. +- Add **analysis** whenever you can define a meaningful health signal; it turns rollout decisions into something measurable instead of purely manual. + +## 6. Useful CLI Commands + +```bash +# Rollout status +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts get rollout -n + +# Promote one paused step +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts promote -n + +# Abort canary rollout +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts abort -n + +# Retry an aborted rollout +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts retry rollout -n + +# Undo to the previous revision +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts undo -n + +# Dashboard +PATH="$HOME/.local/bin:$PATH" kubectl argo rollouts dashboard --port 3101 + +# Raw Kubernetes inspection +kubectl get rollout,rs,pods,svc,analysisrun -n +kubectl describe rollout -n +``` + +## 7. Verification Summary + +- Argo Rollouts controller and dashboard installed successfully +- Helm chart now supports both canary and blue-green rollout strategies +- Canary manual pause, promotion, timed progression, and abort were tested +- Bonus web analysis ran successfully on the good revision and auto-aborted on the failing revision +- Blue-green preview, promotion, undo, and rollback switch were tested +- Screenshots were captured with Playwright from the live dashboard and forwarded services diff --git a/k8s/SECRETS.md b/k8s/SECRETS.md new file mode 100644 index 0000000000..d5ec03055c --- /dev/null +++ b/k8s/SECRETS.md @@ -0,0 +1,601 @@ +# Lab 11 - Kubernetes Secrets & HashiCorp Vault + +## 1. Kubernetes Secrets Fundamentals + +### Secret creation + +I created the required Kubernetes Secret with the imperative command: + +```bash +kubectl create secret generic app-credentials \ + --from-literal=username=devops-admin \ + --from-literal=password=Lab11Passw0rd! +``` + +Output: + +```text +secret/app-credentials created +``` + +### Viewing the Secret + +`kubectl get secret app-credentials -o yaml`: + +```yaml +apiVersion: v1 +data: + password: TGFiMTFQYXNzdzByZCE= + username: ZGV2b3BzLWFkbWlu +kind: Secret +metadata: + creationTimestamp: "2026-04-09T16:59:46Z" + name: app-credentials + namespace: default +type: Opaque +``` + +### Decoding the values + +```bash +printf 'username=' +kubectl get secret app-credentials -o jsonpath='{.data.username}' | base64 -d +printf '\npassword=' +kubectl get secret app-credentials -o jsonpath='{.data.password}' | base64 -d +printf '\n' +``` + +Output: + +```text +username=devops-admin +password=Lab11Passw0rd! +``` + +### Base64 encoding vs encryption + +Base64 is only an encoding format. It makes binary-safe transport and YAML embedding easy, but it does not protect confidentiality. Anyone who can read the Secret object can decode it without a key. + +Encryption is different: + +- Encoding: reversible by anyone, no key required +- Encryption: ciphertext requires a key and a configured crypto system + +### Are Kubernetes Secrets encrypted at rest by default? + +Not by default. Kubernetes stores Secret data in etcd, and without an encryption provider configuration the values are only base64-encoded in the API object representation. + +For this Minikube cluster, I checked the API server manifest: + +```bash +minikube ssh -- "sudo grep -n 'encryption-provider-config' /etc/kubernetes/manifests/kube-apiserver.yaml || true" +``` + +This returned no output, which means the local API server is not configured with an `--encryption-provider-config` flag. + +### What is etcd encryption and when should it be enabled? + +etcd encryption at rest uses an `EncryptionConfiguration` so the API server encrypts selected resource types, including Secrets, before persisting them into etcd. + +It should be enabled in any cluster where: + +- Secrets contain real credentials or tokens +- multiple operators or platform components can reach etcd backups +- compliance or audit requirements apply +- the cluster is anything beyond a disposable learning environment + +In production, I would combine: + +- etcd encryption at rest +- tight RBAC on Secret access +- external secret management for high-value secrets + +## 2. Helm Secret Integration + +### Chart structure + +The Lab 10 chart was extended with a Secret template and a dedicated ServiceAccount for Vault auth: + +```text +k8s/devops-info-service/ +β”œβ”€β”€ Chart.yaml +β”œβ”€β”€ values.yaml +β”œβ”€β”€ values-dev.yaml +β”œβ”€β”€ values-prod.yaml +└── templates/ + β”œβ”€β”€ _helpers.tpl + β”œβ”€β”€ deployment.yaml + β”œβ”€β”€ secrets.yaml + β”œβ”€β”€ service.yaml + β”œβ”€β”€ serviceaccount.yaml + └── NOTES.txt +``` + +### Secret templating + +The chart now renders a Kubernetes Secret from `values.yaml` placeholders: + +```yaml +secret: + enabled: true + create: true + existingSecret: "" + name: "" + type: Opaque + envFrom: true + stringData: + APP_USERNAME: "change-me" + APP_PASSWORD: "change-me" +``` + +Rendered manifest excerpt from `helm get manifest lab11-devops`: + +```yaml +kind: Secret +metadata: + name: lab11-devops-devops-info-service-secret +type: Opaque +stringData: + APP_PASSWORD: "replace-me-in-dev" + APP_USERNAME: "dev-user" +``` + +### Deployment consumption + +The Deployment consumes the Secret with `envFrom` and uses the chart-created ServiceAccount: + +```yaml +serviceAccountName: lab11-devops-devops-info-service +containers: + - name: devops-info-service + env: + - name: PORT + value: "5000" + envFrom: + - secretRef: + name: lab11-devops-devops-info-service-secret +``` + +### Helm validation + +```bash +helm lint k8s/devops-info-service +helm template lab11-devops k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml +helm template lab11-devops k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml +``` + +`helm lint` result: + +```text +==> Linting k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +### Deploy and verify + +Install command: + +```bash +helm upgrade --install lab11-devops k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --wait --wait-for-jobs --timeout 5m +``` + +Result: + +```text +NAME: lab11-devops +NAMESPACE: default +STATUS: deployed +REVISION: 1 +``` + +Resource snapshot: + +```text +NAME READY STATUS RESTARTS AGE +pod/lab11-devops-devops-info-service-6dd47bd6c4-6w25v 1/1 Running 0 21s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/lab11-devops-devops-info-service NodePort 10.97.206.173 80:30081/TCP 21s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/lab11-devops-devops-info-service 1/1 1 1 21s + +NAME TYPE DATA AGE +secret/lab11-devops-devops-info-service-secret Opaque 2 21s + +NAME AGE +serviceaccount/lab11-devops-devops-info-service 21s +``` + +### Environment variable verification + +I verified the pod received the secret-backed environment variables without printing the actual values: + +```bash +kubectl exec lab11-devops-devops-info-service-6dd47bd6c4-6w25v -- \ + sh -c 'printenv | grep "^APP_" | cut -d= -f1 | sort' +``` + +Output: + +```text +APP_PASSWORD +APP_USERNAME +``` + +### Secrets are not exposed in `kubectl describe pod` + +`kubectl describe pod ...` shows the Secret reference, not the values: + +```text +Environment Variables from: + lab11-devops-devops-info-service-secret Secret Optional: false +Environment: + PORT: 5000 +``` + +That is the expected behavior when using `envFrom` with a Secret. + +## 3. Resource Management + +### Configured requests and limits + +The chart already had resource management from Lab 10, and it remains configurable in values files. + +Development values: + +```yaml +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi +``` + +Production-style values: + +```yaml +resources: + requests: + cpu: 150m + memory: 192Mi + limits: + cpu: 500m + memory: 512Mi +``` + +Live pod excerpt: + +```text +Limits: + cpu: 100m + memory: 128Mi +Requests: + cpu: 50m + memory: 64Mi +``` + +### Requests vs limits + +- Requests tell the scheduler the minimum CPU and memory the container needs. +- Limits cap how much CPU and memory the container may consume at runtime. + +For this Flask service, the chosen values are intentionally small for Minikube but still realistic enough to demonstrate scheduling and capacity boundaries. + +### How to choose appropriate values + +For production, I would start with: + +- baseline requests from normal steady-state usage +- limits from load-test peaks plus safety margin +- actual telemetry from Prometheus or platform metrics + +Then I would tune based on: + +- p95 and p99 latency +- CPU throttling events +- OOM kills +- startup time and probe stability + +## 4. Vault Integration + +### Helm repository and chart version + +I added the official HashiCorp Helm repository: + +```bash +helm repo add hashicorp https://helm.releases.hashicorp.com +helm repo update +``` + +Repository search: + +```text +NAME CHART VERSION APP VERSION DESCRIPTION +hashicorp/vault 0.32.0 1.21.2 Official HashiCorp Vault Chart +``` + +### Vault installation + +Install command: + +```bash +helm upgrade --install vault hashicorp/vault \ + --set server.dev.enabled=true \ + --set injector.enabled=true \ + --wait --timeout 8m +``` + +Result: + +```text +NAME: vault +NAMESPACE: default +STATUS: deployed +REVISION: 1 +``` + +Running pods after installation and app upgrade: + +```text +lab11-devops-devops-info-service-5b7674c5cc-cnpdp 2/2 Running 0 53s +vault-0 1/1 Running 0 119s +vault-agent-injector-848dd747d7-wfvkg 1/1 Running 0 2m1s +``` + +### Vault configuration + +I configured a dedicated KV v2 mount and stored application credentials: + +```bash +kubectl exec vault-0 -- sh -c ' + export VAULT_ADDR=http://127.0.0.1:8200 + export VAULT_TOKEN=root + vault secrets enable -path=apps kv-v2 + vault kv put apps/devops-info-service/config \ + username="vault-user" \ + password="vault-password" +' +``` + +Secret path confirmation: + +```text +============ Secret Path ============ +apps/data/devops-info-service/config +``` + +### Kubernetes auth configuration + +I enabled the Kubernetes auth method and bound a role to the Helm release ServiceAccount: + +```bash +kubectl exec vault-0 -- sh -c ' + export VAULT_ADDR=http://127.0.0.1:8200 + export VAULT_TOKEN=root + vault auth enable kubernetes + cat </tmp/devops-info-service-policy.hcl +path "apps/data/devops-info-service/config" { + capabilities = ["read"] +} +EOF + vault policy write devops-info-service /tmp/devops-info-service-policy.hcl + vault write auth/kubernetes/config \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + vault write auth/kubernetes/role/devops-info-service \ + bound_service_account_names="lab11-devops-devops-info-service" \ + bound_service_account_namespaces="default" \ + policies="devops-info-service" \ + ttl="24h" +' +``` + +Policy used: + +```hcl +path "apps/data/devops-info-service/config" { + capabilities = ["read"] +} +``` + +Role readback: + +```text +bound_service_account_names [lab11-devops-devops-info-service] +bound_service_account_namespaces [default] +policies [devops-info-service] +token_ttl 24h +``` + +### Enable Vault agent injection in the chart + +I upgraded the application release with Vault turned on: + +```bash +helm upgrade lab11-devops k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --set vault.enabled=true \ + --set vault.role=devops-info-service \ + --set vault.secretPath=apps/data/devops-info-service/config \ + --wait --wait-for-jobs --timeout 5m +``` + +The Deployment now renders these annotations: + +```text +vault.hashicorp.com/agent-inject: true +vault.hashicorp.com/agent-inject-file-config: config.env +vault.hashicorp.com/agent-inject-secret-config: apps/data/devops-info-service/config +vault.hashicorp.com/agent-inject-status: injected +vault.hashicorp.com/agent-inject-template-config: + {{- with secret "apps/data/devops-info-service/config" -}} + APP_USERNAME={{ .Data.data.username }} + APP_PASSWORD={{ .Data.data.password }} + {{- end -}} +vault.hashicorp.com/auth-path: auth/kubernetes +vault.hashicorp.com/role: devops-info-service +vault.hashicorp.com/secret-volume-path-config: /vault/secrets +``` + +### Proof of sidecar injection + +The new app pod came up as `2/2`, which confirms the application container plus Vault sidecar are both running: + +```text +lab11-devops-devops-info-service-5b7674c5cc-cnpdp 2/2 Running +``` + +`kubectl describe pod` shows: + +- init container: `vault-agent-init` +- sidecar container: `vault-agent` +- shared in-memory volume: `/vault/secrets` + +### Proof of injected file + +File path inside the application container: + +```bash +kubectl exec lab11-devops-devops-info-service-5b7674c5cc-cnpdp \ + -c devops-info-service -- find /vault -maxdepth 2 -type f | sort +``` + +Output: + +```text +/vault/secrets/config.env +``` + +Redacted file contents: + +```bash +kubectl exec lab11-devops-devops-info-service-5b7674c5cc-cnpdp \ + -c devops-info-service -- \ + sh -c 'sed -E "s/=.*/=/" /vault/secrets/config.env' +``` + +Output: + +```text +APP_USERNAME= +APP_PASSWORD= +``` + +### Sidecar injection pattern explanation + +The Vault injector works as a mutating admission webhook: + +1. The Deployment submits a pod with Vault annotations. +2. The webhook mutates the pod spec. +3. A `vault-agent-init` container authenticates to Vault and prepares the initial rendered secrets. +4. A long-running `vault-agent` sidecar continues to manage auth/token lifecycle and template rendering. +5. The application reads the rendered secret files from a shared volume, here `/vault/secrets`. + +This keeps the secret source external to the app image and avoids hardcoding credentials into Git or container layers. + +## 5. Security Analysis + +### Kubernetes Secrets vs Vault + +| Aspect | Kubernetes Secret | Vault | +|--------|-------------------|-------| +| Storage | etcd | Vault storage backend | +| Default confidentiality | Base64 only, no at-rest encryption unless configured | Built for encrypted secret storage | +| Access control | Kubernetes RBAC | Vault policies plus auth methods | +| Rotation | Manual or controller-driven | Strong built-in support, including dynamic secrets | +| Auditability | Kubernetes audit logging | Rich secret access auditing | +| App integration | Native and simple | More moving parts, stronger security model | + +### When to use each + +Use Kubernetes Secrets when: + +- the application is simple +- secrets are low-risk or environment-scoped +- operational complexity must stay minimal +- the cluster already has RBAC and etcd encryption configured + +Use Vault when: + +- credentials are high-value +- secrets need rotation +- multiple platforms or teams consume the same secrets +- audit trails matter +- dynamic short-lived credentials are desirable + +### Production recommendations + +For production, I would do the following: + +- never commit real secret values to Git +- replace placeholder Helm values with external injection at deploy time +- enable etcd encryption at rest +- restrict `get/list/watch` access to Secrets through RBAC +- prefer Vault or another external secret manager for databases, APIs, and shared credentials +- rotate credentials regularly +- audit both Kubernetes and Vault access +- consider a reload strategy if the app must react immediately to secret file updates + +## 6. Bonus - Vault Agent Templates and Named Helpers + +I implemented the bonus pattern in the chart as well. + +### Template annotation + +The chart now renders: + +```yaml +vault.hashicorp.com/agent-inject-template-config: | + {{- with secret "apps/data/devops-info-service/config" -}} + APP_USERNAME={{ .Data.data.username }} + APP_PASSWORD={{ .Data.data.password }} + {{- end -}} +``` + +That produces a `.env`-style file inside the pod: + +```text +/vault/secrets/config.env +``` + +### Named helpers added to `_helpers.tpl` + +I added reusable helpers for: + +- `devops-info-service.envVars` +- `devops-info-service.serviceAccountName` +- `devops-info-service.secretName` +- `devops-info-service.vaultAgentTemplate` +- `devops-info-service.vaultAnnotations` + +This keeps the Deployment template smaller and avoids repeating the Vault annotation block. + +### Secret refresh behavior + +With the sidecar enabled, Vault Agent can continue renewing auth and re-rendering templates when the underlying secret changes. The exact application behavior after a file rewrite depends on the app: + +- apps that read the file on every use see the change naturally +- apps that cache values in memory need a reload mechanism + +If needed, `vault.hashicorp.com/agent-inject-command` can trigger a command after re-rendering, for example sending a reload signal or touching a watched file. + +## 7. Summary + +Lab 11 is complete with: + +- imperative Secret creation and decoding +- Helm-managed Secret templating +- Secret injection into the application pod +- resource requests and limits preserved in the chart +- HashiCorp Vault installed in dev mode +- KV v2, policy, Kubernetes auth, and role configured +- Vault Agent injection working with rendered secret files +- bonus helper templates and Vault Agent templating implemented diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..a05fe057f4 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/component: web + app.kubernetes.io/part-of: devops-core-course +spec: + replicas: 3 + minReadySeconds: 5 + revisionHistoryLimit: 5 + selector: + matchLabels: + app: devops-info-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/component: web + app.kubernetes.io/part-of: devops-core-course + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: devops-info-service + image: devops-info-service:lab09 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + env: + - name: PORT + value: "5000" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + runAsUser: 10001 + runAsGroup: 10001 + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 diff --git a/k8s/devops-info-service/.helmignore b/k8s/devops-info-service/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/k8s/devops-info-service/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/k8s/devops-info-service/Chart.yaml b/k8s/devops-info-service/Chart.yaml new file mode 100644 index 0000000000..098b2013e2 --- /dev/null +++ b/k8s/devops-info-service/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: devops-info-service +description: Helm chart for deploying the DevOps course Flask application +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - flask + - python + - kubernetes + - helm + - devops +sources: + - https://github.com/nonamecorn/DevOps-Core-Course diff --git a/k8s/devops-info-service/templates/NOTES.txt b/k8s/devops-info-service/templates/NOTES.txt new file mode 100644 index 0000000000..71919295e5 --- /dev/null +++ b/k8s/devops-info-service/templates/NOTES.txt @@ -0,0 +1,29 @@ +1. Check the release: + helm status {{ .Release.Name }} + {{- if eq .Values.workload.kind "rollout" }} + kubectl argo rollouts get rollout {{ include "devops-info-service.fullname" . }} + {{- else }} + kubectl get statefulset {{ include "devops-info-service.fullname" . }} + {{- end }} + +2. Inspect resources: + {{- if eq .Values.workload.kind "rollout" }} + kubectl get rollouts.argoproj.io,svc,pods -l app.kubernetes.io/instance={{ .Release.Name }} + {{- else }} + kubectl get statefulset,pods,svc,pvc -l app.kubernetes.io/instance={{ .Release.Name }} + {{- end }} + +3. Access the application: + {{- if eq .Values.service.type "NodePort" }} + minikube service {{ include "devops-info-service.fullname" . }} --url + {{- else if eq .Values.service.type "LoadBalancer" }} + kubectl get svc {{ include "devops-info-service.fullname" . }} -w + {{- else }} + kubectl port-forward service/{{ include "devops-info-service.fullname" . }} 8080:{{ .Values.service.port }} + {{- end }} + {{- if and (eq .Values.workload.kind "rollout") (eq .Values.rollout.strategy "blueGreen") }} + kubectl port-forward service/{{ include "devops-info-service.previewServiceName" . }} 8081:{{ .Values.previewService.port }} + {{- end }} + +4. Verify the health endpoint: + curl http://127.0.0.1:8080/health diff --git a/k8s/devops-info-service/templates/_helpers.tpl b/k8s/devops-info-service/templates/_helpers.tpl new file mode 100644 index 0000000000..5553692337 --- /dev/null +++ b/k8s/devops-info-service/templates/_helpers.tpl @@ -0,0 +1,150 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "devops-info-service.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "devops-info-service.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "devops-info-service.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "devops-info-service.labels" -}} +helm.sh/chart: {{ include "devops-info-service.chart" . }} +{{ include "devops-info-service.selectorLabels" . }} +app.kubernetes.io/component: web +app.kubernetes.io/part-of: devops-core-course +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "devops-info-service.selectorLabels" -}} +app: {{ include "devops-info-service.name" . }} +app.kubernetes.io/name: {{ include "devops-info-service.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Return the ServiceAccount name used by the workload. +*/}} +{{- define "devops-info-service.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "devops-info-service.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Return the Secret name used by the workload. +*/}} +{{- define "devops-info-service.secretName" -}} +{{- if .Values.secret.existingSecret }} +{{- .Values.secret.existingSecret }} +{{- else if .Values.secret.name }} +{{- .Values.secret.name }} +{{- else }} +{{- printf "%s-secret" (include "devops-info-service.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Render static environment variables. +*/}} +{{- define "devops-info-service.envVars" -}} +{{- range .Values.env }} +- name: {{ .name }} + value: {{ .value | quote }} +{{- end }} +{{- end }} + +{{/* +Render release metadata environment variables that make rollout revisions visible in responses. +*/}} +{{- define "devops-info-service.releaseEnvVars" -}} +- name: DEVOPS_SERVICE_VERSION + value: {{ .Values.releaseMetadata.version | quote }} +- name: DEVOPS_RELEASE_TRACK + value: {{ .Values.releaseMetadata.track | quote }} +{{- if .Values.releaseMetadata.color }} +- name: DEVOPS_RELEASE_COLOR + value: {{ .Values.releaseMetadata.color | quote }} +{{- end }} +{{- end }} + +{{/* +Return the preview Service name used by blue-green deployments. +*/}} +{{- define "devops-info-service.previewServiceName" -}} +{{- printf "%s-preview" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +Return the headless Service name used by StatefulSets. +*/}} +{{- define "devops-info-service.headlessServiceName" -}} +{{- printf "%s-headless" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +Return the AnalysisTemplate name used by canary deployments. +*/}} +{{- define "devops-info-service.analysisTemplateName" -}} +{{- printf "%s-success-rate" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +Render the Vault Agent template used for bonus env-style secret files. +*/}} +{{- define "devops-info-service.vaultAgentTemplate" -}} +{{`{{- with secret "`}}{{ .Values.vault.secretPath }}{{`" -}}`}} +APP_USERNAME={{`{{ .Data.data.username }}`}} +APP_PASSWORD={{`{{ .Data.data.password }}`}} +{{`{{- end -}}`}} +{{- end }} + +{{/* +Render Vault injector annotations when Vault integration is enabled. +*/}} +{{- define "devops-info-service.vaultAnnotations" -}} +{{- if .Values.vault.enabled }} +vault.hashicorp.com/agent-inject: "true" +vault.hashicorp.com/auth-path: {{ printf "auth/%s" .Values.vault.authPath | quote }} +vault.hashicorp.com/role: {{ .Values.vault.role | quote }} +vault.hashicorp.com/agent-inject-secret-config: {{ .Values.vault.secretPath | quote }} +vault.hashicorp.com/agent-inject-file-config: {{ .Values.vault.injectFileName | quote }} +vault.hashicorp.com/secret-volume-path-config: {{ .Values.vault.secretMountPath | quote }} +vault.hashicorp.com/agent-pre-populate-only: {{ ternary "true" "false" .Values.vault.agentPrePopulateOnly | quote }} +{{- if and .Values.vault.template.enabled (eq .Values.vault.template.format "env") }} +vault.hashicorp.com/agent-inject-template-config: | +{{ include "devops-info-service.vaultAgentTemplate" . | indent 2 }} +{{- end }} +{{- end }} +{{- end }} diff --git a/k8s/devops-info-service/templates/analysis-template.yaml b/k8s/devops-info-service/templates/analysis-template.yaml new file mode 100644 index 0000000000..51d0fd4676 --- /dev/null +++ b/k8s/devops-info-service/templates/analysis-template.yaml @@ -0,0 +1,19 @@ +{{- if and (eq .Values.workload.kind "rollout") .Values.analysis.enabled (eq .Values.rollout.strategy "canary") }} +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: {{ include "devops-info-service.analysisTemplateName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + metrics: + - name: health-check + interval: {{ .Values.analysis.interval | quote }} + count: {{ .Values.analysis.count }} + failureLimit: {{ .Values.analysis.failureLimit }} + successCondition: {{ .Values.analysis.successCondition | quote }} + provider: + web: + url: {{ printf "http://%s.%s.svc.cluster.local:%v%s" (include "devops-info-service.fullname" .) .Release.Namespace .Values.service.port .Values.analysis.web.path | quote }} + jsonPath: {{ .Values.analysis.web.jsonPath | quote }} +{{- end }} diff --git a/k8s/devops-info-service/templates/headless-service.yaml b/k8s/devops-info-service/templates/headless-service.yaml new file mode 100644 index 0000000000..c5f937d5fd --- /dev/null +++ b/k8s/devops-info-service/templates/headless-service.yaml @@ -0,0 +1,18 @@ +{{- if eq .Values.workload.kind "statefulset" }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.headlessServiceName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + clusterIP: None + publishNotReadyAddresses: true + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} +{{- end }} diff --git a/k8s/devops-info-service/templates/hooks/post-install-job.yaml b/k8s/devops-info-service/templates/hooks/post-install-job.yaml new file mode 100644 index 0000000000..b4659fe49a --- /dev/null +++ b/k8s/devops-info-service/templates/hooks/post-install-job.yaml @@ -0,0 +1,36 @@ +{{- if .Values.hooks.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ include "devops-info-service.fullname" . }}-post-install" + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-weight": {{ .Values.hooks.postInstall.weight | quote }} + "helm.sh/hook-delete-policy": {{ .Values.hooks.postInstall.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + spec: + restartPolicy: Never + containers: + - name: post-install-smoke-test + image: {{ .Values.hooks.image }} + command: + - sh + - -c + - | + echo "Running post-install smoke test" + for i in $(seq 1 20); do + RESPONSE="$(wget -qO- http://{{ include "devops-info-service.fullname" . }}:{{ .Values.service.port }}{{ .Values.hooks.postInstall.smokeTestPath }})" && \ + echo "$RESPONSE" | grep -q '"status":"healthy"' && \ + echo "$RESPONSE" && sleep 8 && exit 0 + sleep 2 + done + echo "Smoke test failed" + exit 1 +{{- end }} diff --git a/k8s/devops-info-service/templates/hooks/pre-install-job.yaml b/k8s/devops-info-service/templates/hooks/pre-install-job.yaml new file mode 100644 index 0000000000..b94442d7cd --- /dev/null +++ b/k8s/devops-info-service/templates/hooks/pre-install-job.yaml @@ -0,0 +1,34 @@ +{{- if .Values.hooks.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ include "devops-info-service.fullname" . }}-pre-install" + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-weight": {{ .Values.hooks.preInstall.weight | quote }} + "helm.sh/hook-delete-policy": {{ .Values.hooks.preInstall.deletePolicy | quote }} +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + spec: + restartPolicy: Never + containers: + - name: pre-install-validation + image: {{ .Values.hooks.image }} + command: + - sh + - -c + - | + echo "Validating Helm values before install" + test {{ .Values.replicaCount }} -ge 1 + test {{ .Values.containerPort }} -ge 1 + echo "Replica count: {{ .Values.replicaCount }}" + echo "Container port: {{ .Values.containerPort }}" + sleep 8 + echo "Pre-install validation completed successfully" +{{- end }} diff --git a/k8s/devops-info-service/templates/preview-service.yaml b/k8s/devops-info-service/templates/preview-service.yaml new file mode 100644 index 0000000000..dec3fd7486 --- /dev/null +++ b/k8s/devops-info-service/templates/preview-service.yaml @@ -0,0 +1,20 @@ +{{- if and (eq .Values.workload.kind "rollout") (eq .Values.rollout.strategy "blueGreen") }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.previewServiceName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.previewService.type }} + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.previewService.port }} + targetPort: {{ .Values.previewService.targetPort }} + {{- if and (eq .Values.previewService.type "NodePort") .Values.previewService.nodePort }} + nodePort: {{ .Values.previewService.nodePort }} + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/templates/rollout.yaml b/k8s/devops-info-service/templates/rollout.yaml new file mode 100644 index 0000000000..5f55c85f23 --- /dev/null +++ b/k8s/devops-info-service/templates/rollout.yaml @@ -0,0 +1,114 @@ +{{- if eq .Values.workload.kind "rollout" }} +{{- $vaultAnnotations := include "devops-info-service.vaultAnnotations" . | trim }} +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + minReadySeconds: {{ .Values.minReadySeconds }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- if or .Values.podAnnotations $vaultAnnotations }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if $vaultAnnotations }} + {{- $vaultAnnotations | nindent 8 }} + {{- end }} + {{- end }} + labels: + {{- include "devops-info-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automount }} + containers: + - name: {{ include "devops-info-service.name" . }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.containerPort }} + protocol: TCP + env: + {{- include "devops-info-service.releaseEnvVars" . | nindent 12 }} + {{- if .Values.env }} + {{- include "devops-info-service.envVars" . | nindent 12 }} + {{- end }} + {{- if and .Values.secret.enabled .Values.secret.envFrom }} + envFrom: + - secretRef: + name: {{ include "devops-info-service.secretName" . }} + {{- end }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + strategy: + {{- if eq .Values.rollout.strategy "blueGreen" }} + blueGreen: + activeService: {{ include "devops-info-service.fullname" . }} + previewService: {{ include "devops-info-service.previewServiceName" . }} + autoPromotionEnabled: {{ .Values.rollout.blueGreen.autoPromotionEnabled }} + scaleDownDelaySeconds: {{ .Values.rollout.blueGreen.scaleDownDelaySeconds }} + {{- with .Values.rollout.blueGreen.autoPromotionSeconds }} + autoPromotionSeconds: {{ . }} + {{- end }} + {{- else }} + canary: + maxSurge: {{ .Values.rollout.canary.maxSurge }} + maxUnavailable: {{ .Values.rollout.canary.maxUnavailable }} + steps: + - setWeight: 20 + - pause: {} + {{- if .Values.analysis.enabled }} + - analysis: + templates: + - templateName: {{ include "devops-info-service.analysisTemplateName" . }} + {{- end }} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/templates/secrets.yaml b/k8s/devops-info-service/templates/secrets.yaml new file mode 100644 index 0000000000..4e6144cbd2 --- /dev/null +++ b/k8s/devops-info-service/templates/secrets.yaml @@ -0,0 +1,13 @@ +{{- if and .Values.secret.enabled .Values.secret.create (not .Values.secret.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "devops-info-service.secretName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +type: {{ .Values.secret.type }} +stringData: + {{- range $key, $value := .Values.secret.stringData }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/templates/service.yaml b/k8s/devops-info-service/templates/service.yaml new file mode 100644 index 0000000000..6534b2258a --- /dev/null +++ b/k8s/devops-info-service/templates/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} diff --git a/k8s/devops-info-service/templates/serviceaccount.yaml b/k8s/devops-info-service/templates/serviceaccount.yaml new file mode 100644 index 0000000000..20bb7f1973 --- /dev/null +++ b/k8s/devops-info-service/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "devops-info-service.serviceAccountName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/k8s/devops-info-service/templates/servicemonitor.yaml b/k8s/devops-info-service/templates/servicemonitor.yaml new file mode 100644 index 0000000000..445cbdb346 --- /dev/null +++ b/k8s/devops-info-service/templates/servicemonitor.yaml @@ -0,0 +1,26 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "devops-info-service.fullname" . }} + {{- if .Values.serviceMonitor.namespace }} + namespace: {{ .Values.serviceMonitor.namespace }} + {{- end }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.additionalLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} +{{- end }} diff --git a/k8s/devops-info-service/templates/statefulset.yaml b/k8s/devops-info-service/templates/statefulset.yaml new file mode 100644 index 0000000000..2f9d1e5ad0 --- /dev/null +++ b/k8s/devops-info-service/templates/statefulset.yaml @@ -0,0 +1,108 @@ +{{- if eq .Values.workload.kind "statefulset" }} +{{- $vaultAnnotations := include "devops-info-service.vaultAnnotations" . | trim }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + serviceName: {{ include "devops-info-service.headlessServiceName" . }} + replicas: {{ .Values.replicaCount }} + podManagementPolicy: OrderedReady + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- if or .Values.podAnnotations $vaultAnnotations }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if $vaultAnnotations }} + {{- $vaultAnnotations | nindent 8 }} + {{- end }} + {{- end }} + labels: + {{- include "devops-info-service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automount }} + containers: + - name: {{ include "devops-info-service.name" . }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.containerPort }} + protocol: TCP + env: + {{- include "devops-info-service.releaseEnvVars" . | nindent 12 }} + {{- if .Values.env }} + {{- include "devops-info-service.envVars" . | nindent 12 }} + {{- end }} + {{- if and .Values.secret.enabled .Values.secret.envFrom }} + envFrom: + - secretRef: + name: {{ include "devops-info-service.secretName" . }} + {{- end }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + updateStrategy: + type: RollingUpdate + {{- if .Values.persistence.enabled }} + persistentVolumeClaimRetentionPolicy: + whenDeleted: {{ .Values.persistence.retentionPolicy.whenDeleted }} + whenScaled: {{ .Values.persistence.retentionPolicy.whenScaled }} + volumeClaimTemplates: + - metadata: + name: data + labels: + {{- include "devops-info-service.labels" . | nindent 10 }} + spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 10 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/values-bluegreen-v2.yaml b/k8s/devops-info-service/values-bluegreen-v2.yaml new file mode 100644 index 0000000000..ff375f7e73 --- /dev/null +++ b/k8s/devops-info-service/values-bluegreen-v2.yaml @@ -0,0 +1,32 @@ +replicaCount: 3 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30082 + +previewService: + type: ClusterIP + port: 80 + targetPort: http + +releaseMetadata: + version: "1.1.0-green" + track: "blue-green" + color: "green" + +rollout: + strategy: blueGreen + blueGreen: + autoPromotionEnabled: false + autoPromotionSeconds: null + scaleDownDelaySeconds: 30 + +analysis: + enabled: false + +secret: + stringData: + APP_USERNAME: "bluegreen-user" + APP_PASSWORD: "replace-me-in-bluegreen" diff --git a/k8s/devops-info-service/values-bluegreen.yaml b/k8s/devops-info-service/values-bluegreen.yaml new file mode 100644 index 0000000000..de7da16efe --- /dev/null +++ b/k8s/devops-info-service/values-bluegreen.yaml @@ -0,0 +1,32 @@ +replicaCount: 3 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30082 + +previewService: + type: ClusterIP + port: 80 + targetPort: http + +releaseMetadata: + version: "1.0.0-blue" + track: "blue-green" + color: "blue" + +rollout: + strategy: blueGreen + blueGreen: + autoPromotionEnabled: false + autoPromotionSeconds: null + scaleDownDelaySeconds: 30 + +analysis: + enabled: false + +secret: + stringData: + APP_USERNAME: "bluegreen-user" + APP_PASSWORD: "replace-me-in-bluegreen" diff --git a/k8s/devops-info-service/values-canary-fail.yaml b/k8s/devops-info-service/values-canary-fail.yaml new file mode 100644 index 0000000000..32552a3b32 --- /dev/null +++ b/k8s/devops-info-service/values-canary-fail.yaml @@ -0,0 +1,29 @@ +replicaCount: 5 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30081 + +releaseMetadata: + version: "1.2.0-canary-fail" + track: "canary" + color: "" + +rollout: + strategy: canary + canary: + maxSurge: 1 + maxUnavailable: 0 + +analysis: + enabled: true + web: + path: /does-not-exist + jsonPath: "{$.status}" + +secret: + stringData: + APP_USERNAME: "canary-user" + APP_PASSWORD: "replace-me-in-canary" diff --git a/k8s/devops-info-service/values-canary-v2.yaml b/k8s/devops-info-service/values-canary-v2.yaml new file mode 100644 index 0000000000..e0d75effd6 --- /dev/null +++ b/k8s/devops-info-service/values-canary-v2.yaml @@ -0,0 +1,26 @@ +replicaCount: 5 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30081 + +releaseMetadata: + version: "1.1.0-canary-v2" + track: "canary" + color: "" + +rollout: + strategy: canary + canary: + maxSurge: 1 + maxUnavailable: 0 + +analysis: + enabled: true + +secret: + stringData: + APP_USERNAME: "canary-user" + APP_PASSWORD: "replace-me-in-canary" diff --git a/k8s/devops-info-service/values-canary.yaml b/k8s/devops-info-service/values-canary.yaml new file mode 100644 index 0000000000..e79641d6e5 --- /dev/null +++ b/k8s/devops-info-service/values-canary.yaml @@ -0,0 +1,26 @@ +replicaCount: 5 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30081 + +releaseMetadata: + version: "1.0.0-canary-v1" + track: "canary" + color: "" + +rollout: + strategy: canary + canary: + maxSurge: 1 + maxUnavailable: 0 + +analysis: + enabled: true + +secret: + stringData: + APP_USERNAME: "canary-user" + APP_PASSWORD: "replace-me-in-canary" diff --git a/k8s/devops-info-service/values-dev.yaml b/k8s/devops-info-service/values-dev.yaml new file mode 100644 index 0000000000..b12e59e255 --- /dev/null +++ b/k8s/devops-info-service/values-dev.yaml @@ -0,0 +1,38 @@ +replicaCount: 1 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30081 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 100m + memory: 128Mi + +secret: + stringData: + APP_USERNAME: "dev-user" + APP_PASSWORD: "replace-me-in-dev" + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 diff --git a/k8s/devops-info-service/values-monitoring.yaml b/k8s/devops-info-service/values-monitoring.yaml new file mode 100644 index 0000000000..d1fc213432 --- /dev/null +++ b/k8s/devops-info-service/values-monitoring.yaml @@ -0,0 +1,34 @@ +workload: + kind: statefulset + +replicaCount: 3 + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30083 + +releaseMetadata: + version: "1.0.0-monitoring" + track: "monitoring" + color: "" + +persistence: + enabled: true + mountPath: /data + size: 128Mi + storageClass: "" + +serviceMonitor: + enabled: true + additionalLabels: + release: monitoring + interval: 15s + scrapeTimeout: 10s + path: /metrics + +secret: + stringData: + APP_USERNAME: "monitoring-user" + APP_PASSWORD: "replace-me-in-monitoring" diff --git a/k8s/devops-info-service/values-prod.yaml b/k8s/devops-info-service/values-prod.yaml new file mode 100644 index 0000000000..1ea7691de9 --- /dev/null +++ b/k8s/devops-info-service/values-prod.yaml @@ -0,0 +1,37 @@ +replicaCount: 3 + +service: + type: LoadBalancer + port: 80 + targetPort: http + +resources: + requests: + cpu: 150m + memory: 192Mi + limits: + cpu: 500m + memory: 512Mi + +secret: + stringData: + APP_USERNAME: "prod-user" + APP_PASSWORD: "replace-me-in-prod" + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 3 diff --git a/k8s/devops-info-service/values.yaml b/k8s/devops-info-service/values.yaml new file mode 100644 index 0000000000..04e270ca1c --- /dev/null +++ b/k8s/devops-info-service/values.yaml @@ -0,0 +1,164 @@ +replicaCount: 3 + +workload: + kind: rollout + +image: + repository: devops-info-service + tag: "lab09" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +containerPort: 5000 + +env: + - name: PORT + value: "5000" + +releaseMetadata: + version: "1.0.0" + track: "stable" + color: "" + +secret: + enabled: true + create: true + existingSecret: "" + name: "" + type: Opaque + envFrom: true + stringData: + APP_USERNAME: "change-me" + APP_PASSWORD: "change-me" + +vault: + enabled: false + role: "devops-info-service" + authPath: "kubernetes" + secretPath: "secret/data/devops-info-service/config" + injectFileName: "config.env" + secretMountPath: "/vault/secrets" + agentPrePopulateOnly: false + template: + enabled: true + format: "env" + +service: + type: NodePort + port: 80 + targetPort: http + nodePort: 30081 + +persistence: + enabled: false + mountPath: /data + accessModes: + - ReadWriteOnce + size: 128Mi + storageClass: "" + retentionPolicy: + whenDeleted: Retain + whenScaled: Retain + +rollout: + strategy: canary + canary: + maxSurge: 1 + maxUnavailable: 0 + blueGreen: + autoPromotionEnabled: false + autoPromotionSeconds: null + scaleDownDelaySeconds: 30 + +previewService: + type: ClusterIP + port: 80 + targetPort: http + nodePort: null + +analysis: + enabled: true + interval: 10s + count: 3 + failureLimit: 1 + successCondition: "result == 'healthy'" + web: + path: /health + jsonPath: "{$.status}" + +minReadySeconds: 5 +revisionHistoryLimit: 5 + +podSecurityContext: + seccompProfile: + type: RuntimeDefault + +securityContext: + runAsUser: 10001 + runAsGroup: 10001 + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +hooks: + enabled: true + image: busybox:1.36 + preInstall: + weight: "-5" + deletePolicy: before-hook-creation,hook-succeeded + postInstall: + weight: "5" + deletePolicy: before-hook-creation,hook-succeeded + smokeTestPath: /health + +serviceMonitor: + enabled: false + namespace: "" + additionalLabels: {} + interval: 15s + scrapeTimeout: 10s + path: /metrics + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/k8s/lab16/init-containers-demo.yaml b/k8s/lab16/init-containers-demo.yaml new file mode 100644 index 0000000000..adaa10d4ee --- /dev/null +++ b/k8s/lab16/init-containers-demo.yaml @@ -0,0 +1,118 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: lab16-init-source +data: + index.html: | + + +

Lab 16 init container demo

+

This file was fetched by an init container before nginx started.

+ + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lab16-init-source +spec: + replicas: 1 + selector: + matchLabels: + app: lab16-init-source + template: + metadata: + labels: + app: lab16-init-source + spec: + containers: + - name: nginx + image: nginx:1.27-alpine + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: source-content + mountPath: /usr/share/nginx/html/index.html + subPath: index.html + volumes: + - name: source-content + configMap: + name: lab16-init-source +--- +apiVersion: v1 +kind: Service +metadata: + name: lab16-init-source +spec: + selector: + app: lab16-init-source + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lab16-init-demo +spec: + replicas: 1 + selector: + matchLabels: + app: lab16-init-demo + template: + metadata: + labels: + app: lab16-init-demo + spec: + initContainers: + - name: wait-for-source + image: busybox:1.36 + command: + - sh + - -c + - | + until nslookup lab16-init-source.default.svc.cluster.local >/dev/null 2>&1; do + echo "waiting for service DNS" + sleep 2 + done + until wget -qO- http://lab16-init-source/ >/dev/null; do + echo "waiting for HTTP endpoint" + sleep 2 + done + - name: download-page + image: busybox:1.36 + command: + - sh + - -c + - | + wget -O /work-dir/index.html http://lab16-init-source/index.html + test -s /work-dir/index.html + volumeMounts: + - name: workdir + mountPath: /work-dir + containers: + - name: nginx + image: nginx:1.27-alpine + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: workdir + mountPath: /usr/share/nginx/html + volumes: + - name: workdir + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: lab16-init-demo +spec: + selector: + app: lab16-init-demo + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s/screenshots/lab14/bluegreen-active-blue-rollback.png b/k8s/screenshots/lab14/bluegreen-active-blue-rollback.png new file mode 100644 index 0000000000..407de66024 Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-active-blue-rollback.png differ diff --git a/k8s/screenshots/lab14/bluegreen-active-blue.png b/k8s/screenshots/lab14/bluegreen-active-blue.png new file mode 100644 index 0000000000..bb052b7eec Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-active-blue.png differ diff --git a/k8s/screenshots/lab14/bluegreen-active-green.png b/k8s/screenshots/lab14/bluegreen-active-green.png new file mode 100644 index 0000000000..02d39858a5 Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-active-green.png differ diff --git a/k8s/screenshots/lab14/bluegreen-paused-dashboard.png b/k8s/screenshots/lab14/bluegreen-paused-dashboard.png new file mode 100644 index 0000000000..d14bde8cbf Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-paused-dashboard.png differ diff --git a/k8s/screenshots/lab14/bluegreen-preview-green.png b/k8s/screenshots/lab14/bluegreen-preview-green.png new file mode 100644 index 0000000000..9be13e4147 Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-preview-green.png differ diff --git a/k8s/screenshots/lab14/bluegreen-rollback-dashboard.png b/k8s/screenshots/lab14/bluegreen-rollback-dashboard.png new file mode 100644 index 0000000000..038da60661 Binary files /dev/null and b/k8s/screenshots/lab14/bluegreen-rollback-dashboard.png differ diff --git a/k8s/screenshots/lab14/canary-40-paused-dashboard.png b/k8s/screenshots/lab14/canary-40-paused-dashboard.png new file mode 100644 index 0000000000..c10b9e61d5 Binary files /dev/null and b/k8s/screenshots/lab14/canary-40-paused-dashboard.png differ diff --git a/k8s/screenshots/lab14/canary-aborted-dashboard.png b/k8s/screenshots/lab14/canary-aborted-dashboard.png new file mode 100644 index 0000000000..4dd0a727cd Binary files /dev/null and b/k8s/screenshots/lab14/canary-aborted-dashboard.png differ diff --git a/k8s/screenshots/lab14/canary-analysis-failed-dashboard.png b/k8s/screenshots/lab14/canary-analysis-failed-dashboard.png new file mode 100644 index 0000000000..08a542b992 Binary files /dev/null and b/k8s/screenshots/lab14/canary-analysis-failed-dashboard.png differ diff --git a/k8s/screenshots/lab14/canary-paused-dashboard.png b/k8s/screenshots/lab14/canary-paused-dashboard.png new file mode 100644 index 0000000000..301da1a252 Binary files /dev/null and b/k8s/screenshots/lab14/canary-paused-dashboard.png differ diff --git a/k8s/screenshots/lab16/alertmanager-alerts.png b/k8s/screenshots/lab16/alertmanager-alerts.png new file mode 100644 index 0000000000..6360b17ba9 Binary files /dev/null and b/k8s/screenshots/lab16/alertmanager-alerts.png differ diff --git a/k8s/screenshots/lab16/init-demo-page.png b/k8s/screenshots/lab16/init-demo-page.png new file mode 100644 index 0000000000..08e7827014 Binary files /dev/null and b/k8s/screenshots/lab16/init-demo-page.png differ diff --git a/k8s/screenshots/lab16/kubelet-dashboard.png b/k8s/screenshots/lab16/kubelet-dashboard.png new file mode 100644 index 0000000000..724602091b Binary files /dev/null and b/k8s/screenshots/lab16/kubelet-dashboard.png differ diff --git a/k8s/screenshots/lab16/namespace-cpu-panel.png b/k8s/screenshots/lab16/namespace-cpu-panel.png new file mode 100644 index 0000000000..46c87eaf1b Binary files /dev/null and b/k8s/screenshots/lab16/namespace-cpu-panel.png differ diff --git a/k8s/screenshots/lab16/node-dashboard.png b/k8s/screenshots/lab16/node-dashboard.png new file mode 100644 index 0000000000..a4d7411ce9 Binary files /dev/null and b/k8s/screenshots/lab16/node-dashboard.png differ diff --git a/k8s/screenshots/lab16/prometheus-targets-servicemonitor.png b/k8s/screenshots/lab16/prometheus-targets-servicemonitor.png new file mode 100644 index 0000000000..8a65cb138c Binary files /dev/null and b/k8s/screenshots/lab16/prometheus-targets-servicemonitor.png differ diff --git a/k8s/screenshots/lab16/prometheus-traffic-query.png b/k8s/screenshots/lab16/prometheus-traffic-query.png new file mode 100644 index 0000000000..77dc422041 Binary files /dev/null and b/k8s/screenshots/lab16/prometheus-traffic-query.png differ diff --git a/k8s/screenshots/lab16/workload-dashboard.png b/k8s/screenshots/lab16/workload-dashboard.png new file mode 100644 index 0000000000..763ca4e22a Binary files /dev/null and b/k8s/screenshots/lab16/workload-dashboard.png differ diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..9f70519c96 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service + labels: + app: devops-info-service + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/component: web + app.kubernetes.io/part-of: devops-core-course +spec: + type: NodePort + selector: + app: devops-info-service + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http + nodePort: 30080 diff --git a/lab17.txt b/lab17.txt new file mode 100644 index 0000000000..998f177e2b --- /dev/null +++ b/lab17.txt @@ -0,0 +1 @@ +lab17 \ No newline at end of file diff --git a/labs/lab04.md b/labs/lab04.md index eefa858953..4651a351c2 100644 --- a/labs/lab04.md +++ b/labs/lab04.md @@ -33,11 +33,11 @@ By using both Terraform and Pulumi for the same task, you'll understand: - How to evaluate IaC tools for your needs **Important for Lab 5:** -The VM you create in this lab will be used in **Lab 5 (Ansible)** for configuration management. You have two options: -- **Option A (Recommended):** Keep your cloud VM running until you complete Lab 5 -- **Option B:** Use a local VM (see Local VM Alternative section below) +The VM you create in this lab will be used in **Lab 5 (Ansible)** for configuration management. -If you choose to destroy your cloud VM after Lab 4, you can easily recreate it later using your Terraform/Pulumi code! +Recommended approach: +- Keep **one** cloud VM running until you complete Lab 5 (to avoid re-creating it). +- If you destroy it after Lab 4, recreate it later from your Terraform/Pulumi code. --- @@ -91,71 +91,6 @@ If Yandex Cloud is unavailable, choose any of these: --- -## Local VM Alternative - -If you cannot or prefer not to use cloud providers, you can use a local VM instead. This VM will need to meet specific requirements for Lab 5 (Ansible). - -### Option 1: VirtualBox/VMware VM - -**Requirements:** -- Ubuntu 24.04 LTS (recommended) or Ubuntu 22.04 LTS -- 1 GB RAM minimum (2 GB recommended) -- 10 GB disk space -- Network adapter in Bridged mode (or NAT with port forwarding) -- SSH server installed and configured -- Your SSH public key added to `~/.ssh/authorized_keys` -- Static or predictable IP address - -**Setup Steps:** -```bash -# Install SSH server (if not installed) -sudo apt update -sudo apt install openssh-server - -# Add your SSH public key -mkdir -p ~/.ssh -echo "your-public-key-here" >> ~/.ssh/authorized_keys -chmod 700 ~/.ssh -chmod 600 ~/.ssh/authorized_keys - -# Verify SSH access from your host machine -ssh username@vm-ip-address -``` - -### Option 2: Vagrant VM - -**Requirements:** -- Vagrant installed on your machine -- VirtualBox (or another Vagrant provider) - -**Basic Vagrantfile:** -```ruby -Vagrant.configure("2") do |config| - config.vm.box = "ubuntu/noble64" # Ubuntu 24.04 LTS - # Or use "ubuntu/jammy64" for Ubuntu 22.04 LTS - config.vm.network "private_network", ip: "192.168.56.10" - config.vm.provider "virtualbox" do |vb| - vb.memory = "2048" - end -end -``` - -### Option 3: WSL2 (Windows Subsystem for Linux) - -**Note:** WSL2 can work but has networking limitations. Bridged mode VM is preferred. - -**If using local VM:** -- You can skip Terraform/Pulumi cloud provider setup -- Document your local VM setup instead -- For Task 1, show VM creation (manual or Vagrant) -- For Task 2, you can skip Pulumi (or use Pulumi to manage Vagrant) -- Focus on understanding IaC concepts with cloud provider research - -**Recommended Approach:** -Even with a local VM, complete the Terraform/Pulumi tasks with a cloud provider to gain real IaC experience. You can destroy the cloud VM after Lab 4 and use your local VM for Lab 5. - ---- - ## Tasks ### Task 1 β€” Terraform VM Creation (4 pts) @@ -905,7 +840,7 @@ Brief comparison (3-5 sentences each): **VM for Lab 5:** - Are you keeping your VM for Lab 5? (Yes/No) - If yes: Which VM (Terraform or Pulumi created)? -- If no: What will you use for Lab 5? (Local VM/Will recreate cloud VM) +- If no: How will you recreate the VM for Lab 5? (Terraform/Pulumi + steps) **Cleanup Status:** - If keeping VM for Lab 5: Show VM is still running and accessible @@ -1339,13 +1274,13 @@ terraform import github_repository.course_repo DevOps-Core-Course - βœ… Check no secrets in code - βœ… Review .gitignore is correct - **If NOT keeping VM for Lab 5:** - - βœ… Run `terraform destroy` - - βœ… Run `pulumi destroy` - - βœ… Verify no resources in cloud console - - βœ… Check no secrets in code - - βœ… Review .gitignore is correct - - βœ… Document your Lab 5 plan (local VM or recreate cloud VM) + **If NOT keeping VM for Lab 5:** + - βœ… Run `terraform destroy` + - βœ… Run `pulumi destroy` + - βœ… Verify no resources in cloud console + - βœ… Check no secrets in code + - βœ… Review .gitignore is correct + - βœ… Document your Lab 5 plan (how you'll recreate the cloud VM from IaC) 4. **Create Pull Requests:** - **PR #1:** `your-fork:lab04` β†’ `course-repo:master` @@ -1386,7 +1321,7 @@ terraform import github_repository.course_repo DevOps-Core-Course - [ ] Terraform implementation documented - [ ] Pulumi implementation documented - [ ] Terraform vs Pulumi comparison provided -- [ ] Lab 5 preparation documented (keeping VM or using local/recreating) + - [ ] Lab 5 preparation documented (keeping VM or recreating it from IaC) - [ ] Cleanup status documented (what's kept, what's destroyed) - [ ] Terminal outputs provided (sanitized, no secrets) @@ -1431,7 +1366,7 @@ terraform import github_repository.course_repo DevOps-Core-Course **Critical Requirements:** - βœ… MUST use free tier resources only -- βœ… MUST document Lab 5 VM plan (keeping, local, or recreating) +- βœ… MUST document Lab 5 VM plan (keeping one VM or recreating it from IaC) - βœ… MUST NOT commit secrets or state files - βœ… MUST provide SSH access proof - ⚠️ Keeping ONE VM for Lab 5 is acceptable (document it!) @@ -1498,7 +1433,7 @@ terraform import github_repository.course_repo DevOps-Core-Course ## Looking Ahead - **Lab 5:** Ansible will provision software on your VM (install Docker, deploy your app from Labs 1-3) - - **You'll need a VM ready** - either keep your cloud VM from this lab, use a local VM, or recreate later + - **You'll need a VM ready** - keep your cloud VM from this lab or recreate later from your IaC code - **Lab 6:** Ansible + Terraform integration (provision and configure in one workflow) - **Lab 9:** Kubernetes will replace individual VMs (but concepts are same) - **Lab 13:** ArgoCD will manage infrastructure changes (GitOps for infrastructure) @@ -1507,4 +1442,4 @@ terraform import github_repository.course_repo DevOps-Core-Course **Good luck!** πŸš€ -> **Remember:** Infrastructure as Code is about automation, repeatability, and collaboration. Focus on understanding WHY we define infrastructure in code, not just HOW. Consider keeping one VM for Lab 5 (Ansible). If destroying resources, document your Lab 5 plan. Never commit secrets! +> **Remember:** Infrastructure as Code is about automation, repeatability, and collaboration. Focus on understanding WHY we define infrastructure in code, not just HOW. Consider keeping one VM for Lab 5 (Ansible). If destroying resources, document how you'll recreate the VM from your IaC code. Never commit secrets! diff --git a/labs/lab16.md b/labs/lab16.md index 6fa7220f36..b5fd6455e0 100644 --- a/labs/lab16.md +++ b/labs/lab16.md @@ -252,7 +252,7 @@ kubectl port-forward svc/monitoring-kube-prometheus-prometheus -n monitoring 909 Congratulations on completing the core Kubernetes labs! You now have experience with the complete DevOps lifecycle from development to production monitoring. -**Optional:** Labs 17-18 are exam alternatives covering Fly.io and 4EVERLAND. +**Optional:** Labs 17-18 are exam alternatives covering Cloudflare Workers and Nix. --- diff --git a/labs/lab17.md b/labs/lab17.md index c0ca8ed79d..957a8800e1 100644 --- a/labs/lab17.md +++ b/labs/lab17.md @@ -1,28 +1,37 @@ -# Lab 17 β€” Fly.io Edge Deployment +# Lab 17 β€” Cloudflare Workers Edge Deployment ![difficulty](https://img.shields.io/badge/difficulty-intermediate-yellow) ![topic](https://img.shields.io/badge/topic-Edge%20Computing-blue) ![points](https://img.shields.io/badge/points-20-orange) ![type](https://img.shields.io/badge/type-Exam%20Alternative-purple) -> Deploy your application globally on Fly.io's edge infrastructure and experience simplified cloud deployment. +> Build and deploy a serverless HTTP API on Cloudflare's global edge network using Cloudflare Workers. ## Overview -Fly.io is a platform for running applications close to users worldwide. Unlike Kubernetes which requires cluster management, Fly.io abstracts infrastructure away while still giving you control over deployment, scaling, and observability. +Cloudflare Workers is a serverless edge platform for running code close to users worldwide without managing servers or choosing VM regions manually. Unlike Kubernetes or Docker-based PaaS platforms, Workers uses a lightweight runtime, automatic global distribution, built-in `workers.dev` URLs, and platform bindings for configuration, secrets, and state. **This is an Exam Alternative Lab** β€” Complete both Lab 17 and Lab 18 to replace the final exam. **What You'll Learn:** - Edge computing concepts -- Platform-as-a-Service deployment -- Global application distribution -- Kubernetes vs PaaS trade-offs -- Modern deployment workflows +- Serverless deployment workflows +- Cloudflare Workers and Wrangler CLI +- Global request metadata and routing +- Secrets, environment variables, and KV persistence +- Rollbacks and observability +- Kubernetes vs Workers trade-offs -**Prerequisites:** Working Docker image from Lab 2 +**Prerequisites:** +- Git +- Node.js 18+ and npm +- Basic HTTP/JSON familiarity -**Tech Stack:** Fly.io | flyctl CLI | Docker | Multi-region deployment +**Important:** This lab does not deploy your Docker image from Lab 2. Cloudflare Workers is a serverless runtime, not a Docker host. You will build a Workers-native API that preserves similar operational concerns: routes, health checks, configuration, state, logs, deployments, and public access. + +> **Regional connectivity note:** In some countries and networks, including Russia, Cloudflare services may be partially restricted. If commands such as `npx wrangler whoami` or `npx wrangler deploy` fail with vague network errors, the problem may be your network path rather than your code. If you use a VPN, prefer full-tunnel or global-routing mode. Proxy or split-tunnel setups can allow Node.js and Wrangler traffic to bypass the VPN and still hit the restricted network. + +**Tech Stack:** Cloudflare Workers | Wrangler | TypeScript | Workers KV | `workers.dev` --- @@ -39,363 +48,408 @@ Fly.io is a platform for running applications close to users worldwide. Unlike K ## Tasks -### Task 1 β€” Fly.io Setup (3 pts) +### Task 1 β€” Cloudflare Setup (3 pts) -**Objective:** Set up Fly.io account and CLI. +**Objective:** Set up your Cloudflare account and Workers tooling. **Requirements:** 1. **Create Account** - - Sign up at [fly.io](https://fly.io) - - No credit card required for free tier - - Verify email + - Sign up for a Cloudflare account + - Confirm you can access Workers from the dashboard + - Understand what a `workers.dev` subdomain is -2. **Install flyctl CLI** - - Install for your operating system - - Authenticate with `fly auth login` - - Verify with `fly version` +2. **Create Project** + - Create a new Workers project using C3 (`create-cloudflare`) + - Choose the `Worker only` template + - Use TypeScript for the required path in this lab -3. **Explore Platform Concepts** - - Understand Fly Machines (VMs) - - Understand Fly Volumes (persistent storage) - - Understand Regions and edge deployment +3. **Authenticate CLI** + - Log in with Wrangler + - Verify your account with `npx wrangler whoami` + - Understand the role of `wrangler.jsonc` + +4. **Explore Platform Concepts** + - Understand the Workers runtime + - Understand `workers.dev` URLs + - Understand bindings: vars, secrets, and KV namespaces
πŸ’‘ Hints -**Installation:** +**Create the project:** ```bash -# macOS -brew install flyctl - -# Linux -curl -L https://fly.io/install.sh | sh - -# Windows (PowerShell) -pwsh -Command "iwr https://fly.io/install.ps1 -useb | iex" +npm create cloudflare@latest -- edge-api +cd edge-api ``` -**Authentication:** -```bash -fly auth login -# Opens browser for authentication +**Recommended choices during setup:** +- Hello World example +- Worker only +- TypeScript +- Git: Yes +- Deploy now: No -fly auth whoami -# Verify logged in +**Authenticate:** +```bash +npx wrangler login +npx wrangler whoami ``` -**Free Tier Includes:** -- 3 shared-cpu-1x VMs (256MB RAM) -- 3GB persistent storage -- 160GB outbound bandwidth +**What to look for in the generated project:** +- `src/index.ts` - Worker source code +- `wrangler.jsonc` - Worker configuration +- `package.json` - local scripts and dependencies **Resources:** -- [Fly.io Docs](https://fly.io/docs/) -- [Getting Started](https://fly.io/docs/getting-started/) +- [Cloudflare Workers Overview](https://developers.cloudflare.com/workers/) +- [Get started with Wrangler](https://developers.cloudflare.com/workers/get-started/guide/) +- [Wrangler commands](https://developers.cloudflare.com/workers/wrangler/commands/)
--- -### Task 2 β€” Deploy Application (4 pts) +### Task 2 β€” Build and Deploy a Worker API (4 pts) -**Objective:** Deploy your application to Fly.io. +**Objective:** Build a small HTTP API and deploy it to Cloudflare's edge. **Requirements:** -1. **Prepare Application** - - Ensure Dockerfile works locally - - Application should listen on port 8080 (or configure in fly.toml) +1. **Implement Routes** + - Create at least 3 HTTP endpoints + - Include `/health` + - Include one endpoint that returns JSON metadata about the deployment -2. **Launch Application** - - Run `fly launch` in your app directory - - Configure app name and region - - Review generated `fly.toml` +2. **Run Locally** + - Start local development with `npx wrangler dev` + - Test routes in the browser or with `curl` + - Verify correct status codes and JSON responses 3. **Deploy** - - Run `fly deploy` - - Wait for deployment to complete - - Access your application via provided URL + - Deploy with `npx wrangler deploy` + - Access the public `workers.dev` URL + - Confirm the deployed Worker responds correctly -4. **Verify** - - Test all endpoints work - - Check application logs - - Verify health checks pass +4. **Use Versioned Source Control** + - Commit your Worker project to Git + - Keep a clean deployment history you can refer to later
πŸ’‘ Hints -**Launch Process:** -```bash -cd app_python # or app_go - -fly launch -# Follow prompts: -# - App name: your-unique-name -# - Region: select closest -# - Postgres/Redis: No (for now) -# - Deploy now: Yes +**Example route set:** +- `/` - general app information +- `/health` - health status +- `/edge` - edge metadata +- `/counter` - KV-backed persisted counter + +**Minimal TypeScript example:** +```ts +export interface Env { + APP_NAME: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/health") { + return Response.json({ status: "ok" }); + } + + if (url.pathname === "/") { + return Response.json({ + app: env.APP_NAME, + message: "Hello from Cloudflare Workers", + timestamp: new Date().toISOString(), + }); + } + + return new Response("Not Found", { status: 404 }); + }, +}; ``` -**fly.toml Configuration:** -```toml -app = "your-app-name" -primary_region = "ams" # Amsterdam, or your choice - -[build] - dockerfile = "Dockerfile" - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = true - auto_start_machines = true - min_machines_running = 0 - -[checks] - [checks.health] - type = "http" - port = 8080 - path = "/health" - interval = "10s" - timeout = "2s" +**Local development:** +```bash +npx wrangler dev ``` -**Useful Commands:** +**Deploy:** ```bash -fly status # App status -fly logs # View logs -fly open # Open in browser -fly ssh console # SSH into machine +npx wrangler deploy +``` + +**Expected public URL format:** +```text +https://..workers.dev ```
--- -### Task 3 β€” Multi-Region Deployment (4 pts) +### Task 3 β€” Global Edge Behavior (4 pts) -**Objective:** Deploy your application to multiple regions worldwide. +**Objective:** Inspect how your Worker behaves on Cloudflare's global network. **Requirements:** -1. **Add Regions** - - Deploy to at least 3 regions (e.g., ams, iad, sin) - - Understand region codes +1. **Add Edge Metadata Endpoint** + - Return information from the incoming request context + - Include at least `colo` and `country` + - Include at least 1 additional field such as `asn`, `city`, `httpProtocol`, or `tlsVersion` -2. **Verify Global Distribution** - - Check machines in each region - - Access from different regions if possible +2. **Verify Public Edge Execution** + - Call your deployed Worker using the public URL + - Capture the JSON response from the metadata endpoint + - Show evidence that Cloudflare provides request metadata at the edge -3. **Test Latency** - - Document response times from different regions - - Understand how Fly routes requests to nearest region +3. **Explain Global Distribution** + - Briefly explain how Workers distributes execution globally + - Compare this with manually choosing regions in VM or PaaS platforms + - Explain why there is no `deploy to 3 regions` step in Workers -4. **Scale Machines** - - Scale to 2 machines in primary region - - Understand scaling commands +4. **Document Routing Concepts** + - Explain the difference between `workers.dev`, Routes, and Custom Domains + - Use `workers.dev` for the required deployment + - Custom domain setup is optional
πŸ’‘ Hints -**Region Codes:** -- `ams` - Amsterdam -- `iad` - Virginia, USA -- `sin` - Singapore -- `syd` - Sydney -- `lhr` - London - -**Adding Regions:** -```bash -# Add regions -fly regions add iad sin - -# List regions -fly regions list - -# Check machines -fly machines list +**Useful request metadata:** +```ts +if (url.pathname === "/edge") { + return Response.json({ + colo: request.cf?.colo, + country: request.cf?.country, + city: request.cf?.city, + asn: request.cf?.asn, + httpProtocol: request.cf?.httpProtocol, + tlsVersion: request.cf?.tlsVersion, + }); +} ``` -**Scaling:** +**Test with `curl`:** ```bash -# Scale in specific region -fly scale count 2 --region ams - -# Or modify fly.toml and deploy +curl https://..workers.dev/edge ``` -**Verify Distribution:** -```bash -fly status -# Shows machines in each region +**Routing concepts:** +- `workers.dev` gives you a public URL quickly +- Routes attach Workers to traffic for an existing Cloudflare zone +- Custom Domains make your Worker the origin for a domain or subdomain -fly ping -# Test connectivity to regions -``` +**Resources:** +- [Request API and `request.cf`](https://developers.cloudflare.com/workers/runtime-apis/request/) +- [How Workers works](https://developers.cloudflare.com/workers/reference/how-workers-works/) +- [`workers.dev` routing](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/) +- [Routes and domains](https://developers.cloudflare.com/workers/configuration/routing/)
--- -### Task 4 β€” Secrets & Persistence (3 pts) +### Task 4 β€” Configuration, Secrets & Persistence (3 pts) -**Objective:** Configure secrets and persistent storage. +**Objective:** Configure your Worker with variables, secrets, and persistent state. **Requirements:** -1. **Configure Secrets** - - Set at least 2 secrets using `fly secrets` - - Verify secrets are available in application - - Understand secret management on Fly +1. **Add Environment Variables** + - Define at least 1 plaintext variable in `wrangler.jsonc` + - Use it in your Worker response + - Explain why plaintext vars are not suitable for secrets + +2. **Add Secrets** + - Create at least 2 secrets with Wrangler + - Use the values through the `env` object + - Do not commit secret values to Git + +3. **Add Persistence with Workers KV** + - Create a KV namespace + - Bind it to your Worker + - Store and retrieve at least 1 value through your API -2. **Attach Volume** (if app needs persistence) - - Create Fly Volume - - Attach to application - - Verify data persists across deployments +4. **Verify Persistence** + - Confirm the stored value still exists after a redeploy + - Document what you stored and how you verified it
πŸ’‘ Hints +**Plaintext vars in `wrangler.jsonc`:** +```json +{ + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "devops-core" + } +} +``` + **Secrets:** ```bash -# Set secrets -fly secrets set DATABASE_URL="postgres://..." API_KEY="secret123" - -# List secrets (names only) -fly secrets list - -# Secrets available as env vars in app +npx wrangler secret put API_TOKEN +npx wrangler secret put ADMIN_EMAIL ``` -**Volumes:** +**Create KV namespace:** ```bash -# Create volume -fly volumes create myapp_data --size 1 --region ams - -# Update fly.toml -[mounts] - source = "myapp_data" - destination = "/data" +npx wrangler kv namespace create SETTINGS +``` -# Deploy -fly deploy +Add the returned namespace ID to `wrangler.jsonc`: +```json +{ + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "" + } + ] +} ``` -**Verify Persistence:** -```bash -fly ssh console -# Inside machine -cat /data/visits +**Example KV-backed counter:** +```ts +export interface Env { + APP_NAME: string; + API_TOKEN: string; + ADMIN_EMAIL: string; + SETTINGS: KVNamespace; +} + +if (url.pathname === "/counter") { + const raw = await env.SETTINGS.get("visits"); + const visits = Number(raw ?? "0") + 1; + await env.SETTINGS.put("visits", String(visits)); + return Response.json({ visits }); +} ``` +**Resources:** +- [Environment variables](https://developers.cloudflare.com/workers/configuration/environment-variables/) +- [Secrets](https://developers.cloudflare.com/workers/configuration/secrets/) +- [Workers KV getting started](https://developers.cloudflare.com/kv/get-started/) +- [Workers KV pricing](https://developers.cloudflare.com/kv/platform/pricing/) +
--- -### Task 5 β€” Monitoring & Operations (3 pts) +### Task 5 β€” Observability & Operations (3 pts) -**Objective:** Monitor and manage your deployed application. +**Objective:** Observe your Worker in production and manage deployments. **Requirements:** -1. **View Metrics** - - Access Fly.io dashboard - - View CPU, memory, network metrics - - Understand machine states +1. **Inspect Logs** + - Add at least 1 `console.log()` statement + - View logs with `npx wrangler tail` or in the dashboard + - Capture an example log entry -2. **Manage Deployments** - - Deploy a new version - - View deployment history - - Understand rollback capability +2. **Inspect Metrics** + - Open the Worker in the Cloudflare dashboard + - Review request counts, errors, or execution metrics + - Briefly explain what metric you looked at -3. **Health Checks** - - Configure HTTP health checks - - Verify health check execution - - Understand failure behavior +3. **Manage Deployments** + - Deploy at least 2 versions of your Worker + - View deployment history + - Perform or describe a rollback to a previous version
πŸ’‘ Hints -**Dashboard:** -- Visit https://fly.io/dashboard -- Select your app -- View Metrics, Machines, Volumes tabs +**Console logging example:** +```ts +console.log("path", url.pathname, "colo", request.cf?.colo); +``` -**Deployments:** +**Tail logs from the terminal:** ```bash -fly releases -# Shows deployment history - -fly deploy --strategy rolling -# Rolling deployment +npx wrangler tail +``` -fly deploy --strategy immediate -# Immediate replacement +**View deployments:** +```bash +npx wrangler deployments list ``` -**Health Checks in fly.toml:** -```toml -[checks] - [checks.health] - type = "http" - port = 8080 - path = "/health" - interval = "10s" - timeout = "2s" - grace_period = "30s" +**Rollback:** +```bash +npx wrangler rollback ``` +**Resources:** +- [Observability overview](https://developers.cloudflare.com/workers/observability/) +- [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) +- [Versions & Deployments](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/) +- [Rollbacks](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/) +
--- ### Task 6 β€” Documentation & Comparison (3 pts) -**Objective:** Document deployment and compare with Kubernetes. +**Objective:** Document your deployment and compare Workers with Kubernetes. -**Create `FLYIO.md` with:** +**Create `WORKERS.md` with:** 1. **Deployment Summary** - - App URL - - Regions deployed + - Worker URL + - Main routes - Configuration used -2. **Screenshots** - - Fly.io dashboard - - Multi-region machines - - Metrics view +2. **Evidence** + - Screenshot of Cloudflare dashboard + - Example `/edge` JSON response + - Example log or metrics screenshot -3. **Kubernetes vs Fly.io Comparison** +3. **Kubernetes vs Cloudflare Workers Comparison** -| Aspect | Kubernetes | Fly.io | -|--------|------------|--------| +| Aspect | Kubernetes | Cloudflare Workers | +|--------|------------|--------------------| | Setup complexity | | | | Deployment speed | | | | Global distribution | | | | Cost (for small apps) | | | -| Learning curve | | | +| State/persistence model | | | | Control/flexibility | | | | Best use case | | | 4. **When to Use Each** - Scenarios favoring Kubernetes - - Scenarios favoring Fly.io + - Scenarios favoring Workers - Your recommendation +5. **Reflection** + - What felt easier than Kubernetes? + - What felt more constrained? + - What changed because Workers is not a Docker host? + --- ## Checklist -- [ ] Fly.io account created -- [ ] flyctl CLI installed and authenticated -- [ ] Application deployed successfully -- [ ] Multiple regions configured (3+) -- [ ] Secrets configured -- [ ] Persistence tested (if applicable) -- [ ] Health checks working -- [ ] Metrics accessible -- [ ] `FLYIO.md` documentation complete +- [ ] Cloudflare account created +- [ ] Workers project initialized +- [ ] Wrangler authenticated +- [ ] Worker deployed to `workers.dev` +- [ ] `/health` endpoint working +- [ ] Edge metadata endpoint implemented +- [ ] At least 1 plaintext variable configured +- [ ] At least 2 secrets configured +- [ ] KV namespace created and bound +- [ ] Persistence verified after redeploy +- [ ] Logs or metrics reviewed +- [ ] Deployment history viewed +- [ ] `WORKERS.md` documentation complete - [ ] Kubernetes comparison documented --- @@ -405,43 +459,74 @@ fly deploy --strategy immediate | Criteria | Points | |----------|--------| | **Setup** | 3 pts | -| **Deployment** | 4 pts | -| **Multi-Region** | 4 pts | -| **Secrets & Persistence** | 3 pts | -| **Monitoring** | 3 pts | +| **Worker API** | 4 pts | +| **Edge Behavior** | 4 pts | +| **Configuration & Persistence** | 3 pts | +| **Operations** | 3 pts | | **Documentation** | 3 pts | | **Total** | **20 pts** | **Grading:** -- **18-20:** Excellent global deployment, thorough comparison -- **16-17:** Working deployment, good documentation -- **14-15:** Basic deployment, missing regions or docs -- **<14:** Incomplete deployment +- **18-20:** Excellent deployment, strong edge analysis, thorough comparison +- **16-17:** Working Worker, good documentation, minor gaps +- **14-15:** Basic deployment works, missing KV, observability, or analysis detail +- **<14:** Incomplete implementation --- ## Resources
-πŸ“š Fly.io Documentation +πŸ“š Core Cloudflare Workers Docs + +- [Cloudflare Workers Overview](https://developers.cloudflare.com/workers/) +- [Get started with Wrangler](https://developers.cloudflare.com/workers/get-started/guide/) +- [Wrangler commands](https://developers.cloudflare.com/workers/wrangler/commands/) +- [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) + +
+ +
+🌍 Edge Runtime & Routing + +- [How Workers works](https://developers.cloudflare.com/workers/reference/how-workers-works/) +- [Request API and `request.cf`](https://developers.cloudflare.com/workers/runtime-apis/request/) +- [`workers.dev`](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/) +- [Routes and domains](https://developers.cloudflare.com/workers/configuration/routing/) +- [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) + +
+ +
+πŸ” Config, Secrets & State + +- [Environment variables](https://developers.cloudflare.com/workers/configuration/environment-variables/) +- [Secrets](https://developers.cloudflare.com/workers/configuration/secrets/) +- [Workers KV getting started](https://developers.cloudflare.com/kv/get-started/) +- [Workers KV pricing](https://developers.cloudflare.com/kv/platform/pricing/) + +
+ +
+πŸ“Š Observability & Deployments -- [Fly.io Docs](https://fly.io/docs/) -- [flyctl Reference](https://fly.io/docs/flyctl/) -- [Fly Machines](https://fly.io/docs/machines/) -- [Fly Volumes](https://fly.io/docs/volumes/) +- [Observability overview](https://developers.cloudflare.com/workers/observability/) +- [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) +- [Versions & Deployments](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/) +- [Rollbacks](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/)
-🌍 Regions +🐍 Optional Python Track -- [Available Regions](https://fly.io/docs/reference/regions/) -- [Region Selection](https://fly.io/docs/reference/scaling/#regions) +- [Python Workers](https://developers.cloudflare.com/workers/languages/python/) +- [Python Worker packages](https://developers.cloudflare.com/workers/languages/python/packages/)
--- -**Good luck!** ✈️ +**Good luck!** 🌍 -> **Remember:** Fly.io is great for global, low-latency applications. Kubernetes gives more control but requires more management. Choose the right tool for your use case. +> **Remember:** Cloudflare Workers is excellent for globally distributed APIs and lightweight edge logic. Kubernetes gives you more control, broader runtime flexibility, and stronger patterns for long-running container workloads. Choose the right model for the workload. diff --git a/labs/lab18.md b/labs/lab18.md index 3491394659..864df70baa 100644 --- a/labs/lab18.md +++ b/labs/lab18.md @@ -1,430 +1,1306 @@ -# Lab 18 β€” Decentralized Hosting with 4EVERLAND & IPFS +# Lab 18 β€” Reproducible Builds with Nix ![difficulty](https://img.shields.io/badge/difficulty-intermediate-yellow) -![topic](https://img.shields.io/badge/topic-Web3%20Infrastructure-blue) -![points](https://img.shields.io/badge/points-20-orange) -![type](https://img.shields.io/badge/type-Exam%20Alternative-purple) +![topic](https://img.shields.io/badge/topic-Nix%20%26%20Reproducibility-blue) +![points](https://img.shields.io/badge/points-12-orange) -> Deploy content to the decentralized web using IPFS and 4EVERLAND for permanent, censorship-resistant hosting. +> **Goal:** Learn to create truly reproducible builds using Nix, eliminating "works on my machine" problems and achieving bit-for-bit reproducibility. +> **Deliverable:** A PR/MR from `feature/lab18` to the course repo with `labs/submission18.md` containing build artifacts, hash comparisons, Nix expressions, and analysis. Submit the PR/MR link via Moodle. -## Overview - -The decentralized web (Web3) offers an alternative to traditional hosting where content is stored across a distributed network rather than centralized servers. IPFS (InterPlanetary File System) is the foundation, and 4EVERLAND provides a user-friendly gateway to this ecosystem. +--- -**This is an Exam Alternative Lab** β€” Complete both Lab 17 and Lab 18 to replace the final exam. +## Overview -**What You'll Learn:** -- IPFS fundamentals and content addressing -- Decentralized storage concepts -- Pinning services and persistence -- 4EVERLAND hosting platform -- Centralized vs decentralized trade-offs +In this lab you will practice: +- Installing Nix and understanding the Nix philosophy +- Writing Nix derivations to build software reproducibly +- Creating reproducible Docker images using Nix +- Using Nix Flakes for modern, declarative dependency management +- **Comparing Nix with your previous work from Labs 1-2** -**Prerequisites:** Basic understanding of web hosting, completed Docker lab +**Why Nix?** Traditional build tools (Docker, npm, pip, etc.) claim to be reproducible, but they're not: +- `Dockerfile` with `apt-get install nodejs` gets different versions over time +- `pip install -r requirements.txt` without hash pinning can vary +- Docker builds include timestamps and vary across machines -**Tech Stack:** IPFS | 4EVERLAND | Docker | Content Addressing +**Nix solves this:** Every build is isolated in a sandbox with exact dependencies. The same Nix expression produces **identical binaries** on any machine, forever. -**Provided Files:** -- `labs/lab18/index.html` β€” A beautiful course landing page ready to deploy +**Building on Your Work:** Throughout this lab, you'll revisit your DevOps Info Service from Lab 1 and compare: +- **Lab 1**: `requirements.txt` vs Nix derivations for dependency management +- **Lab 2**: Traditional `Dockerfile` vs Nix `dockerTools` for containerization +- **Lab 10** *(bonus task)*: Helm `values.yaml` version pinning vs Nix Flakes locking --- -## Exam Alternative Requirements +## Prerequisites -| Requirement | Details | -|-------------|---------| -| **Deadline** | 1 week before exam date | -| **Minimum Score** | 16/20 points | -| **Must Complete** | Both Lab 17 AND Lab 18 | -| **Total Points** | 40 pts (replaces 40 pt exam) | +- **Required:** Completed Labs 1-16 (all required course labs) +- **Key Labs Referenced:** + - Lab 1: Python DevOps Info Service (you'll rebuild with Nix) + - Lab 2: Docker containerization (you'll compare with Nix dockerTools) + - Lab 10: Helm charts (you'll compare version pinning with Nix Flakes) +- Linux, macOS, or WSL2 +- Basic understanding of package managers +- Your `app_python/` directory from Lab 1-2 available --- ## Tasks -### Task 1 β€” IPFS Fundamentals (3 pts) +### Task 1 β€” Build Reproducible Python App (Revisiting Lab 1) (6 pts) + +**Objective:** Use Nix to build your DevOps Info Service from Lab 1 and compare Nix's reproducibility guarantees with traditional `pip install -r requirements.txt`. + +**Why This Matters:** You've already built this app in Lab 1 using `requirements.txt`. Now you'll see how Nix provides **true reproducibility** that `pip` cannot guarantee - the same derivation produces bit-for-bit identical results across different machines and times. + +#### 1.1: Install Nix Package Manager + +> ⚠️ **Important Installation Requirements:** +> - Requires sudo/admin access on your machine +> - Creates `/nix` directory at system root (Linux/macOS) or `C:\nix` (Windows WSL) +> - Modifies shell configuration files (`~/.bashrc`, `~/.zshrc`, etc.) +> - Installation size: ~500MB-1GB for base system +> - **Cannot be installed in home directory only** +> - Uninstallation requires manual cleanup (see [official guide](https://nixos.org/manual/nix/stable/installation/uninstall.html)) + +1. **Install Nix using the Determinate Systems installer (recommended):** + + ```bash + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install + ``` + + > **Why Determinate Nix?** It enables flakes by default and provides better defaults for modern Nix usage. + +
+ 🐧 Alternative: Official Nix installer + + ```bash + sh <(curl -L https://nixos.org/nix/install) --daemon + ``` + + Then enable flakes by adding to `~/.config/nix/nix.conf`: + ``` + experimental-features = nix-command flakes + ``` + +
+ +2. **Verify Installation:** + + ```bash + nix --version + ``` + + You should see Nix 2.x or higher. + + **Restart your terminal** after installation to load Nix into your PATH. + +3. **Test Basic Nix Usage:** + + ```bash + # Try running a program without installing it + nix run nixpkgs#hello + ``` + + This downloads and runs `hello` without installing it permanently. + +#### 1.2: Prepare Your Python Application + +1. **Copy your Lab 1 app to the lab18 directory:** + + ```bash + mkdir -p labs/lab18/app_python + cp -r app_python/* labs/lab18/app_python/ + cd labs/lab18/app_python + ``` + + You should have: + - `app.py` - Your DevOps Info Service + - `requirements.txt` - Your Python dependencies (Flask/FastAPI) + +2. **Review your traditional workflow (Lab 1):** + + Recall how you built this in Lab 1: + ```bash + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt + python app.py + ``` + + **Problems with this approach:** + - Different Python versions on different machines + - `pip install` without hashes can pull different package versions + - Virtual environment is not portable + - No guarantee of reproducibility over time + +#### 1.3: Write a Nix Derivation for Your Python App + +1. **Create a Nix derivation:** + + Create `default.nix` in `labs/lab18/app_python/`: + +
+ πŸ“š Where to learn Nix Python derivation syntax + + - [nix.dev - Python](https://nix.dev/tutorials/nixos/building-and-running-python-apps) + - [nixpkgs Python documentation](https://nixos.org/manual/nixpkgs/stable/#python) + - [Nix Pills - Chapter 6: Our First Derivation](https://nixos.org/guides/nix-pills/our-first-derivation.html) + + **Key concepts you need:** + - `python3Packages.buildPythonApplication` - Function to build Python apps + - `propagatedBuildInputs` - Python dependencies (Flask/FastAPI) + - `makeWrapper` - Wraps Python script with interpreter + - `pname` - Package name + - `version` - Package version + - `src` - Source code location (use `./.` for current directory) + - `format = "other"` - For apps without setup.py + + **Translating requirements.txt to Nix:** + Your Lab 1 `requirements.txt` might have: + ``` + Flask==3.1.0 + Werkzeug>=2.0 + click + ``` + + In Nix, you reference packages from nixpkgs (not exact PyPI versions): + - `Flask==3.1.0` β†’ `pkgs.python3Packages.flask` + - `fastapi==0.115.0` β†’ `pkgs.python3Packages.fastapi` + - `uvicorn[standard]` β†’ `pkgs.python3Packages.uvicorn` + + **Note:** Nix uses versions from the pinned nixpkgs, not PyPI directly. This is intentional for reproducibility. + + **Example structure (Flask):** + ```nix + { pkgs ? import {} }: + + pkgs.python3Packages.buildPythonApplication { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + format = "other"; + + propagatedBuildInputs = with pkgs.python3Packages; [ + flask + ]; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + mkdir -p $out/bin + cp app.py $out/bin/devops-info-service + + # Wrap with Python interpreter so it can execute + wrapProgram $out/bin/devops-info-service \ + --prefix PYTHONPATH : "$PYTHONPATH" + ''; + } + ``` + + **Example for FastAPI:** + ```nix + propagatedBuildInputs = with pkgs.python3Packages; [ + fastapi + uvicorn + ]; + ``` + + **Hint:** If you get "command not found" errors, make sure you're using `makeWrapper` in the installPhase. + +
+ +2. **Build your application with Nix:** + + ```bash + nix-build + ``` + + This creates a `result` symlink pointing to the Nix store path. + +3. **Run the Nix-built application:** + + ```bash + ./result/bin/devops-info-service + ``` + + Visit `http://localhost:5000` (or your configured port) - it should work identically to your Lab 1 version! + +#### 1.4: Prove Reproducibility (Compare with Lab 1 approach) + +1. **Record the Nix store path:** + + ```bash + readlink result + ``` + + Note the store path (e.g., `/nix/store/abc123-devops-info-service-1.0.0/`) + +2. **Build again and compare:** + + ```bash + rm result + nix-build + readlink result + ``` + + **Observation:** The store path is **identical**! But wait - did Nix rebuild it or reuse it? + + **Answer: Nix reused the cached build!** Same inputs = same hash = reuse existing store path. + +3. **Force an actual rebuild to prove reproducibility:** + + ```bash + # First, find your build's store path + STORE_PATH=$(readlink result) + echo "Original store path: $STORE_PATH" + + # Delete it from the Nix store + nix-store --delete $STORE_PATH + + # Now rebuild (this forces actual compilation) + rm result + nix-build + readlink result + ``` + + **Observation:** Same store path returns! Nix rebuilt it from scratch and got the exact same hash. -**Objective:** Understand IPFS concepts and run a local node. +3. **Compare with traditional pip approach:** -**Requirements:** + **Demonstrate pip's limitations:** -1. **Study IPFS Concepts** - - Content addressing vs location addressing - - CIDs (Content Identifiers) - - Pinning and garbage collection - - IPFS gateways + ```bash + # Test 1: Install without version pins (shows immediate non-reproducibility) + echo "flask" > requirements-unpinned.txt # No version specified -2. **Run Local IPFS Node** - - Use Docker to run IPFS node - - Access the Web UI - - Understand node configuration + python -m venv venv1 + source venv1/bin/activate + pip install -r requirements-unpinned.txt + pip freeze | grep -i flask > freeze1.txt + deactivate -3. **Add Content Locally** - - Add a file to your local IPFS node - - Retrieve the CID - - Access via local gateway + # Simulate time passing: clear pip cache + pip cache purge 2>/dev/null || rm -rf ~/.cache/pip + + python -m venv venv2 + source venv2/bin/activate + pip install -r requirements-unpinned.txt + pip freeze | grep -i flask > freeze2.txt + deactivate + + # Compare Flask versions + diff freeze1.txt freeze2.txt + ``` + + **Observation:** + - Without version pins, you get whatever's latest + - **Even with pinned versions** in requirements.txt, you only pin direct dependencies + - Transitive dependencies (dependencies of your dependencies) can still drift + - Over weeks/months, `pip install -r requirements.txt` can produce different environments + + **The fundamental problem:** + ``` + Lab 1 approach: requirements.txt pins what YOU install + Problem: Doesn't pin what FLASK installs (Werkzeug, Click, etc.) + Result: Different machines = different transitive dependency versions + + Nix approach: Pins EVERYTHING in the entire dependency tree + Result: Bit-for-bit identical on all machines, forever + ``` + +4. **Understand Nix's caching behavior:** + + **Key insight:** Nix uses content-addressable storage: + ``` + Store path format: /nix/store/-- + Example: /nix/store/abc123xyz-devops-info-service-1.0.0 + + The is computed from: + - All source code + - All dependencies (transitively!) + - Build instructions + - Compiler flags + - Everything needed to reproduce the build + + Same inputs β†’ Same hash β†’ Reuse existing build (cache hit) + Different inputs β†’ Different hash β†’ New build required + ``` + +5. **Nix's guarantee:** + + ```bash + # Hash the entire Nix output + nix-hash --type sha256 result + ``` + + This hash will be **identical** on any machine, any time, forever - if the inputs don't change. + + This is why Nix can safely share binary caches (cache.nixos.org) - the hash proves the content! + +**πŸ“Š Comparison Table - Lab 1 vs Lab 18:** + +| Aspect | Lab 1 (pip + venv) | Lab 18 (Nix) | +|--------|-------------------|--------------| +| Python version | System-dependent | Pinned in derivation | +| Dependency resolution | Runtime (`pip install`) | Build-time (pure) | +| Reproducibility | Approximate (with lockfiles) | Bit-for-bit identical | +| Portability | Requires same OS + Python | Works anywhere Nix runs | +| Binary cache | No | Yes (cache.nixos.org) | +| Isolation | Virtual environment | Sandboxed build | +| Store path | N/A | Content-addressable hash | + +#### 1.5: Optional - Go Application (If you completed Lab 1 Bonus)
-πŸ’‘ Hints +🎁 For students who built the Go version in Lab 1 Bonus -**IPFS Concepts:** -- **Content Addressing:** Files identified by hash of content, not location -- **CID:** Unique identifier derived from content hash (e.g., `QmXxx...` or `bafyxxx...`) -- **Pinning:** Marking content to keep it (prevent garbage collection) -- **Gateway:** HTTP interface to IPFS network +If you implemented the compiled language bonus in Lab 1, you can also build it with Nix: -**Run IPFS with Docker:** -```bash -docker run -d --name ipfs \ - -p 4001:4001 \ - -p 8080:8080 \ - -p 5001:5001 \ - ipfs/kubo:latest - -# Web UI at http://localhost:5001/webui -# Gateway at http://localhost:8080 -``` +1. **Copy your Go app:** + ```bash + mkdir -p labs/lab18/app_go + cp -r app_go/* labs/lab18/app_go/ + cd labs/lab18/app_go + ``` -**Add Content:** -```bash -# Create test file -echo "Hello IPFS from DevOps course!" > hello.txt +2. **Create `default.nix` for Go:** + ```nix + { pkgs ? import {} }: -# Add to IPFS -docker exec ipfs ipfs add /hello.txt -# Returns: added QmXxx... hello.txt + pkgs.buildGoModule { + pname = "devops-info-service-go"; + version = "1.0.0"; + src = ./.; -# Access via gateway -curl http://localhost:8080/ipfs/QmXxx... -``` + vendorHash = null; # or use pkgs.lib.fakeHash if you have dependencies + } + ``` -**Resources:** -- [IPFS Docs](https://docs.ipfs.tech/) -- [IPFS Concepts](https://docs.ipfs.tech/concepts/) +3. **Build and compare binary size:** + ```bash + nix-build + ls -lh result/bin/ + ``` + + Compare this with your multi-stage Docker build from Lab 2 Bonus!
+In `labs/submission18.md`, document: +- Installation steps and verification output +- Your `default.nix` file with explanations of each field +- Store path from multiple builds (prove they're identical) +- Comparison table: `pip install` vs Nix derivation +- Why does `requirements.txt` provide weaker guarantees than Nix? +- Screenshots showing your Lab 1 app running from Nix-built version +- Explanation of the Nix store path format and what each part means +- **Reflection:** How would Nix have helped in Lab 1 if you had used it from the start? + --- -### Task 2 β€” 4EVERLAND Setup (3 pts) +### Task 2 β€” Reproducible Docker Images (Revisiting Lab 2) (4 pts) + +**Objective:** Use Nix's `dockerTools` to containerize your DevOps Info Service and compare with your traditional Dockerfile from Lab 2. + +**Why This Matters:** In Lab 2, you created a `Dockerfile` that built your Python app. While Docker provides isolation, it's **not reproducible**: +- Build timestamps differ between builds +- Base image tags like `python:3.13-slim` can point to different versions over time +- `apt-get` installs latest packages, which change +- Two builds of the same Dockerfile can produce different image hashes + +Nix's `dockerTools` creates **truly reproducible** container images with content-addressable layers. + +#### 2.1: Review Your Lab 2 Dockerfile + +1. **Find your Dockerfile from Lab 2:** + + ```bash + # From repository root directory + cat app_python/Dockerfile + ``` + + You likely have something like: + ```dockerfile + FROM python:3.13-slim + RUN useradd -m appuser + WORKDIR /app + COPY requirements.txt . + RUN pip install -r requirements.txt + COPY app.py . + USER appuser + EXPOSE 5000 + CMD ["python", "app.py"] + ``` + +
+ πŸ’‘ Don't have your Lab 2 Dockerfile? + + If you lost your Lab 2 work, create a minimal Dockerfile now: + + ```dockerfile + FROM python:3.13-slim + WORKDIR /app + COPY requirements.txt app.py ./ + RUN pip install -r requirements.txt + EXPOSE 5000 + CMD ["python", "app.py"] + ``` + + Save as `app_python/Dockerfile`. + +
+ +2. **Test Lab 2 Dockerfile reproducibility:** + + ```bash + # Make sure you're in repository root + cd ~/path/to/DevOps-Core-Course # Adjust to your path + + # Build from app_python directory + docker build -t lab2-app:v1 ./app_python + docker inspect lab2-app:v1 | grep Created + + # Wait a few seconds, then rebuild + sleep 5 + docker build -t lab2-app:v2 ./app_python + docker inspect lab2-app:v2 | grep Created + ``` + + **Observation:** Different creation timestamps! The image hashes are different even though the content is identical. + +#### 2.2: Build Docker Image with Nix + +1. **Create a Nix Docker image using `dockerTools`:** + + Create `labs/lab18/app_python/docker.nix`: + +
+ πŸ“š Where to learn about dockerTools + + - [nix.dev - Building Docker images](https://nix.dev/tutorials/nixos/building-and-running-docker-images.html) + - [nixpkgs dockerTools documentation](https://ryantm.github.io/nixpkgs/builders/images/dockertools/) + + **Key concepts:** + - `pkgs.dockerTools.buildLayeredImage` - Builds efficient layered images + - `name` - Image name + - `tag` - Image tag (optional, defaults to latest) + - `contents` - Packages/derivations to include in the image + - `config.Cmd` - Default command to run + - `config.ExposedPorts` - Ports to expose + + **Critical for reproducibility:** + - **DO NOT** use `created = "now"` - this breaks reproducibility! + - **DO** use `created = "1970-01-01T00:00:01Z"` for reproducible builds + - **DO** use exact derivations (from Task 1) instead of arbitrary packages + + **Example structure:** + ```nix + { pkgs ? import {} }: + + let + app = import ./default.nix { inherit pkgs; }; + in + pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "1.0.0"; + + contents = [ app ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + ExposedPorts = { + "5000/tcp" = {}; + }; + }; + + created = "1970-01-01T00:00:01Z"; # Reproducible timestamp + } + ``` + +
+ +2. **Build the Nix Docker image:** + + ```bash + cd labs/lab18/app_python + nix-build docker.nix + ``` + + This creates a tarball in `result`. + +3. **Load into Docker:** + + ```bash + docker load < result + ``` + + Output shows the image was loaded with a specific tag. + +4. **Run both containers side-by-side:** + + ```bash + # First, clean up any existing containers to avoid port conflicts + docker stop lab2-container nix-container 2>/dev/null || true + docker rm lab2-container nix-container 2>/dev/null || true + + # Run Lab 2 traditional Docker image on port 5000 + docker run -d -p 5000:5000 --name lab2-container lab2-app:v1 + + # Run Nix-built image on port 5001 (mapped to container's 5000) + docker run -d -p 5001:5000 --name nix-container devops-info-service-nix:1.0.0 + ``` + + Test both: + ```bash + curl http://localhost:5000/health # Lab 2 version + curl http://localhost:5001/health # Nix version + ``` + + Both should work identically! + + **Troubleshooting:** + - If port 5000 is in use: `lsof -i :5000` to find the process + - Container won't start: Check logs with `docker logs lab2-container` + - Permission denied: Make sure Docker daemon is running + +#### 2.3: Compare Reproducibility - Lab 2 vs Lab 18 + +**Test 1: Rebuild Reproducibility** -**Objective:** Set up 4EVERLAND account and explore the platform. +1. **Rebuild Nix image multiple times:** -**Requirements:** + ```bash + rm result + nix-build docker.nix + sha256sum result -1. **Create Account** - - Sign up at [4everland.org](https://www.4everland.org/) - - Connect with GitHub or wallet - - Explore dashboard + rm result + nix-build docker.nix + sha256sum result + ``` -2. **Understand Services** - - Hosting: Deploy websites/apps - - Storage: IPFS pinning - - Gateway: Access IPFS content + **Observation:** Identical SHA256 hashes! The tarball is bit-for-bit identical. -3. **Explore Free Tier** - - Understand limits and capabilities - - Review pricing for reference +2. **Compare with Lab 2 Dockerfile:** + + ```bash + # Make sure you're in repository root + # Build Lab 2 Dockerfile twice and compare saved image hashes + + docker build -t lab2-app:test1 ./app_python/ + docker save lab2-app:test1 | sha256sum + + sleep 2 # Wait a moment + + docker build -t lab2-app:test2 ./app_python/ + docker save lab2-app:test2 | sha256sum + ``` + + **Observation:** Different hashes! Even though the Dockerfile and source are identical, Lab 2's approach is not reproducible. + +**Test 2: Image Size Comparison** + +```bash +docker images | grep -E "lab2-app|devops-info-service-nix" +``` + +Create a comparison table: + +| Metric | Lab 2 Dockerfile | Lab 18 Nix dockerTools | +|--------|------------------|------------------------| +| Image size | ~150MB (with python:3.13-slim) | ~50-80MB (minimal closure) | +| Reproducibility | ❌ Different hashes each build | βœ… Identical hashes | +| Build caching | Layer-based (timestamp-dependent) | Content-addressable | +| Base image dependency | Yes (python:3.13-slim) | No base image needed | + +**Test 3: Layer Analysis** + +1. **Examine Lab 2 image layers:** + + ```bash + docker history lab2-app:v1 + ``` + + Note the timestamps in the "CREATED" column - they vary between builds! + +2. **Examine Nix image layers:** + + ```bash + docker history devops-info-service-nix:1.0.0 + ``` + + Nix uses content-addressable layers - same content = same layer hash. + +#### 2.4: Advanced Comparison - Multi-Stage Builds
-πŸ’‘ Hints +🎁 Optional: Compare with Lab 2 Bonus Multi-Stage Build -**4EVERLAND Services:** -- **Hosting:** Deploy from Git repos, automatic builds -- **Bucket (Storage):** Upload files, get IPFS CIDs -- **Gateway:** Access content via 4everland.link +If you completed the Lab 2 bonus with Go and multi-stage builds, you can compare: -**Dashboard:** -- Projects: Your deployed sites -- Bucket: File storage -- Domains: Custom domain setup +**Your Lab 2 multi-stage Dockerfile:** +```dockerfile +FROM golang:1.22 AS builder +COPY . . +RUN go build -o app main.go -**Free Tier Includes:** -- 100 deployments/month -- 5GB storage -- 100GB bandwidth +FROM alpine:latest +COPY --from=builder /app/app /app +ENTRYPOINT ["/app"] +``` + +**Problems:** +- `golang:1.22` and `alpine:latest` change over time +- Build includes timestamps +- Not reproducible across machines + +**Nix equivalent (fully reproducible):** +```nix +pkgs.dockerTools.buildLayeredImage { + name = "go-app-nix"; + contents = [ goApp ]; # Built in Task 1.5 + config.Cmd = [ "${goApp}/bin/go-app" ]; + created = "1970-01-01T00:00:01Z"; +} +``` -**Resources:** -- [4EVERLAND Docs](https://docs.4everland.org/) +Same result size, but **fully reproducible**!
+**πŸ“Š Comprehensive Comparison - Lab 2 vs Lab 18:** + +| Aspect | Lab 2 Traditional Dockerfile | Lab 18 Nix dockerTools | +|--------|------------------------------|------------------------| +| **Base images** | `python:3.13-slim` (changes over time) | No base image (pure derivations) | +| **Timestamps** | Different on each build | Fixed or deterministic | +| **Package installation** | `pip install` at build time | Nix store paths (immutable) | +| **Reproducibility** | ❌ Same Dockerfile β†’ Different images | βœ… Same docker.nix β†’ Identical images | +| **Caching** | Layer-based (breaks on timestamp) | Content-addressable (perfect caching) | +| **Image size** | ~150MB+ with full base image | ~50-80MB with minimal closure | +| **Portability** | Requires Docker | Requires Nix (then loads to Docker) | +| **Security** | Base image vulnerabilities | Minimal dependencies, easier auditing | +| **Lab 2 Learning** | Best practices, non-root user | Build on Lab 2 knowledge | + +In `labs/submission18.md`, document: +- Your `docker.nix` file with explanations of each field +- Side-by-side comparison: Lab 2 Dockerfile vs Nix docker.nix +- SHA256 hash comparison proving Nix reproducibility +- Image size comparison table with analysis +- `docker history` output for both approaches +- Screenshots showing both containers running simultaneously +- **Analysis:** Why can't traditional Dockerfiles achieve bit-for-bit reproducibility? +- **Reflection:** If you could redo Lab 2 with Nix, what would you do differently? +- Practical scenarios where Nix's reproducibility matters (CI/CD, security audits, rollbacks) + --- -### Task 3 β€” Deploy Static Content (4 pts) +### Bonus Task β€” Modern Nix with Flakes (Includes Lab 10 Comparison) (2 pts) + +**Objective:** Modernize your Nix expressions using Flakes for better dependency locking and reproducibility. Compare Nix Flakes with Helm's version pinning approach from Lab 10. + +**Why This Matters:** Nix Flakes are the modern standard (2026) for Nix projects. They provide: +- Automatic dependency locking via `flake.lock` +- Standardized project structure +- Better reproducibility across time +- Easier sharing and collaboration + +**Comparison with Lab 10:** In Lab 10 (Helm), you used `values.yaml` to pin image versions. Flakes take this concept further by locking **all** dependencies, not just container images. + +#### Bonus.1: Convert to Flake + +1. **Create a `flake.nix`:** + + Create `labs/lab18/app_python/flake.nix`: + +
+ πŸ“š Where to learn about Flakes + + - [Zero to Nix - Flakes](https://zero-to-nix.com/concepts/flakes) + - [NixOS Wiki - Flakes](https://wiki.nixos.org/wiki/Flakes) + - [Nix Flakes explained](https://nix.dev/concepts/flakes) + + **Key structure:** + ```nix + { + description = "DevOps Info Service - Reproducible Build"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; # Pin exact nixpkgs version + }; + + outputs = { self, nixpkgs }: + let + # ⚠️ Architecture note: This example uses x86_64-linux + # - Works on: Linux (x86_64), WSL2 + # - Mac Intel: Change to "x86_64-darwin" + # - Mac M1/M2/M3: Change to "aarch64-darwin" + # - For multi-system support, see: https://github.com/numtide/flake-utils + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.${system} = { + default = import ./default.nix { inherit pkgs; }; + dockerImage = import ./docker.nix { inherit pkgs; }; + }; + + # Development shell with all dependencies + devShells.${system}.default = pkgs.mkShell { + buildInputs = with pkgs; [ + python313 + python313Packages.flask # or fastapi + ]; + }; + }; + } + ``` + + **Platform-specific adjustments:** + - **Linux/WSL2**: Use `system = "x86_64-linux";` (shown above) + - **Mac Intel**: Use `system = "x86_64-darwin";` + - **Mac ARM (M1/M2/M3)**: Use `system = "aarch64-darwin";` + + **Hint:** Use `nix flake init` to generate a template, then modify it. + +
+ +2. **Generate lock file:** + + ```bash + cd labs/lab18/app_python + nix flake update + ``` + + This creates `flake.lock` with pinned dependencies. + +3. **Build using flake:** + + ```bash + nix build # Builds default package + nix build .#dockerImage # Builds Docker image + ./result/bin/devops-info-service # Run the app + ``` + +#### Bonus.2: Compare with Lab 10 Helm Values + +**Lab 10 Helm approach to version pinning:** + +In `k8s/mychart/values.yaml`: +```yaml +image: + repository: yourusername/devops-info-service + tag: "1.0.0" # Pin specific version + pullPolicy: IfNotPresent + +# Environment-specific overrides +# values-prod.yaml: +image: + tag: "1.0.0" # Explicit version for prod +``` + +**Limitations:** +- Only pins the container image tag +- Doesn't lock Python dependencies inside the image +- Doesn't lock Helm chart dependencies +- Image tag `1.0.0` could point to different content if rebuilt + +**Nix Flakes approach:** + +`flake.lock` locks **everything**: +```json +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1704321342, + "narHash": "sha256-abc123...", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "52e3e80afff4b16ccb7c52e9f0f5220552f03d04", + "type": "github" + } + } + } +} +``` + +This locks: +- βœ… Exact nixpkgs revision (all 80,000+ packages) +- βœ… Python version and all dependencies +- βœ… Build tools and compilers +- βœ… Everything in the closure + +**Combined Approach:** + +You can use both together! +1. Build reproducible image with Nix: `nix build .#dockerImage` +2. Load to Docker and tag: `docker load < result` +3. Reference in Helm with content hash: `image.tag: "sha256-abc123..."` + +This gives you: +- Helm's declarative Kubernetes deployment +- Nix's perfect reproducibility for the image + +Create a comparison table in your submission. + +#### Bonus.3: Test Cross-Machine Reproducibility + +1. **Commit your flake to git:** + + ```bash + git add flake.nix flake.lock default.nix docker.nix + git commit -m "feat: add Nix flake for reproducible builds" + git push + ``` + +2. **Test on another machine or ask a classmate:** + + ```bash + # Build directly from GitHub + nix build github:yourusername/DevOps-Core-Course?dir=labs/lab18/app_python#default + ``` + +3. **Compare store paths:** + + ```bash + readlink result + ``` + + Both machines should get **identical store paths** - same hash, same content! + +#### Bonus.4: Add Development Shell + +1. **Enter the dev shell:** + + ```bash + nix develop + ``` + + This gives you an isolated environment with exact Python version and dependencies. -**Objective:** Deploy a static site to 4EVERLAND. +2. **Compare with Lab 1 virtual environment:** -**Requirements:** + **Lab 1 approach:** + ```bash + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt + ``` -1. **Use the Provided Static Site** - - A course landing page is provided at `labs/lab18/index.html` - - Review the HTML/CSS to understand the structure - - You may customize it or create your own + **Lab 18 Nix approach:** + ```bash + nix develop + # Python and all dependencies instantly available + # Same environment on every machine + ``` -2. **Deploy via 4EVERLAND** - - Connect your GitHub repository - - Configure build settings - - Deploy to IPFS via 4EVERLAND +3. **Try it:** -3. **Verify Deployment** - - Access via 4EVERLAND URL - - Access via IPFS gateway - - Note the CID + ```bash + nix develop + python --version # Exact pinned version + python -c "import flask; print(flask.__version__)" + ``` -4. **Test Permanence** - - Understand that content with same hash = same CID - - Make a change, redeploy, observe new CID + Exit and enter again - same versions, always! + +**πŸ“Š Dependency Management Comparison:** + +| Aspect | Lab 1 (venv + requirements.txt) | Lab 10 (Helm values.yaml) | Lab 18 (Nix Flakes) | +|--------|--------------------------------|---------------------------|---------------------| +| **Locks Python version** | ❌ Uses system Python | ❌ Uses image Python | βœ… Pinned in flake | +| **Locks dependencies** | ⚠️ Approximate (versions drift) | ❌ Only image tag | βœ… Exact hashes | +| **Locks build tools** | ❌ No | ❌ No | βœ… Yes | +| **Reproducibility** | ⚠️ Probabilistic | ⚠️ Tag-based | βœ… Cryptographic | +| **Cross-machine** | ❌ Varies | ⚠️ Depends on image | βœ… Identical | +| **Dev environment** | βœ… Yes (venv) | ❌ No | βœ… Yes (nix develop) | +| **Time-stable** | ❌ Packages update | ⚠️ Tags can change | βœ… Locked forever | + +In `labs/submission18.md`, document: +- Your complete `flake.nix` with explanations +- `flake.lock` snippet showing locked dependencies (especially nixpkgs revision) +- Build outputs from `nix build` +- Proof that builds are identical across machines/time +- Dev shell experience: Compare `nix develop` vs Lab 1's `venv` +- Comparison with Lab 10 Helm values.yaml approach (Bonus.2) +- **Reflection:** How do Flakes improve upon traditional dependency management? +- Practical scenarios where flake.lock prevented a "works on my machine" problem + +--- + +## Troubleshooting Common Issues
-πŸ’‘ Hints - -**Provided Static Site:** -The course provides a beautiful landing page at `labs/lab18/index.html` that you can deploy. It includes: -- Modern responsive design -- Course curriculum overview -- Learning roadmap -- "Deployed on IPFS" badge - -**Deployment Steps:** -1. Go to 4EVERLAND Dashboard β†’ Hosting -2. Click "New Project" -3. Import from GitHub -4. Select your repository and branch -5. Configure: - - Framework: None (static) - - Build command: (leave empty for static) - - Output directory: `labs/lab18` (or root if you moved the file) -6. Deploy - -**Alternative: Create Your Own** -You can also create your own static site. Keep it simple: -```html - - - - My DevOps Portfolio - - -

Welcome to My DevOps Journey

-

Deployed on IPFS via 4EVERLAND

- - +πŸ”§ Python app doesn't run: "command not found" or "No such file or directory" + +**Problem:** Your `app.py` doesn't have a shebang line and isn't being wrapped with Python interpreter. + +**Solution:** Ensure you're using `makeWrapper` in your `default.nix`: + +```nix +nativeBuildInputs = [ pkgs.makeWrapper ]; + +installPhase = '' + mkdir -p $out/bin + cp app.py $out/bin/devops-info-service + + wrapProgram $out/bin/devops-info-service \ + --prefix PYTHONPATH : "$PYTHONPATH" +''; ``` -**Access URLs:** -- 4EVERLAND: `https://your-project.4everland.app` -- IPFS Gateway: `https://ipfs.4everland.link/ipfs/CID` +Alternatively, add a shebang to your `app.py`: +```python +#!/usr/bin/env python3 +```
---- +
+πŸ”§ "error: hash mismatch in fixed-output derivation" + +**Problem:** The hash you specified doesn't match the actual content. + +**Solution:** +1. Use `pkgs.lib.fakeHash` initially to get the correct hash +2. Nix will fail and tell you the expected hash +3. Replace `fakeHash` with the correct hash from the error message + +Example: +```nix +vendorHash = pkgs.lib.fakeHash; # Start with this +# Error will say: "got: sha256-abc123..." +# Then use: vendorHash = "sha256-abc123..."; +``` -### Task 4 β€” IPFS Pinning (4 pts) +
-**Objective:** Use 4EVERLAND's storage (Bucket) for IPFS pinning. +
+πŸ”§ Docker image doesn't load or fails to run -**Requirements:** +**Common causes:** -1. **Upload Files to Bucket** - - Upload multiple files (images, documents, etc.) - - Get CIDs for each file +1. **Image tarball not built:** Check `result` is a `.tar.gz` file + ```bash + file result + # Should show: gzip compressed data + ``` -2. **Create a Directory Structure** - - Upload a folder with multiple files - - Understand directory CIDs +2. **Wrong Cmd path:** Verify the app path in docker.nix + ```nix + config.Cmd = [ "${app}/bin/devops-info-service" ]; + # Make sure this matches your installPhase output + ``` -3. **Access via Multiple Gateways** - - Access your content via: - - 4EVERLAND gateway - - Public IPFS gateways (ipfs.io, dweb.link) - - Understand gateway differences +3. **Missing dependencies in image:** Add required packages to `contents` + ```nix + contents = [ app pkgs.coreutils ]; # Add tools if needed + ``` -4. **Verify Pinning** - - Confirm content is pinned - - Understand pinning vs local storage +
-πŸ’‘ Hints +πŸ”§ Port conflicts when running containers -**Bucket Upload:** -1. Dashboard β†’ Bucket -2. Create new bucket -3. Upload files or folders -4. Get CID from file details +**Problem:** Port 5000 or 5001 already in use. -**Multiple Gateways:** +**Solution:** ```bash -# 4EVERLAND -https://ipfs.4everland.link/ipfs/QmXxx... - -# IPFS.io -https://ipfs.io/ipfs/QmXxx... +# Find what's using the port +lsof -i :5000 -# Cloudflare -https://cloudflare-ipfs.com/ipfs/QmXxx... +# Stop old containers +docker stop $(docker ps -aq) 2>/dev/null -# DWeb.link -https://dweb.link/ipfs/QmXxx... +# Or use different ports +docker run -d -p 5002:5000 --name my-container my-image ``` -**Directory Upload:** -- Upload entire folder -- Get directory CID -- Access files: `gateway/ipfs/DirCID/filename` +
+ +
+πŸ”§ Flakes don't work: "experimental features" error -**Pinning Importance:** -- Unpinned content may be garbage collected -- Pinning services keep content available -- Multiple pins = more redundancy +**Problem:** Flakes not enabled in your Nix configuration. + +**Solution:** +```bash +# Check if flakes are enabled +nix flake --help + +# If error, enable flakes: +mkdir -p ~/.config/nix +echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf + +# Restart terminal +```
---- +
+πŸ”§ Build fails on macOS: "unsupported system" -### Task 5 β€” IPNS & Updates (3 pts) +**Problem:** Flake hardcodes `x86_64-linux` but you're on macOS. -**Objective:** Understand mutable content with IPNS. +**Solution:** Change the system in `flake.nix`: +```nix +# For Mac Intel: +system = "x86_64-darwin"; -**Requirements:** +# For Mac M1/M2/M3: +system = "aarch64-darwin"; +``` -1. **Understand IPNS** - - IPFS = immutable (content changes = new CID) - - IPNS = mutable pointer to IPFS content - - IPNS name stays same, content can change +
-2. **Explore 4EVERLAND Domains** - - Custom domains for your deployment - - How 4EVERLAND handles updates +
+πŸ”§ "cannot build derivation: no builder for this system" + +**Problem:** Trying to build Linux binaries on macOS or vice versa. -3. **Update Deployment** - - Make changes to your static site - - Redeploy - - Observe: same URL, new CID +**Solution:** Either: +1. Match your system architecture in the flake +2. Use Docker builds which work cross-platform +3. Use Nix's cross-compilation features (advanced) + +
-πŸ’‘ Hints +πŸ”§ Don't have Lab 1/2 artifacts to use + +**No problem!** Create a minimal example: -**IPFS vs IPNS:** -- **IPFS CID:** `QmXxx...` - changes when content changes -- **IPNS Name:** `/ipns/k51xxx...` - stays same, points to current CID +1. **Create simple Flask app:** + ```python + # app.py + from flask import Flask, jsonify + app = Flask(__name__) -**4EVERLAND Handles This:** -- Your project URL stays constant -- Behind scenes, updates the IPNS pointer -- Users always get latest version + @app.route('/health') + def health(): + return jsonify({"status": "healthy"}) -**Domain Configuration:** -1. Dashboard β†’ Hosting β†’ Your Project -2. Settings β†’ Domains -3. Add custom domain or use provided subdomain + if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) + ``` + +2. **Create requirements.txt:** + ``` + flask + ``` + +3. **Create basic Dockerfile:** + ```dockerfile + FROM python:3.13-slim + WORKDIR /app + COPY requirements.txt app.py ./ + RUN pip install -r requirements.txt + EXPOSE 5000 + CMD ["python", "app.py"] + ``` + +Now you can proceed with the lab using these minimal examples!
--- -### Task 6 β€” Documentation & Analysis (3 pts) +## How to Submit -**Objective:** Document your work and analyze decentralized hosting. +1. Create a branch for this lab and push it: -**Create `4EVERLAND.md` with:** + ```bash + git switch -c feature/lab18 + # create labs/submission18.md with your findings + git add labs/submission18.md labs/lab18/ + git commit -m "docs: add lab18 submission - Nix reproducible builds" + git push -u origin feature/lab18 + ``` -1. **Deployment Summary** - - What you deployed - - URLs (4EVERLAND and IPFS gateways) - - CIDs obtained +2. **Open a PR (GitHub) or MR (GitLab)** from your fork's `feature/lab18` branch β†’ **course repository's main branch**. -2. **Screenshots** - - 4EVERLAND dashboard - - Deployed site - - Bucket storage - - Multiple gateway access +3. In the PR/MR description, include: -3. **Centralized vs Decentralized Comparison** + ```text + Platform: [GitHub / GitLab] -| Aspect | Traditional Hosting | IPFS/4EVERLAND | -|--------|---------------------|----------------| -| Content addressing | | | -| Single point of failure | | | -| Censorship resistance | | | -| Update mechanism | | | -| Cost model | | | -| Speed/latency | | | -| Best use cases | | | + - [x] Task 1 β€” Build Reproducible Artifacts from Scratch (6 pts) + - [x] Task 2 β€” Reproducible Docker Images with Nix (4 pts) + - [ ] Bonus Task β€” Modern Nix with Flakes (2 pts) [if completed] + ``` -4. **Use Case Analysis** - - When decentralized hosting makes sense - - When traditional hosting is better - - Your recommendations +4. **Copy the PR/MR URL** and submit it via **Moodle before the deadline**. --- -## Checklist +## Acceptance Criteria -- [ ] IPFS concepts understood -- [ ] Local IPFS node running -- [ ] Content added to local IPFS -- [ ] 4EVERLAND account created -- [ ] Static site deployed via 4EVERLAND -- [ ] Files uploaded to Bucket -- [ ] Content accessed via multiple gateways -- [ ] IPNS/updates understood -- [ ] `4EVERLAND.md` documentation complete -- [ ] Comparison analysis complete +- βœ… Branch `feature/lab18` exists with commits for each task +- βœ… File `labs/submission18.md` contains required outputs and analysis for all completed tasks +- βœ… Directory `labs/lab18/` contains your application code and Nix expressions +- βœ… Nix derivations successfully build reproducible artifacts +- βœ… Docker image built with Nix and compared to traditional Dockerfile +- βœ… Hash comparisons prove reproducibility +- βœ… **Bonus (if attempted):** `flake.nix` and `flake.lock` present and working +- βœ… PR/MR from `feature/lab18` β†’ **course repo main branch** is open +- βœ… PR/MR link submitted via Moodle before the deadline --- -## Rubric - -| Criteria | Points | -|----------|--------| -| **IPFS Fundamentals** | 3 pts | -| **4EVERLAND Setup** | 3 pts | -| **Static Deployment** | 4 pts | -| **IPFS Pinning** | 4 pts | -| **IPNS & Updates** | 3 pts | -| **Documentation** | 3 pts | -| **Total** | **20 pts** | +## Rubric (12 pts max) -**Grading:** -- **18-20:** Excellent understanding, thorough deployment, insightful analysis -- **16-17:** Working deployment, good documentation -- **14-15:** Basic deployment, incomplete analysis -- **<14:** Incomplete deployment +| Criterion | Points | +| --------------------------------------------------- | -----: | +| Task 1 β€” Build Reproducible Artifacts from Scratch | **6** | +| Task 2 β€” Reproducible Docker Images with Nix | **4** | +| Bonus Task β€” Modern Nix with Flakes | **2** | +| **Total** | **12** | --- -## Resources +## Guidelines + +- Use clear Markdown headers to organize sections in `submission18.md` +- Include command outputs and written analysis for each task +- Explain WHY Nix provides better reproducibility than traditional tools +- Compare before/after results when proving reproducibility +- Document challenges encountered and how you solved them +- Include code snippets with explanations, not just paste
-πŸ“š IPFS Documentation +πŸ“š Helpful Resources + +**Official Documentation:** +- [nix.dev - Official tutorials](https://nix.dev/) +- [Zero to Nix - Beginner-friendly guide](https://zero-to-nix.com/) +- [Nix Pills - Deep dive](https://nixos.org/guides/nix-pills/) +- [NixOS Package Search](https://search.nixos.org/) + +**Docker with Nix:** +- [Building Docker images - nix.dev](https://nix.dev/tutorials/nixos/building-and-running-docker-images.html) +- [dockerTools reference](https://ryantm.github.io/nixpkgs/builders/images/dockertools/) + +**Flakes:** +- [Nix Flakes - NixOS Wiki](https://wiki.nixos.org/wiki/Flakes) +- [Flakes - Zero to Nix](https://zero-to-nix.com/concepts/flakes) +- [Practical Nix Flakes](https://serokell.io/blog/practical-nix-flakes) -- [IPFS Docs](https://docs.ipfs.tech/) -- [IPFS Concepts](https://docs.ipfs.tech/concepts/) -- [Content Addressing](https://docs.ipfs.tech/concepts/content-addressing/) -- [IPNS](https://docs.ipfs.tech/concepts/ipns/) +**Community:** +- [awesome-nix - Curated resources](https://github.com/nix-community/awesome-nix) +- [NixOS Discourse](https://discourse.nixos.org/)
-🌐 4EVERLAND +πŸ’‘ Nix Tips -- [4EVERLAND Docs](https://docs.4everland.org/) -- [Hosting Guide](https://docs.4everland.org/hosting/overview) -- [Bucket (Storage)](https://docs.4everland.org/storage/bucket) +1. **Store paths are content-addressable:** Same inputs = same output hash +2. **Use `nix-shell -p pkg` for quick testing** before adding to derivations +3. **Garbage collect unused builds:** `nix-collect-garbage -d` +4. **Search for packages:** `nix search nixpkgs golang` +5. **Read error messages carefully:** Nix errors are verbose but informative +6. **Use `lib.fakeHash` initially** when you don't know the hash yet +7. **Avoid network access in builds:** Nix sandboxes block network by default +8. **Pin nixpkgs version** for maximum reproducibility
-πŸ”— Public Gateways +πŸ”§ Troubleshooting + +**If Nix installation fails:** +- Ensure you have multi-user support (daemon mode recommended) +- Check `/nix` directory permissions +- Try the Determinate Systems installer instead of official + +**If builds fail with "hash mismatch":** +- Update the hash in your derivation to match the error message +- Use `lib.fakeHash` to discover the correct hash + +**If Docker load fails:** +- Verify result is a valid tarball: `file result` +- Check Docker daemon is running: `docker info` +- Try `docker load -i result` instead of `docker load < result` + +**If flakes don't work:** +- Ensure experimental features are enabled in `~/.config/nix/nix.conf` +- Run `nix flake check` to validate flake syntax +- Make sure your flake is in a git repository -- [IPFS Gateway Checker](https://ipfs.github.io/public-gateway-checker/) -- [Gateway List](https://docs.ipfs.tech/concepts/ipfs-gateway/#gateway-providers) +**If cross-machine builds differ:** +- Check nixpkgs input is locked in `flake.lock` +- Verify both machines use same Nix version +- Ensure no `created = "now"` or timestamps in image builds
---- +
+🎯 Understanding Reproducibility + +**What makes a build reproducible?** +- βœ… Deterministic inputs (exact versions, hashes) +- βœ… Isolated environment (no system dependencies) +- βœ… No timestamps or random values +- βœ… Same compiler, same flags, same libraries +- βœ… Content-addressable storage + +**Why traditional tools fail:** +```bash +# Docker - timestamps in layers +docker build . # Different timestamp = different image hash + +# npm - lockfiles help but aren't perfect +npm install # Still uses local cache, system libraries + +# apt/yum - version drift +apt-get install nodejs # Gets different version next week +``` -**Good luck!** 🌐 +**How Nix succeeds:** +```bash +# Nix - pure, sandboxed, content-addressed +nix-build # Same inputs = bit-for-bit identical output + # Today, tomorrow, on any machine +``` + +**Real-world impact:** +- **CI/CD:** No more "works on my machine" +- **Security:** Audit exact dependency tree +- **Rollback:** Atomic updates with perfect rollbacks +- **Collaboration:** Everyone gets identical environment + +
+ +
+🌟 Advanced Concepts (Optional Reading) + +**Content-Addressable Store:** +- Every package has a unique hash based on its inputs +- `/nix/store/abc123...` where `abc123` = hash of inputs +- Same inputs = same hash = reuse existing build + +**Sandboxing:** +- Builds run in isolated namespaces +- No network access (except for fixed-output derivations) +- No access to `/home`, `/tmp`, or system paths +- Only declared dependencies are available + +**Lazy Evaluation:** +- Nix expressions are lazily evaluated +- Only builds what's actually needed +- Enables massive codebase (all of nixpkgs) without performance issues + +**Binary Cache:** +- cache.nixos.org provides pre-built binaries +- If your build matches a cached hash, download instead of rebuild +- Set up private caches for your team + +**Cross-Compilation:** +- Nix makes cross-compilation trivial +- `pkgs.pkgsCross.aarch64-multiplatform.hello` +- Same reproducibility guarantees across architectures -> **Remember:** Decentralized hosting trades some convenience for resilience and censorship resistance. Content-addressed storage ensures integrity - the same content always has the same identifier. +
diff --git a/labs/lab18/index.html b/labs/lab18/index.html deleted file mode 100644 index b3de65bc8b..0000000000 --- a/labs/lab18/index.html +++ /dev/null @@ -1,927 +0,0 @@ - - - - - - DevOps Core Course | Production-Grade Practices - - - - - - - -
- -
- -
-
-
-
-
- 2026 Edition — 7th Year — Evolved every semester -
-

Master Production-Grade DevOps Practices

-

16 lectures and hands-on labs covering Kubernetes, GitOps, CI/CD, Monitoring, and beyond. 18 weeks of learning to build real-world skills.

- -
-
-
- -
-
-
-
7
-
Years Running
-
-
-
1000+
-
Students Trained
-
-
-
16
-
Lectures & Labs
-
-
-
18
-
Weeks of Learning
-
-
-
- -
-
-

Why This Course?

-

Build production-ready skills through hands-on practice with tools used by top tech companies worldwide.

-
-
-
-
-

Cloud-Native Architecture

-

Master Kubernetes, Helm, StatefulSets, and container orchestration for scalable deployments.

-
-
-
-

GitOps & Automation

-

Implement ArgoCD, Argo Rollouts, and progressive delivery for safe, automated deployments.

-
-
-
🔒
-

Security & Secrets

-

Learn HashiCorp Vault, Kubernetes Secrets, and secure configuration management practices.

-
-
-
📊
-

Observability

-

Build monitoring stacks with Prometheus, Grafana, Loki, and implement effective alerting.

-
-
-
-

Infrastructure as Code

-

Automate infrastructure with Terraform and Ansible for reproducible environments.

-
-
-
🌐
-

Beyond Kubernetes

-

Explore edge computing with Fly.io and decentralized hosting with IPFS and 4EVERLAND.

-
-
-
- -
-
-

Lectures & Labs

-

16 lectures with corresponding hands-on labs, plus 2 bonus labs as exam alternatives.

-
-
-
-
01
-
-

Web Application Development

-

Python/Go, Best Practices

-
-
-
-
02
-
-

Containerization

-

Docker, Multi-stage Builds

-
-
-
-
03
-
-

Continuous Integration

-

GitHub Actions, Snyk

-
-
-
-
04
-
-

Infrastructure as Code

-

Terraform, Cloud Providers

-
-
-
-
05
-
-

Configuration Management

-

Ansible Basics

-
-
-
-
06
-
-

Continuous Deployment

-

Ansible Advanced

-
-
-
-
07
-
-

Logging

-

Promtail, Loki, Grafana

-
-
-
-
08
-
-

Monitoring

-

Prometheus, Grafana

-
-
-
-
09
-
-

Kubernetes Basics

-

Minikube, Deployments, Services

-
-
-
-
10
-
-

Helm Charts

-

Templating, Hooks

-
-
-
-
11
-
-

Secrets Management

-

K8s Secrets, HashiCorp Vault

-
-
-
-
12
-
-

Configuration & Storage

-

ConfigMaps, PVCs

-
-
-
-
13
-
-

GitOps

-

ArgoCD

-
-
-
-
14
-
-

Progressive Delivery

-

Argo Rollouts

-
-
-
-
15
-
-

StatefulSets

-

Persistent Storage, Headless Services

-
-
-
-
16
-
-

Cluster Monitoring

-

Kube-Prometheus, Init Containers

-
-
-
-
17
-
-

Fly.io Edge Deployment

-

Global Distribution, PaaS

- Exam Alternative -
-
-
-
18
-
-

4EVERLAND & IPFS

-

Decentralized Hosting

- Exam Alternative -
-
-
-
- -
-
-

Learning Roadmap

-

A structured 16-week journey from foundations to advanced production patterns, plus 2 weeks for bonus labs or exam preparation.

-
-
-
-
- Phase - 1 -
-
-

Foundations (Weeks 1-6)

-

Build core skills in containerization, CI/CD, and infrastructure automation.

-
- Docker - GitHub Actions - Terraform - Ansible -
-
-
-
-
- Phase - 2 -
-
-

Observability (Weeks 7-8)

-

Master logging and monitoring for production visibility.

-
- Prometheus - Grafana - Loki - Alerting -
-
-
-
-
- Phase - 3 -
-
-

Kubernetes Core (Weeks 9-12)

-

Deep dive into Kubernetes orchestration and package management.

-
- Kubernetes - Helm - Secrets - ConfigMaps -
-
-
-
-
- Phase - 4 -
-
-

Advanced Patterns (Weeks 13-16)

-

Implement GitOps, progressive delivery, stateful workloads, and production monitoring.

-
- ArgoCD - Argo Rollouts - StatefulSets - Vault -
-
-
-
-
- Bonus - +2 -
-
-

Bonus Labs / Exam Prep (Weeks 17-18)

-

Complete exam alternative labs or prepare for the final exam.

-
- Fly.io - IPFS - 4EVERLAND - Edge Computing -
-
-
-
-
- -
-
-

Ready to Start Your DevOps Journey?

-

Join 1000+ students who have built production-ready skills through this battle-tested curriculum.

- - Get Started Free → - -
-
-
- -
-
-

© 2020–2026 DevOps Core Course. 7 years of continuous improvement. Open source educational content.

- -
-
- -
-
🌐
-
- Deployed on
- IPFS via 4EVERLAND -
-
- - diff --git a/lectures/lec1.md b/lectures/lec1.md index 00ead8aabc..4b319056f7 100644 --- a/lectures/lec1.md +++ b/lectures/lec1.md @@ -518,16 +518,16 @@ flowchart LR ```mermaid flowchart TD - subgraph πŸ“‹ Plan & Code + subgraph "πŸ“‹ Plan & Code" L1[πŸ”¬ Labs 1-3: Git, GitHub] end - subgraph πŸ”¨ Build & Test + subgraph "πŸ”¨ Build & Test" L2[🐳 Labs 4-6: Docker, CI/CD] end - subgraph πŸš€ Deploy & Operate + subgraph "πŸš€ Deploy & Operate" L3[☸️ Labs 7-10: K8s, Helm] end - subgraph πŸ” Secure & Monitor + subgraph "πŸ” Secure & Monitor" L4[πŸ“Š Labs 11-15: Vault, Monitoring] end ``` @@ -560,16 +560,16 @@ flowchart TD ```mermaid flowchart LR - subgraph 😱 Chaos - Manual[πŸ“‹ Manual Work] - Silos[🧱 Silos] - Fear[😨 Fear] - end - subgraph 🌊 Flow + subgraph "🌊 Flow" Auto[πŸ€– Automation] Collab[🀝 Collaboration] Confidence[πŸ’ͺ Confidence] end + subgraph "😱 Chaos" + Manual[πŸ“‹ Manual Work] + Silos[🧱 Silos] + Fear[😨 Fear] + end Chaos -->|πŸš€ DevOps| Flow ``` diff --git a/lectures/lec11.md b/lectures/lec11.md index 779e7917bd..97738c1fb2 100644 --- a/lectures/lec11.md +++ b/lectures/lec11.md @@ -76,11 +76,11 @@ Section 5: Production Patterns β†’ πŸ“ POST Quiz ```mermaid flowchart LR - subgraph 😰 Developer + subgraph "😰 Developer" D1[πŸš€ Ship Fast] D2[πŸ”§ Easy Access] end - subgraph πŸ” Security + subgraph "πŸ” Security" S1[πŸ›‘οΈ Protect Data] S2[πŸ“‹ Audit Access] end @@ -333,7 +333,7 @@ resources: ```mermaid flowchart TD - subgraph 😰 Limitations + subgraph "😰 Limitations" A[πŸ”„ No Rotation] B[πŸ“Š Limited Audit] C[🌍 K8s Only] @@ -359,7 +359,7 @@ flowchart TD ```mermaid flowchart LR - subgraph 🏰 Vault + subgraph "🏰 Vault" A[πŸ” Secret Engine] B[πŸ”‘ Auth Methods] C[πŸ“‹ Policies] @@ -377,18 +377,18 @@ flowchart LR ```mermaid flowchart TD - subgraph πŸ‘₯ Clients + subgraph "πŸ‘₯ Clients" K8s[☸️ K8s Pods] CLI[πŸ’» CLI] API[πŸ”Œ API] end - subgraph 🏰 Vault Server + subgraph "🏰 Vault Server" Auth[πŸ”‘ Auth Methods] Policy[πŸ“‹ Policies] Secrets[πŸ” Secret Engines] Audit[πŸ“Š Audit Device] end - subgraph πŸ’Ύ Storage + subgraph "πŸ’Ύ Storage" Backend[πŸ—„οΈ Storage Backend] end K8s --> Auth @@ -460,12 +460,12 @@ path "secret/data/admin/*" { ```mermaid flowchart LR - subgraph πŸ“¦ Pod + Vault[🏰 Vault Server] + subgraph "πŸ“¦ Pod" App[πŸš€ App Container] Agent[πŸ” Vault Agent] Vol[πŸ“ Shared Volume] end - Vault[🏰 Vault Server] Agent -->|πŸ”‘ Auth| Vault Vault -->|πŸ” Secrets| Agent Agent -->|πŸ“ Write| Vol @@ -561,17 +561,17 @@ vault read database/creds/readonly ```mermaid flowchart TD - subgraph πŸ—οΈ Foundation + subgraph "πŸ—οΈ Foundation" L2[πŸ“¦ Lab 2: Docker] L10[β›΅ Lab 10: Helm] end - subgraph πŸ” Security + subgraph "πŸ” Security" L11[πŸ”’ Lab 11: Secrets] end - subgraph πŸ“‹ Config + subgraph "πŸ“‹ Config" L12[πŸ“ Lab 12: ConfigMaps] end - subgraph πŸš€ Deployment + subgraph "πŸš€ Deployment" L13[πŸ”„ Lab 13: ArgoCD] end L2 --> L10 diff --git a/lectures/lec12.md b/lectures/lec12.md index ef8ff54778..13b08692f7 100644 --- a/lectures/lec12.md +++ b/lectures/lec12.md @@ -93,18 +93,23 @@ By the end of this lecture, you will: ```mermaid flowchart TD - subgraph 😰 Hardcoded - A[app-dev.jar] --> D1[Dev DB] - B[app-staging.jar] --> D2[Staging DB] - C[app-prod.jar] --> D3[Prod DB] + subgraph "😰 Hardcoded" + A[app-dev.jar] + B[app-staging.jar] + C[app-prod.jar] end - - subgraph πŸš€ Externalized + subgraph "πŸš€ Externalized" E[app.jar] --> F{Config} - F --> D1 - F --> D2 - F --> D3 end + D1[Dev DB] + D2[Staging DB] + D3[Prod DB] + F --> D1 + F --> D2 + F --> D3 + A --> D1 + B --> D2 + C --> D3 ``` * 😰 **Hardcoded:** Different artifact per environment @@ -155,7 +160,7 @@ COPY . /app **Stateless containers + persistent data = πŸ’₯** ```mermaid -flowchart LR +flowchart TD A[πŸ“¦ Container v1] --> B[πŸ’Ύ /app/uploads] B --> C[πŸ”„ Deployment] C --> D[πŸ“¦ Container v2] @@ -331,7 +336,7 @@ Your application: **Situation:** Developer accidentally deploys with staging database URL to production ```mermaid -flowchart LR +flowchart TD A[πŸ‘¨β€πŸ’» Dev pushes] --> B[πŸ”„ CI/CD] B --> C[πŸ“¦ Deploy to Prod] C --> D[πŸ”— Connects to Staging DB] diff --git a/lectures/lec13.md b/lectures/lec13.md index 370ae7821a..3ec5195ac1 100644 --- a/lectures/lec13.md +++ b/lectures/lec13.md @@ -91,17 +91,19 @@ By the end of this lecture, you will: **Traditional Deployment Models:** ```mermaid -flowchart TD - subgraph 😰 Manual - A[πŸ‘¨β€πŸ’» Developer] --> B[⌨️ kubectl apply] - B --> C[☸️ Cluster] - end - - subgraph πŸ”„ CI/CD Push +flowchart LR + subgraph "πŸ”„ CI/CD Push" + direction LR D[πŸ“ Git Push] --> E[πŸ”§ CI Pipeline] E --> F[⌨️ kubectl apply] F --> G[☸️ Cluster] end + + subgraph "😰 Manual" + direction LR + A[πŸ‘¨β€πŸ’» Developer] --> B[⌨️ kubectl apply] + B --> C[☸️ Cluster] + end ``` * 😰 **Manual:** No audit trail, human error, inconsistent @@ -227,16 +229,18 @@ flowchart LR ## πŸ“ Slide 13 – πŸ”„ Push vs Pull Deployment ```mermaid -flowchart TD - subgraph πŸ”„ Push Model - A[πŸ“ Git] --> B[πŸ”§ CI/CD] - B --> |Push credentials needed| C[☸️ Cluster] - end - - subgraph πŸš€ Pull Model - GitOps +flowchart LR + subgraph "πŸš€ Pull Model - GitOps" + direction LR D[πŸ“ Git] --> |Pull| E[πŸ€– Agent in Cluster] E --> |Apply| F[☸️ Same Cluster] end + + subgraph "πŸ”„ Push Model" + direction LR + A[πŸ“ Git] --> B[πŸ”§ CI/CD] + B --> |Push credentials needed| C[☸️ Cluster] + end ``` | πŸ“‹ Aspect | πŸ”„ Push | πŸš€ Pull (GitOps) | @@ -743,7 +747,7 @@ flowchart TD **Adopting GitOps incrementally:** ```mermaid -flowchart LR +flowchart TD A[1️⃣ Non-critical app] --> B[2️⃣ Dev environment] B --> C[3️⃣ More apps] C --> D[4️⃣ Staging] diff --git a/lectures/lec14.md b/lectures/lec14.md index 9ca7297ccd..39fc2b6d5f 100644 --- a/lectures/lec14.md +++ b/lectures/lec14.md @@ -250,16 +250,17 @@ flowchart TD **Two identical environments, instant switchover** ```mermaid -flowchart LR +flowchart TD + subgraph "After Switch" + direction TD + D[πŸ”΅ Blue v1] --> |0%| F[🌐 Traffic] + E[🟒 Green v2] --> |100%| F + end subgraph Before + direction TD A[πŸ”΅ Blue v1] --> |100%| C[🌐 Traffic] B[🟒 Green v2] --> |0%| C end - - subgraph After Switch - D[πŸ”΅ Blue v1] --> |0%| F[🌐 Traffic] - E[🟒 Green v2] --> |100%| F - end ``` **Characteristics:** @@ -710,7 +711,7 @@ kubectl argo rollouts dashboard **Netflix Progressive Delivery:** ```mermaid -flowchart LR +flowchart TD A[πŸ“¦ v2] --> B[🎯 Internal 1%] B --> C[🌍 One Region 5%] C --> D[🌍 All Regions 25%] diff --git a/lectures/lec15.md b/lectures/lec15.md index eb828de609..3ca6365e67 100644 --- a/lectures/lec15.md +++ b/lectures/lec15.md @@ -90,13 +90,16 @@ By the end of this lecture, you will: ```mermaid flowchart TD - A[πŸ“¦ Deployment] --> B[πŸ“¦ Pod-abc123] - A --> C[πŸ“¦ Pod-def456] - A --> D[πŸ“¦ Pod-ghi789] - - E[Pods are interchangeable] - F[Random names] - G[Any pod can be replaced] + subgraph Deployments + A[πŸ“¦ Deployment] --> B[πŸ“¦ Pod-abc123] + A --> C[πŸ“¦ Pod-def456] + A --> D[πŸ“¦ Pod-ghi789] + end + subgraph Characteristics + E[Pods are interchangeable] + F[Random names] + G[Any pod can be replaced] + end ``` * βœ… **Great for:** Web servers, API services, workers @@ -499,7 +502,7 @@ topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))) **All-in-one monitoring solution:** ```mermaid -flowchart TD +flowchart LR A[πŸ“¦ kube-prometheus-stack] --> B[πŸ“Š Prometheus] A --> C[πŸ“ˆ Grafana] A --> D[πŸ”” Alertmanager] diff --git a/lectures/lec16.md b/lectures/lec16.md index 2f8bf01d5b..23b3cc12bc 100644 --- a/lectures/lec16.md +++ b/lectures/lec16.md @@ -124,7 +124,7 @@ flowchart TD ## πŸ“ Slide 8 – πŸ“Š The Abstraction Spectrum ```mermaid -flowchart LR +flowchart TD A[πŸ–₯️ Bare Metal] --> B[☁️ VMs/IaaS] B --> C[☸️ Kubernetes] C --> D[✈️ PaaS] diff --git a/lectures/lec2.md b/lectures/lec2.md index 46a3e0485a..7523c9ebdf 100644 --- a/lectures/lec2.md +++ b/lectures/lec2.md @@ -122,11 +122,13 @@ flowchart TD ```mermaid flowchart TD - subgraph VM1[πŸ–₯️ VM 1 - 15GB] + subgraph VM1["πŸ–₯️ VM 1 - 15GB"] + direction TD App1[πŸ“± App] --> OS1[πŸ–₯️ Full OS] OS1 --> Kernel1[🧠 Kernel] end - subgraph VM2[πŸ–₯️ VM 2 - 15GB] + subgraph VM2["πŸ–₯️ VM 2 - 15GB"] + direction TD App2[πŸ“± App] --> OS2[πŸ–₯️ Full OS] OS2 --> Kernel2[🧠 Kernel] end @@ -226,12 +228,12 @@ flowchart LR ```mermaid flowchart TD - subgraph Host[πŸ–₯️ Host System] - subgraph NS1[πŸ“¦ Container 1 Namespace] + subgraph Host["πŸ–₯️ Host System"] + subgraph NS1["πŸ“¦ Container 1 Namespace"] P1[PID 1: app] Net1[eth0: 172.17.0.2] end - subgraph NS2[πŸ“¦ Container 2 Namespace] + subgraph NS2["πŸ“¦ Container 2 Namespace"] P2[PID 1: app] Net2[eth0: 172.17.0.3] end @@ -275,12 +277,12 @@ flowchart LR ```mermaid flowchart TD - subgraph Image[πŸ“¦ Image Layers - Read Only] + subgraph Image["πŸ“¦ Image Layers - Read Only"] L1[🐧 Layer 1: Base OS] L2[πŸ“¦ Layer 2: Dependencies] L3[πŸ“ Layer 3: App Code] end - subgraph Container[πŸƒ Container Layer - Read/Write] + subgraph Container["πŸƒ Container Layer - Read/Write"] L4[✏️ Layer 4: Runtime Changes] end L1 --> L2 --> L3 --> L4 @@ -297,13 +299,13 @@ flowchart TD ```mermaid flowchart TD - subgraph Docker[🐳 Docker Engine] + subgraph Docker["🐳 Docker Engine"] CLI[πŸ–₯️ Docker CLI] Daemon[βš™οΈ dockerd] Containerd[πŸ“¦ containerd] Runc[πŸƒ runc] end - subgraph Kernel[🐧 Linux Kernel] + subgraph Kernel["🐧 Linux Kernel"] NS[πŸ”’ Namespaces] CG[πŸŽ›οΈ cgroups] UFS[πŸ“‚ overlay2] @@ -694,11 +696,11 @@ tests/ ```mermaid flowchart LR - subgraph Stage1[πŸ”¨ Builder Stage] + subgraph Stage1["πŸ”¨ Builder Stage"] SDK[πŸ“¦ Full SDK] Compile[βš™οΈ Compile] end - subgraph Stage2[πŸš€ Runtime Stage] + subgraph Stage2["πŸš€ Runtime Stage"] Binary[πŸ“¦ Binary Only] Minimal[🐧 Minimal OS] end diff --git a/lectures/lec3.md b/lectures/lec3.md index 9afebb8b15..428f4032c6 100644 --- a/lectures/lec3.md +++ b/lectures/lec3.md @@ -233,7 +233,7 @@ flowchart LR ```mermaid flowchart TD - subgraph Pyramid[πŸ”Ί Testing Pyramid] + subgraph Pyramid["πŸ”Ί Testing Pyramid"] E2E[🌐 E2E Tests
Few, Slow, Expensive] INT[πŸ”— Integration Tests
Some, Moderate] UNIT[πŸ§ͺ Unit Tests
Many, Fast, Cheap] diff --git a/lectures/lec4.md b/lectures/lec4.md index acfe810526..a710d34350 100644 --- a/lectures/lec4.md +++ b/lectures/lec4.md @@ -98,12 +98,12 @@ flowchart LR ```mermaid flowchart TD - subgraph 🐢 Pets + subgraph Pets["🐢 Pets"] P1[web-prod-01] P2[db-master] P3[app-legacy] end - subgraph πŸ„ Cattle + subgraph Cattle["πŸ„ Cattle"] C1[instance-001] C2[instance-002] C3[instance-003] @@ -223,11 +223,13 @@ flowchart LR ```mermaid flowchart TD subgraph Declarative + direction TD D1[πŸ“ Define desired state] D2[πŸ€– Tool figures out how] D1 --> D2 end subgraph Imperative + direction TD I1[πŸ“ Define exact steps] I2[πŸ”§ Execute step by step] I1 --> I2 @@ -602,16 +604,18 @@ flowchart TD ## πŸ“ Slide 30 – 🌊 From Snowflakes to Cattle ```mermaid -flowchart LR - subgraph 😱 Snowflakes +flowchart TD + subgraph Snowflakes["😱 Snowflakes"] Manual[πŸ”§ Manual Setup] Unique[❄️ Unique Servers] Drift[πŸ“‹ Configuration Drift] + Manual --> Unique --> Drift end - subgraph πŸ„ Cattle + subgraph Cattle["πŸ„ Cattle"] Code[πŸ“ Code-Defined] Identical[πŸ”„ Identical Servers] Reproducible[βœ… Reproducible] + Code --> Identical --> Reproducible end Snowflakes -->|πŸš€ IaC| Cattle ``` diff --git a/lectures/lec5.md b/lectures/lec5.md index 5bcfba6c1a..cac8b28512 100644 --- a/lectures/lec5.md +++ b/lectures/lec5.md @@ -330,11 +330,12 @@ ansible-playbook -i inventory/hosts.ini playbook.yml ```mermaid flowchart TD - subgraph ❌ Without Roles + subgraph "❌ Without Roles" + direction TD P1[πŸ“ One huge playbook] P1 --> Problem[😰 Hard to maintain] end - subgraph βœ… With Roles + subgraph "βœ… With Roles" R1[πŸ“¦ common role] R2[πŸ“¦ docker role] R3[πŸ“¦ app role] @@ -623,15 +624,17 @@ server1 : ok=15 changed=0 unreachable=0 failed=0 ```mermaid flowchart LR - subgraph 😱 Manual + subgraph Manual["😱 Manual"] SSH[πŸ”Œ SSH Sessions] Commands[πŸ’» Run Commands] Hope[πŸ™ Hope It Works] + SSH --> Commands --> Hope end - subgraph πŸ€– Automated + subgraph Automated["πŸ€– Automated"] Playbook[πŸ“ Playbooks] Roles[πŸ“¦ Roles] Consistent[βœ… Consistent] + Playbook --> Roles --> Consistent end Manual -->|πŸš€ Ansible| Automated ``` diff --git a/lectures/lec6.md b/lectures/lec6.md index a78ba20b5b..8473690b74 100644 --- a/lectures/lec6.md +++ b/lectures/lec6.md @@ -221,15 +221,17 @@ flowchart TD ## πŸ“ Slide 13 – πŸ›‘οΈ Block Benefits ```mermaid -flowchart LR - subgraph Without Blocks - T1[Task 1] --> T2[Task 2] - T2 -->|❌ Fail| Stop[😱 Playbook Stops] - end - subgraph With Blocks +flowchart TD + subgraph "With Blocks" + direction TD B1[🧱 Block] -->|❌ Fail| R1[πŸ”§ Rescue] R1 --> A1[βœ… Always] end + subgraph "Without Blocks" + direction TD + T1[Task 1] --> T2[Task 2] + T2 -->|❌ Fail| Stop[😱 Playbook Stops] + end ``` **πŸ›‘οΈ Advantages:** @@ -651,15 +653,17 @@ flowchart TD ```mermaid flowchart LR - subgraph 😱 Manual + subgraph Manual["😱 Manual"] SSH[πŸ”Œ SSH to servers] Commands[πŸ’» Run commands] Hope[πŸ™ Hope it works] + SSH --> Commands --> Hope end - subgraph πŸ€– Automated + subgraph Automated["πŸ€– Automated"] Push[πŸ“€ Git push] CI[πŸ”„ CI/CD] Deploy[πŸš€ Ansible] + Push --> CI --> Deploy end Manual -->|πŸš€ Automate| Automated ``` diff --git a/lectures/lec7.md b/lectures/lec7.md index b00d0ad39f..555253856b 100644 --- a/lectures/lec7.md +++ b/lectures/lec7.md @@ -247,10 +247,10 @@ flowchart LR ```mermaid flowchart LR - subgraph ❌ Unstructured + subgraph "❌ Unstructured" U1[ERROR: Failed to connect to db at 10:23] end - subgraph βœ… Structured + subgraph "βœ… Structured" S1[JSON with fields] end U1 --> Hard[😰 Hard to parse] @@ -637,15 +637,17 @@ flowchart LR ```mermaid flowchart LR - subgraph 😱 Blind + subgraph Blind["😱 Blind"] SSH[πŸ”Œ SSH grep] Guess[🀷 Guesswork] Slow[⏱️ Hours] + SSH --- Guess --- Slow end - subgraph πŸ” Observable + subgraph Observable["πŸ” Observable"] Dashboard[πŸ“Š Dashboard] Query[πŸ” LogQL] Fast[⚑ Minutes] + Dashboard --- Query --- Fast end Blind -->|πŸš€ Loki| Observable ``` @@ -730,7 +732,7 @@ healthcheck: ## πŸ“ Slide 34 – πŸ“ˆ Career Path: Observability Skills ```mermaid -flowchart LR +flowchart TD Junior[🌱 Junior: Basic logging] --> Mid[πŸ’Ό Mid: Structured logging & dashboards] Mid --> Senior[⭐ Senior: Full observability stack] Senior --> Principal[πŸ† Principal: Observability strategy] diff --git a/lectures/lec8.md b/lectures/lec8.md index 0b921df100..563d095570 100644 --- a/lectures/lec8.md +++ b/lectures/lec8.md @@ -93,12 +93,12 @@ flowchart LR ```mermaid flowchart TD - subgraph πŸ“‹ Logs + subgraph Logs L1[What happened?] L2[Detailed events] L3[High cardinality] end - subgraph πŸ“Š Metrics + subgraph Metrics M1[How much/fast?] M2[Aggregated numbers] M3[Low cardinality] @@ -187,11 +187,11 @@ flowchart LR ```mermaid flowchart TD - subgraph Pull (Prometheus) + subgraph "Pull Prometheus" P1[πŸ’Ύ Prometheus] -->|πŸ”„ Scrape| T1[πŸ“¦ Target] P1 -->|πŸ”„ Scrape| T2[πŸ“¦ Target] end - subgraph Push (StatsD) + subgraph "Push StatsD" S1[πŸ“¦ App] -->|πŸ“€ Push| D1[πŸ’Ύ Collector] S2[πŸ“¦ App] -->|πŸ“€ Push| D1 end @@ -584,15 +584,17 @@ flowchart LR ```mermaid flowchart LR - subgraph 😱 Guessing + subgraph Guessing["😱 Guessing"] NoData[🀷 No Data] Reactive[πŸ”₯ Reactive] Slow[⏱️ Slow Detection] + NoData --- Reactive --- Slow end - subgraph πŸ“Š Measuring + subgraph Measuring["πŸ“Š Measuring"] Metrics[πŸ“ˆ Real Metrics] Proactive[⚑ Proactive] Fast[πŸš€ Instant Detection] + Metrics --- Proactive --- Fast end Guessing -->|πŸš€ Prometheus| Measuring ``` diff --git a/lectures/lec9.md b/lectures/lec9.md index e5a265fd11..181d506508 100644 --- a/lectures/lec9.md +++ b/lectures/lec9.md @@ -185,11 +185,13 @@ flowchart LR ```mermaid flowchart TD subgraph Declarative + direction TD D1[πŸ“ Define: 3 replicas] D2[☸️ K8s makes it happen] D1 --> D2 end subgraph Imperative + direction TD I1[πŸ’» Run: create pod 1] I2[πŸ’» Run: create pod 2] I3[πŸ’» Run: create pod 3] @@ -209,18 +211,18 @@ flowchart TD ## πŸ“ Slide 13 – πŸ—οΈ Kubernetes Architecture ```mermaid -flowchart TD - subgraph Control Plane +flowchart LR + subgraph "Worker Nodes" + Kubelet[πŸ€– kubelet] + Proxy[🌐 kube-proxy] + Runtime[🐳 Container Runtime] + end + subgraph "Control Plane" API[πŸ“‘ API Server] Scheduler[πŸ“Š Scheduler] Controller[πŸ”„ Controller Manager] ETCD[πŸ’Ύ etcd] end - subgraph Worker Nodes - Kubelet[πŸ€– kubelet] - Proxy[🌐 kube-proxy] - Runtime[🐳 Container Runtime] - end API --> Scheduler API --> Controller API --> ETCD @@ -644,15 +646,17 @@ spec: ```mermaid flowchart LR - subgraph 😱 Manual + subgraph Manual["😱 Manual"] SSH[πŸ”Œ SSH to servers] Docker[🐳 docker run] Restart[πŸ”„ Manual restart] + SSH --- Docker --- Restart end - subgraph ☸️ Orchestrated + subgraph Orchestrated["☸️ Orchestrated"] Manifest[πŸ“ YAML Manifest] Apply[kubectl apply] AutoHeal[πŸ₯ Auto-healing] + Manifest --- Apply --- AutoHeal end Manual -->|πŸš€ Kubernetes| Orchestrated ``` diff --git a/monitoring/.env.example b/monitoring/.env.example new file mode 100644 index 0000000000..9f0b71a95f --- /dev/null +++ b/monitoring/.env.example @@ -0,0 +1,9 @@ +# Copy to .env and update values for your environment. +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change-me-now +APP_PYTHON_IMAGE=devops-info-service:lab08 +APP_PYTHON_PORT=8000 + +# Optional: enable with --profile bonus +APP_BONUS_IMAGE=ghcr.io/example/bonus-app:latest +APP_BONUS_PORT=8001 diff --git a/monitoring/.gitignore b/monitoring/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/monitoring/.gitignore @@ -0,0 +1 @@ +.env diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..5fa8175ac7 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,206 @@ +services: + loki: + image: grafana/loki:3.0.0 + command: -config.file=/etc/loki/config.yml + restart: unless-stopped + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + labels: + logging: "promtail" + app: "devops-loki" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + networks: + - logging + + prometheus: + image: prom/prometheus:v3.9.0 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=10GB + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + labels: + logging: "promtail" + app: "devops-prometheus" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + networks: + - logging + + promtail: + image: grafana/promtail:3.0.0 + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped + ports: + - "9080:9080" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-positions:/tmp + labels: + logging: "promtail" + app: "devops-promtail" + depends_on: + loki: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "bash -lc 'exec 3<>/dev/tcp/127.0.0.1/9080 && printf \"GET /ready HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q \"200\" <&3'", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + reservations: + cpus: "0.10" + memory: 128M + networks: + - logging + + grafana: + image: grafana/grafana:12.3.1 + restart: unless-stopped + ports: + - "3000:3000" + environment: + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER:-admin}" + GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in .env}" + GF_SECURITY_ALLOW_EMBEDDING: "false" + GF_METRICS_ENABLED: "true" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + labels: + logging: "promtail" + app: "devops-grafana" + depends_on: + loki: + condition: service_healthy + prometheus: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: "0.50" + memory: 512M + reservations: + cpus: "0.10" + memory: 128M + networks: + - logging + + app-python: + image: "${APP_PYTHON_IMAGE:?Set APP_PYTHON_IMAGE in .env}" + restart: unless-stopped + ports: + - "${APP_PYTHON_PORT:-8000}:5000" + environment: + PORT: "5000" + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + reservations: + cpus: "0.10" + memory: 64M + networks: + - logging + + app-bonus: + image: "${APP_BONUS_IMAGE:-ghcr.io/example/bonus-app:latest}" + profiles: ["bonus"] + restart: unless-stopped + ports: + - "${APP_BONUS_PORT:-8001}:5000" + environment: + PORT: "5000" + labels: + logging: "promtail" + app: "devops-bonus" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + reservations: + cpus: "0.10" + memory: 64M + networks: + - logging + +networks: + logging: + name: logging + +volumes: + prometheus-data: + loki-data: + grafana-data: + promtail-positions: diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..9720ccd783 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1,228 @@ +# LAB07 Report - Observability and Logging with Loki Stack + +## 1. Architecture + +Centralized logging stack: + +- `app-python` (and optional `app-bonus`) emit stdout logs. +- `promtail` discovers Docker containers via `/var/run/docker.sock`. +- Promtail filters containers with label `logging=promtail` and pushes to Loki. +- `loki` stores logs with TSDB (`schema v13`) on local filesystem. +- `grafana` reads Loki and provides Explore + dashboard visualization. + +```text ++----------------------+ +---------------------+ +| app-python / bonus | ----> | Promtail | +| labels: logging, app | logs | docker_sd + relabel | ++----------------------+ +----------+----------+ + | + | push logs + v + +--------+--------+ + | Loki 3.0 (TSDB) | + +--------+--------+ + | + | query + v + +--------+--------+ + | Grafana 12.3.1 | + +-----------------+ +``` + +## 2. Setup Guide + +### 2.1 Prepare environment + +```bash +cd monitoring +cp .env.example .env +# edit .env and set: +# - GRAFANA_ADMIN_PASSWORD +# - APP_PYTHON_IMAGE (from your Lab 2 image) +``` + +### 2.2 Deploy + +```bash +docker compose up -d +docker compose ps +``` + +Optional bonus app (if you completed Lab 1 bonus): + +```bash +docker compose --profile bonus up -d +``` + +### 2.3 Verify endpoints + +```bash +curl http://localhost:3100/ready +curl http://localhost:9080/ready +curl http://localhost:3000/api/health +``` + +Grafana URL: `http://localhost:3000` + +- Anonymous access is disabled. +- Login with credentials from `.env`. +- Loki data source is auto-provisioned from `grafana/provisioning/datasources/loki.yml`. + +## 3. Configuration + +### Loki (`monitoring/loki/config.yml`) + +Key decisions: + +- `store: tsdb` + `schema: v13` for Loki 3.0 recommended storage path. +- `object_store: filesystem` for single-node lab environment. +- `retention_period: 168h` (7 days) under `limits_config`. +- `compactor.retention_enabled: true` for retention cleanup. + +Snippet: + +```yaml +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 +limits_config: + retention_period: 168h +``` + +### Promtail (`monitoring/promtail/config.yml`) + +Key decisions: + +- Docker service discovery (`docker_sd_configs`) via socket. +- Label filter to scrape only containers marked for logging. +- Relabel rules for `container`, `app`, `service`, `container_id` labels. + +Snippet: + +```yaml +docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: label + values: ["logging=promtail"] +``` + +## 4. Application Logging + +The Flask app was upgraded to structured JSON logging: + +- Custom `JSONFormatter` outputs one JSON object per line. +- Request lifecycle logging: + - `@app.before_request`: method/path/client/user-agent + - `@app.after_request`: status_code + request context +- Error handlers log structured warning/error events. + +Example log line: + +```json +{"timestamp":"2026-03-12T11:10:20.123456+00:00","level":"INFO","logger":"devops-info-service","message":"request_completed","method":"GET","path":"/health","status_code":200,"client_ip":"172.20.0.1"} +``` + +## 5. Dashboard + +Dashboard contains 4 required panels: + +1. Logs Table + - Query: `{app=~"devops-.*"}` +2. Request Rate (time series) + - Query: `sum by (app) (rate({app=~"devops-.*"}[1m]))` +3. Error Logs + - Query: `{app=~"devops-.*"} | json | level="ERROR"` +4. Log Level Distribution + - Query: `sum by (level) (count_over_time({app=~"devops-.*"} | json [5m]))` + +Useful Explore queries used during testing: + +```logql +{app="devops-python"} +{app="devops-python"} |= "ERROR" +{app="devops-python"} | json | method="GET" +``` + +## 6. Production Config + +Implemented production-readiness baseline: + +- Resource limits and reservations for Loki/Promtail/Grafana/apps in compose. +- Grafana anonymous authentication disabled. +- Admin password moved to `.env` (not committed; `.env` is gitignored). +- Healthchecks added for Loki and Grafana; Promtail and app readiness are validated via service status and queries. +- Retention policy configured for 7 days. + +## 7. Testing + +Traffic generation: + +```bash +for i in {1..20}; do curl -s http://localhost:8000/ >/dev/null; done +for i in {1..20}; do curl -s http://localhost:8000/health >/dev/null; done +``` + +Compose/health: + +```bash +docker compose ps +curl http://localhost:3100/ready +curl http://localhost:9080/targets +curl http://localhost:3000/api/health +``` + +Ansible bonus deployment: + +```bash +cd ansible +ANSIBLE_HOME=/tmp/.ansible \ +ANSIBLE_LOCAL_TEMP=/tmp/ansible-local-tmp \ +ANSIBLE_REMOTE_TEMP=/tmp/ansible-local-tmp \ +ANSIBLE_COLLECTIONS_PATH=$PWD/.ansible/collections \ +ANSIBLE_ROLES_PATH=$PWD/roles \ +../.venv/bin/ansible-playbook -i inventory/hosts.ini playbooks/deploy-monitoring.yml --syntax-check --vault-password-file ../.vault_pass + +ANSIBLE_HOME=/tmp/.ansible \ +ANSIBLE_LOCAL_TEMP=/tmp/ansible-local-tmp \ +ANSIBLE_REMOTE_TEMP=/tmp/ansible-local-tmp \ +ANSIBLE_COLLECTIONS_PATH=$PWD/.ansible/collections \ +ANSIBLE_ROLES_PATH=$PWD/roles \ +../.venv/bin/ansible-playbook -i inventory/hosts.ini playbooks/deploy-monitoring.yml --list-tags --vault-password-file ../.vault_pass +``` + +Idempotency check: + +```bash +ANSIBLE_CONFIG=$PWD/ansible.cfg ansible-playbook playbooks/deploy-monitoring.yml --vault-password-file ../.vault_pass +ANSIBLE_CONFIG=$PWD/ansible.cfg ansible-playbook playbooks/deploy-monitoring.yml --vault-password-file ../.vault_pass +``` + +Expected: second run should report mostly `ok` with minimal/no `changed`. + +## 8. Challenges + +1. Compose security vs convenience + - Promtail needs Docker socket access for discovery. + - Mitigation: read-only mount and label-based target filtering. + +2. Loki 3.0 TSDB migration details + - Older examples often use boltdb-shipper. + - Solution: use v13 + tsdb + compactor retention settings. + +3. Grafana bootstrap + - Manual data source setup is error-prone. + - Solution: datasource provisioning file mounted at startup. + +## Evidence Placeholders + +Add screenshots before submission: + +- `monitoring/docs/screenshots/explore-3-containers.png` +- `monitoring/docs/screenshots/json-logs.png` +- `monitoring/docs/screenshots/dashboard-4-panels.png` +- `monitoring/docs/screenshots/compose-healthy.png` +- `monitoring/docs/screenshots/grafana-login.png` diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..9f9c60ea4e --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,320 @@ +# LAB08 Report - Metrics & Monitoring with Prometheus + +## 1. Architecture + +Lab 8 extends the Lab 7 logging stack with application metrics and Prometheus scraping. + +```text ++-------------------+ scrape /metrics +----------------------+ +| app-python | --------------------------------> | Prometheus 3.9.0 | +| Flask + metrics | | retention: 15d/10GB | ++---------+---------+ +----------+-----------+ + | | + | logs via stdout | PromQL + v v ++---------+---------+ push logs +----------------------+ +----------------------+ +| Promtail 3.0 | --------------> | Loki 3.0 | | Grafana 12.3.1 | +| docker_sd + labels| | log storage + query | | dashboards + alerts | ++-------------------+ +----------------------+ +----------------------+ +``` + +Main flow for Lab 8: + +- `app-python` exposes Prometheus metrics on `/metrics` +- `prometheus` scrapes `app`, `prometheus`, `loki`, and `grafana` every `15s` +- `grafana` has two provisioned data sources: Loki and Prometheus +- Grafana provides both the Lab 7 logs dashboard and the new Lab 8 metrics dashboard + +## 2. Application Instrumentation + +Files updated: + +- `app_python/src/app.py` +- `app_python/requirements.txt` +- `app_python/pyproject.toml` +- `app_python/tests/test_app.py` + +### Metrics added + +HTTP RED metrics: + +- `http_requests_total{method, endpoint, status_code}` - request counter +- `http_request_duration_seconds{method, endpoint, status_code}` - latency histogram +- `http_requests_in_progress{method, endpoint}` - active request gauge + +Application-specific metrics: + +- `devops_info_endpoint_calls_total{endpoint}` - endpoint usage counter +- `devops_info_system_info_collection_seconds` - system info collection histogram + +Implementation details: + +- Added `@app.before_request`, `@app.after_request`, and `@app.teardown_request` +- Added endpoint normalization so 404s are grouped as `unmatched` +- Excluded `/metrics` from the RED HTTP counter to avoid scrape noise +- Kept `/metrics` available in the root endpoint listing and exposed raw Prometheus output + +### Example local metric output + +Observed from `curl http://localhost:8000/metrics` after traffic generation: + +```text +http_requests_total{endpoint="/health",method="GET",status_code="200"} 57.0 +http_requests_total{endpoint="/",method="GET",status_code="200"} 15.0 +http_requests_total{endpoint="unmatched",method="GET",status_code="404"} 3.0 +devops_info_endpoint_calls_total{endpoint="/"} 15.0 +devops_info_system_info_collection_seconds_count 15.0 +``` + +## 3. Prometheus Configuration + +Prometheus config lives in `monitoring/prometheus/prometheus.yml`. + +Scrape setup: + +- global scrape interval: `15s` +- `prometheus` -> `localhost:9090` +- `app` -> `app-python:5000/metrics` +- `loki` -> `loki:3100/metrics` +- `grafana` -> `grafana:3000/metrics` + +Retention and persistence are configured in Compose: + +- `--storage.tsdb.retention.time=15d` +- `--storage.tsdb.retention.size=10GB` +- persistent volume: `prometheus-data` + +### Validation results + +Prometheus `up` query: + +```json +{ + "grafana:3000": "1", + "localhost:9090": "1", + "loki:3100": "1", + "app-python:5000": "1" +} +``` + +Prometheus `/api/v1/targets` showed all four targets with `"health":"up"` and empty `lastError`. + +## 4. Dashboard Walkthrough + +### Metrics dashboard + +File: `monitoring/grafana/dashboards/devops-info-service-metrics.json` + +Panels: + +1. `Request Rate by Endpoint` + Query: `sum by (endpoint) (rate(http_requests_total[5m]))` +2. `Error Rate` + Query: `sum(rate(http_requests_total{status_code=~"5.."}[5m]))` +3. `Request Duration p95` + Query: `histogram_quantile(0.95, sum by (le, endpoint) (rate(http_request_duration_seconds_bucket[5m])))` +4. `Request Duration Heatmap` + Query: `sum by (le) (rate(http_request_duration_seconds_bucket[5m]))` +5. `Active Requests` + Query: `sum(http_requests_in_progress)` +6. `Status Code Distribution` + Query: `sum by (status_code) (rate(http_requests_total[5m]))` +7. `Application Uptime` + Query: `up{job="app"}` + +### Logs dashboard + +File: `monitoring/grafana/dashboards/devops-info-service-logs.json` + +Panels: + +1. `Application Logs` + Query: `{app=~"devops-.*"}` +2. `Log Rate by App` + Query: `sum by (app) (rate({app=~"devops-.*"}[1m]))` +3. `Log Level Distribution` + Query: `sum by (level) (count_over_time({app=~"devops-.*"} | json [5m]))` +4. `Error Logs (5m)` + Query: `sum(count_over_time({app=~"devops-.*"} | json | level="ERROR" [5m]))` + +### Grafana provisioning validation + +Verified through Grafana HTTP API: + +- data sources: + - `Loki` + - `Prometheus` +- dashboards: + - `DevOps Info Service Metrics` + - `DevOps Logs Overview` + +## 5. PromQL Examples + +1. Availability of all scraped services: + +```promql +up +``` + +2. Request rate per endpoint: + +```promql +sum by (endpoint) (rate(http_requests_total[5m])) +``` + +3. 5xx error rate: + +```promql +sum(rate(http_requests_total{status_code=~"5.."}[5m])) +``` + +4. 95th percentile latency by endpoint: + +```promql +histogram_quantile(0.95, sum by (le, endpoint) (rate(http_request_duration_seconds_bucket[5m]))) +``` + +5. Concurrent requests: + +```promql +sum(http_requests_in_progress) +``` + +6. Status-code distribution: + +```promql +sum by (status_code) (rate(http_requests_total[5m])) +``` + +7. Raw business metric for system info collection: + +```promql +rate(devops_info_system_info_collection_seconds_sum[5m]) +/ +rate(devops_info_system_info_collection_seconds_count[5m]) +``` + +## 6. Production Setup + +Implemented production-oriented baseline: + +- health checks on `loki`, `prometheus`, `promtail`, `grafana`, `app-python`, and optional `app-bonus` +- resource limits and reservations for all services +- persistent volumes: + - `prometheus-data` + - `loki-data` + - `grafana-data` + - `promtail-positions` +- Grafana anonymous access disabled +- Prometheus retention set to `15d` and `10GB` + +Resource profile used: + +- Prometheus: `1 CPU`, `1G` +- Loki: `1 CPU`, `1G` +- Grafana: `0.5 CPU`, `512M` +- Apps: `0.5 CPU`, `256M` +- Promtail: `0.5 CPU`, `256M` + +### Persistence proof + +After `docker compose restart grafana`, the Grafana API still returned both provisioned dashboards: + +- `DevOps Info Service Metrics` +- `DevOps Logs Overview` + +This confirms dashboard persistence across restart with the mounted volume and provisioning files. + +## 7. Testing Results + +### Python tests + +```text +.venv/bin/pytest app_python/tests +13 passed in 1.95s +``` + +### Compose validation + +```text +docker compose -f monitoring/docker-compose.yml config +CONFIG_OK +``` + +### Healthy core services + +Observed after final restart: + +```text +app-python Up (healthy) +grafana Up (healthy) +loki Up (healthy) +prometheus Up (healthy) +promtail Up (healthy) +``` + +### Prometheus evidence + +- `curl http://localhost:9090/api/v1/query?query=up` returned `1` for all four jobs +- `curl http://localhost:9090/api/v1/targets` showed all targets `up` +- `curl http://localhost:8000/metrics` exposed counters, histograms, and gauges correctly + +### Grafana evidence + +- `GET /api/datasources` returned both `Loki` and `Prometheus` +- `GET /api/search?query=DevOps` returned both provisioned dashboards + +### Ansible bonus validation + +The monitoring role was extended with Prometheus config, dashboard provisioning, and dual data-source provisioning. + +Syntax check passed: + +```text +playbook: playbooks/deploy-monitoring.yml +``` + +Note: the repo `.vault_pass` helper uses CRLF line endings in this environment, so validation was executed with a temporary LF-normalized copy of the same script content. + +### Screenshots + +Screenshots are provided in `monitoring/docs/screenshots` + +## 8. Metrics vs Logs + +Metrics and logs answer different questions: + +- Metrics are best for trends, rates, latency, uptime, and alert conditions +- Logs are best for event details, request context, stack traces, and debugging specific failures + +Examples from this lab: + +- Use Prometheus for request rate, p95 latency, and uptime +- Use Loki for request details, log levels, and structured error investigation + +Together they give a more complete observability picture than either one alone. + +## 9. Challenges & Solutions + +1. Grafana upgrade compatibility from Lab 7 + - Problem: the persisted Grafana DB already contained a manually created Loki data source with a generated UID, and fixed UIDs in provisioning caused Grafana startup failure. + - Solution: switched datasource provisioning to name-based configuration instead of hard-coded UIDs. + +2. Promtail healthcheck failure + - Problem: the `grafana/promtail:3.0.0` image does not include `wget`, so the original healthcheck always failed. + - Solution: replaced it with a `bash` `/dev/tcp` readiness probe that works with the shipped image. + +3. Ansible vault helper on WSL/Windows filesystem + - Problem: `.vault_pass` has CRLF line endings, which breaks `/usr/bin/env bash`. + - Solution: used a temporary LF-normalized copy for syntax validation without changing the repo helper. + +## 10. Summary + +Lab 8 is implemented end to end: + +- Python app instrumented with Prometheus metrics +- Prometheus added to the monitoring stack and scraping all required targets +- Grafana provisioned with Prometheus and Loki data sources +- Custom metrics and logs dashboards provisioned automatically +- Health checks, limits, retention, and persistence configured +- Ansible monitoring role extended for the bonus path diff --git a/monitoring/docs/screenshots/.gitkeep b/monitoring/docs/screenshots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/monitoring/docs/screenshots/Screenshot 2026-03-12 223934.png b/monitoring/docs/screenshots/Screenshot 2026-03-12 223934.png new file mode 100644 index 0000000000..4156509785 Binary files /dev/null and b/monitoring/docs/screenshots/Screenshot 2026-03-12 223934.png differ diff --git a/monitoring/docs/screenshots/Screenshot 2026-04-09 211052.png b/monitoring/docs/screenshots/Screenshot 2026-04-09 211052.png new file mode 100644 index 0000000000..d0631a8bcd Binary files /dev/null and b/monitoring/docs/screenshots/Screenshot 2026-04-09 211052.png differ diff --git a/monitoring/docs/screenshots/Screenshot 2026-04-09 211754.png b/monitoring/docs/screenshots/Screenshot 2026-04-09 211754.png new file mode 100644 index 0000000000..dfb6b69a17 Binary files /dev/null and b/monitoring/docs/screenshots/Screenshot 2026-04-09 211754.png differ diff --git a/monitoring/docs/screenshots/Screenshot 2026-04-09 211915.png b/monitoring/docs/screenshots/Screenshot 2026-04-09 211915.png new file mode 100644 index 0000000000..2d6f5f6a02 Binary files /dev/null and b/monitoring/docs/screenshots/Screenshot 2026-04-09 211915.png differ diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000000..b6e32b4c2f --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: default + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/loki.yml b/monitoring/grafana/provisioning/datasources/loki.yml new file mode 100644 index 0000000000..11f3fa87af --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: false + editable: true diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000000..1a57b69c8a --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..f8b2c0a3f0 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,39 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + delete_request_store: filesystem + +limits_config: + retention_period: 168h diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..d6b362228f --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,27 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: + - localhost:9090 + + - job_name: app + metrics_path: /metrics + static_configs: + - targets: + - app-python:5000 + + - job_name: loki + metrics_path: /metrics + static_configs: + - targets: + - loki:3100 + + - job_name: grafana + metrics_path: /metrics + static_configs: + - targets: + - grafana:3000 diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..687e8f430d --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,33 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: + - logging=promtail + pipeline_stages: + - docker: {} + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: /(.*) + target_label: container + - source_labels: [__meta_docker_container_label_app] + target_label: app + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: service + - source_labels: [__meta_docker_container_id] + target_label: container_id + - target_label: job + replacement: docker diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..45e3ed234c --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,52 @@ +# Lab 04 Local VM (WSL) Mode + +This project is configured for the local VM alternative from `labs/lab04.md`. +Instead of a cloud VM, use your WSL instance as the host for Lab 4/5 practice. + +## What this means for Lab 4 + +- Task 1: document local host setup (WSL) and SSH accessibility. +- Task 2: document that cloud recreation with Pulumi is skipped in local mode, or + recreate an equivalent local flow if your instructor requires it. +- Task 3: fill `docs/LAB04.md` with evidence and command outputs. + +## WSL setup checklist + +Run in WSL: + +```bash +sudo apt update +sudo apt install -y openssh-server +mkdir -p ~/.ssh +chmod 700 ~/.ssh +``` + +Add your public key: + +```bash +cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +``` + +Enable/start SSH service: + +```bash +sudo service ssh start +sudo service ssh status +``` + +Verify SSH: + +```bash +ssh -o StrictHostKeyChecking=no "$USER@localhost" +``` + +## Suggested evidence to collect + +- `uname -a` +- `lsb_release -a` +- `ip a` +- `sudo service ssh status` +- successful SSH command output + +Use these outputs in `docs/LAB04.md`. diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..7d7164e95b --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,7 @@ +terraform { + required_version = ">= 1.9.0" +} + +# Local VM alternative: +# This repository uses WSL as the lab host instead of a cloud VM. +# Cloud provider resources are intentionally not declared here. diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..5be07f1c60 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,3 @@ +# Local VM alternative: +# No terraform.tfvars is required for Terraform in this mode. +# Leave this file as documentation marker for the lab structure. diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..455081bd52 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,3 @@ +# Local VM alternative: +# No input variables are required for Terraform in this mode. +# Keep this file so the terraform/ structure still matches the lab layout.