diff --git a/.github/workflows/ansible-deploy.yml b/.github/workflows/ansible-deploy.yml new file mode 100644 index 0000000000..3bd3d06fd5 --- /dev/null +++ b/.github/workflows/ansible-deploy.yml @@ -0,0 +1,132 @@ +name: Ansible Deployment + +on: + workflow_dispatch: + push: + branches: [ main, master, lab06 ] + paths: + - "ansible/**" + - ".github/workflows/ansible-deploy.yml" + pull_request: + branches: [ main, master ] + paths: + - "ansible/**" + - ".github/workflows/ansible-deploy.yml" + +permissions: + contents: read + +env: + ANSIBLE_DIR: ansible + APP_PORT: "5000" + +jobs: + lint: + name: Ansible Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.ANSIBLE_DIR }} + 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 ansible-lint + run: | + python -m pip install --upgrade pip + pip install ansible ansible-lint + ansible-galaxy collection install -r requirements.yml + + - name: Run ansible-lint + run: | + ansible-lint playbooks/*.yml \ + -x yaml[truthy],yaml[line-length],yaml[empty-lines],var-naming,key-order,name,ignore-errors,no-changed-when,fqcn,command-instead-of-module + + deploy: + name: Deploy Application + needs: lint + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.ANSIBLE_DIR }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate required deploy secrets + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + VM_HOST: ${{ secrets.VM_HOST }} + VM_USER: ${{ secrets.VM_USER }} + run: | + missing=0 + [ -z "$SSH_PRIVATE_KEY" ] && echo "Missing secret: SSH_PRIVATE_KEY" && missing=1 + [ -z "$VM_HOST" ] && echo "Missing secret: VM_HOST" && missing=1 + [ -z "$VM_USER" ] && echo "Missing secret: VM_USER" && missing=1 + if [ "$missing" -eq 1 ]; then + echo "Required deploy secrets are not configured." + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ansible and collections + run: | + python -m pip install --upgrade pip + pip install ansible + ansible-galaxy collection install -r requirements.yml + + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + VM_HOST: ${{ secrets.VM_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$VM_HOST" >> ~/.ssh/known_hosts + + - name: Prepare Ansible Vault password file (optional) + env: + ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} + run: | + if [ -n "$ANSIBLE_VAULT_PASSWORD" ]; then + echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass + fi + + - name: Deploy with Ansible + env: + VM_HOST: ${{ secrets.VM_HOST }} + VM_USER: ${{ secrets.VM_USER }} + run: | + if [ -f /tmp/vault_pass ]; then + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file /tmp/vault_pass + else + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini + fi + + - name: Cleanup Vault password file + if: always() + run: | + rm -f /tmp/vault_pass + + - name: Verify Deployment + env: + VM_HOST: ${{ secrets.VM_HOST }} + run: | + sleep 10 + curl -f "http://${VM_HOST}:${APP_PORT}/" >/dev/null + curl -f "http://${VM_HOST}:${APP_PORT}/health" >/dev/null + diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..d8b47915b5 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,107 @@ +name: Python CI (app_python) + +on: + push: + # Run tests on any branch when Python app or workflow changes + branches: + - "**" + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: + - "**" + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +permissions: + contents: read + +env: + PYTHON_APP_DIR: app_python + IMAGE_NAME: devops-info-service + +jobs: + test: + name: Lint & Test (pytest) + runs-on: ubuntu-latest + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + defaults: + run: + working-directory: ${{ env.PYTHON_APP_DIR }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Security scan (Snyk) + if: ${{ env.SNYK_TOKEN != '' }} + uses: snyk/actions/python@master + with: + command: test + args: --file=app_python/requirements.txt --package-manager=pip --skip-unresolved=true + + - name: Lint (ruff) + run: | + ruff check . + + - name: Unit tests (pytest) + run: | + pytest + + docker: + name: Docker build & push (CalVer) + runs-on: ubuntu-latest + needs: test + # Only publish images from main/master/lab03 branches on push + if: > + github.event_name == 'push' && + (github.ref == 'refs/heads/master' || + github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/lab03') + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute CalVer tags (UTC) + id: calver + run: | + echo "date=$(date -u +'%Y.%m.%d')" >> "$GITHUB_OUTPUT" + echo "month=$(date -u +'%Y.%m')" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./${{ env.PYTHON_APP_DIR }} + file: ./${{ env.PYTHON_APP_DIR }}/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ steps.calver.outputs.date }} + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ steps.calver.outputs.month }} + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30d74d2584..0e5ec01323 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,26 @@ -test \ No newline at end of file +# Terraform (root-level convenience; also see terraform/.gitignore) +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +*.tfvars.json + +# Credentials / keys (keep patterns narrow to avoid hiding legit JSON) +*.pem +*.key +credentials +terraform/*.json +terraform/**/*.json + +# Pulumi +pulumi/venv/ +Pulumi.*.yaml +.pulumi/ + +# Ansible +*.retry +.vault_pass +ansible/inventory/*.pyc +__pycache__/ \ No newline at end of file diff --git a/WORKERS.md b/WORKERS.md new file mode 100644 index 0000000000..62f07eddbb --- /dev/null +++ b/WORKERS.md @@ -0,0 +1,235 @@ +# Lab 17 — Cloudflare Workers Edge Deployment + +This document records the `edge-api` Worker: configuration, public URL, HTTP behaviour, and operational evidence for the course lab. + +--- + +## 1. Cloudflare setup (Task 1) + +- Cloudflare account with Workers enabled; CLI authenticated with **`npx wrangler login`**. +- **`npx wrangler whoami`** confirms an OAuth token and account access. +- **workers.dev:** account subdomain **`mararokkel-workers`**. Workers are served at **`https://.mararokkel-workers.workers.dev`**. +- Project root: **`edge-api/`** — `src/index.ts` (handler), **`wrangler.jsonc`** (name, `compatibility_date`, `vars`, observability). + +--- + +## Deployment summary + +| Item | Value | +|------|--------| +| **Public URL** | `https://edge-api.mararokkel-workers.workers.dev` | +| **Worker name** | `edge-api` (`wrangler.jsonc` → `name`) | +| **Account workers.dev host** | `mararokkel-workers.workers.dev` | +| **Bindings** | `env.APP_NAME`, `env.COURSE_NAME` (plaintext `vars`); **`API_TOKEN`**, **`ADMIN_EMAIL`** (secrets); **`SETTINGS`** (KV) | +| **Version ID (current, 100% traffic)** | `2b30b82b-7ab8-4bf4-a97b-5c096db0bd9f` | +| **Version ID (superseded by rollback, 2026-05-14)** | `38eec0b5-6f15-4154-a6fd-f23e8c1fa5b6` | +| **Version ID (earlier deploy)** | `06357887-b67a-4b2b-bd5b-558a1fce9538` | +| **Version ID (initial deploy)** | `d40848fa-98df-40b6-a241-00c2935a722c` | + +Traffic was rolled back from **`38eec0b5-…`** to **`2b30b82b-…`** with `npx wrangler rollback` (details under **Deployments and rollback** below); KV and secrets bindings stayed the same. + +**Configuration (`edge-api/wrangler.jsonc`):** `compatibility_date` **2025-05-01**, observability enabled, **`vars`:** `APP_NAME` = `edge-api`, `COURSE_NAME` = `devops-core`; **`kv_namespaces`:** binding **`SETTINGS`**, id **`357a049dc0484faeaecdc8025341583f`**. Secrets are not stored in this file. + +--- + +## 2. HTTP API (Task 2) + +**Source:** `edge-api/src/index.ts` +**Local dev:** `npx wrangler dev` → `http://127.0.0.1:8787` + +| Method | Path | Response | +|--------|------|------------| +| GET | `/health` | `{"status":"ok"}` | +| GET | `/` | JSON: `app`, `course`, `message`, `timestamp` | +| GET | `/deploy-info` | JSON: `worker`, `course`, `runtime`, `compatibilityDate`, `observedAt` | +| GET | `/edge` | JSON from `request.cf`: `colo`, `country`, `city`, `asn`, `httpProtocol`, `tlsVersion` | +| GET | `/secrets-status` | JSON: secret presence flags and `apiTokenLength` (no raw secrets) | +| GET | `/counter` | JSON: KV-backed `visits` counter | +| * | other | `404 Not Found` | + +### Production checks + +Commands (host matches the deployed URL above): + +```bash +curl -sS "https://edge-api.mararokkel-workers.workers.dev/health" +curl -sS "https://edge-api.mararokkel-workers.workers.dev/" +curl -sS "https://edge-api.mararokkel-workers.workers.dev/deploy-info" +curl -sS "https://edge-api.mararokkel-workers.workers.dev/edge" +curl -sS "https://edge-api.mararokkel-workers.workers.dev/counter" +curl -sS "https://edge-api.mararokkel-workers.workers.dev/secrets-status" +curl -sS -o /dev/null -w "%{http_code}\n" "https://edge-api.mararokkel-workers.workers.dev/unknown-route" +``` + +**Observed responses (2026-05-14):** + +```text +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/health" +{"status":"ok"} + +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/" +{"app":"edge-api","course":"devops-core","message":"edge-api worker","timestamp":"2026-05-14T17:24:58.998Z"} + +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/deploy-info" +{"worker":"edge-api","course":"devops-core","runtime":"cloudflare-workers","compatibilityDate":"2025-05-01","observedAt":"2026-05-14T17:25:02.873Z"} + +$ curl -sS -o /dev/null -w "%{http_code}\n" "https://edge-api.mararokkel-workers.workers.dev/unknown-route" +404 + +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/edge" +{"colo":"CDG","country":"FR","city":"Paris","asn":56971,"httpProtocol":"HTTP/2","tlsVersion":"TLSv1.3"} +``` + +The first `curl` to `/edge` before this deploy returned **404 Not Found** (previous bundle had no route); after **`npx wrangler deploy`** the handler served edge metadata (POP **CDG**, country **FR**, city **Paris**, ASN **56971**, **HTTP/2**, **TLSv1.3**). + +Right after the first deploy, **`curl` to the public URL could hang** until DNS for the new `workers.dev` record propagated; retries after a few minutes succeeded as shown above. + +### Local development (browser) + +![Local Wrangler dev — root response over HTTP](./edge-api/screenshots/workers-wrangler-dev-local.png) + +--- + +## 3. Global edge behaviour (Task 3) + +**Route:** `GET /edge` — returns **`request.cf`** fields: `colo`, `country`, `city`, `asn`, `httpProtocol`, `tlsVersion` (null where the runtime does not populate them, e.g. some local `wrangler dev` cases). + +```bash +curl -sS "https://edge-api.mararokkel-workers.workers.dev/edge" +``` + +**Sample production response** (captured when **`06357887-b67a-4b2b-bd5b-558a1fce9538`** was live; the same `/edge` shape applies on the current bundle): + +```json +{"colo":"CDG","country":"FR","city":"Paris","asn":56971,"httpProtocol":"HTTP/2","tlsVersion":"TLSv1.3"} +``` + +The response shows Cloudflare attaching **edge request metadata** (`colo`, `country`, etc.) to the `Request` object at the POP that handled the call (here **CDG** / **Paris**, **FR**). + +**Edge vs regions:** Workers run close to the client on Cloudflare’s network; you do not pick “three regions” per deploy the way you might with VMs or many PaaS defaults—the platform schedules isolates across the edge. + +**workers.dev** — public hostname under `*.workers.dev`. **Routes** — attach a Worker to URLs in a zone managed in Cloudflare. **Custom domains** — serve a custom hostname as the Worker origin; this deployment used **`workers.dev`** only. + +--- + +## 4. Configuration, secrets, and KV (Task 4) + +### Plaintext `vars` + +`wrangler.jsonc` defines **`APP_NAME`** and **`COURSE_NAME`** under **`vars`**. They are visible in Git and in the dashboard configuration, so they must not hold credentials—only **Secrets** (or other secret stores) are appropriate for tokens and private addresses. + +### Secrets + +| Name | Role | +|------|------| +| **`API_TOKEN`** | Uploaded with **`npx wrangler secret put API_TOKEN`** (value not stored in the repo). | +| **`ADMIN_EMAIL`** | Uploaded with **`npx wrangler secret put ADMIN_EMAIL`**; value stays in Cloudflare only. | + +The Worker exposes **`GET /secrets-status`**, which returns booleans and the **length** of `API_TOKEN` only—never the raw secret strings. + +### Workers KV + +| Field | Value | +|-------|--------| +| Namespace title | `SETTINGS` | +| Namespace ID | `357a049dc0484faeaecdc8025341583f` | +| Binding | `SETTINGS` → `env.SETTINGS` (`KVNamespace`) | + +`wrangler.jsonc` includes the `kv_namespaces` block above (added by Wrangler when the namespace was created). For **`npx wrangler dev`**, **remote KV** was left disabled (**no**), so local runs use a local KV simulation; production uses the remote namespace. + +### API + +- **`GET /counter`** — reads key **`visits`** from `SETTINGS`, increments, writes back, returns `{ visits, key }`. +- **`GET /secrets-status`** — proves secrets are bound without leaking values. + +### Verification + +Secrets **`API_TOKEN`** and **`ADMIN_EMAIL`** were uploaded with **`wrangler secret put`** (values only in Cloudflare). **`GET /counter`** was called repeatedly on the public URL until **`visits`** reached **3**; after **`npx wrangler deploy`** the counter read **4** on the next request, so KV was not reset by redeploy. **`GET /secrets-status`** confirms both secrets are bound without exposing values (transcript below). + +### Production evidence (2026-05-14) + +```text +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/secrets-status" +{"apiTokenConfigured":true,"adminEmailConfigured":true,"apiTokenLength":25} + +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/counter" +{"visits":1,"key":"visits"} +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/counter" +{"visits":2,"key":"visits"} +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/counter" +{"visits":3,"key":"visits"} + +$ npx wrangler deploy +… Current Version ID: 2b30b82b-7ab8-4bf4-a97b-5c096db0bd9f + +$ curl -sS "https://edge-api.mararokkel-workers.workers.dev/counter" +{"visits":4,"key":"visits"} +``` + +--- + +## 5. Observability and operations + +Logging in code (`edge-api/src/index.ts`): + +```ts +console.log("path", url.pathname, "colo", request.cf?.colo); +console.log("method", request.method); +``` + +### 5.1 Tail logs (CLI) + +`npx wrangler tail` was run from **`edge-api/`** while a second shell sent traffic to production: + +```bash +BASE="https://edge-api.mararokkel-workers.workers.dev" +curl -sS "$BASE/health" >/dev/null +curl -sS "$BASE/counter" >/dev/null +``` + +Tail lines included **`path`**, **`colo`**, and **`method`** for those requests. + +![Wrangler tail sample](./edge-api/screenshots/workers-wrangler-tail.png) + +### 5.2 Metrics (dashboard) + +In [Cloudflare dashboard](https://dash.cloudflare.com/) → **Workers & Pages** → **edge-api** → **Metrics**, the **Requests** series was reviewed: it shows total HTTP requests served by the Worker in the selected interval, together with error and resource charts for the same window. + +![Worker metrics](./edge-api/screenshots/workers-dashboard-metrics.png) + +### 5.3 Deployments and rollback + +Deployment history from the CLI: + +```bash +cd edge-api +npx wrangler deployments list +``` + +![Deployments list](./edge-api/screenshots/workers-deployments-list.png) + +**Rollback:** `npx wrangler rollback` (interactive) was used on **2026-05-14** with message `Rollback`. Traffic moved from **`38eec0b5-6f15-4154-a6fd-f23e8c1fa5b6`** to **`2b30b82b-7ab8-4bf4-a97b-5c096db0bd9f`**. That operation creates a new deployment that points 100% of traffic at the chosen older Worker version; KV and secrets bindings are unchanged ([Cloudflare rollbacks](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/)). + +![Wrangler rollback CLI](./edge-api/screenshots/workers-rollback.png) + +--- + +## 6. Kubernetes vs Cloudflare Workers (Task 6) + +| Aspect | Kubernetes | Cloudflare Workers | +|--------|--------------|---------------------| +| Setup complexity | Cluster, networking, manifests/Helm, often CI | Account + Wrangler + small project | +| Deployment speed | Image build, push, rollout minutes–tens of min | Seconds for script upload | +| Global distribution | Multi-region clusters or traffic steering | Automatic edge placement | +| Cost (small apps) | Control plane + nodes even when idle | Generous free tier; pay per use at scale | +| State / persistence | PVCs, DBs, operators | KV, D1, R2, Durable Objects; not arbitrary POSIX | +| Control / flexibility | Full OS, any binary, sidecars | Sandboxed JS/TS (or limited WASM), platform APIs | +| Best use case | Stateful systems, batch, arbitrary containers | HTTP APIs, routing, edge logic close to users | + +**When to prefer Kubernetes:** long-running services, databases on cluster, custom networking, workloads that need a full container image. + +**When to prefer Workers:** latency-sensitive HTTP at the edge, small APIs, global fan-out without managing regions. + +**Conclusion:** Workers fits this lab’s HTTP API; Kubernetes remains appropriate when the workload needs a full container, in-cluster state, or control planes already used elsewhere in the course. + +**Reflection:** Workers avoids image builds and node pools but limits runtime and persistence compared to Kubernetes; deployment is a Workers bundle, not the Lab 2 container image reused as-is. diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..8ed55f0068 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,36 @@ +# Ansible — Lab 5 + +[![Ansible Deployment](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) + +## Quick start + +1. **Install Ansible** (macOS: `brew install ansible`). Check: `ansible --version`. + +2. **Install collections:** + ```bash + ansible-galaxy collection install -r requirements.yml + ``` + +3. **Set your VM IP** in `inventory/hosts.ini`: replace `` with the VM public IP (e.g. from `terraform output vm_public_ip`). + +4. **Test connectivity:** + ```bash + cd ansible + ansible all -m ping + ansible webservers -a "uname -a" + ``` + +5. **Provision (common + Docker):** + ```bash + ansible-playbook playbooks/provision.yml + ``` + Run it again to confirm idempotency (all tasks should be `ok` on the second run). + +6. **Vault and deploy:** + - Create encrypted variables: `ansible-vault create group_vars/all.yml` + - Use the content from `group_vars/all.yml.example` (set your Docker Hub username and access token). + - Deploy: `ansible-playbook playbooks/deploy.yml --ask-vault-pass` + +7. **Verify:** `ansible webservers -a "docker ps"`, then `curl http://:5000/health`. + +Documentation: `docs/LAB05.md`. 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..627f96132f --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,217 @@ +# Lab 5 — Ansible Fundamentals + +## 1. Architecture Overview + +- **Ansible version:** `ansible [core 2.20.2]` (Python 3.14.3, Homebrew install on macOS). +- **Target VM OS:** Ubuntu 25.04 aarch64 (Ubuntu reports that this release is end‑of‑life; Docker is installed from the `noble` repository to stay on a supported Docker CE channel). +- **Topology:** + - Control node: macOS host with Ansible, Docker CLI and project sources. + - Target node: local Ubuntu VM reachable over SSH (`maria@192.168.64.6`) with password sudo. + - Application: containerised Python service exposed on `http://192.168.64.6:5000`. +- **Role structure:** + - `common` — baseline system provisioning (APT cache, common packages, timezone). + - `docker` — Docker CE installation and configuration. + - `app_deploy` — pulling and running the application container, plus health checks. +- **Why roles instead of a single playbook:** roles keep provisioning, Docker setup and application deployment cleanly separated, reusable, and easier to test in isolation. Playbooks (`provision.yml`, `deploy.yml`, `site.yml`) become very short and only orchestrate which roles to run. + +## 2. Roles Documentation + +### common + +- **Purpose:** Provide a minimal, consistent base configuration on every host before any application‑specific logic runs. +- **Main tasks:** + - Remove a broken HashiCorp APT source file (`/etc/apt/sources.list.d/hashicorp.list`) if present, to avoid failing `apt update`. + - Refresh APT cache with `cache_valid_time: 3600`. + - Install a configurable list of baseline tools (`python3-pip`, `curl`, `git`, `vim`, `htop`, etc.). + - Detect the current timezone using `timedatectl` and change it only when it differs from the desired value. +- **Variables (defaults):** + - `common_packages` — list of Debian packages to ensure present. + - `common_timezone` — system timezone (default `"UTC"`). +- **Handlers:** none. +- **Dependencies:** none; this role is expected to run first on every host. + +### docker + +- **Purpose:** Install a working Docker engine on the Ubuntu VM, configure the official Docker APT repository and enable non‑root Docker usage for the SSH user. +- **Main tasks:** + - Install APT prerequisites (`ca-certificates`, `curl`, `gnupg`). + - Ensure `/etc/apt/keyrings` exists and download the Docker GPG key there. + - Configure the Docker APT repository with an architecture‑aware `arch=` value and a safe `signed-by=` reference to the keyring. For unsupported Ubuntu releases, fall back to using `noble` as the repo codename. + - Install Docker CE packages (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`) and keep them in the desired state. + - Enable and start the `docker` systemd service. + - Add the SSH user to the `docker` group so that Docker commands can be run without `sudo`. + - Install `python3-docker` so Ansible Docker modules can work on the target host. +- **Variables (defaults):** + - `docker_group_user` — user that should be added to the `docker` group (derived from Ansible facts). + - `docker_install_state` — package state, `present` by default. + - `docker_apt_release` — codename used for the Docker APT repo (defaults to the host’s distribution release, but a fact‑driven helper chooses a supported fallback if needed). +- **Handlers:** + - `restart docker` — restarts the Docker service whenever the Docker repo or packages change. +- **Dependencies:** assumes the `common` role has run and APT is healthy. + +### app_deploy + +- **Purpose:** Log in to Docker Hub, pull the application image, manage the lifecycle of the application container, and validate that the service is healthy. +- **Main tasks:** + - Authenticate to Docker Hub using credentials stored in Ansible Vault (`docker_login` with `no_log: true` to avoid leaking secrets). + - Pull the configured image and tag from Docker Hub (`docker_image`). + - Attempt to stop any existing container with the same name (errors are ignored on first deploy). + - Remove any previous container with the same name to avoid conflicting state. + - Run the container with a stable name, port mapping `5000:5000`, restart policy, and optional environment variables. + - Wait for the application port to become available on the target host. + - Call the `/health` HTTP endpoint and assert HTTP 200 to verify the deployment. +- **Variables (from Vault / group vars):** + - `dockerhub_username` — Docker Hub username. + - `dockerhub_password` — Docker Hub personal access token. + - `app_name` — logical application name, also used as the container name. + - `docker_image` — full image name, e.g. `mararokkel/devops-info-service`. + - `docker_image_tag` — image tag, `arm64` in this deployment. + - `app_port` — container and host port, `5000`. + - `app_container_name` — container name (`devops-info-service`). +- **Variables (defaults):** + - `app_restart_policy` — Docker restart policy, `unless-stopped`. + - `app_env` — optional environment variables for the container (empty map by default). +- **Handlers:** + - `restart app container` — restarts the existing application container using `docker restart` when notified. +- **Dependencies:** expects Docker to be installed and running (i.e. `docker` role has completed successfully). + +## 3. Idempotency Demonstration + +Provisioning was executed twice using the same `playbooks/provision.yml` playbook. + +- **First run (provision.yml):** + - `common` role: + - removed the broken HashiCorp APT source file; + - updated the APT cache (after several retries due to the invalid HashiCorp repo); + - installed all `common_packages`; + - detected the current timezone and changed it to the configured value. + - `docker` role: + - installed Docker prerequisites; + - configured the Docker APT repository and keyring; + - installed Docker CE packages; + - enabled and started the Docker service; + - added user `maria` to the `docker` group; + - installed `python3-docker`. + - The final recap showed `changed` for system setup and Docker configuration tasks, as expected for the initial provisioning. + +- **Second run (provision.yml):** + - APT cache was already up to date, packages were installed, timezone matched the configured value, the Docker repository and keyring were present, Docker CE packages were installed, and the service was already running. + - All tasks reported `ok` with **`changed=0`** in the play recap, and the timezone task was skipped because the current timezone matched the target value. + +- **Why the roles are idempotent:** + - Package installation uses `state: present`, so packages are only installed when missing. + - The timezone is only changed when `timedatectl` reports a different value. + - The Docker APT repository and keyring tasks use declarative modules (`apt_repository`, `file`, `get_url`), which only make changes when the configuration actually differs. + - Group membership is managed with `append: yes`, so re‑running the play does not duplicate or remove users from other groups. + - Systemd service management uses `state: started` and `enabled: yes`, which become no‑ops when Docker is already running and enabled. + +## 4. Ansible Vault Usage + +- **What is encrypted:** + - Docker Hub credentials and application‑specific configuration are stored in `group_vars/all.yml`, which is encrypted with Ansible Vault (`ansible-vault create group_vars/all.yml`). + - The file contains values such as: + + ```yaml + dockerhub_username: mararokkel + dockerhub_password: + app_name: devops-info-service + docker_image: "{{ dockerhub_username }}/{{ app_name }}" + docker_image_tag: arm64 + app_port: 5000 + app_container_name: "{{ app_name }}" + ``` + +- **How it is used:** + - Playbooks are executed with `--ask-vault-pass`, and the deploy playbook explicitly includes the Vault file via `vars_files: ../group_vars/all.yml` in `playbooks/deploy.yml`. + - The `app_deploy` role reads `dockerhub_username` and `dockerhub_password` to perform `docker_login`, and uses the image variables to pull and run the correct container. + - Tasks that handle credentials are marked with `no_log: true`, so secrets do not appear in Ansible output or logs. + +- **Vault password handling:** + - Vault password is supplied interactively for this lab (`--ask-vault-pass`). + - `.vault_pass` is explicitly ignored in `.gitignore` to prevent committing any local password files. + - Only the encrypted Vault file is committed to Git; no plaintext secret files are tracked. + +- **Why Vault is important:** + - It allows Docker Hub tokens and other secrets to live in version control without exposing them in plaintext. + - Multiple environments can share the same playbooks and roles while keeping different credentials encrypted with different Vault passwords. + - Combined with `no_log: true`, Vault ensures that secrets are not printed to CI logs or shared accidentally during troubleshooting. + +## 5. Deployment Verification + +Application deployment was executed with: + +```bash +ansible-playbook playbooks/deploy.yml --ask-become-pass --ask-vault-pass +``` + +Key parts of the output: + +- **Docker Hub login and image pull:** + - `app_deploy : Log in to Docker Hub` → `ok` + - `app_deploy : Pull Docker image` → `changed`, pulling `mararokkel/devops-info-service:arm64` onto the Ubuntu VM. + +- **Container lifecycle:** + - On the first run, an attempt to stop a non‑existent container produced a handled error (`Cannot create container when image is not specified!`), which was ignored as expected. + - `app_deploy : Remove old container if exists` → `ok`. + - `app_deploy : Run application container` → `changed`, starting the container with name `devops-info-service` and port mapping `0.0.0.0:5000->5000/tcp`. + +- **Runtime verification via Ansible:** + + Running: + + ```bash + ansible webservers -a "docker ps" --ask-become-pass --ask-vault-pass + ``` + + produced: + + ```text + CONTAINER ID IMAGE COMMAND PORTS NAMES + fd99fbbef119 mararokkel/devops-info-service:arm64 "python app.py" 0.0.0.0:5000->5000/tcp devops-info-service + ``` + +- **HTTP health checks from the control node:** + + ```bash + curl http://192.168.64.6:5000/health + ``` + + returned: + + ```json + {"status":"healthy","timestamp":"2026-02-24T08:03:38.148803+00:00","uptime_seconds":248} + ``` + + and: + + ```bash + curl http://192.168.64.6:5000/ + ``` + + returned a JSON document describing the service, including: + - service name `devops-info-service` and version `1.0.0`; + - available endpoints (`/` and `/health`); + - runtime information (uptime, current time, timezone `UTC`); + - system details (architecture `aarch64`, kernel version, Python version). + +Together these outputs confirm that the container is running on the VM, the port is exposed correctly, and the application responds successfully on both the health endpoint and the main endpoint. + +## 6. Key Decisions + +- **Why use roles instead of plain playbooks:** separating `common`, `docker`, and `app_deploy` into roles keeps each concern focused and testable. Playbooks become thin orchestration layers that can be combined in different ways (for example, a full site deploy versus provisioning only). + +- **How roles improve reusability:** roles are parameterised through defaults and group variables, so the same `common` and `docker` roles can be re‑used for other labs or projects by only changing inventory and variables, without touching task logic. + +- **What makes a task idempotent:** it checks the existing state and only performs work if something is missing or different (for example, `state: present` for packages, conditional timezone changes, `state: started` for services, and `docker_container` with a fixed name and configuration). Re‑running the same play does not cause further changes when the system already matches the desired state. + +- **How handlers improve efficiency:** handlers run only when notified by tasks that actually changed something (for example, after Docker packages or its repository configuration change). This avoids unnecessary restarts of services and keeps playbook output cleaner. + +- **Why Ansible Vault is necessary:** it allows storing Docker Hub access tokens and other secrets alongside playbooks in Git without exposing them in plaintext, while still making them available to roles at runtime through a controlled decryption mechanism. + +## 7. Challenges + +- **Broken third‑party APT repository:** the VM had an outdated HashiCorp APT entry that caused `apt update` to fail. The `common` role explicitly removes the HashiCorp list file before refreshing the cache. +- **Deprecation of `apt-key` on newer Ubuntu:** the original Docker role used `apt_key`, which is no longer available; the role was updated to use `/etc/apt/keyrings` and `signed-by=` in the Docker APT repo definition. +- **Unsupported Ubuntu release for Docker CE:** the VM is running Ubuntu 25.04 (end‑of‑life), while Docker CE officially supports stable LTS releases. The role derives a supported codename (for example, `noble`) for the Docker repository to ensure package availability. +- **Architecture mismatch for Docker images:** Docker Hub initially only had `amd64` images, while the VM is `arm64`. A new `arm64` image for `mararokkel/devops-info-service` was built on the control node and pushed to Docker Hub, and the deploy role was configured to use the `arm64` tag. +- **SSH and sudo configuration for Ansible:** password‑based SSH was used initially, then SSH keys were added for convenience; `--ask-become-pass` is used so Ansible can run privileged tasks with `sudo` without hard‑coding passwords anywhere in configuration. diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..efcdb2bf63 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,534 @@ +# Lab 6: Advanced Ansible & CI/CD — Submission + +## Overview + +Lab 6 extends Lab 5 Ansible automation with blocks, tags, Docker Compose, wipe logic, and CI/CD. Technologies used: Ansible 2.16+, Docker Compose v2, GitHub Actions, Jinja2. + +--- + +## Task 1: Blocks & Tags (2 pts) + +### common role + +**Blocks:** +- **packages** — HashiCorp cleanup, apt cache update, package installation. Tags: `packages`, `common`. Rescue: `apt-get update --fix-missing` on failure. Always: log to `/tmp/ansible-common-packages.log`. +- **users** — Ensure `common_app_user` exists. Tags: `users`, `common`. Always: log to `/tmp/ansible-common-users.log`. +- **timezone** — Set system timezone. Tag: `common`. + +**Tag strategy:** `packages` | `users` | `common` (entire role). + +### docker role + +**Blocks:** +- **docker_install** — Prerequisites, GPG key, repository, Docker packages. Tags: `docker`, `docker_install`. Rescue: pause 10s + apt update. Always: ensure Docker service enabled and started. +- **docker_config** — Add user to docker group, install `python3-docker`. Tags: `docker`, `docker_config`. Always: ensure Docker service enabled. + +**Tag strategy:** `docker` | `docker_install` | `docker_config`. + +### Execution examples + +```bash +ansible-playbook playbooks/provision.yml --tags "docker" +ansible-playbook playbooks/provision.yml --skip-tags "common" +ansible-playbook playbooks/provision.yml --tags "packages" +ansible-playbook playbooks/provision.yml --tags "docker_install" +ansible-playbook playbooks/provision.yml --list-tags +``` + +### Evidence + +**1. Connectivity check (`ping` module with SSH + sudo passwords)** + +```bash +ansible webservers -m ping -k --ask-become-pass +``` + +```text +lab06-vm | SUCCESS => { + "changed": false, + "ping": "pong" +} +``` + +**2. Listing all available tags for Task 1** + +```bash +ansible-playbook playbooks/provision.yml --list-tags -k --ask-become-pass +``` + +```text +playbook: playbooks/provision.yml + + play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] +``` + +**3. Selective execution — only `docker` role (tags: `docker`, `docker_install`, `docker_config`)** + +```bash +ansible-playbook playbooks/provision.yml --tags "docker" -k --ask-become-pass +``` + +```text +PLAY [Provision web servers] ************************************************************ + +TASK [Gathering Facts] ***************************************************************** +ok: [lab06-vm] + +TASK [docker : Install prerequisites for Docker] *************************************** +changed: [lab06-vm] + +TASK [docker : Choose Docker apt repo release (fallback if unsupported)] ************** +ok: [lab06-vm] + +TASK [docker : Ensure apt keyrings directory exists] *********************************** +ok: [lab06-vm] + +TASK [docker : Download Docker GPG key (keyring)] ************************************** +changed: [lab06-vm] + +TASK [docker : Add Docker repository] ************************************************** +changed: [lab06-vm] + +TASK [docker : Install Docker packages] ************************************************ +changed: [lab06-vm] + +TASK [docker : Ensure Docker service is enabled and started] *************************** +ok: [lab06-vm] + +TASK [docker : Add user to docker group] *********************************************** +changed: [lab06-vm] + +TASK [docker : Install python3-docker for Ansible docker modules] ********************** +changed: [lab06-vm] + +TASK [docker : Ensure Docker service is enabled] *************************************** +ok: [lab06-vm] + +RUNNING HANDLER [docker : restart docker] ********************************************** +changed: [lab06-vm] + +PLAY RECAP ***************************************************************************** +lab06-vm : ok=12 changed=7 unreachable=0 failed=0 +``` + +**4. Selective execution — only `packages` block of `common` role** + +```bash +ansible-playbook playbooks/provision.yml --tags "packages" -k --ask-become-pass +``` + +```text +PLAY [Provision web servers] ************************************************************ + +TASK [Gathering Facts] ***************************************************************** +ok: [lab06-vm] + +TASK [common : Disable broken HashiCorp apt repo (if present)] ************************* +ok: [lab06-vm] + +TASK [common : Update apt cache] ******************************************************* +ok: [lab06-vm] + +TASK [common : Install common packages] ************************************************ +changed: [lab06-vm] + +TASK [common : Log package block completion] ******************************************* +changed: [lab06-vm] + +PLAY RECAP ***************************************************************************** +lab06-vm : ok=5 changed=2 unreachable=0 failed=0 +``` + +**5. Selective execution — skip entire `common` role** + +```bash +ansible-playbook playbooks/provision.yml --skip-tags "common" -k --ask-become-pass +``` + +```text +PLAY [Provision web servers] ************************************************************ + +TASK [Gathering Facts] ***************************************************************** +ok: [lab06-vm] + +TASK [docker : Install prerequisites for Docker] *************************************** +ok: [lab06-vm] + +TASK [docker : Choose Docker apt repo release (fallback if unsupported)] ************** +ok: [lab06-vm] + +TASK [docker : Ensure apt keyrings directory exists] *********************************** +ok: [lab06-vm] + +TASK [docker : Download Docker GPG key (keyring)] ************************************** +ok: [lab06-vm] + +TASK [docker : Add Docker repository] ************************************************** +ok: [lab06-vm] + +TASK [docker : Install Docker packages] ************************************************ +ok: [lab06-vm] + +TASK [docker : Ensure Docker service is enabled and started] *************************** +ok: [lab06-vm] + +TASK [docker : Add user to docker group] *********************************************** +ok: [lab06-vm] + +TASK [docker : Install python3-docker for Ansible docker modules] ********************** +ok: [lab06-vm] + +TASK [docker : Ensure Docker service is enabled] *************************************** +ok: [lab06-vm] + +PLAY RECAP ***************************************************************************** +lab06-vm : ok=11 changed=0 unreachable=0 failed=0 +``` + +--- + +## Task 2: Docker Compose (3 pts) + +### Implementation + +- **Role rename**: Renamed the application role from `app_deploy` to `web_app` and updated `playbooks/deploy.yml` to use the new role name: + +```yaml +roles: + - web_app +``` + +- **Docker Compose template**: Added a Docker Compose template `roles/web_app/templates/docker-compose.yml.j2` with parametrised image, tag, ports and environment: + +```jinja2 +version: "{{ docker_compose_version | default('3.8') }}" + +services: + {{ app_name }}: + image: "{{ docker_image }}:{{ docker_tag | default('latest') }}" + container_name: "{{ app_name }}" + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + {% for key, value in app_env.items() %} + {{ key }}: "{{ value }}" + {% endfor %} + restart: unless-stopped +``` + +- **Defaults for the web app**: Updated role defaults in `roles/web_app/defaults/main.yml`: + +```yaml +app_name: devops-app +docker_image: mararokkel/devops-info-service +docker_tag: arm64 +app_port: 5000 +app_internal_port: 5000 +compose_project_dir: "/opt/{{ app_name }}" +docker_compose_version: "3.8" +app_env: {} +``` + +- **Role dependency**: Added a role dependency in `roles/web_app/meta/main.yml` so that Docker is always installed before the application: + +```yaml +--- +dependencies: + - role: docker +``` + +- **Compose-based deployment**: Replaced the old container-based deployment in `roles/web_app/tasks/main.yml` with a Compose-based block that: + - creates `compose_project_dir`, + - templates `docker-compose.yml`, + - runs `community.docker.docker_compose_v2` with `project_src: "{{ compose_project_dir }}"`, `state: present`, `pull: always`, + - waits for `app_port` with `wait_for`, + - verifies `/health` using `uri`, + - wraps the tasks in `block` + `rescue` and tags them as `app_deploy` and `compose`. + +### Evidence + +**Compose deployment run:** + +```bash +ansible-playbook playbooks/deploy.yml -k --ask-become-pass +``` + +```text +TASK [web_app : Create application directory] ********************************** +ok: [lab06-vm] + +TASK [web_app : Template docker-compose.yml] *********************************** +changed: [lab06-vm] + +TASK [web_app : Deploy application stack with Docker Compose v2] *************** +[WARNING]: Docker compose: unknown None: /opt/devops-app/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion +changed: [lab06-vm] + +TASK [web_app : Wait for application port to be open] ************************** +ok: [lab06-vm] + +TASK [web_app : Verify application health endpoint] **************************** +ok: [lab06-vm] + +PLAY RECAP ********************************************************************* +lab06-vm : ok=16 changed=2 unreachable=0 failed=0 +``` + +**Container running on the VM:** + +```bash +ansible webservers -a "docker ps" -k --ask-become-pass +``` + +```text +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +99b3cf0b319e mararokkel/devops-info-service:arm64 "python app.py" About a minute ago Up About a minute 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp devops-app +``` + +**Application accessibility:** + +```bash +curl http://localhost:5000 +curl http://localhost:5000/health +``` + +```json +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}], ... "service":{"name":"devops-info-service","version":"1.0.0"}, ...} +{"status":"healthy","timestamp":"2026-03-12T14:44:59.585515+00:00","uptime_seconds":83} +``` + +**Idempotency:** A second run of + +```bash +ansible-playbook playbooks/deploy.yml -k --ask-become-pass +``` + +shows that the `docker` role is fully idempotent (all tasks `ok`), and only the templating and Compose tasks in `web_app` may report `changed`, which satisfies the idempotency requirement. + +--- + +## Task 3: Wipe Logic (1 pt) + +### Implementation + +- **Dedicated wipe task file**: Added a dedicated wipe task file `roles/web_app/tasks/wipe.yml`: + +```yaml +--- +- name: Wipe web application + block: + - name: Stop and remove Docker Compose stack + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + ignore_errors: true + + - name: Remove docker-compose.yml file + ansible.builtin.file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ app_name }} wiped successfully" + when: web_app_wipe | default(false) | bool + tags: + - web_app_wipe + ignore_errors: true +``` + +- **Included wipe tasks in role main**: Included wipe tasks at the top of `roles/web_app/tasks/main.yml`: + +```yaml +- name: Include wipe tasks + include_tasks: wipe.yml + tags: + - web_app_wipe +``` + +- **Default guard flag**: Added a default flag to `roles/web_app/defaults/main.yml`: + +```yaml +web_app_wipe: false +``` + +This implements the required **double safety**: wipe logic is guarded by the `web_app_wipe` variable and the `web_app_wipe` tag (for a wipe-only run). By default the application is never removed. + +### Test results + +**Scenario 1 — normal deployment (wipe should not run)** + +```bash +ansible-playbook playbooks/deploy.yml -k --ask-become-pass +``` + +```text +TASK [web_app : Include wipe tasks] ................................ included +TASK [web_app : Stop and remove Docker Compose stack] ............. skipping +TASK [web_app : Remove docker-compose.yml file] ................... skipping +TASK [web_app : Remove application directory] ..................... skipping +TASK [web_app : Log wipe completion] .............................. skipping +PLAY RECAP ........................................................ ok=17 changed=0 failed=0 skipped=4 +``` + +**Scenario 2 — wipe only (remove deployment, no redeploy)** + +```bash +ansible-playbook playbooks/deploy.yml \ + -e "web_app_wipe=true" \ + --tags web_app_wipe \ + -k --ask-become-pass +``` + +```text +TASK [web_app : Include wipe tasks] ............................... included +TASK [web_app : Stop and remove Docker Compose stack] ............. changed +TASK [web_app : Remove docker-compose.yml file] ................... changed +TASK [web_app : Remove application directory] ..................... changed +TASK [web_app : Log wipe completion] .............................. ok ("Application devops-app wiped successfully") +PLAY RECAP ........................................................ ok=6 changed=3 failed=0 +``` + +**Scenario 3 — clean reinstall (wipe → deploy)** + +```bash +ansible-playbook playbooks/deploy.yml \ + -e "web_app_wipe=true" \ + -k --ask-become-pass +``` + +```text +TASK [web_app : Include wipe tasks] ............................... included +TASK [web_app : Stop and remove Docker Compose stack] ............. "/opt/devops-app" is not a directory ... ignoring +TASK [web_app : Remove docker-compose.yml file] ................... ok +TASK [web_app : Remove application directory] ..................... ok +TASK [web_app : Log wipe completion] .............................. ok ("Application {{ app_name }} wiped successfully") +TASK [web_app : Create application directory] ..................... changed +TASK [web_app : Template docker-compose.yml] ...................... changed +TASK [web_app : Deploy application stack with Docker Compose v2] .. changed +TASK [web_app : Wait for application port to be open] ............. ok +TASK [web_app : Verify application health endpoint] ............... ok +PLAY RECAP ........................................................ ok=21 changed=3 failed=0 ignored=1 +``` + +This run first wipes any existing deployment and then performs a fresh Compose-based deploy in the same playbook execution, which matches the required **clean reinstall** behaviour. + +--- + +## Task 4: CI/CD (3 pts) + +### Implementation + +- Created a dedicated workflow `.github/workflows/ansible-deploy.yml` for Ansible: + - triggers on `push` to `main`, `master` and `lab06` when files under `ansible/**` or the workflow itself change; + - triggers on `pull_request` to `main`/`master` for the same paths. +- **Job `lint`**: + - runs on `ubuntu-latest`; + - installs `ansible`, `ansible-lint` and collections from `ansible/requirements.yml`; + - executes `ansible-lint playbooks/*.yml` (with style-only rules excluded). +- **Job `deploy`** (depends on `lint`): + - runs on `ubuntu-latest`; + - sets up SSH using `SSH_PRIVATE_KEY` and `VM_HOST` from GitHub Secrets; + - optionally writes `ANSIBLE_VAULT_PASSWORD` into `/tmp/vault_pass` if the secret is defined; + - runs `ansible-playbook playbooks/deploy.yml -i inventory/hosts.ini` (with or without `--vault-password-file`); + - verifies deployment via `curl -f http://$VM_HOST:5000/` and `/health`. + +Required secrets (repository → Settings → Secrets and variables → Actions): + +- `SSH_PRIVATE_KEY` — private SSH key for the VM; +- `VM_HOST` — VM public IP/hostname; +- `VM_USER` — SSH username (for completeness); +- `ANSIBLE_VAULT_PASSWORD` — optional, used only if Vault is required in CI. + +The workflow badge is exposed in `ansible/README.md`: + +```markdown +[![Ansible Deployment](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/ansible-deploy.yml/badge.svg)](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/ansible-deploy.yml) +``` + +In the current setup the `lint` job passes successfully, while the `deploy` job fails on the SSH setup step because the target VM is on a private `192.168.x.x` network and is not reachable from GitHub-hosted runners. With a cloud VM (public IP) or a self-hosted runner on the target VM, the same workflow would perform a full end-to-end deployment on every push. + +--- + +## Task 5: Documentation + +This file (`ansible/docs/LAB06.md`) serves as the documentation. Code comments added in modified Ansible files. + +--- + +## Testing Results + +The following end-to-end tests were executed: + +- **Task 1 (Blocks & Tags)**: + - Verified connectivity with `ansible webservers -m ping -k --ask-become-pass`. + - Listed available tags with `ansible-playbook playbooks/provision.yml --list-tags -k --ask-become-pass`. + - Tested selective execution using `--tags docker`, `--tags packages`, and `--skip-tags common`, confirming that only the expected blocks and roles ran. + +- **Task 2 (Docker Compose)**: + - Ran `ansible-playbook playbooks/deploy.yml -k --ask-become-pass` twice to confirm idempotent behaviour of the `docker` and `web_app` roles. + - Confirmed the container was running with `ansible webservers -a "docker ps" -k --ask-become-pass`. + - Verified application responses from the VM using `curl http://localhost:5000` and `curl http://localhost:5000/health`. + +- **Task 3 (Wipe Logic)**: + - Normal deployment (wipe skipped): `ansible-playbook playbooks/deploy.yml -k --ask-become-pass`. + - Wipe-only: `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" --tags web_app_wipe -k --ask-become-pass`. + - Clean reinstall (wipe → deploy): `ansible-playbook playbooks/deploy.yml -e "web_app_wipe=true" -k --ask-become-pass`. + +- **Task 4 (CI/CD)**: + - Pushed changes to the `lab06` branch to trigger the `Ansible Deployment` workflow in GitHub Actions. + - The `lint` job passed successfully. + - The `deploy` job failed at the SSH setup step because the target VM is on a private `192.168.64.x` network with no outbound internet access; in a cloud environment or with a self-hosted runner on the VM the same workflow would be able to complete the deployment and verification steps. + +--- + +## Challenges & Solutions + +- **Local VM networking constraints**: The lab used a local VM with a private `192.168.64.x` address and no outbound internet, which prevented both `apt` (for some packages) and GitHub-hosted runners from reaching it. All provisioning and deployment was performed from the laptop via Ansible directly to the VM; for CI/CD the limitation is documented and a self-hosted runner or cloud VM is proposed as the production-ready solution. + +- **Role rename breaking linting**: After renaming `app_deploy` to `web_app`, Ansible and `ansible-lint` reported a missing role in `site.yml`. Updating `playbooks/site.yml` to reference `web_app` fixed the error. + +- **Docker image architecture mismatch**: The initial Compose deployment failed with `no matching manifest for linux/arm64/v8` when using the `latest` tag. Switching to the existing `arm64` tag in `roles/web_app/defaults/main.yml` resolved the issue on the ARM-based VM. + +- **SSH and Vault interaction**: Existing Vault configuration and SSH settings initially blocked even simple Ansible calls. Temporarily moving the encrypted `group_vars/all.yml` aside and using `-k`/`--ask-become-pass` stabilised connectivity; Vault usage in CI is now controlled via an optional `ANSIBLE_VAULT_PASSWORD` secret. + +- **Strict ansible-lint rules**: Default `ansible-lint` reported many style-only violations (truthy values, variable prefixes, key order, long lines). In the CI workflow, non-functional style rules were excluded so that linting focuses on real syntax and structural problems instead of formatting. + +--- + +## Research Answers + +### Task 1 +- **What happens if rescue block also fails?** The play fails; rescue does not re-raise unless a task inside it fails. +- **Can you have nested blocks?** Yes. Blocks can contain other blocks. +- **How do tags inherit to tasks within blocks?** Tags on a block apply to all tasks in that block; tasks inherit them. + +### Task 2 +- **restart: always vs unless-stopped?** `always` — restart even after manual stop. `unless-stopped` — do not restart if user stopped the container. +- **Docker Compose networks vs bridge?** Compose creates user-defined networks; bridge is the default driver. Compose networks allow service discovery by name. +- **Vault variables in Jinja2 templates?** Yes. Vault-decrypted variables are available at play runtime and can be used in templates. + +### Task 3 +- **Why variable AND tag?** Double safety: variable controls intent, tag controls selective execution. Prevents accidental wipe when running with wrong tag. +- **never tag vs this approach?** `never` excludes tasks by default; our approach uses `when` + tag for explicit opt-in. +- **Wipe before deployment in main.yml?** Enables clean reinstall: `-e "web_app_wipe=true"` runs wipe then deploy in one playbook run. +- **Clean reinstall vs rolling update?** Clean reinstall for major changes or broken state; rolling update for minor updates with minimal downtime. +- **Extend to wipe images/volumes?** Add tasks to `docker image prune` and `docker volume rm` when `web_app_wipe: true`. + +### Task 4 +- **SSH keys in GitHub Secrets?** Stored encrypted; injected at runtime. Risk: exposure in logs. Mitigation: `no_log`, short-lived keys, least privilege. +- **Staging → production pipeline?** Separate workflows or environments; promotion via manual approval or different branches; different inventories. +- **Rollbacks?** Tag-based deployment; playbook with previous image tag; or snapshot/backup restore. +- **Self-hosted vs GitHub-hosted security?** Self-hosted keeps secrets on your infra; GitHub-hosted uses ephemeral runners and encrypted secrets. + +--- + +## Summary + +In this lab I refactored existing Ansible roles to use blocks, tags and safer error handling, migrated the application deployment to Docker Compose via a dedicated `web_app` role, implemented double-gated wipe logic, and wired everything into a GitHub Actions workflow. All core playbooks and roles were tested against a local VM, including idempotent Compose deployments and multiple wipe scenarios. Although the CI/CD workflow cannot fully deploy to a private `192.168.64.x` VM from GitHub-hosted runners, it is ready to do so in a cloud or self-hosted runner setup. The main takeaways are how to structure production-ready Ansible roles, handle destructive operations safely, and integrate Ansible into a repeatable CI/CD pipeline. diff --git a/ansible/group_vars/all.yml.bak b/ansible/group_vars/all.yml.bak new file mode 100644 index 0000000000..9c1de0289f --- /dev/null +++ b/ansible/group_vars/all.yml.bak @@ -0,0 +1,21 @@ +$ANSIBLE_VAULT;1.1;AES256 +62346431633832363633346434376361613636643462633364323135386239623661306663363635 +3834383439336166343337636533616530656465303136320a663131646362613436393463373461 +33376131383632366165623638303863333664613838396264623835396363626561313463316361 +6633663430383837310a386330633162373366343763363061316363393730353837356539386132 +33386364663731663163366134336130343737373561376534366262646261346331613334663765 +61353331643536666132343464636538666332623265393962366561373134373533653366656461 +35323965333163313236366537376530356335626431323435326432343235386434376366623337 +64366635323134353932343165343564626163653563656133346362643732656136356431316531 +64303734376466366565393863636464373934306235313632323438626432633035333765613937 +36623739633138336136396136376136643836633465303862316335373936393739343165613662 +35373834366565383562356434306462326361393265306338666539393232386663363638313661 +39653064306138373335333566396337656333356361643062666361653030613930326135353437 +65356133323135656433303334306235313463646561616331613930373139306534666435653532 +64356530383164313839363865356237626262623138633764613936356530343139656161613732 +30336363383534626361346633363764643534653131616566313733643534336163333062386334 +37346239646634623233633165613666343835316237633561356235386237336636313565663266 +34613333353466323736316564613961653335366561353837623366393766633835623165373562 +39636261376566306561653164393737623232646363383165653565326535393535646639373130 +32313561363832346262663332333339646531376466656263613635613133303566326232323532 +64323363636230613530 diff --git a/ansible/group_vars/all.yml.example b/ansible/group_vars/all.yml.example new file mode 100644 index 0000000000..64ac3b5951 --- /dev/null +++ b/ansible/group_vars/all.yml.example @@ -0,0 +1,14 @@ +# Copy this to group_vars/all.yml and encrypt with Ansible Vault: +# ansible-vault create group_vars/all.yml +# Then paste the content below (with your real credentials). +--- +# Docker Hub credentials (use access token, not password) +dockerhub_username: your-username +dockerhub_password: your-access-token + +# Application configuration +app_name: devops-app +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: latest +app_port: 5000 +app_container_name: "{{ app_name }}" diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..8a3ed0a2c8 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +lab06-vm ansible_host=192.168.64.9 ansible_user=maria + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 \ No newline at end of file diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..f3923b77bb --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy application + hosts: webservers + become: true + + vars_files: + - ../group_vars/all.yml + + roles: + - web_app diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..7cc2e6678d --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: true + + roles: + - common + - docker diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..33aabe3ff5 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,10 @@ +--- +# Main playbook - runs full provisioning and deployment +- name: Full site setup + hosts: webservers + become: true + + roles: + - common + - docker + - web_app diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..b3d5ddec5f --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,5 @@ +--- +# Install with: ansible-galaxy collection install -r requirements.yml +collections: + - name: community.docker + version: ">=3.0.0" diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..af9efa7a65 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,18 @@ +--- +# List of packages to install on all servers +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - unzip + - ca-certificates + - gnupg + - lsb-release + +# Timezone (optional) +common_timezone: "UTC" + +# User to ensure exists (for deployment/app) +common_app_user: "deploy" diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..dfafea660e --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,75 @@ +--- +# Role: common — system packages, users, timezone +# Tags: packages, users, common (role-level) + +- name: Install common packages + block: + - name: Disable broken HashiCorp apt repo (if present) + ansible.builtin.file: + path: /etc/apt/sources.list.d/hashicorp.list + state: absent + + - name: Update apt cache + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + + - name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + + rescue: + - name: Retry apt update with fix-missing on failure + ansible.builtin.command: apt-get update --fix-missing + changed_when: false + + always: + - name: Log package block completion + ansible.builtin.copy: + content: "packages block completed at {{ ansible_date_time.iso8601 }}\n" + dest: /tmp/ansible-common-packages.log + mode: "0644" + + when: ansible_os_family == "Debian" + become: true + tags: + - packages + - common + +- name: Manage users + block: + - name: Ensure application user exists + ansible.builtin.user: + name: "{{ common_app_user }}" + system: yes + shell: /usr/sbin/nologin + create_home: no + state: present + + always: + - name: Log users block completion + ansible.builtin.copy: + content: "users block completed at {{ ansible_date_time.iso8601 }}\n" + dest: /tmp/ansible-common-users.log + mode: "0644" + + become: true + tags: + - users + - common + +- name: Set timezone + block: + - name: Get current timezone + ansible.builtin.command: timedatectl show -p Timezone --value + register: current_tz + changed_when: false + + - name: Set timezone + ansible.builtin.command: timedatectl set-timezone "{{ common_timezone }}" + when: current_tz.stdout != common_timezone + + become: true + tags: + - common diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..5bc950a050 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,10 @@ +--- +# User to add to docker group (will run docker without sudo) +docker_group_user: "{{ ansible_user }}" + +# Docker package state +docker_install_state: present + +# If your Ubuntu release is not supported by Docker's apt repo yet, +# set this explicitly (e.g. "noble" for Ubuntu 24.04). +docker_apt_release: "{{ ansible_facts['distribution_release'] | default(ansible_distribution_release) }}" diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..1a5058da5e --- /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..97adc91012 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,103 @@ +--- +# Role: docker — installation and configuration +# Tags: docker, docker_install, docker_config + +- name: Install Docker + block: + - name: Install prerequisites for Docker + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + + - name: Choose Docker apt repo release (fallback if unsupported) + ansible.builtin.set_fact: + docker_apt_release_effective: >- + {{ + ( + docker_apt_release | default(ansible_facts['distribution_release'] | default(ansible_distribution_release)) + ) if ( + (docker_apt_release | default(ansible_facts['distribution_release'] | default(ansible_distribution_release))) in ['jammy', 'noble'] + ) else 'noble' + }} + + - name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Download Docker GPG key (keyring) + 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={{ (ansible_facts['architecture'] | default(ansible_architecture) == 'x86_64') | ternary('amd64', ((ansible_facts['architecture'] | default(ansible_architecture)) == 'aarch64') | ternary('arm64', (ansible_facts['architecture'] | default(ansible_architecture)))) }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ docker_apt_release_effective }} stable" + state: present + filename: docker + notify: restart docker + + - name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: "{{ docker_install_state }}" + update_cache: yes + notify: restart docker + + rescue: + - name: Wait before retry (GPG key / network timeout) + ansible.builtin.pause: + seconds: 10 + prompt: "Retrying Docker GPG/repo setup..." + + - name: Retry apt update + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 0 + + always: + - name: Ensure Docker service is enabled and started + ansible.builtin.service: + name: docker + state: started + enabled: yes + + become: true + tags: + - docker + - docker_install + +- name: Configure Docker + block: + - name: Add user to docker group + ansible.builtin.user: + name: "{{ docker_group_user }}" + groups: docker + append: yes + + - name: Install python3-docker for Ansible docker modules + ansible.builtin.apt: + name: python3-docker + state: present + + always: + - name: Ensure Docker service is enabled + ansible.builtin.service: + name: docker + state: started + enabled: yes + + become: true + tags: + - docker + - docker_config diff --git a/ansible/roles/web_app/defaults/main.yml b/ansible/roles/web_app/defaults/main.yml new file mode 100644 index 0000000000..0d58d2123a --- /dev/null +++ b/ansible/roles/web_app/defaults/main.yml @@ -0,0 +1,19 @@ +--- +# Application configuration +app_name: devops-app +docker_image: mararokkel/devops-info-service +docker_tag: arm64 + +app_port: 5000 +app_internal_port: 5000 + +# Docker Compose configuration +compose_project_dir: "/opt/{{ app_name }}" +docker_compose_version: "3.8" + +# Extra environment variables for the application +app_env: {} + +# Wipe logic control +web_app_wipe: false + diff --git a/ansible/roles/web_app/handlers/main.yml b/ansible/roles/web_app/handlers/main.yml new file mode 100644 index 0000000000..eb2cb7e74c --- /dev/null +++ b/ansible/roles/web_app/handlers/main.yml @@ -0,0 +1,3 @@ +--- +- name: restart app container + ansible.builtin.command: docker restart "{{ app_container_name }}" diff --git a/ansible/roles/web_app/meta/main.yml b/ansible/roles/web_app/meta/main.yml new file mode 100644 index 0000000000..5bc8a25865 --- /dev/null +++ b/ansible/roles/web_app/meta/main.yml @@ -0,0 +1,4 @@ +--- +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..470c8a8f38 --- /dev/null +++ b/ansible/roles/web_app/tasks/main.yml @@ -0,0 +1,51 @@ +--- +- name: Include wipe tasks + include_tasks: wipe.yml + tags: + - web_app_wipe + +- name: Deploy application with Docker Compose + block: + - name: Create application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: directory + mode: "0755" + + - name: Template docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ compose_project_dir }}/docker-compose.yml" + mode: "0644" + + - name: Deploy application stack with Docker Compose v2 + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: present + pull: always + + - name: Wait for application port to be open + ansible.builtin.wait_for: + host: "127.0.0.1" + port: "{{ app_port }}" + delay: 2 + timeout: 60 + + - name: Verify application health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/health" + status_code: 200 + timeout: 5 + register: health_check + changed_when: false + failed_when: health_check.status != 200 + + rescue: + - name: Log Docker Compose deployment failure + ansible.builtin.debug: + msg: "Docker Compose deployment failed for {{ app_name }}" + + tags: + - app_deploy + - compose + diff --git a/ansible/roles/web_app/tasks/wipe.yml b/ansible/roles/web_app/tasks/wipe.yml new file mode 100644 index 0000000000..ab0ba455d2 --- /dev/null +++ b/ansible/roles/web_app/tasks/wipe.yml @@ -0,0 +1,28 @@ +--- +- name: Wipe web application + block: + - name: Stop and remove Docker Compose stack + community.docker.docker_compose_v2: + project_src: "{{ compose_project_dir }}" + state: absent + ignore_errors: true + + - name: Remove docker-compose.yml file + ansible.builtin.file: + path: "{{ compose_project_dir }}/docker-compose.yml" + state: absent + + - name: Remove application directory + ansible.builtin.file: + path: "{{ compose_project_dir }}" + state: absent + + - name: Log wipe completion + ansible.builtin.debug: + msg: "Application {{ app_name }} wiped successfully" + + when: web_app_wipe | default(false) | bool + tags: + - web_app_wipe + ignore_errors: true + 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..c850885d04 --- /dev/null +++ b/ansible/roles/web_app/templates/docker-compose.yml.j2 @@ -0,0 +1,13 @@ +version: "{{ docker_compose_version | default('3.8') }}" + +services: + {{ app_name }}: + image: "{{ docker_image }}:{{ docker_tag | default('latest') }}" + container_name: "{{ app_name }}" + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + {% for key, value in app_env.items() %} + {{ key }}: "{{ value }}" + {% endfor %} + restart: unless-stopped diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..bb24ab550e --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,130 @@ +# DevOps Info Service (Go) + +## Overview + +This is a Go implementation of the **DevOps Info Service**, providing the same two endpoints as the Python version: + +- `GET /` — service, system, runtime, request information, and a list of endpoints +- `GET /health` — simple health status with uptime + +The service is designed for use in DevOps labs and multi-stage Docker builds. + +## Prerequisites + +- Go 1.22+ installed (`go version`) +- macOS / Linux / WSL (or any OS supported by Go) + +## Project Structure + +```text +app_go/ + ├── main.go + ├── go.mod + ├── README.md + └── docs/ + ├── LAB01.md + ├── GO.md + └── screenshots/ +``` + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|----------|------------------------------| +| HOST | 0.0.0.0 | Address to bind the service | +| PORT | 8080 | Port to listen on | + +## Running the Service (development) + +From the `app_go` directory: + +```bash +go run main.go +``` + +With custom host/port: + +```bash +HOST=127.0.0.1 PORT=9090 go run main.go +``` + +## Building the Binary + +From the `app_go` directory: + +```bash +go build -o devops-info-service-go +``` + +Run the binary: + +```bash +./devops-info-service-go +``` + +Or with custom config: + +```bash +HOST=127.0.0.1 PORT=8080 ./devops-info-service-go +``` + +## API Endpoints + +### GET / + +**Request:** + +```bash +curl -s http://127.0.0.1:8080/ | python3 -m json.tool +``` + +Returns JSON with: + +- `service` — name, version, description, framework (`net/http`) +- `system` — hostname, platform, architecture, CPU count, Go version +- `runtime` — uptime, current time (UTC), timezone +- `request` — client_ip, user_agent, method, path +- `endpoints` — list of available endpoints + +### GET /health + +**Request:** + +```bash +curl -s http://127.0.0.1:8080/health | python3 -m json.tool +``` + +Returns JSON: + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T13:17:37.656980Z", + "uptime_seconds": 123 +} +``` + +## Binary Size vs Python + +To compare the Go binary size to the Python version: + +1. **Build the Go binary:** + + ```bash + cd app_go + go build -o devops-info-service-go + ls -lh devops-info-service-go + ``` + +2. **Check approximate footprint of the Python app:** + + ```bash + cd ../app_python + du -sh venv + ``` + +In a typical setup, the single Go binary is much smaller and self-contained compared to the full Python virtual environment, which makes Go attractive for small container images and multi-stage Docker builds. + + diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..bf3eba484e --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,22 @@ +# GO.md — Why Go for the Bonus Task + +## Why Go + +Go is a compiled, statically typed language designed for building simple, fast, and reliable network services — which matches this lab perfectly. + +Key reasons for choosing Go: + +- **Small, self-contained binaries** — easy to ship in Docker images and ideal for multi-stage builds. +- **Fast compilation** — quick feedback loop while developing. +- **Standard library HTTP server** — `net/http` provides everything needed for simple REST-style services. +- **Cross-platform** — the same code works on macOS, Linux, and Windows with minimal changes. + +## Comparison with Python Version + +- **Startup**: the Go binary starts instantly, without requiring a virtual environment or interpreter. +- **Deployment**: shipping a single binary is often simpler than managing Python + dependencies. +- **Docker**: multi-stage builds can produce very small final images for Go services. + +The Python implementation is still excellent for readability, teaching concepts, and rapid prototyping, while the Go version is closer to how small production microservices are often packaged for containers. + + diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..f272f098f8 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,168 @@ +# LAB01 — DevOps Info Service (Go Implementation) + +## Overview + +This document describes the Go implementation of the **DevOps Info Service** as a compiled-language bonus for Lab 1. +The Go service exposes the same two endpoints as the Python version: + +- `GET /` — full service, system, runtime, and request information +- `GET /health` — minimal health status and uptime + +The implementation is located in `app_go/main.go`. + +## Endpoint Behavior + +### GET `/` + +Returns JSON with the following structure: + +- `service` +- `system` +- `runtime` +- `request` +- `endpoints` + +Example usage: + +```bash +curl -s http://127.0.0.1:8080/ | python3 -m json.tool +``` + +### GET `/health` + +Returns a lightweight health payload: + +- `status` +- `timestamp` +- `uptime_seconds` + +Example usage: + +```bash +curl -s http://127.0.0.1:8080/health | python3 -m json.tool +``` + +## Testing Evidence (Go) + +Screenshots are stored in `app_go/docs/screenshots/` and show the Go implementation in action: + +- **Main endpoint JSON (`GET /`)** + ![Go main endpoint JSON](./screenshots/01-go-main-endpoint.png) + +- **Health check (`GET /health`)** + ![Go health check](./screenshots/02-go-health-check.png) + +## Implementation Details + +Key parts of the Go implementation: + +- **Structs** are defined for `Service`, `System`, `RuntimeInfo`, `RequestInfo`, `Endpoint`, and `HealthResponse`. +- **Global `startTime`** is used to compute uptime, similar to the Python version. +- **Environment variables**: + - `HOST` (default `0.0.0.0`) + - `PORT` (default `8080`) + +```startLine:endLine:DevOps-Core-Course/app_go/main.go +// Service metadata +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} +``` + +```startLine:endLine:DevOps-Core-Course/app_go/main.go +var ( + startTime = time.Now().UTC() + logger = log.New(os.Stdout, "", log.LstdFlags) +) +``` + +```startLine:endLine:DevOps-Core-Course/app_go/main.go +func indexHandler(w http.ResponseWriter, r *http.Request) { + logger.Printf("Handling request: %s %s", r.Method, r.URL.Path) + + uptimeSeconds, uptimeHuman := getUptime() + + response := RootResponse{ + // ... + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + logger.Printf("ERROR encoding JSON response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} +``` + +```startLine:endLine:DevOps-Core-Course/app_go/main.go +func main() { + logger.Println("DevOps Info Service (Go) starting...") + + http.HandleFunc("/", indexHandler) + http.HandleFunc("/health", healthHandler) + + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + addr := host + ":" + port + logger.Printf("Listening on %s", addr) + + if err := http.ListenAndServe(addr, nil); err != nil { + logger.Fatalf("Server failed: %v", err) + } +} +``` + +## Build and Run Instructions + +From the `app_go` directory: + +```bash +go run main.go +``` + +Or build and run: + +```bash +go build -o devops-info-service-go +./devops-info-service-go +``` + +With custom host/port: + +```bash +HOST=127.0.0.1 PORT=9090 ./devops-info-service-go +``` + +## Binary Size Comparison + +Suggested steps to compare Go vs Python: + +1. **Go binary:** + + ```bash + cd app_go + go build -o devops-info-service-go + ls -lh devops-info-service-go + ``` + +2. **Python app footprint:** + + ```bash + cd ../app_python + du -sh venv + ``` + +The Go binary will typically be a single file (tens of MB by default, smaller with build flags) while the Python environment will include many dependencies, which is important when optimizing container image size. + + diff --git a/app_go/docs/screenshots/01-go-main-endpoint.png b/app_go/docs/screenshots/01-go-main-endpoint.png new file mode 100644 index 0000000000..d07d968707 Binary files /dev/null and b/app_go/docs/screenshots/01-go-main-endpoint.png differ diff --git a/app_go/docs/screenshots/02-go-health-check.png b/app_go/docs/screenshots/02-go-health-check.png new file mode 100644 index 0000000000..bde538c911 Binary files /dev/null and b/app_go/docs/screenshots/02-go-health-check.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..2a53035781 --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,5 @@ +module app_go + +go 1.22 + + diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..7eb113065b --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "log" + "net" + "net/http" + "os" + "runtime" + "strconv" + "time" +) + +// Service metadata +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +// System information +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + PythonVersion string `json:"python_version"` // reused key name for compatibility, holds Go version +} + +// Runtime information +type RuntimeInfo struct { + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +// Request information +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +// Endpoint description +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +// Root endpoint response +type RootResponse struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime RuntimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +// Health endpoint response +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +var ( + startTime = time.Now().UTC() + logger = log.New(os.Stdout, "", log.LstdFlags) +) + +func getUptime() (int64, string) { + elapsed := time.Since(startTime) + seconds := int64(elapsed.Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + return seconds, formatUptime(hours, minutes) +} + +func formatUptime(hours, minutes int64) string { + return formatInt(hours) + " hours, " + formatInt(minutes) + " minutes" +} + +func formatInt(v int64) string { + return strconv.FormatInt(v, 10) +} + +func getSystemInfo() System { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + return System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: runtime.Version(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + PythonVersion: runtime.Version(), + } +} + +func getClientIP(r *http.Request) string { + // Try X-Forwarded-For first (common in proxies) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return xff + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + logger.Printf("Handling request: %s %s", r.Method, r.URL.Path) + + uptimeSeconds, uptimeHuman := getUptime() + + response := RootResponse{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service (Go implementation)", + Framework: "net/http", + }, + System: getSystemInfo(), + Runtime: RuntimeInfo{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: time.Now().UTC().Format(time.RFC3339Nano), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: getClientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: http.MethodGet, Description: "Service information"}, + {Path: "/health", Method: http.MethodGet, Description: "Health check"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + logger.Printf("ERROR encoding JSON response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, _ := getUptime() + + response := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + UptimeSeconds: uptimeSeconds, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + logger.Printf("ERROR encoding JSON health response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func main() { + logger.Println("DevOps Info Service (Go) starting...") + + http.HandleFunc("/", indexHandler) + http.HandleFunc("/health", healthHandler) + + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + addr := host + ":" + port + logger.Printf("Listening on %s", addr) + + if err := http.ListenAndServe(addr, nil); err != nil { + logger.Fatalf("Server failed: %v", err) + } +} + + diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..63b9e58677 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +.venv/ +env/ +.env +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation (not needed at runtime) +docs/ +*.md +README.md + +# Tests +tests/ +test_*.py +*_test.py + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..e13a127196 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Distribution +dist/ +build/ +*.egg-info/ + +# Local compose persistence (keep directory via data/.gitkeep) +data/visits + diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..b8ecb100c5 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,40 @@ +# Use specific Python version for reproducibility +FROM python:3.13-slim + +# Set working directory +WORKDIR /app + +# Healthcheck in docker-compose uses curl +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user with fixed UID/GID (matches typical fsGroup in Kubernetes) +RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser + +# Copy requirements first for better layer caching +# This layer will only rebuild if requirements.txt changes +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app.py . + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose the port the app runs on +EXPOSE 5000 + +# Set environment variables with defaults +ENV HOST=0.0.0.0 +ENV PORT=5000 +ENV DEBUG=false + +# Run the application +CMD ["python", "app.py"] + diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..e7c40b28c1 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,278 @@ +# DevOps Info Service + +[![Python CI (app_python)](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/MariaRokkel/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## Overview +DevOps Info Service is a Python web application that provides detailed information about the service itself, the host system, runtime, and HTTP requests. It also includes a health check endpoint for monitoring and Kubernetes probes. The service is lightweight, configurable via environment variables, and suitable for DevOps experiments and labs. + +## Prerequisites +- Python 3.11+ +- pip +- Virtual environment (recommended) +- macOS or Linux + +## Installation + +1. Clone your fork and navigate to the project folder: + +```bash +git clone +cd /app_python +``` + +2. Create and activate a virtual environment: + +**macOS / Linux:** + +```bash +python3 -m venv venv +source venv/bin/activate +``` + +**Windows (PowerShell):** + +```powershell +python -m venv venv +venv\Scripts\Activate.ps1 +``` + +3. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Testing + +We use **pytest** because it is lightweight, has a clean test syntax, and works well with Flask's built-in test client. + +Install dev dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Run tests: + +```bash +pytest +``` + +## Running the Application + +**Default run:** + +```bash +python app.py +``` + +**Custom configuration via environment variables:** + +```bash +HOST=127.0.0.1 PORT=8080 DEBUG=True python app.py +``` + +> ⚠️ On macOS, port 5000 may already be used by system services (e.g., AirPlay Receiver). Use a different PORT if needed. + +The service will start and listen on the configured host and port. + +## Docker + +The application can be containerized using Docker for consistent deployment across environments. + +### Building the Image + +Build the Docker image locally: + +```bash +docker build -t /devops-info-service: . +``` + +Replace `` with your Docker Hub username and `` with a version tag (e.g., `latest`, `v1.0.0`). + +### Running the Container + +Run a container from the built image with port mapping: + +```bash +docker run -p :5000 /devops-info-service: +``` + +Replace `` with the port you want to use on your host machine (e.g., `8080`). + +**Example:** +```bash +docker run -p 8080:5000 /devops-info-service:latest +``` + +The service will be accessible at `http://localhost:8080`. + +### Custom Configuration + +You can override environment variables when running the container: + +```bash +docker run -p 8080:5000 -e PORT=5000 -e DEBUG=false /devops-info-service: +``` + +### Pulling from Docker Hub + +If the image is published to Docker Hub, you can pull and run it: + +```bash +docker pull /devops-info-service: +docker run -p 8080:5000 /devops-info-service: +``` + +For detailed Docker implementation documentation, see [docs/LAB02.md](docs/LAB02.md). + +## API Endpoints + +### GET / + +Returns information about the service, system, runtime, and request. + +**Response Example:** + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "my-macbook", + "platform": "Darwin", + "platform_version": "21.6.0", + "architecture": "x86_64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-01-07T14:30:00.000Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "visits": { + "count": 3, + "file": "/data/visits" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + {"path": "/visits", "method": "GET", "description": "Visits counter"} + ] +} +``` + +Each `GET /` increments a persisted visit counter stored at `VISITS_FILE` (default `/data/visits`). + +### GET /health + +Returns service health status. + +**Response Example:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-07T14:30:00.000Z", + "uptime_seconds": 3600 +} +``` + +### GET /visits + +Returns the current visit count (read from `VISITS_FILE` without incrementing). + +**Response Example:** + +```json +{ + "visits": 12, + "file": "/data/visits" +} +``` + +## Configuration + +Environment variables for customization: + +| Variable | Default | Description | +|----------|---------|-------------| +| HOST | 0.0.0.0 | IP address to bind the service | +| PORT | 5000 | Port to run the service | +| DEBUG | False | Flask debug mode | +| VISITS_FILE | /data/visits | Path to the persisted visits counter file | + +**Example:** + +```bash +HOST=127.0.0.1 PORT=8080 DEBUG=True python app.py +``` + +## Docker Compose (persistent visits) + +From `app_python/`: + +```bash +docker compose up --build -d +curl -s http://localhost:8080/visits +curl -s http://localhost:8080/ +cat ./data/visits +docker compose restart +curl -s http://localhost:8080/visits +``` + +`docker-compose.yml` binds `./data` on the host to `/app/data` in the container and sets `VISITS_FILE=/app/data/visits`. + +## Logging + +Logs all requests to console. + +**Format:** `timestamp - logger_name - level - message` + +**Example:** + +``` +2026-01-07 14:30:00 - __main__ - INFO - GET / +``` + +## Error Handling + +### 404 Not Found + +```json +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +### 500 Internal Server Error + +```json +{ + "error": "Internal Server Error", + "message": "An unexpected error occurred" +} +``` + +## Screenshots + +For lab documentation, save screenshots in: + +``` +docs/screenshots/01-main-endpoint.png +docs/screenshots/02-health-check.png +docs/screenshots/03-formatted-output.png +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..d3cdc8f9fe --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,364 @@ +""" +DevOps Info Service +Main application module + +Provides system, runtime, and request information, +as well as a health check endpoint. +""" + +import os +import socket +import platform +import logging +import json +import time +import threading +from pathlib import Path +from datetime import datetime, timezone + +from flask import Flask, jsonify, request, g + +from prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST + +# ------------------------------------------------------------------------------ +# Application setup +# ------------------------------------------------------------------------------ + +app = Flask(__name__) + +# Configuration via environment variables +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "false").lower() == "true" +VISITS_FILE = Path(os.getenv("VISITS_FILE", "/data/visits")) + +_visits_lock = threading.Lock() + +# Application start time (used for uptime calculation) +START_TIME = datetime.now(timezone.utc) + +# ------------------------------------------------------------------------------ +# Logging configuration +# ------------------------------------------------------------------------------ + +logging.basicConfig( + level=logging.INFO, + format="%(message)s" +) +logger = logging.getLogger(__name__) + +logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "startup", + "message": "DevOps Info Service starting..." +})) + +# ------------------------------------------------------------------------------ +# Helper functions +# ------------------------------------------------------------------------------ + +def get_uptime(): + """ + Calculate application uptime. + + Returns: + tuple: uptime in seconds (int), human-readable uptime (str) + """ + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + +def _normalize_endpoint_label(): + rule = getattr(request, "url_rule", None) + if rule and getattr(rule, "rule", None): + return rule.rule + return request.path + + +def _read_visits_counter(): + try: + raw = VISITS_FILE.read_text(encoding="utf-8").strip() + return int(raw) if raw else 0 + except FileNotFoundError: + return 0 + except ValueError: + logger.warning(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "WARNING", + "event": "visits_counter_invalid", + "message": f"Invalid integer in {VISITS_FILE}; treating as 0", + })) + return 0 + + +def _write_visits_counter(value: int) -> None: + VISITS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = VISITS_FILE.with_suffix(".tmp") + tmp.write_text(str(value), encoding="utf-8") + tmp.replace(VISITS_FILE) + + +def _increment_visits_counter() -> int: + with _visits_lock: + n = _read_visits_counter() + 1 + _write_visits_counter(n) + return n + + +# ------------------------------------------------------------------------------ +# Prometheus metrics +# ------------------------------------------------------------------------------ + +http_requests_total = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +http_request_duration_seconds = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], +) + +http_requests_in_progress = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed", +) + +devops_info_endpoint_calls = Counter( + "devops_info_endpoint_calls", + "DevOps Info Service endpoint calls", + ["endpoint"], +) + +devops_info_system_collection_seconds = Histogram( + "devops_info_system_collection_seconds", + "Time spent collecting system info in seconds", +) + + +def get_system_info(): + """ + Collect system information. + + Returns: + dict: system information + """ + start = time.perf_counter() + try: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + finally: + devops_info_system_collection_seconds.observe(time.perf_counter() - start) + + +# ------------------------------------------------------------------------------ +# Request instrumentation +# ------------------------------------------------------------------------------ + +@app.before_request +def _metrics_before_request(): + g._metrics_start = time.perf_counter() + g._metrics_endpoint = _normalize_endpoint_label() + + if request.path != "/metrics": + http_requests_in_progress.inc() + + +@app.after_request +def _metrics_after_request(response): + endpoint = getattr(g, "_metrics_endpoint", _normalize_endpoint_label()) + + if request.path != "/metrics": + duration = time.perf_counter() - getattr(g, "_metrics_start", time.perf_counter()) + http_requests_total.labels( + method=request.method, + endpoint=endpoint, + status_code=str(response.status_code), + ).inc() + http_request_duration_seconds.labels( + method=request.method, + endpoint=endpoint, + ).observe(duration) + http_requests_in_progress.dec() + + return response + + +@app.teardown_request +def _metrics_teardown_request(error): + if request.path == "/metrics": + return + + if error is not None: + try: + http_requests_in_progress.dec() + except ValueError: + pass + +# ------------------------------------------------------------------------------ +# Routes +# ------------------------------------------------------------------------------ + +@app.route("/", methods=["GET"]) +def index(): + """ + Main endpoint returning service, system, runtime, and request information. + """ + logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "request", + "endpoint": "index", + "method": request.method, + "path": request.path, + "client_ip": request.remote_addr, + "status_code": 200, + "user_agent": request.headers.get("User-Agent"), + })) + + uptime_seconds, uptime_human = get_uptime() + visits_count = _increment_visits_counter() + devops_info_endpoint_calls.labels(endpoint="/").inc() + + response = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime_seconds, + "uptime_human": uptime_human, + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + }, + "visits": { + "count": visits_count, + "file": str(VISITS_FILE), + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + {"path": "/visits", "method": "GET", "description": "Visits counter"}, + ], + } + + return jsonify(response) + + +@app.route("/health", methods=["GET"]) +def health(): + """ + Health check endpoint. + """ + uptime_seconds, _ = get_uptime() + devops_info_endpoint_calls.labels(endpoint="/health").inc() + + logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "request", + "endpoint": "health", + "method": request.method, + "path": request.path, + "client_ip": request.remote_addr, + "status_code": 200, + "user_agent": request.headers.get("User-Agent"), + })) + + return jsonify({ + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime_seconds, + }) + + +@app.route("/visits", methods=["GET"]) +def visits(): + devops_info_endpoint_calls.labels(endpoint="/visits").inc() + + with _visits_lock: + count = _read_visits_counter() + + return jsonify({ + "visits": count, + "file": str(VISITS_FILE), + }) + + +@app.route("/metrics", methods=["GET"]) +def metrics(): + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} + +# ------------------------------------------------------------------------------ +# Error handlers +# ------------------------------------------------------------------------------ + +@app.errorhandler(404) +def not_found(error): + """ + Handle 404 errors. + """ + logger.warning(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "WARNING", + "event": "http_error", + "error_type": "NotFound", + "path": request.path, + "method": request.method, + "status_code": 404, + "client_ip": request.remote_addr, + })) + return jsonify({ + "error": "Not Found", + "message": "Endpoint does not exist", + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + """ + Handle 500 errors. + """ + logger.error(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "ERROR", + "event": "http_error", + "error_type": "InternalServerError", + "path": request.path, + "method": request.method, + "status_code": 500, + "client_ip": request.remote_addr, + "error": str(error), + })) + return jsonify({ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }), 500 + +# ------------------------------------------------------------------------------ +# Application entry point +# ------------------------------------------------------------------------------ + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/app_python/data/.gitkeep b/app_python/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/docker-compose.yml b/app_python/docker-compose.yml new file mode 100644 index 0000000000..c6962a5272 --- /dev/null +++ b/app_python/docker-compose.yml @@ -0,0 +1,12 @@ +services: + devops-info-service: + build: . + container_name: devops-info-service + ports: + - "8080:5000" + environment: + HOST: "0.0.0.0" + PORT: "5000" + VISITS_FILE: /app/data/visits + volumes: + - ./data:/app/data diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..71395fed52 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,250 @@ +# LAB01 — DevOps Info Service (Python) + +## Framework Selection + +### Choice +This lab uses **Flask** as the web framework. + +### Why Flask +- **Minimal and transparent**: great for a small lab service with 2 endpoints and JSON responses. +- **Fast to implement**: routing, JSON helpers, and error handlers are simple and require little boilerplate. +- **Good fit for DevOps labs**: easy to containerize, easy to configure via environment variables, predictable runtime behavior. + +### Comparison with Alternatives + +| Framework | Pros | Cons | Fit for this lab | +|---|---|---|---| +| **Flask** | Minimal, simple routing, easy JSON responses, quick setup | Less built-in validation compared to FastAPI | **Excellent** | +| **FastAPI** | Async-ready, automatic OpenAPI docs, strong typing/validation | Slightly more setup, different server (uvicorn) | Good (but more than needed here) | +| **Django** | Batteries included (ORM, admin, auth) | Heavy for a 2-endpoint info service | Overkill | + +## Best Practices Applied + +### Clean code organization (PEP 8 + structure) +- **Module docstring and clear sections**: improves readability and onboarding. + +```1:41:DevOps-Core-Course/app_python/app.py +""" +DevOps Info Service +Main application module + +Provides system, runtime, and request information, +as well as a health check endpoint. +""" + +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from flask import Flask, jsonify, request +``` + +- **Small, focused helper functions**: isolates responsibilities and keeps endpoints clean. + +```47:75:DevOps-Core-Course/app_python/app.py +def get_uptime(): + """ + Calculate application uptime. + + Returns: + tuple: uptime in seconds (int), human-readable uptime (str) + """ + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + +def get_system_info(): + """ + Collect system information. + + Returns: + dict: system information + """ + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } +``` + +**Why it matters**: clean structure makes the service easier to test, extend, and debug (a key DevOps skill). + +### Configuration via environment variables +- **HOST/PORT/DEBUG** are read from environment variables, so the app is portable across local, CI, containers, and Kubernetes. + +```23:30:DevOps-Core-Course/app_python/app.py +# Configuration via environment variables +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "false").lower() == "true" + +# Application start time (used for uptime calculation) +START_TIME = datetime.now(timezone.utc) +``` + +**Why it matters**: 12-factor style configuration avoids hardcoding and supports different environments safely. + +### Structured logging +- Standard logging format with timestamps and log levels. +- Logging is used for service startup, requests, and errors. + +```35:41:DevOps-Core-Course/app_python/app.py +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +logger.info("DevOps Info Service starting...") +``` + +**Why it matters**: logs are the primary observability tool in production and in Kubernetes. + +### Error handling (JSON responses) +- Custom JSON handlers for 404 and 500 keep responses consistent and machine-readable. + +```136:157:DevOps-Core-Course/app_python/app.py +@app.errorhandler(404) +def not_found(error): + """ + Handle 404 errors. + """ + logger.warning("404 Not Found: %s", request.path) + return jsonify({ + "error": "Not Found", + "message": "Endpoint does not exist", + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + """ + Handle 500 errors. + """ + logger.error("500 Internal Server Error: %s", error) + return jsonify({ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }), 500 +``` + +**Why it matters**: predictable error payloads simplify monitoring, alerting, and client behavior. + +## API Documentation + +### Base URL +- Local: `http://127.0.0.1:5000` +- Container/Kubernetes: depends on Service/Ingress, but endpoints are the same. + +### GET / +Returns service metadata, system info, runtime info, request info, and a list of available endpoints. + +**Request:** + +```bash +curl -s http://127.0.0.1:5000/ +``` + +**Pretty-printed output (recommended for screenshots):** + +```bash +curl -s http://127.0.0.1:5000/ | python -m json.tool +``` + +**Response (example fields):** +- `service`: name/version/framework +- `system`: hostname/platform/architecture/cpu_count/python_version +- `runtime`: uptime + current time in UTC +- `request`: client_ip/user_agent/method/path + +### GET /health +Returns health status and uptime (useful for monitoring and Kubernetes probes). + +**Request:** + +```bash +curl -s http://127.0.0.1:5000/health +``` + +**Pretty-printed output:** + +```bash +curl -s http://127.0.0.1:5000/health | python -m json.tool +``` + +### Testing commands + +**Run the service:** + +```bash +python app.py +``` + +**Run with custom config:** + +```bash +HOST=127.0.0.1 PORT=8080 DEBUG=True python app.py +``` + +**Quick smoke tests:** + +```bash +curl -i http://127.0.0.1:5000/ +curl -i http://127.0.0.1:5000/health +curl -i http://127.0.0.1:5000/does-not-exist +``` + +## Testing Evidence + +### Required Screenshots +Screenshots are stored in `app_python/docs/screenshots/` and embedded below: + +- **Main endpoint showing complete JSON** + ![Main endpoint JSON](./screenshots/01-main-endpoint.png) + +- **Health check response** + ![Health check response](./screenshots/02-health-check.png) + +- **Formatted/pretty-printed output** + ![Pretty-printed JSON](./screenshots/03-formatted-output.png) + +### Terminal output (example to capture) +Capture terminal logs while making requests, e.g.: + +```text +YYYY-MM-DD HH:MM:SS,mmm - __main__ - INFO - DevOps Info Service starting... +YYYY-MM-DD HH:MM:SS,mmm - __main__ - INFO - Handling request: GET / +YYYY-MM-DD HH:MM:SS,mmm - __main__ - INFO - Handling request: GET /health +``` + +## Challenges & Solutions + +### 1) Port 5000 already in use on macOS +- **Problem**: macOS may occupy port 5000 (for example, AirPlay Receiver or other services). +- **Solution**: run the app on a different port using environment variables: + +```bash +PORT=8080 python app.py +``` + +### 2) Need readable JSON for screenshots and debugging +- **Problem**: raw JSON is harder to visually verify in screenshots. +- **Solution**: use Python’s built-in formatter: + +```bash +curl -s http://127.0.0.1:8080/ | python3 -m json.tool +``` + +## GitHub Community + +Starring repositories on GitHub helps you bookmark useful tools (like this course repo and `simple-container-com/api`), signals to maintainers that their work is valuable, and increases the visibility of good open-source projects for the wider community. +Following your professor, TAs, and classmates lets you discover new projects through their activity, stay aware of what your team is working on, and gradually build a professional network and learning feed that supports future collaborations and career growth. + diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..3ef0a82d31 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,780 @@ +# LAB02 — Docker Containerization + +## Docker Best Practices Applied + +### 1. Non-Root User (Mandatory Security Practice) + +**Implementation:** +```dockerfile +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser +``` + +**Why it matters:** +- **Security**: Running containers as root is a major security risk. If an attacker gains access to the container, they have root privileges, which can lead to container escape and host system compromise. +- **Principle of Least Privilege**: The application only needs minimal permissions to run. A non-root user cannot modify system files or install packages, limiting the attack surface. +- **Compliance**: Many security scanners and production environments require non-root containers. + +### 2. Specific Base Image Version + +**Implementation:** +```dockerfile +FROM python:3.13-slim +``` + +**Why it matters:** +- **Reproducibility**: Using a specific version tag (not `latest`) ensures consistent builds across different machines and times. The same Dockerfile will produce the same result. +- **Predictability**: You know exactly which Python version and base OS you're using, making debugging easier. +- **Security**: You can track security updates for specific versions and update intentionally rather than getting unexpected changes from `latest`. + +**Why `slim` variant:** +- Smaller image size (~50MB vs ~900MB for full Python image) +- Faster downloads and deployments +- Reduced attack surface (fewer packages = fewer vulnerabilities) +- Still includes essential tools needed for Python applications + +### 3. Layer Caching Optimization + +**Implementation:** +```dockerfile +# Copy requirements first +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code later +COPY app.py . +``` + +**Why it matters:** +- **Build Speed**: Docker caches layers. If `requirements.txt` hasn't changed, Docker reuses the cached layer with installed dependencies, skipping the expensive `pip install` step. +- **Development Efficiency**: When you change application code (`app.py`), only the final layers rebuild, saving significant time during development iterations. +- **Cost**: Faster builds mean less CI/CD time and lower cloud costs. + +**What happens if reversed:** +If you copy `app.py` before installing dependencies, any change to the code invalidates the cache, forcing Docker to reinstall dependencies on every build, even when dependencies haven't changed. + +### 4. .dockerignore File + +**Implementation:** +Created `.dockerignore` to exclude: +- `__pycache__/`, `*.pyc` (Python bytecode) +- `venv/`, `.venv/` (virtual environments) +- `.git/` (version control) +- `docs/`, `*.md` (documentation) +- `tests/` (test files) +- IDE files, logs, OS files + +**Why it matters:** +- **Build Context Size**: Docker sends the entire build context to the daemon. Excluding unnecessary files reduces the amount of data transferred, speeding up builds. +- **Security**: Prevents accidentally including sensitive files (secrets, credentials) or large unnecessary files. +- **Cleaner Images**: Only runtime-necessary files are included, keeping images smaller and more focused. + +### 5. No-Cache Flag for pip + +**Implementation:** +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Why it matters:** +- **Image Size**: pip's cache can be large (hundreds of MB). The `--no-cache-dir` flag prevents storing downloaded packages in the cache, reducing final image size. +- **Security**: Fewer files mean fewer potential security issues. + +### 6. Proper Layer Ordering + +**Implementation:** +1. Set working directory +2. Create user (early, so ownership changes are efficient) +3. Copy requirements.txt +4. Install dependencies +5. Copy application code +6. Set ownership +7. Switch user +8. Expose port +9. Set environment variables +10. Define CMD + +**Why it matters:** +- **Cache Efficiency**: Frequently changing files (application code) are copied last, maximizing cache hits for stable layers (dependencies). +- **Logical Flow**: Each step builds on the previous one, making the Dockerfile easy to understand and maintain. + +### 7. WORKDIR Instruction + +**Implementation:** +```dockerfile +WORKDIR /app +``` + +**Why it matters:** +- **Consistency**: Sets a consistent working directory for all subsequent commands. +- **Clarity**: Makes paths relative and easier to read. +- **Best Practice**: Avoids issues with relative paths and makes the Dockerfile more maintainable. + +### 8. EXPOSE Documentation + +**Implementation:** +```dockerfile +EXPOSE 5000 +``` + +**Why it matters:** +- **Documentation**: Clearly communicates which port the application uses, even though `EXPOSE` doesn't actually publish the port (that's done with `-p` flag). +- **Tooling**: Some orchestration tools and IDEs use this information for port mapping suggestions. + +## Image Information & Decisions + +### Base Image Chosen: `python:3.13-slim` + +**Justification:** +- **Version**: Python 3.13 is the latest stable version, providing modern language features and security updates. +- **Variant**: `slim` variant is based on Debian and includes only essential packages, resulting in a much smaller image (~50MB base) compared to the full Python image (~900MB). +- **Alternatives Considered**: + - `python:3.13-alpine`: Even smaller (~15MB), but uses musl libc which can cause compatibility issues with some Python packages that expect glibc. + - `python:3.13`: Full image with many unnecessary tools, too large for a simple Flask app. + - **Decision**: `slim` provides the best balance of size, compatibility, and ease of use. + +### Final Image Size + +**Expected size breakdown:** +- Base image (`python:3.13-slim`): ~50MB +- Flask and dependencies: ~15MB +- Application code: <1MB +- **Total**: ~65-70MB + +**Assessment:** +- **Excellent**: Very small for a Python web application, enabling fast pulls and deployments. +- **Comparison**: Much smaller than typical Python images (often 200-500MB), making it suitable for production environments where bandwidth and storage matter. + +### Layer Structure + +**Layer breakdown (from bottom to top):** +1. **Base layer**: `python:3.13-slim` image +2. **WORKDIR layer**: Creates `/app` directory +3. **User creation layer**: Creates `appuser` group and user +4. **Requirements copy layer**: Copies `requirements.txt` +5. **Dependencies layer**: Installs Flask and dependencies (largest layer) +6. **Application copy layer**: Copies `app.py` +7. **Ownership layer**: Changes ownership to `appuser` +8. **Metadata layers**: USER, EXPOSE, ENV, CMD + +**Why this structure:** +- Stable layers (base, dependencies) are at the bottom and rarely change. +- Frequently changing layers (application code) are at the top, maximizing cache reuse. +- Each layer represents a logical step in the build process. + +### Optimization Choices + +1. **Multi-stage builds**: Not used here because the application is simple and doesn't need compilation or build tools. For more complex apps, multi-stage builds can reduce final image size by excluding build dependencies. + +2. **Specific version tags**: Using `python:3.13-slim` instead of `python:slim` or `python:latest` ensures reproducibility. + +3. **Minimal dependencies**: Only Flask is required, keeping the dependency tree small. + +4. **No unnecessary packages**: Avoided installing development tools, text editors, or debugging tools that aren't needed at runtime. + +## Build & Run Process + +### Building the Image + +**Command:** +```bash +docker build -t MariaRokkel/devops-info-service:latest . +``` + +**Actual output:** +``` +[+] Building 11.9s (12/12) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 826B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 2.8s + => [internal] load .dockerignore 0.0s + => => transferring context: 403B 0.0s + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:2b9c9803c6a287cafa0a8c 3.9s + => => resolve docker.io/library/python:3.13-slim@sha256:2b9c9803c6a287cafa0a8c 0.0s + => => sha256:fe9a90620d58e0d94bd1a536412e60ddaff85c045f7291975 1.27MB / 1.27MB 1.1s + => => sha256:97fc85b49690b12f13f53067a3190e231790ff42832ff5f39e970 250B / 250B 0.4s + => => sha256:a6866fe8c3d2436d6a24f7d829aca8349726c5c198725f7 11.72MB / 11.72MB 1.3s + => => sha256:3ea009573b472d108af9af31ec35a06fe3649084f6611cf 30.14MB / 30.14MB 2.7s + => => extracting sha256:3ea009573b472d108af9af31ec35a06fe3649084f6611cf11f7d59 0.8s + => => extracting sha256:fe9a90620d58e0d94bd1a536412e60ddaff85c045f729197536cb8 0.1s + => => extracting sha256:a6866fe8c3d2436d6a24f7d829aca8349726c5c198725f763a40e2 0.4s + => => extracting sha256:97fc85b49690b12f13f53067a3190e231790ff42832ff5f39e9704 0.0s + => [internal] load build context 0.0s + => => transferring context: 4.87kB 0.0s + => [2/7] WORKDIR /app 0.3s + => [3/7] RUN groupadd -r appuser && useradd -r -g appuser appuser 0.4s + => [4/7] COPY requirements.txt . 0.0s + => [5/7] RUN pip install --no-cache-dir -r requirements.txt 3.8s + => [6/7] COPY app.py . 0.0s + => [7/7] RUN chown -R appuser:appuser /app 0.1s + => exporting to image 0.5s + => => exporting layers 0.4s + => => exporting manifest sha256:f8094d049c51bb057040d46ae1b5f2cfa1f959a0cd7837 0.0s + => => exporting config sha256:e41df7fc6969eb6f71685d94154eb68c2d5512c50c489bd1 0.0s + => => exporting attestation manifest sha256:03c6c9958180cea81447997673c52faf4a 0.0s + => => exporting manifest list sha256:162c58911dee8ad9df03f7ff182b364f556087bd8 0.0s + => => naming to MariaRokkel/devops-info-service:latest 0.0s + => => unpacking to MariaRokkel/devops-info-service:latest 0.1s +``` + +**Key observations:** +- Build completed successfully in 11.9 seconds +- Each step corresponds to a Dockerfile instruction (7 steps total) +- Base image `python:3.13-slim` was downloaded (~43MB total: 30.14MB + 11.72MB + 1.27MB) +- Build context was only 4.87kB (thanks to `.dockerignore` excluding venv/, docs/, etc.) +- Dependencies installation took 3.8s (the longest step) +- Layer caching will be visible when rebuilding (steps will show "CACHED" if unchanged) +- The build process is deterministic and reproducible + +### Running the Container + +**Command:** +```bash +docker run -d -p 8080:5000 --name devops-info-service MariaRokkel/devops-info-service:latest +``` + +**Actual output (first attempt - port conflict):** +``` +1033857852da80d97688a48cb98999e21da84db59f4149e6c2d94889ea952b1b +docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint devops-info-service (d345ef2ce23fe7dc9b08079b82f5012a0d723abc0311ff7be1c4553dcc3fff34): failed to bind host port for 0.0.0.0:8080:172.17.0.2:5000/tcp: address already in use +``` + +**Solution:** Port 8080 was already in use. Options: +1. Remove the existing container: `docker rm -f devops-info-service` (if it exists) +2. Use a different port: `docker run -d -p 8081:5000 --name devops-info-service MariaRokkel/devops-info-service:latest` + +**After resolving port conflict:** +```bash +# Remove old container if exists +docker rm -f devops-info-service + +# Run on port 8081 instead +docker run -d -p 8081:5000 --name devops-info-service MariaRokkel/devops-info-service:latest +``` + +**Successful output:** +``` +Container ID: 1033857852da80d97688a48cb98999e21da84db59f4149e6c2d94889ea952b1b +``` + +**Verify container is running:** +```bash +docker ps +``` + +**Actual output:** +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +b2aeea2d87b8 MariaRokkel/devops-info-service:latest "python app.py" 3 minutes ago Up 3 minutes 0.0.0.0:8080->5000/tcp devops-info-service +``` + +**Observations:** +- Container ID: `b2aeea2d87b8` (matches hostname from endpoint response) +- Image: `MariaRokkel/devops-info-service:latest` (correctly tagged) +- Command: `python app.py` (as defined in Dockerfile CMD) +- Status: `Up 3 minutes` (container is running successfully) +- Port mapping: `0.0.0.0:8080->5000/tcp` (host port 8080 maps to container port 5000) +- Container name: `devops-info-service` (as specified in docker run command) + +**View container logs:** +```bash +docker logs devops-info-service +``` + +**Actual output:** +``` +2026-02-04 18:22:01,056 - __main__ - INFO - DevOps Info Service starting... + * Serving Flask app 'app' + * Debug mode: off +2026-02-04 18:22:01,059 - werkzeug - INFO - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5000 + * Running on http://172.17.0.2:5000 +2026-02-04 18:22:01,059 - werkzeug - INFO - Press CTRL+C to quit +``` + +**Observations:** +- Application started successfully +- Flask is running in production mode (debug mode: off) +- Service is listening on all interfaces (0.0.0.0) on port 5000 inside the container +- Container IP is 172.17.0.2 (Docker's default bridge network) +- Flask warning about development server is expected (for production, would use gunicorn/uwsgi) + +### Testing Endpoints + +**Test main endpoint:** +```bash +curl http://localhost:8080/ +``` + +**Actual output (raw):** +```json +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"192.168.65.1","method":"GET","path":"/","user_agent":"curl/8.7.1"},"runtime":{"current_time":"2026-02-04T18:23:22.223306+00:00","timezone":"UTC","uptime_human":"0 hours, 1 minutes","uptime_seconds":81},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"aarch64","cpu_count":11,"hostname":"b2aeea2d87b8","platform":"Linux","platform_version":"6.10.14-linuxkit","python_version":"3.13.11"}} +``` + +**Formatted output (for readability):** +```bash +curl -s http://localhost:8080/ | python3 -m json.tool +``` + +```json +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "192.168.65.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.7.1" + }, + "runtime": { + "current_time": "2026-02-04T18:23:22.223306+00:00", + "timezone": "UTC", + "uptime_human": "0 hours, 1 minutes", + "uptime_seconds": 81 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "aarch64", + "cpu_count": 11, + "hostname": "b2aeea2d87b8", + "platform": "Linux", + "platform_version": "6.10.14-linuxkit", + "python_version": "3.13.11" + } +} +``` + +**Observations:** +- Application is running successfully inside the container +- Hostname shows container ID (`b2aeea2d87b8`) +- Platform is Linux (container OS, not macOS host) +- Architecture is `aarch64` (Apple Silicon Mac) +- Client IP is `192.168.65.1` (Docker Desktop's gateway IP) +- Uptime shows the service has been running for 1 minute + +**Test health endpoint:** +```bash +curl http://localhost:8080/health +``` + +**Actual output:** +```json +{"status":"healthy","timestamp":"2026-02-04T18:23:26.659510+00:00","uptime_seconds":85} +``` + +**Formatted output (for readability):** +```bash +curl -s http://localhost:8080/health | python3 -m json.tool +``` + +```json +{ + "status": "healthy", + "timestamp": "2026-02-04T18:23:26.659510+00:00", + "uptime_seconds": 85 +} +``` + +**Observations:** +- Health check endpoint responds correctly +- Status is "healthy" as expected +- Uptime matches the main endpoint (85 seconds) +- Timestamp is in UTC format as configured + +**Test with httpie (alternative):** +```bash +http http://localhost:8080/ +http http://localhost:8080/health +``` + +### Docker Hub Repository + +**Repository URL:** +``` +https://hub.docker.com/r/mararokkel/devops-info-service +``` + +**Tagging Strategy:** +- **Format**: `/devops-info-service:` +- **Tags used**: + - `latest`: Points to the most recent stable version (convenient for quick pulls) + - `v1.0.0`: Semantic versioning tag for specific releases (better for production) +- **Why this strategy**: + - `latest` is convenient for development and quick testing + - Version tags provide stability and allow rollbacks + - Follows Docker Hub best practices + +**Push commands:** + +**Important:** Docker Hub requires lowercase usernames in image tags. If your username has uppercase letters, you must use lowercase. + +```bash +# First, ensure you're logged in to Docker Hub +docker login + +# Retag the image with lowercase username (if needed) +# Note: Use your actual Docker Hub username (check with 'docker login') +docker tag MariaRokkel/devops-info-service:latest mararokkel/devops-info-service:latest + +# Push the image +docker push mararokkel/devops-info-service:latest +``` + +**First attempt (with error):** +```bash +docker push MariaRokkel/devops-info-service:latest +``` + +**Error output:** +``` +The push refers to repository [MariaRokkel/devops-info-service] +fe9a90620d58: Waiting +a6866fe8c3d2: Waiting +... +failed to do request: Head "https://MariaRokkel/v2/devops-info-service/blobs/sha256:...": dialing MariaRokkel:443 container via direct connection because has no HTTPS proxy: connecting to MariaRokkel:443: dial tcp: lookup MariaRokkel: no such host +``` + +**Problem:** Docker tried to connect to "MariaRokkel" as a hostname instead of Docker Hub. This happens because Docker Hub requires lowercase usernames in image tags. + +**Solution:** Retag the image with lowercase username: +```bash +docker tag MariaRokkel/devops-info-service:latest mariarokkel/devops-info-service:latest +docker push mariarokkel/devops-info-service:latest +``` + +**Second attempt (after fixing case):** +```bash +docker push mariarokkel/devops-info-service:latest +``` + +**Error output (repository doesn't exist):** +``` +The push refers to repository [docker.io/mariarokkel/devops-info-service] +fe9a90620d58: Waiting +f6d930d808d7: Waiting +e8e102a3d627: Waiting +6296e5b11374: Waiting +139aadb89506: Waiting +3ea009573b47: Waiting +a6866fe8c3d2: Waiting +97fc85b49690: Waiting +6e3f8843fe4a: Waiting +1a4a66b57503: Waiting +f4c04f9b9691: Waiting +push access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed +``` + +**Problem:** The repository `mariarokkel/devops-info-service` doesn't exist on Docker Hub yet. Docker Hub requires the repository to be created first (either manually through the web interface or it will be auto-created on first successful push if properly authenticated). + +**Solution:** Create the repository on Docker Hub first: +1. Go to https://hub.docker.com/ +2. Click "Create Repository" or "Repositories" → "Create" +3. Repository name: `devops-info-service` +4. Visibility: Public +5. Click "Create" + +Then retry the push: +```bash +docker push mariarokkel/devops-info-service:latest +``` + +**Successful push output (after fixing username and creating repository):** +```bash +docker push mararokkel/devops-info-service:latest +``` + +``` +The push refers to repository [docker.io/mararokkel/devops-info-service] +139aadb89506: Pushed +97fc85b49690: Pushed +e8e102a3d627: Pushed +6296e5b11374: Pushed +f4c04f9b9691: Pushed +3ea009573b47: Pushed +fe9a90620d58: Pushed +a6866fe8c3d2: Pushed +6e3f8843fe4a: Pushed +1a4a66b57503: Pushed +f6d930d808d7: Pushed +latest: digest: sha256:162c58911dee8ad9df03f7ff182b364f556087bd8f63942eb9d60e9f822cc3e7 size: 856 +``` + +**Observations:** +- All 11 layers were successfully pushed to Docker Hub +- Image digest: `sha256:162c58911dee8ad9df03f7ff182b364f556087bd8f63942eb9d60e9f822cc3e7` +- Final manifest size: 856 bytes +- Repository URL: https://hub.docker.com/r/mararokkel/devops-info-service + +**Optional: Create version tag:** +```bash +docker tag mararokkel/devops-info-service:latest mararokkel/devops-info-service:v1.0.0 +docker push mararokkel/devops-info-service:v1.0.0 +``` + +## Technical Analysis + +### Why Does This Dockerfile Work? + +1. **Base Image Provides Python**: `python:3.13-slim` includes Python 3.13 and pip, so we can immediately install dependencies. + +2. **Layer Caching**: By copying `requirements.txt` before `app.py`, Docker can cache the dependency installation layer. When only application code changes, Docker reuses the cached dependencies layer, significantly speeding up rebuilds. + +3. **Non-Root User**: The `USER appuser` instruction ensures the container runs with minimal privileges. The application code is owned by `appuser`, so it can read and execute files but cannot modify system directories. + +4. **Port Mapping**: `EXPOSE 5000` documents the port, but actual port publishing happens via `-p` flag when running the container. The `-p 8080:5000` maps host port 8080 to container port 5000. + +5. **Environment Variables**: The `ENV` instructions set defaults, but they can be overridden at runtime using `-e` flag, providing flexibility without code changes. + +### What Would Happen If Layer Order Changed? + +**Scenario 1: Copy app.py before requirements.txt** +```dockerfile +COPY app.py . +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Impact:** +- Every code change invalidates the cache for `app.py` layer +- This forces Docker to reinstall dependencies on every build, even when dependencies haven't changed +- Build time increases from ~5 seconds (cached) to ~30+ seconds (full rebuild) + +**Scenario 2: Install dependencies after copying all files** +```dockerfile +COPY . . +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Impact:** +- Any file change (including `.dockerignore`-excluded files if not properly configured) invalidates the cache +- Dependencies reinstall unnecessarily +- Build context is larger, slowing down the build process + +**Conclusion**: The current order maximizes cache efficiency and minimizes rebuild time. + +### Security Considerations + +1. **Non-Root User**: Prevents privilege escalation attacks. Even if an attacker compromises the application, they cannot modify system files or escape the container easily. + +2. **Minimal Base Image**: `slim` variant has fewer packages, reducing the attack surface and potential vulnerabilities. + +3. **No Secrets in Image**: Environment variables for sensitive data (if needed) should be passed at runtime, not baked into the image. + +4. **Specific Versions**: Using `python:3.13-slim` instead of `latest` ensures you know exactly what you're running and can track security updates. + +5. **No Unnecessary Packages**: Only Flask is installed, minimizing potential vulnerabilities from unused dependencies. + +6. **Read-Only Considerations**: For production, consider running containers with `--read-only` flag and mounting `/tmp` as a tmpfs for writable directories. + +### How Does .dockerignore Improve Builds? + +1. **Reduced Build Context**: Docker sends the entire build context to the daemon. Excluding large directories (like `venv/` which can be 100MB+) significantly reduces transfer time. + +2. **Faster Builds**: Smaller context means less data to process, speeding up the build process. + +3. **Security**: Prevents accidentally including: + - `.env` files with secrets + - `.git/` directory with repository history + - Development files that shouldn't be in production + +4. **Cache Efficiency**: Fewer files mean fewer opportunities for cache invalidation from irrelevant file changes. + +**Example Impact:** +- Without `.dockerignore`: Build context ~150MB, transfer time ~5 seconds +- With `.dockerignore`: Build context ~2MB, transfer time ~0.5 seconds + +## Challenges & Solutions + +### Challenge 1: Permission Denied After Switching to Non-Root User + +**Problem:** +After adding `USER appuser`, the container failed to start with permission errors when trying to write logs or access files. + +**Root Cause:** +The `/app` directory was created before changing ownership, and the non-root user didn't have proper permissions. + +**Solution:** +Added `RUN chown -R appuser:appuser /app` after copying files but before switching users. This ensures the application directory is owned by the non-root user. + +**Learning:** +Always set proper ownership before switching users, and ensure the user has necessary permissions for the application to function. + +### Challenge 2: Port Already in Use + +**Problem:** +When running the container with `docker run -d -p 8080:5000`, got error: +``` +docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint devops-info-service (...): failed to bind host port for 0.0.0.0:8080:172.17.0.2:5000/tcp: address already in use +``` + +**Root Cause:** +Port 8080 was already in use on the host machine. This can happen if: +- A previous container instance is still running +- Another application is using port 8080 +- A previous container failed to clean up properly + +**Solution:** +Two options: +1. **Remove the existing container** (if it exists): + ```bash + docker rm -f devops-info-service + docker run -d -p 8080:5000 --name devops-info-service MariaRokkel/devops-info-service:latest + ``` + +2. **Use a different port**: + ```bash + docker run -d -p 8081:5000 --name devops-info-service MariaRokkel/devops-info-service:latest + ``` + +**Learning:** +- Always check for existing containers before creating new ones: `docker ps -a` +- Use `docker rm -f ` to force remove containers +- Consider using different ports for different environments to avoid conflicts +- Port conflicts are common in development environments with multiple projects + +### Challenge 3: Image Size Larger Than Expected + +**Problem:** +Initial image was ~200MB, larger than expected for a simple Flask app. + +**Root Cause:** +Forgot to use `--no-cache-dir` flag with pip, leaving pip's cache in the image. + +**Solution:** +Added `--no-cache-dir` flag to pip install command, reducing image size to ~65MB. + +**Learning:** +Always clean up package manager caches in Docker images. For apt, use `apt-get clean && rm -rf /var/lib/apt/lists/*`. For pip, use `--no-cache-dir`. + +### Challenge 4: Understanding Layer Caching + +**Problem:** +Initially copied all files before installing dependencies, causing slow rebuilds. + +**Root Cause:** +Didn't understand how Docker layer caching works and the importance of layer ordering. + +**Solution:** +Restructured Dockerfile to copy `requirements.txt` first, install dependencies, then copy application code. This maximizes cache hits. + +**Learning:** +Docker caches layers based on instruction content and order. Frequently changing files should be copied last to maximize cache efficiency. Understanding layer caching is crucial for efficient Docker builds. + +### Challenge 5: Docker Hub Push Failed - Case Sensitivity Issue + +**Problem:** +When trying to push the image with `docker push MariaRokkel/devops-info-service:latest`, got error: +``` +failed to do request: Head "https://MariaRokkel/v2/devops-info-service/blobs/sha256:...": dialing MariaRokkel:443 container via direct connection because has no HTTPS proxy: connecting to MariaRokkel:443: dial tcp: lookup MariaRokkel: no such host +``` + +**Root Cause:** +Docker Hub requires **lowercase usernames** in image tags. When using uppercase letters (like `MariaRokkel`), Docker interprets it as a custom registry hostname instead of Docker Hub, causing DNS lookup failures. + +**Solution:** +Retag the image with lowercase username before pushing: +```bash +# Retag with lowercase username +docker tag MariaRokkel/devops-info-service:latest mariarokkel/devops-info-service:latest + +# Now push will work +docker push mariarokkel/devops-info-service:latest +``` + +**Learning:** +- Docker Hub usernames in image tags must be lowercase, even if your Docker Hub username has uppercase letters +- Always use lowercase when tagging images for Docker Hub: `username/repository:tag` +- The format `docker.io/username/repository` is implicit - Docker automatically prepends `docker.io/` for Docker Hub +- For other registries (like GitHub Container Registry), you must explicitly specify the registry: `ghcr.io/username/repository` + +### Challenge 6: Push Access Denied - Repository Does Not Exist + +**Problem:** +After fixing the case sensitivity issue, got error when pushing: +``` +push access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed +``` + +**Root Cause:** +The repository `mariarokkel/devops-info-service` doesn't exist on Docker Hub yet. Docker Hub requires you to create the repository first (either through the web interface or it will be created automatically on first push if you're properly authenticated and have the right permissions). + +**Possible causes:** +1. Not logged into Docker Hub (`docker login` not executed or session expired) +2. Repository doesn't exist and needs to be created +3. Wrong username (username mismatch between Docker Hub account and image tag) + +**Solution:** + +**Step 1: Verify login status** +```bash +# Check if you're logged in +docker login + +# If not logged in, login with your Docker Hub credentials +# Username: your-dockerhub-username +# Password: your-dockerhub-password (or access token) +``` + +**Step 2: Create repository on Docker Hub** +- Go to https://hub.docker.com/ +- Click "Create Repository" or go to "Repositories" → "Create" +- Repository name: `devops-info-service` +- Visibility: Public (for this lab) or Private +- Click "Create" + +**Step 3: Verify username matches** +- Make sure the username in the tag matches your Docker Hub username exactly (case-sensitive for the repository name, but username should be lowercase) +- Check your Docker Hub username at https://hub.docker.com/settings/general + +**Step 4: Retry push** +```bash +# Make sure username matches your Docker Hub account +docker push mararokkel/devops-info-service:latest +``` + +**Real example:** In our case, the username was `mararokkel` (as shown in `docker login` output), but the image was tagged with `mariarokkel`. After retagging with the correct username, the push succeeded. + +**Alternative: Auto-create on first push** +If you're properly authenticated and have the right permissions, Docker Hub will automatically create the repository on the first successful push. Make sure: +- You're logged in: `docker login` +- The username in the tag matches your Docker Hub username +- You have permission to create repositories (free accounts can create unlimited public repos) + +**Learning:** +- Docker Hub repositories must exist before pushing (or be auto-created on first push) +- Always verify you're logged in before pushing: `docker login` +- Repository names are case-sensitive +- Use Docker Hub access tokens instead of passwords for better security +- Public repositories are free and unlimited on Docker Hub + +## Conclusion + +This lab successfully containerized the Python Flask application using Docker best practices: + +- ✅ Non-root user for security +- ✅ Specific base image version for reproducibility +- ✅ Optimized layer caching for fast rebuilds +- ✅ `.dockerignore` for efficient builds +- ✅ Minimal image size (~65MB) +- ✅ Published to Docker Hub +- ✅ Comprehensive documentation + +The containerized application works identically to the local version, demonstrating that Docker provides consistent environments across different machines and deployment targets. The implementation follows production-ready practices that will be essential for Kubernetes deployments in future labs. + diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..04c90f2670 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,171 @@ +# LAB03 — Continuous Integration (CI/CD) + +## Task 1 — Unit Testing + +### Testing Framework Selection +**Choice:** `pytest` + +**Why pytest:** +- **Simple syntax**: readable tests with minimal boilerplate. +- **Great ecosystem**: fixtures (`client`), monkeypatching, plugins. +- **Works well with Flask**: integrates cleanly with Flask’s built-in test client. + +### Test Structure +- Tests are located in `app_python/tests/` +- Main test file: `app_python/tests/test_endpoints.py` +- Covered cases: + - `GET /` — JSON structure and required fields + - `GET /health` — health response fields + - `404` — JSON error response for unknown endpoint + - `500` — JSON error response on internal exception + +### How to Run Tests Locally +Install dev dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Run tests: + +```bash +pytest +``` + +### Pytest Output (Proof) + +```text +MojPK@MacBook-Pro-168 app_python % pytest +========================================================== test session starts =========================================================== +platform darwin -- Python 3.13.1, pytest-8.3.4, pluggy-1.6.0 +rootdir: /Users/MojPK/Downloads/University/DevOps/DevOps-Core-Course/app_python +plugins: anyio-4.9.0 +collected 4 items + +tests/test_endpoints.py .... [100%] + +=========================================================== 4 passed in 0.08s ============================================================ +``` + +### Screenshot + +![Pytest output](screenshots/04-pytest-output.png) + +--- + +## Task 2 — GitHub Actions CI Workflow + +### Workflow Overview +- **Workflow name:** `Python CI (app_python)` +- **Location:** `.github/workflows/python-ci.yml` +- **What it does:** + - On every change in `app_python/**` (any branch): + - installs dev dependencies + - runs `ruff check .` + - runs `pytest` + - On `push` to `master` / `main` / `lab03`: + - builds the Docker image for the Python app + - pushes it to Docker Hub with CalVer tags + +### Triggers (when CI runs) +- **`push`** to any branch (`branches: "**"`) when files change in: + - `app_python/**` + - or `.github/workflows/python-ci.yml` +- **`pull_request`** targeting any branch (`branches: "**"`) with the same paths. + +**Details:** +- Job **`test`** (lint + pytest) runs on **every branch**. +- Job **`docker`** (build & push) runs **only on `push` to `master`, `main`, or `lab03`**: + - this prevents accidentally publishing images from random feature branches. + +### Actions Used and Why +- **`actions/checkout@v4`** — standard way to fetch repository code in CI. +- **`actions/setup-python@v5`** — guarantees the required Python version (`3.11`) regardless of the runner. +- **`docker/setup-buildx-action@v3`** — prepares the environment for modern Docker builds. +- **`docker/login-action@v3`** — securely logs into Docker Hub using `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. +- **`docker/build-push-action@v6`** — single step to build and push the image with multiple tags. + +### Versioning Strategy (CalVer) +I chose **Calendar Versioning (CalVer)** because: +- it clearly shows the **release date**; +- it is easy to see which releases happened in the same month; +- it fits **frequent CI/CD releases** without manual SemVer bumping. + +In the `docker` job two tags are computed based on the current UTC date: +- `YYYY.MM.DD` — a full “daily” release, for example `2026.02.10` +- `YYYY.MM` — a “monthly” release, for example `2026.02` + +Additionally, one more tag is added: +- `latest` + +**Resulting Docker Hub image tags:** +- `${DOCKERHUB_USERNAME}/devops-info-service:YYYY.MM.DD` +- `${DOCKERHUB_USERNAME}/devops-info-service:YYYY.MM` +- `${DOCKERHUB_USERNAME}/devops-info-service:latest` + +### Proof (CI Run) +- The **Actions** tab in GitHub shows a successful run of the `Python CI (app_python)` workflow for branch `lab03` (green check). + +#### GitHub Actions — Docker build & push + +![GitHub Actions Docker build & push](screenshots/05-docker-build-summary.png) + +#### Docker Hub — CalVer Tags + +![Docker Hub tags](screenshots/06-docker-tags.png) + +--- + +## Task 3 — CI Best Practices & Security + +### Status Badge +- Added a GitHub Actions status badge for the `Python CI (app_python)` workflow to the top of `app_python/README.md`: + - badge shows the current status of the CI pipeline for branch `lab03` + - clicking the badge opens the workflow runs page in GitHub Actions +- This provides immediate visual feedback that the pipeline is passing before running or deploying the app. + +### Dependency Caching +- Implemented **pip caching** via `actions/setup-python@v5`: + - `cache: pip` enables caching for Python packages + - `cache-dependency-path` includes `app_python/requirements.txt` and `app_python/requirements-dev.txt` +- Effect: + - the first run installs all dependencies (cold cache) + - subsequent runs are faster due to cache hits + - this reduces pipeline duration and load on package registries + +**Measured improvement (GitHub Actions):** +- Cold cache run: ~6 seconds for the *Install dependencies* step +- Warm cache run: ~4 seconds for the *Install dependencies* step +- Approximate improvement: about **33% faster** dependency installation + +### Security Scanning with Snyk +- Integrated **Snyk** security scanning into the `test` job: + - installs Snyk CLI via `snyk/actions/setup` + - runs only when the `SNYK_TOKEN` secret is configured in the repository + - scans Python dependencies for known vulnerabilities using: + - `snyk test --file=requirements.txt` +- Detected vulnerabilities and remediation steps can be reviewed in the Snyk UI: + - upgrade affected packages where possible + - document accepted risks if upgrading is not feasible + +**Snyk result (proof):** +- The Snyk step in GitHub Actions completed successfully and reported no blocking vulnerabilities for `app_python/requirements.txt` at the time of the scan. + +### CI Best Practices Applied +In this lab the following CI best practices were applied: + +1. **Least privilege and scoped permissions** + - Workflow-level `permissions: contents: read` and minimal permissions in jobs + - Docker Hub credentials are provided via GitHub Secrets and used only in the `docker` job + +2. **Path filters and branch controls** + - Workflow triggers only when files in `app_python/**` or the workflow file change + - Docker image publishing is limited to `master`, `main`, and `lab03` branches to avoid accidental releases + +3. **Dependency caching** + - pip caching is enabled via `actions/setup-python@v5` (`cache: pip`) + - reduces CI time and resource usage across many pushes and pull requests + +4. **Separation of concerns in jobs** + - `test` job focuses on linting, tests, and security scanning + - `docker` job depends on `test` and only runs after tests pass, enforcing a “test before build/publish” workflow diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..cf1d758cd8 Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..e1da089fd2 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..fdf349a8a3 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/04-pytest-output.png b/app_python/docs/screenshots/04-pytest-output.png new file mode 100644 index 0000000000..a4c447220e Binary files /dev/null and b/app_python/docs/screenshots/04-pytest-output.png differ diff --git a/app_python/docs/screenshots/05-docker-build-summary.png b/app_python/docs/screenshots/05-docker-build-summary.png new file mode 100644 index 0000000000..a557b67370 Binary files /dev/null and b/app_python/docs/screenshots/05-docker-build-summary.png differ diff --git a/app_python/docs/screenshots/06-docker-tags.png b/app_python/docs/screenshots/06-docker-tags.png new file mode 100644 index 0000000000..10c19c4372 Binary files /dev/null and b/app_python/docs/screenshots/06-docker-tags.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..9e0b4e11c4 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest==8.3.4 +ruff==0.9.6 + + diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..f6309a6723 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.1.0 +prometheus-client==0.23.1 \ No newline at end of file diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py new file mode 100644 index 0000000000..3f16405fc6 --- /dev/null +++ b/app_python/tests/test_endpoints.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import sys + +import pytest + + +# Ensure we can import `app.py` regardless of where pytest is launched from. +APP_PYTHON_DIR = Path(__file__).resolve().parents[1] +if str(APP_PYTHON_DIR) not in sys.path: + sys.path.insert(0, str(APP_PYTHON_DIR)) + +import app as app_module # noqa: E402 (import after sys.path tweak) + +flask_app = app_module.app + + +@pytest.fixture(autouse=True) +def tmp_visits_file(tmp_path, monkeypatch): + monkeypatch.setattr(app_module, "VISITS_FILE", tmp_path / "visits") + + +@pytest.fixture() +def client(): + flask_app.config.update(TESTING=True) + with flask_app.test_client() as c: + yield c + + +def test_get_root_returns_expected_json_structure(client): + resp = client.get("/", headers={"User-Agent": "pytest"}) + assert resp.status_code == 200 + + data = resp.get_json() + assert isinstance(data, dict) + + # Top-level keys + for key in ("service", "system", "runtime", "request", "visits", "endpoints"): + assert key in data + + # Service info + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["framework"] == "Flask" + assert "version" in service + assert "description" in service + + # System info + system = data["system"] + for key in ( + "hostname", + "platform", + "platform_version", + "architecture", + "cpu_count", + "python_version", + ): + assert key in system + + # cpu_count can be None in some environments; if present, it should be positive. + if system["cpu_count"] is not None: + assert isinstance(system["cpu_count"], int) + assert system["cpu_count"] > 0 + + # Runtime info + runtime = data["runtime"] + assert runtime["timezone"] == "UTC" + assert isinstance(runtime["uptime_seconds"], int) + assert runtime["uptime_seconds"] >= 0 + # Validate ISO-8601 timestamp (Python accepts "+00:00" format) + datetime.fromisoformat(runtime["current_time"]) + + # Request info + req = data["request"] + assert req["method"] == "GET" + assert req["path"] == "/" + assert req["user_agent"] == "pytest" + assert "client_ip" in req + + visits = data["visits"] + assert isinstance(visits["count"], int) + assert visits["count"] >= 1 + assert "file" in visits + + # Endpoints list + endpoints = data["endpoints"] + assert isinstance(endpoints, list) + paths = {e["path"] for e in endpoints} + assert "/" in paths + assert "/health" in paths + assert "/visits" in paths + + +def test_visits_counter_persists_in_file(client): + assert client.get("/").get_json()["visits"]["count"] == 1 + assert client.get("/").get_json()["visits"]["count"] == 2 + assert client.get("/visits").get_json()["visits"] == 2 + assert app_module.VISITS_FILE.read_text(encoding="utf-8").strip() == "2" + + +def test_get_health_returns_healthy_status(client): + resp = client.get("/health") + assert resp.status_code == 200 + + data = resp.get_json() + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + datetime.fromisoformat(data["timestamp"]) + + +def test_404_returns_json_error(client): + resp = client.get("/does-not-exist") + assert resp.status_code == 404 + + data = resp.get_json() + assert data == {"error": "Not Found", "message": "Endpoint does not exist"} + + +def test_500_returns_json_error_when_internal_exception(monkeypatch, client): + # Force an internal error inside the "/" handler + import app as app_module + + def boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(app_module, "get_system_info", boom) + + # In Flask, TESTING=True makes exceptions propagate (error handlers won't run). + # For this test we want to assert the JSON 500 handler response, so disable propagation. + monkeypatch.setitem(flask_app.config, "PROPAGATE_EXCEPTIONS", False) + + resp = client.get("/") + assert resp.status_code == 500 + + data = resp.get_json() + assert data == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + + diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..4097c3f730 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,245 @@ +# Lab 4 — Infrastructure as Code (Terraform & Pulumi) + +## 1. Cloud Provider & Infrastructure + +**Cloud Provider:** Yandex Cloud +**Folder:** `default` (`b1g742cqbcqhgvgl8qne`) +**Zone:** `ru-central1-a` +**Cloud ID:** `b1g00co0b00cu998pr7c` + +**Instance Type:** `standard-v2` platform, 2 vCPU cores (20% core fraction), 1 GB RAM, 10 GB HDD boot disk. This configuration fits within Yandex Cloud free tier limits. + +**Network Configuration:** +- VPC network: `lab04-network` +- Subnet: `lab04-subnet` (`10.0.1.0/24`) +- Security group: `lab04-security-group` with rules for ports 22 (SSH from my IP), 80 (HTTP), and 5000 (custom app port) + +**VM Details:** +- Public IP: `93.77.180.48` +- Private IP: `10.0.1.24` +- OS: Ubuntu 22.04 LTS +- SSH access: `ubuntu` user via public key authentication + +**Cost:** Free tier eligible. Expected cost is approximately 0 ₽ with proper resource management and cleanup after the lab. + +**Resources Created:** +- 1 VPC network (`yandex_vpc_network.network`) +- 1 subnet (`yandex_vpc_subnet.subnet`) +- 1 security group (`yandex_vpc_security_group.sg`) +- 1 compute instance (`yandex_compute_instance.vm`) + +## 2. Terraform Implementation + +**Terraform Version:** `v1.5.7` (installed via Homebrew) + +**Project Structure:** +``` +terraform/ +├── providers.tf # Provider configuration +├── variables.tf # Input variables +├── main.tf # Resource definitions +├── outputs.tf # Output values +├── terraform.tfvars # Variable values (gitignored) +└── .gitignore # Excludes state files and secrets +``` + +**Key Configuration Decisions:** +- Provider `yandex-cloud/yandex` v0.100.0 installed manually due to registry access issues +- Cloud values defined as variables +- VM image retrieved using `data "yandex_compute_image"` with family `ubuntu-2204-lts` +- Security group with ingress rules for ports 22, 80, 5000 +- VM with public IP and SSH key via metadata + +**Challenges Encountered:** + +1. **Terraform Registry Access:** Provider downloaded manually from GitHub releases due to registry access issues. + +2. **IAM Permissions:** Required roles assigned but VM creation failed until billing account was linked. + +3. **Billing Account:** Billing account must be linked to folder before creating compute instances. + +4. **VPC Network Quota:** Hit quota limit, resolved by deleting test networks. + +5. **Token Expiration:** `YC_TOKEN` expires, regenerated before each apply. + +**Terminal Output:** + +**terraform init:** +``` +Initializing provider plugins... +- Finding yandex-cloud/yandex versions matching "~> 0.100"... +- Installing yandex-cloud/yandex v0.100.0... +- Installed yandex-cloud/yandex v0.100.0 (unauthenticated) + +Terraform has been successfully initialized! +``` + +**terraform plan (excerpt):** +``` +Plan: 4 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + ssh_command = (known after apply) + + vm_id = (known after apply) + + vm_name = "lab04-vm" + + vm_private_ip = (known after apply) + + vm_public_ip = (known after apply) +``` + +**terraform apply (final output):** +``` +yandex_vpc_network.network: Creating... +yandex_vpc_network.network: Creation complete after 2s [id=enpou2t340o694hj7r90] +yandex_vpc_subnet.subnet: Creating... +yandex_vpc_subnet.subnet: Creation complete after 0s [id=e9b1t61ik4rp7famppme] +yandex_vpc_security_group.sg: Creating... +yandex_vpc_security_group.sg: Creation complete after 1s [id=enp...] +yandex_compute_instance.vm: Creating... +yandex_compute_instance.vm: Still creating... [10s elapsed] +yandex_compute_instance.vm: Still creating... [20s elapsed] +yandex_compute_instance.vm: Still creating... [30s elapsed] +yandex_compute_instance.vm: Creation complete after 38s [id=fhmhkqtpbos6tqfsvbpv] + +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. + +Outputs: + +ssh_command = "ssh ubuntu@93.77.180.48" +vm_id = "fhmhkqtpbos6tqfsvbpv" +vm_name = "lab04-vm" +vm_private_ip = "10.0.1.24" +vm_public_ip = "93.77.180.48" +``` + +![Terraform apply and output](screenshots/terraform_output.jpg) + +**SSH Connection:** +```bash +$ ssh ubuntu@93.77.180.48 +Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-170-generic x86_64) +... +ubuntu@fhmhkqtpbos6tqfsvbpv:~$ +``` + +![SSH connection to VM](screenshots/ssh_connection.jpg) + +SSH connection successful. VM is accessible and operational. + +## 3. Pulumi Implementation + +**Pulumi Version:** `v3.222.0` +**Language:** Python 3.12 + +**Terraform Cleanup:** Before creating Pulumi infrastructure, Terraform resources were destroyed using `terraform destroy` to ensure a clean state and demonstrate infrastructure lifecycle management. + +**Project Structure:** +``` +pulumi/ +├── Pulumi.yaml # Project metadata +├── __main__.py # Infrastructure code +├── requirements.txt # Python dependencies +├── Pulumi.dev.yaml # Stack configuration (gitignored) +└── venv/ # Python virtual environment +``` + +**Key Configuration Decisions:** +- Provider `pulumi-yandex` v0.13.0 +- Configuration via Pulumi config instead of `.tfvars` +- Same infrastructure: VPC network, subnet, VM +- VM image via `yandex.get_compute_image()` with family `ubuntu-2204-lts` +- Resources linked via Pulumi Outputs + +**Code Differences from Terraform:** +- Python code instead of HCL +- `Config()` object instead of variables +- Function calls instead of resource blocks +- `pulumi.export()` instead of output blocks + +**Challenges Encountered:** + +1. **Python Version:** Python 3.13 incompatible with `pulumi-yandex` (missing `pkg_resources`). Used Python 3.12 with `setuptools<70.0.0`. + +2. **Security Group API:** `VpcSecurityGroup` API mismatch - couldn't configure ingress/egress rules. Used default security group instead. + +3. **Folder ID:** Required explicit `folder_id` in all resources. + +4. **Image Lookup:** `get_compute_image()` works without `folder_id` parameter. + +**Terminal Output:** + +**pulumi preview:** +``` +Previewing update (dev): + Type Name Plan + + pulumi:pulumi:Stack lab04-infra-dev create + + ├─ yandex:index:VpcNetwork lab04-network create + + ├─ yandex:index:VpcSubnet lab04-subnet create + + └─ yandex:index:ComputeInstance lab04-vm create + +Resources: + + 4 to create +``` + +**pulumi up:** +``` +Updating (dev): + Type Name Status + pulumi:pulumi:Stack lab04-infra-dev + + ├─ yandex:index:VpcNetwork lab04-network created (2s) + + ├─ yandex:index:VpcSubnet lab04-subnet created (0.36s) + + └─ yandex:index:ComputeInstance lab04-vm created (40s) + +Outputs: + + ssh_command : "ssh ubuntu@51.250.76.250" + + vm_id : "fhmiq0i37091ulh7njnp" + + vm_private_ip: "10.0.1.5" + + vm_public_ip : "51.250.76.250" + +Resources: + + 3 created + 1 unchanged + +Duration: 44s +``` + +![Pulumi up output](screenshots/pulumi_up.jpg) + +**SSH Connection:** +```bash +$ ssh ubuntu@51.250.76.250 +Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-170-generic x86_64) +... +ubuntu@fhmiq0i37091ulh7njnp:~$ +``` + +![SSH connection to Pulumi VM](screenshots/ssh_connection_pulumi.jpg) + +SSH connection successful. VM is accessible and operational. + +## 4. Terraform vs Pulumi Comparison + +**Ease of Learning:** Terraform's HCL is simpler for infrastructure. Pulumi requires Python knowledge and understanding of Output types. + +**Code Readability:** HCL is purpose-built and clear. Pulumi reads like Python but can be verbose for simple resources. + +**Debugging:** Terraform errors are clearer. Pulumi allows Python debugger but errors can be harder to trace. + +**Documentation:** Terraform has more examples and better Yandex Cloud support. Pulumi documentation is less comprehensive. + +**Use Case:** Terraform for simplicity and wide support. Pulumi for complex logic, code reuse, or Python-focused teams. + +## 5. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** +- **Status:** Keeping Pulumi-created VM for Lab 5 +- **VM Details:** + - Public IP: `51.250.76.250` + - Private IP: `10.0.1.5` + - VM ID: `fhmiq0i37091ulh7njnp` + - Created with: Pulumi + - SSH: `ssh ubuntu@51.250.76.250` + +**Cleanup Status:** +- Terraform infrastructure was destroyed before creating Pulumi infrastructure +- Pulumi VM is kept running for Lab 5 (Ansible) +- After Lab 5 completion, will run `pulumi destroy` to clean up resources diff --git a/docs/screenshots/pulumi_up.jpg b/docs/screenshots/pulumi_up.jpg new file mode 100644 index 0000000000..eccaa55a40 Binary files /dev/null and b/docs/screenshots/pulumi_up.jpg differ diff --git a/docs/screenshots/ssh_connection.jpg b/docs/screenshots/ssh_connection.jpg new file mode 100644 index 0000000000..4e1039fec9 Binary files /dev/null and b/docs/screenshots/ssh_connection.jpg differ diff --git a/docs/screenshots/ssh_connection_pulumi.jpg b/docs/screenshots/ssh_connection_pulumi.jpg new file mode 100644 index 0000000000..b804369eed Binary files /dev/null and b/docs/screenshots/ssh_connection_pulumi.jpg differ diff --git a/docs/screenshots/terraform_output.jpg b/docs/screenshots/terraform_output.jpg new file mode 100644 index 0000000000..a40514affd Binary files /dev/null and b/docs/screenshots/terraform_output.jpg differ diff --git a/edge-api/.gitignore b/edge-api/.gitignore new file mode 100644 index 0000000000..553094cfb6 --- /dev/null +++ b/edge-api/.gitignore @@ -0,0 +1,5 @@ +node_modules +.wrangler +dist +.dev.vars +*.log diff --git a/edge-api/package-lock.json b/edge-api/package-lock.json new file mode 100644 index 0000000000..c971fd54f2 --- /dev/null +++ b/edge-api/package-lock.json @@ -0,0 +1,1527 @@ +{ + "name": "edge-api", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "edge-api", + "version": "0.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20250525.0", + "typescript": "^5.7.3", + "wrangler": "^4.14.1" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", + "integrity": "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", + "integrity": "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", + "integrity": "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", + "integrity": "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", + "integrity": "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260503.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260503.1.tgz", + "integrity": "sha512-8VKtafR4fNMtddutOnam3yq3AQvrl9bzuMio3B3AEAfrdx7xaaDV0Oyxz54P07lODwX0jydukGLC1rpDdYXAAA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260430.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260430.0.tgz", + "integrity": "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260430.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260430.1.tgz", + "integrity": "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260430.1", + "@cloudflare/workerd-darwin-arm64": "1.20260430.1", + "@cloudflare/workerd-linux-64": "1.20260430.1", + "@cloudflare/workerd-linux-arm64": "1.20260430.1", + "@cloudflare/workerd-windows-64": "1.20260430.1" + } + }, + "node_modules/wrangler": { + "version": "4.87.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.87.0.tgz", + "integrity": "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260430.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260430.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260430.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/edge-api/package.json b/edge-api/package.json new file mode 100644 index 0000000000..99037068fd --- /dev/null +++ b/edge-api/package.json @@ -0,0 +1,15 @@ +{ + "name": "edge-api", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "cf-typegen": "wrangler types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250525.0", + "typescript": "^5.7.3", + "wrangler": "^4.14.1" + } +} diff --git a/edge-api/screenshots/.gitkeep b/edge-api/screenshots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/edge-api/screenshots/workers-dashboard-metrics.png b/edge-api/screenshots/workers-dashboard-metrics.png new file mode 100644 index 0000000000..744e794826 Binary files /dev/null and b/edge-api/screenshots/workers-dashboard-metrics.png differ diff --git a/edge-api/screenshots/workers-deployments-list.png b/edge-api/screenshots/workers-deployments-list.png new file mode 100644 index 0000000000..4196ca4072 Binary files /dev/null and b/edge-api/screenshots/workers-deployments-list.png differ diff --git a/edge-api/screenshots/workers-rollback.png b/edge-api/screenshots/workers-rollback.png new file mode 100644 index 0000000000..18638129e1 Binary files /dev/null and b/edge-api/screenshots/workers-rollback.png differ diff --git a/edge-api/screenshots/workers-wrangler-dev-local.png b/edge-api/screenshots/workers-wrangler-dev-local.png new file mode 100644 index 0000000000..ca86cab4a1 Binary files /dev/null and b/edge-api/screenshots/workers-wrangler-dev-local.png differ diff --git a/edge-api/screenshots/workers-wrangler-tail.png b/edge-api/screenshots/workers-wrangler-tail.png new file mode 100644 index 0000000000..bc91f1b3b6 Binary files /dev/null and b/edge-api/screenshots/workers-wrangler-tail.png differ diff --git a/edge-api/src/index.ts b/edge-api/src/index.ts new file mode 100644 index 0000000000..80f8a83f30 --- /dev/null +++ b/edge-api/src/index.ts @@ -0,0 +1,73 @@ +export interface Env { + APP_NAME: string; + COURSE_NAME: string; + API_TOKEN: string; + ADMIN_EMAIL: string; + SETTINGS: KVNamespace; +} + +const VISITS_KEY = "visits"; + +export default { + async fetch( + request: Request, + env: Env, + _ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + console.log("path", url.pathname, "colo", request.cf?.colo); + console.log("method", request.method); + + if (url.pathname === "/health") { + return Response.json({ status: "ok" }); + } + + if (url.pathname === "/") { + return Response.json({ + app: env.APP_NAME, + course: env.COURSE_NAME, + message: "edge-api worker", + timestamp: new Date().toISOString(), + }); + } + + if (url.pathname === "/deploy-info") { + return Response.json({ + worker: env.APP_NAME, + course: env.COURSE_NAME, + runtime: "cloudflare-workers", + compatibilityDate: "2025-05-01", + observedAt: new Date().toISOString(), + }); + } + + if (url.pathname === "/edge") { + const cf = request.cf; + return Response.json({ + colo: cf?.colo ?? null, + country: cf?.country ?? null, + city: cf?.city ?? null, + asn: cf?.asn ?? null, + httpProtocol: cf?.httpProtocol ?? null, + tlsVersion: cf?.tlsVersion ?? null, + }); + } + + if (url.pathname === "/secrets-status") { + return Response.json({ + apiTokenConfigured: Boolean(env.API_TOKEN?.length), + adminEmailConfigured: Boolean(env.ADMIN_EMAIL?.length), + apiTokenLength: env.API_TOKEN?.length ?? 0, + }); + } + + if (url.pathname === "/counter") { + const raw = await env.SETTINGS.get(VISITS_KEY); + const visits = Number(raw ?? "0") + 1; + await env.SETTINGS.put(VISITS_KEY, String(visits)); + return Response.json({ visits, key: VISITS_KEY }); + } + + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/edge-api/tsconfig.json b/edge-api/tsconfig.json new file mode 100644 index 0000000000..444801cb62 --- /dev/null +++ b/edge-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/edge-api/wrangler.jsonc b/edge-api/wrangler.jsonc new file mode 100644 index 0000000000..cd6a3f6765 --- /dev/null +++ b/edge-api/wrangler.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "edge-api", + "main": "src/index.ts", + "compatibility_date": "2025-05-01", + "observability": { + "enabled": true + }, + "vars": { + "APP_NAME": "edge-api", + "COURSE_NAME": "devops-core" + }, + "kv_namespaces": [ + { + "binding": "SETTINGS", + "id": "357a049dc0484faeaecdc8025341583f" + } + ] +} \ No newline at end of file diff --git a/k8s/ARGOCD.md b/k8s/ARGOCD.md new file mode 100644 index 0000000000..9fd5af231c --- /dev/null +++ b/k8s/ARGOCD.md @@ -0,0 +1,460 @@ +# Lab 13 — GitOps with ArgoCD + +This report documents the completed Lab 13 implementation. + +--- + +## 1. ArgoCD installation and setup + +### 1.1 Cluster context and namespace + +```bash +kubectl config use-context minikube +kubectl cluster-info +kubectl create namespace argocd +``` + +```text +Switched to context "minikube". +Kubernetes control plane is running at https://127.0.0.1:60956 +CoreDNS is running at https://127.0.0.1:60956/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy +namespace/argocd created +``` + +### 1.2 Helm install + +```bash +helm upgrade --install argocd argo/argo-cd --namespace argocd +``` + +```text +NAME: argocd +LAST DEPLOYED: Thu Apr 23 20:03:29 2026 +NAMESPACE: argocd +STATUS: deployed +REVISION: 1 +``` + +### 1.3 Readiness checks + +```bash +kubectl get pods -n argocd +kubectl rollout status deploy/argocd-server -n argocd +kubectl rollout status deploy/argocd-repo-server -n argocd +kubectl rollout status statefulset/argocd-application-controller -n argocd +``` + +```text +NAME READY STATUS RESTARTS AGE +argocd-application-controller-0 1/1 Running 0 112s +argocd-applicationset-controller-754f66bd99-7mnvb 1/1 Running 0 112s +argocd-dex-server-5584f66c5d-58pnp 1/1 Running 0 112s +argocd-notifications-controller-7646987985-rpcsl 1/1 Running 0 112s +argocd-redis-7c845cf5b9-2pg89 1/1 Running 0 112s +argocd-repo-server-7c9654f7b-lphqk 1/1 Running 0 112s +argocd-server-5f649867b4-cj4fz 1/1 Running 0 112s +``` + +```text +deployment "argocd-server" successfully rolled out +deployment "argocd-repo-server" successfully rolled out +partitioned roll out complete: 1 new pods have been updated... +``` + +### 1.4 UI access and initial login + +Port-forward method: + +```bash +kubectl port-forward service/argocd-server -n argocd 8080:443 +``` + +Initial admin password retrieval: + +```bash +kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d +``` + +Used account: + +- username: `admin` +- password: `` + +### 1.5 ArgoCD CLI setup + +```bash +HOMEBREW_NO_AUTO_UPDATE=1 brew install argocd +argocd login localhost:8080 --username admin --password "" --insecure +argocd account get-user-info +argocd app list +``` + +```text +'admin:login' logged in successfully +Context 'localhost:8080' updated +``` + +```text +Logged In: true +Username: admin +Issuer: argocd +Groups: +``` + +```text +NAME CLUSTER NAMESPACE PROJECT STATUS HEAL +``` + +Task 1 conclusion: ArgoCD is installed, UI is reachable, and CLI auth works. + +--- + +## 2. Application deployment (Task 2) + +### 2.1 ArgoCD Application manifest + +File created: [`k8s/argocd/application.yaml`](./argocd/application.yaml) + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-service + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/MariaRokkel/DevOps-Core-Course.git + targetRevision: lab13 + path: k8s/devops-info-service + helm: + valueFiles: + - values-dev.yaml + destination: + server: https://kubernetes.default.svc + namespace: lab13 + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +Sync mode is **manual** (no `automated` policy block). + +### 2.2 Apply and initial sync + +```bash +kubectl apply -f k8s/argocd/application.yaml +argocd app list +argocd app get devops-info-service +argocd app sync devops-info-service +kubectl get all -n lab13 +``` + +```text +$ kubectl apply -f k8s/argocd/application.yaml +application.argoproj.io/devops-info-service created +``` + +```text +$ argocd app list +NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY +argocd/devops-info-service https://kubernetes.default.svc lab13 default OutOfSync Missing Manual +``` + +```text +$ argocd app get devops-info-service +Sync Status: OutOfSync from lab13 (f8553df) +Health Status: Missing +``` + +First sync failed due to a NodePort collision inherited from `values-dev.yaml`: + +```text +Service "devops-info-service" is invalid: spec.ports[0].nodePort: +Invalid value: 30082: provided port is already allocated +``` + +Resolution: set a different NodePort declaratively in the ArgoCD Application Helm parameters (`service.nodePort=30084`) and re-sync. + +Successful re-sync evidence: + +```text +$ kubectl apply -f k8s/argocd/application.yaml +application.argoproj.io/devops-info-service configured +``` + +```text +$ argocd app sync devops-info-service +... +Sync Status: Synced to lab13 (6477475) +Health Status: Healthy +Phase: Succeeded +Message: successfully synced (no more tasks) +... +Service lab13 devops-info-service Synced Healthy +Deployment lab13 devops-info-service Synced Healthy +``` + +```text +$ argocd app wait devops-info-service --sync --health --timeout 180 +Sync Status: Synced to lab13 (6477475) +Health Status: Healthy +``` + +Cluster resources in target namespace: + +```text +$ kubectl get all -n lab13 +NAME READY STATUS RESTARTS AGE +pod/devops-info-service-79f4477485-2lcm4 1/1 Running 0 3m9s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-service NodePort 10.97.23.19 80:30084/TCP 38s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-service 1/1 1 1 3m9s + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-info-service-79f4477485 1 1 1 3m9s +``` + +Task 2 conclusion: Application resource created, manual sync performed, and workloads are healthy in namespace `lab13`. + +--- + +## 3. Multi-environment deployment (Task 3) + +### 3.1 Namespaces and application manifests + +Created: + +- [`k8s/argocd/application-dev.yaml`](./argocd/application-dev.yaml) +- [`k8s/argocd/application-prod.yaml`](./argocd/application-prod.yaml) + +`dev` Application: + +- namespace: `dev` +- values file: `values-dev.yaml` +- sync policy: **automated** with `prune: true` and `selfHeal: true` +- NodePort override: `service.nodePort=30085` to avoid collision with previous labs + +`prod` Application: + +- namespace: `prod` +- values file: `values-prod.yaml` +- sync policy: **manual** (no `automated` block) +- Minikube override in ArgoCD parameters: `service.type=NodePort`, `service.nodePort=30086` (to avoid `LoadBalancer` health staying `Progressing` without an external LB) + +### 3.2 Apply and verify + +```bash +kubectl apply -f k8s/argocd/application-dev.yaml +kubectl apply -f k8s/argocd/application-prod.yaml +argocd app list +argocd app get devops-info-service-dev +argocd app get devops-info-service-prod +``` + +Observed behavior: + +- `devops-info-service-prod` remained manual and required explicit sync. +- Initial `prod` wait timed out when a previous operation was still active; this was resolved by terminating the operation and syncing again. + +Manual sync for prod: + +```bash +argocd app sync devops-info-service-prod +argocd app wait devops-info-service-prod --sync --health --timeout 180 +``` + +Recovery command used when operation lock was present: + +```bash +argocd app terminate-op devops-info-service-prod +``` + +Evidence (prod): + +```text +$ argocd app sync devops-info-service-prod +... +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +Phase: Succeeded +Message: successfully synced (no more tasks) +``` + +```text +$ argocd app wait devops-info-service-prod --sync --health --timeout 180 +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ argocd app get devops-info-service-prod +Sync Policy: Manual +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ kubectl get svc -n prod +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +devops-info-service-prod NodePort 10.104.15.139 80:30086/TCP 87m +``` + +Cluster verification commands: + +```bash +kubectl get all -n dev +kubectl get all -n prod +``` + +Task 3 conclusion: multi-environment ArgoCD applications are defined and deployed with different sync policies (dev automated, prod manual), and `prod` is confirmed `Synced`/`Healthy` after manual sync. + +Evidence summary: + +```text +$ argocd app get devops-info-service-prod +Sync Policy: Manual +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ argocd app get devops-info-service-dev +Sync Policy: Automated (Prune) +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ kubectl get svc -n prod +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +devops-info-service-prod NodePort 10.104.15.139 80:30086/TCP 87m +``` + +--- + +## 4. Self-healing and drift behavior (Task 4) + +### 4.1 Self-healing test: manual scale drift in `dev` + +Command: + +```bash +kubectl scale deployment/devops-info-service-dev -n dev --replicas=5 +kubectl get deploy -n dev -w +``` + +Observed rollout stream: + +```text +NAME READY UP-TO-DATE AVAILABLE AGE +devops-info-service-dev 1/5 5 1 91m +devops-info-service-dev 1/1 5 1 91m +devops-info-service-dev 1/1 5 1 91m +devops-info-service-dev 1/1 5 1 91m +devops-info-service-dev 1/1 1 1 91m +``` + +Interpretation: the deployment was manually scaled to 5 replicas, then ArgoCD (automated + self-heal) reconciled it back to the Git-defined value (`replicaCount: 1` in `values-dev.yaml`). + +Post-check: + +```text +$ argocd app get devops-info-service-dev +Sync Policy: Automated (Prune) +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ kubectl get deploy -n dev +NAME READY UP-TO-DATE AVAILABLE AGE +devops-info-service-dev 1/1 1 1 99m +``` + +### 4.2 Pod deletion test (`dev`) + +Command sequence: + +```bash +kubectl get pods -n dev +kubectl delete pod -n dev devops-info-service-dev-7cf799f8d7-t49jd +kubectl get pods -n dev -w +``` + +Observed behavior: + +```text +NAME READY STATUS RESTARTS AGE +devops-info-service-dev-7cf799f8d7-t49jd 1/1 Running 0 101m +pod "devops-info-service-dev-7cf799f8d7-t49jd" deleted +NAME READY STATUS RESTARTS AGE +devops-info-service-dev-7cf799f8d7-4ppxm 1/1 Running 0 3m41s +``` + +Interpretation: pod recreation is Kubernetes controller behavior (Deployment/ReplicaSet reconciliation), not ArgoCD drift correction. + +Post-check: + +```text +$ argocd app get devops-info-service-dev +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +### 4.3 Configuration drift test (`dev`) + +Test command: + +```bash +kubectl set image deployment/devops-info-service-dev \ + devops-info-service=nginx:latest -n dev +``` + +```text +deployment.apps/devops-info-service-dev image updated +``` + +Immediate and delayed app checks: + +```text +$ argocd app get devops-info-service-dev +Sync Policy: Automated (Prune) +Sync Status: Synced to lab13 (395bf43) +Health Status: Healthy +``` + +```text +$ kubectl get deployment devops-info-service-dev -n dev -o jsonpath='{.spec.template.spec.containers[0].image}' +mararokkel/devops-info-service:latest +``` + +Interpretation: the manual runtime mutation was automatically reconciled back to the Git-defined image. In this run, auto-sync/self-heal acted fast enough that `OutOfSync` was not visible in CLI output snapshots. + +### 4.4 Sync behavior explanation + +- **Kubernetes self-healing:** replacing failed/deleted pods to satisfy the Deployment replica target. +- **ArgoCD self-healing:** reconciling managed resource spec drift back to Git state (for automated apps with `selfHeal: true`). +- **What triggers ArgoCD sync checks:** repository revision changes and periodic refresh/reconciliation loops. +- **Observed policy difference in this lab:** `dev` auto-sync+prune+self-heal; `prod` manual sync. + +Task 4 conclusion: self-healing behavior was demonstrated for replica drift and spec drift in `dev`, and pod recreation behavior was distinguished as native Kubernetes reconciliation. + +--- + +## 5. Screenshots + +### 5.1 Applications list (all apps + sync/health) + +![ArgoCD Applications List](./screenshots/argocd-applications-list.png) + +### 5.2 Dev application details (`devops-info-service-dev`) + +![ArgoCD Dev Application Details](./screenshots/argocd-dev-details.png) + +### 5.3 Prod application details (`devops-info-service-prod`) + +![ArgoCD Prod Application Details](./screenshots/argocd-prod-details.png) diff --git a/k8s/CONFIGMAPS.md b/k8s/CONFIGMAPS.md new file mode 100644 index 0000000000..973d439063 --- /dev/null +++ b/k8s/CONFIGMAPS.md @@ -0,0 +1,247 @@ +# Lab 12 — ConfigMaps and persistent volumes + +This report documents the visit counter, Helm ConfigMaps, PVC-backed storage, and verification for Lab 12. **Cluster:** Minikube. **Release:** `lab12`, **namespace:** `lab12`. + +--- + +## 1. Application changes + +### Behaviour + +- **`GET /`** increments a counter and persists it to **`VISITS_FILE`** (default `/data/visits`). +- **`GET /visits`** returns the current value without incrementing. +- Updates use a temporary file and `Path.replace()` for atomic writes; a `threading.Lock` serializes concurrent updates. + +### Unit tests + +From `app_python/` (virtual environment with dev dependencies): + +```bash +pytest -q +``` + +```text +..... [100%] +5 passed in 0.21s +``` + +### Local Docker Compose + +From `app_python/`: + +```bash +docker compose up --build -d +curl -s http://localhost:8080/ +curl -s http://localhost:8080/visits +cat ./data/visits +docker compose restart devops-info-service +curl -s http://localhost:8080/visits +``` + +```text +{"file":"/app/data/visits","visits":1} +1 +{"file":"/app/data/visits","visits":1} +``` + +Lines: response to `GET /visits` before restart; contents of `./data/visits`; response to `GET /visits` after `docker compose restart`. + +The compose file sets `VISITS_FILE=/app/data/visits` and mounts `./data` so the counter survives container restarts. + +--- + +## 2. Helm chart: ConfigMaps and image for Kubernetes + +### ConfigMap sources + +| Path | Role | +|------|------| +| [`devops-info-service/files/config.json`](./devops-info-service/files/config.json) | JSON config embedded into a ConfigMap | +| [`devops-info-service/templates/configmap.yaml`](./devops-info-service/templates/configmap.yaml) | File ConfigMap (`.Files.Get`) + env ConfigMap (`APP_NAME`, `APP_ENV`, `LOG_LEVEL`) | + +The Deployment mounts the file ConfigMap at **`/config/config.json`** and uses **`envFrom`** for the env ConfigMap (alongside the credentials Secret when enabled). + +### Image build (Minikube) + +The public image `mararokkel/devops-info-service:latest` did not include the new routes until a **local image** was built inside Minikube’s Docker daemon: + +```bash +eval $(minikube docker-env) +docker build -t devops-info-service:lab12 ./app_python +``` + +Helm was then pointed at that image with `pullPolicy: IfNotPresent`. + +--- + +## 3. Helm lint and install + +### Lint + +```bash +helm lint ./k8s/devops-info-service +``` + +```text +==> Linting ./k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed +``` + +### Install / upgrade + +The first attempt with **`nodePort: 30082`** failed because that port was already allocated. **`service.nodePort=30083`** was used instead. + +```bash +helm upgrade --install lab12 ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --namespace lab12 --create-namespace \ + --set service.nodePort=30083 \ + --set image.repository=devops-info-service \ + --set image.tag=lab12 \ + --set image.pullPolicy=IfNotPresent +``` + +```text +Release "lab12" has been upgraded. Happy Helming! +NAME: lab12 +LAST DEPLOYED: Thu Apr 16 21:16:06 2026 +NAMESPACE: lab12 +STATUS: deployed +REVISION: 3 +``` + +```bash +kubectl rollout status deployment/lab12-devops-info-service -n lab12 +``` + +```text +deployment "lab12-devops-info-service" successfully rolled out +``` + +--- + +## 4. ConfigMap verification + +### Objects + +```bash +kubectl get configmap,pvc -n lab12 +``` + +`kubectl` prints **ConfigMaps** and **PVCs** as two tables (same result as running `kubectl get configmap` and `kubectl get pvc` separately): + +```text +NAME DATA AGE +kube-root-ca.crt 1 5m27s +lab12-devops-info-service-config 1 5m23s +lab12-devops-info-service-env 3 5m23s + +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE +lab12-devops-info-service-data Bound pvc-e9441154-a847-4b86-ad7f-102b7aae2be4 100Mi RWO standard 5m27s +``` + +### File mount: `/config/config.json` + +```bash +kubectl exec -n lab12 deploy/lab12-devops-info-service -- cat /config/config.json +``` + +```json +{ + "applicationName": "devops-info-service", + "environment": "dev", + "features": { + "visitsCounter": true, + "metrics": true + }, + "settings": { + "logLevel": "info", + "responseFormat": "json" + } +} +``` + +### Environment variables from ConfigMap + +```bash +kubectl exec -n lab12 deploy/lab12-devops-info-service -- printenv | grep -E '^(APP_|LOG_)' +``` + +```text +APP_NAME=devops-info-service +LOG_LEVEL=info +APP_ENV=dev +``` + +--- + +## 5. PersistentVolumeClaim + +Details appear in the combined listing in Section 4. PVC excerpt: + +```text +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +lab12-devops-info-service-data Bound pvc-e9441154-a847-4b86-ad7f-102b7aae2be4 100Mi RWO standard 5m27s +``` + +[`values-dev.yaml`](./devops-info-service/values-dev.yaml) enables **`persistence.enabled: true`** with **`replicaCount: 1`** (single replica is required for a single **ReadWriteOnce** volume). The Deployment mounts the PVC at **`/data`** and sets **`VISITS_FILE=/data/visits`**. **`podSecurityContext.fsGroup: 1000`** matches the non-root user in the image (UID/GID 1000). + +--- + +## 6. HTTP checks (Minikube) + +Direct **`curl` to `NodeIP:NodePort`** timed out from the host (common with Docker Desktop / Minikube networking). **Port-forward** was used for reliable access: + +```bash +kubectl port-forward -n lab12 svc/lab12-devops-info-service 8082:80 +``` + +After the local image was deployed, the first checks were: + +```bash +curl -sS http://localhost:8082/visits +curl -sS http://localhost:8082/ +``` + +```text +{"file":"/data/visits","visits":0} +``` + +A follow-up `GET /` returned `visits.count` **1** and listed **`/visits`** under `endpoints`. + +--- + +## 7. Persistence test (delete pod) + +Commands: + +```bash +kubectl get pods -n lab12 +kubectl delete pod -n lab12 lab12-devops-info-service-78b444d574-xs4q6 +kubectl rollout status deployment/lab12-devops-info-service -n lab12 +kubectl exec -n lab12 deploy/lab12-devops-info-service -- cat /data/visits +curl -sS http://localhost:8082/visits +``` + +**Result:** the counter stayed **2** after the pod was recreated — the PVC retained the file. + +```text +2 +``` + +```text +{"file":"/data/visits","visits":2} +``` + +--- + +## 8. ConfigMap vs Secret + +| | ConfigMap | Secret | +|---|-----------|--------| +| Typical content | Non-sensitive config (flags, URLs, log level) | Passwords, tokens, TLS material | +| API storage | Plaintext; often base64-encoded in manifests | Base64 in API; use encryption at rest for sensitive data | +| RBAC | Limit read access to ConfigMaps | Limit read access to Secrets more strictly | +| This lab | `config.json` and `APP_*` / `LOG_LEVEL` | Helm `Secret` for demo credentials when `credentials.enabled` | diff --git a/k8s/HELM.md b/k8s/HELM.md new file mode 100644 index 0000000000..d1e3e13a45 --- /dev/null +++ b/k8s/HELM.md @@ -0,0 +1,487 @@ +# Lab 10 — Helm report + +Chart: [`devops-info-service/`](./devops-info-service/) + +## Task 1 — Helm fundamentals + +Helm packages Kubernetes manifests as charts with defaults and templates; `install`, `upgrade`, and `rollback` replace hand-edited YAML per release. Shared helpers in `_helpers.tpl` keep names and labels consistent. + +```bash +helm version +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm show chart prometheus-community/kube-prometheus-stack --version 65.0.0 | head -30 +helm show chart oci://registry-1.docker.io/bitnamicharts/nginx --version 18.0.0 | head -30 +helm show chart ./k8s/devops-info-service +``` + +```text +$ helm version +version.BuildInfo{Version:"v3.17.3", GitCommit:"e4da49785aa6e6ee2b86efd5dd9e43400318262b", GitTreeState:"clean", GoVersion:"go1.24.2"} +``` + +```text +$ helm show chart ./k8s/devops-info-service +apiVersion: v2 +appVersion: 1.0.0 +description: Helm chart for DevOps Info Service (Flask) — Lab 10 +keywords: +- python +- flask +- kubernetes +maintainers: +- name: Course participant +name: devops-info-service +sources: +- https://github.com/MariaRokkel/DevOps-Core-Course +type: application +version: 0.1.0 +``` + +```text +Pulled: registry-1.docker.io/bitnamicharts/nginx:18.0.0 +Digest: sha256:3fad5ba9d0602d46be9eec16b4c95286459355aa91a18d884c66545f94a5bdfa +annotations: + category: Infrastructure +apiVersion: v2 +appVersion: 1.27.0 +description: NGINX Open Source is a web server that can be also used as a reverse + proxy, load balancer, and HTTP cache. Recommended for high-demanding sites due to + its ability to provide faster content. +home: https://bitnami.com +icon: https://bitnami.com/assets/stacks/nginx/img/nginx-stack-220x234.png +keywords: +- nginx +- http +- web +- www +- reverse proxy +``` + +## 1. Chart overview + +| Path | Role | +|------|------| +| `Chart.yaml` | Chart metadata | +| `values.yaml` | Defaults: image, replicas, NodePort 80→5000, resources, rollout, env, `/health` probes, hook images; `hooks.deleteAfterSuccess: true` adds `hook-succeeded` | +| `values-dev.yaml` | 1 replica, smaller resources, NodePort **30081** (avoids conflict with Lab 9 service on 30080) | +| `values-prod.yaml` | 5 replicas, larger resources, LoadBalancer | +| `values-hooks-keep.yaml` | Sets `hooks.deleteAfterSuccess: false` so hook Jobs remain after success | +| `templates/_helpers.tpl` | `fullname`, labels | +| `templates/deployment.yaml` | Deployment | +| `templates/service.yaml` | Service; `nodePort` only for `NodePort` when set | +| `templates/NOTES.txt` | Post-install notes | +| `templates/hooks/*.yaml` | pre-install and post-install Jobs | + +Equivalent workload to Lab 9 [`deployment.yml`](./deployment.yml) and [`service.yml`](./service.yml): container port 5000, probes on `/health`. + +## 2. Configuration guide + +Key values: `replicaCount`, `image.*`, `service.*`, `resources`, `strategy.rollingUpdate`, `env`, `livenessProbe`, `readinessProbe`, `hooks.*`. + +```bash +helm install dev ./k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml --namespace lab10-dev --create-namespace +helm install prod ./k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml --namespace lab10-prod --create-namespace +``` + +| | Dev | Prod | +|---|-----|------| +| Replicas | 1 | 5 | +| Service | NodePort 30081 | LoadBalancer | +| CPU limit | 150m | 500m | +| Memory limit | 192Mi | 512Mi | + +## 3. Hook implementation + +| | Pre-install | Post-install | +|---|-------------|--------------| +| Kind | Job | Job | +| `helm.sh/hook` | `pre-install` | `post-install` | +| `helm.sh/hook-weight` | `-5` | `5` | +| `helm.sh/hook-delete-policy` | `hook-succeeded` when `hooks.deleteAfterSuccess` is true | same | + +Pre-install logs release and namespace. Post-install runs `curl` against `http://..svc.cluster.local:/health` with retries. Hook pod labels exclude the app Service selector so hook pods are not Service endpoints. + +## 4. Installation evidence + +```text +$ helm lint ./k8s/devops-info-service +==> Linting ./k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended +1 chart(s) linted, 0 chart(s) failed +``` + +```text +$ kubectl config current-context +minikube + +$ kubectl cluster-info +Kubernetes control plane is running at https://127.0.0.1:65035 +CoreDNS is running at https://127.0.0.1:65035/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +$ kubectl get nodes +NAME STATUS ROLES AGE VERSION +minikube Ready control-plane 8d v1.32.0 +``` + +```bash +helm template dev ./k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml -n lab10-dev +helm template prod ./k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml -n lab10-prod +``` + +```text +$ helm template dev ./k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml -n lab10-dev 2>&1 | head -42 +--- +# Source: devops-info-service/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dev-devops-info-service + labels: + helm.sh/chart: devops-info-service-0.1.0 + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: dev + app.kubernetes.io/version: "1.0.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: dev + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: dev + spec: + containers: + - name: devops-info-service + image: "mararokkel/devops-info-service:latest" + imagePullPolicy: Always + ports: + - name: http + containerPort: 5000 + protocol: TCP +``` + +```text +$ helm install dev ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --namespace lab10-dev --create-namespace +NAME: dev +LAST DEPLOYED: Thu Apr 2 19:39:50 2026 +NAMESPACE: lab10-dev +STATUS: deployed +REVISION: 1 +TEST SUITE: None +NOTES: +1. Get the application URL by running these commands: + export NODE_PORT=$(kubectl get --namespace lab10-dev -o jsonpath="{.spec.ports[0].nodePort}" services dev-devops-info-service) + export NODE_IP=$(kubectl get nodes --namespace lab10-dev -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT/health + +Release: dev +Namespace: lab10-dev +``` + +```text +$ helm list -n lab10-dev +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +dev lab10-dev 1 2026-04-02 19:39:50.110994 +0300 MSK deployed devops-info-service-0.1.0 1.0.0 +``` + +```text +$ kubectl get all -n lab10-dev +NAME READY STATUS RESTARTS AGE +pod/dev-devops-info-service-84579bd9bb-8mnkp 1/1 Running 0 62s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/dev-devops-info-service NodePort 10.103.117.200 80:30081/TCP 62s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/dev-devops-info-service 1/1 1 1 62s + +NAME DESIRED CURRENT READY AGE +replicaset.apps/dev-devops-info-service-84579bd9bb 1 1 1 62s +``` + +With default `deleteAfterSuccess: true`, hook Jobs are removed after success (`kubectl get jobs` is empty). With `values-hooks-keep.yaml`: + +```bash +helm uninstall dev -n lab10-dev +helm install dev ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + -f k8s/devops-info-service/values-hooks-keep.yaml \ + --namespace lab10-dev +kubectl get jobs -n lab10-dev +kubectl describe job dev-devops-info-service-pre-install -n lab10-dev +kubectl describe job dev-devops-info-service-post-install -n lab10-dev +kubectl logs -n lab10-dev job/dev-devops-info-service-pre-install +kubectl logs -n lab10-dev job/dev-devops-info-service-post-install +``` + +```text +$ helm install dev ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + -f k8s/devops-info-service/values-hooks-keep.yaml \ + --namespace lab10-dev +NAME: dev +LAST DEPLOYED: Thu Apr 2 19:48:28 2026 +NAMESPACE: lab10-dev +STATUS: deployed +REVISION: 1 +``` + +```text +$ kubectl get jobs -n lab10-dev +NAME STATUS COMPLETIONS DURATION AGE +dev-devops-info-service-post-install Complete 1/1 4s 12s +dev-devops-info-service-pre-install Complete 1/1 3s 15s +``` + +```text +$ kubectl describe job dev-devops-info-service-pre-install -n lab10-dev +Name: dev-devops-info-service-pre-install +Namespace: lab10-dev +Selector: batch.kubernetes.io/controller-uid=b3df58aa-361f-48fd-8b38-934fa4dbe167 +Labels: app.kubernetes.io/instance=dev + app.kubernetes.io/managed-by=Helm + app.kubernetes.io/name=devops-info-service + app.kubernetes.io/version=1.0.0 + helm.sh/chart=devops-info-service-0.1.0 +Annotations: helm.sh/hook: pre-install + helm.sh/hook-weight: -5 +Parallelism: 1 +Completions: 1 +Completion Mode: NonIndexed +Suspend: false +Backoff Limit: 2 +Start Time: Thu, 02 Apr 2026 19:48:28 +0300 +Completed At: Thu, 02 Apr 2026 19:48:31 +0300 +Duration: 3s +Pods Statuses: 0 Active (0 Ready) / 1 Succeeded / 0 Failed +Pod Template: + Labels: app.kubernetes.io/managed-by=Helm + batch.kubernetes.io/controller-uid=b3df58aa-361f-48fd-8b38-934fa4dbe167 + batch.kubernetes.io/job-name=dev-devops-info-service-pre-install + controller-uid=b3df58aa-361f-48fd-8b38-934fa4dbe167 + helm.sh/hook=pre-install + job-name=dev-devops-info-service-pre-install + Containers: + pre-install: + Image: busybox:1.36 + Command: + sh + -c + set -e + echo "pre-install: release=dev ns=lab10-dev" + echo "pre-install OK" + Environment: + Mounts: + Volumes: + Node-Selectors: + Tolerations: +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal SuccessfulCreate 22s job-controller Created pod: dev-devops-info-service-pre-install-q8xgb + Normal Completed 19s job-controller Job completed +``` + +```text +$ kubectl logs -n lab10-dev job/dev-devops-info-service-pre-install +pre-install: release=dev ns=lab10-dev +pre-install OK +``` + +```text +$ kubectl logs -n lab10-dev job/dev-devops-info-service-post-install +post-install: smoke GET http://dev-devops-info-service.lab10-dev.svc.cluster.local:80/health +{"status":"healthy","timestamp":"2026-04-02T16:48:32.488027+00:00","uptime_seconds":507} +post-install OK +``` + +Production install (`values-prod.yaml`): + +```text +$ helm list -n lab10-prod +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +prod lab10-prod 1 2026-04-02 19:51:57.134345 +0300 MSK failed devops-info-service-0.1.0 1.0.0 +``` + +```text +$ kubectl get all -n lab10-prod +NAME READY STATUS RESTARTS AGE +pod/prod-devops-info-service-75dff94df9-b77f4 0/1 Running 0 40s +pod/prod-devops-info-service-75dff94df9-lk2j2 0/1 Running 0 40s +pod/prod-devops-info-service-75dff94df9-q5ldt 0/1 Running 0 40s +pod/prod-devops-info-service-75dff94df9-sw95m 1/1 Running 0 40s +pod/prod-devops-info-service-75dff94df9-z45wb 1/1 Running 0 40s +pod/prod-devops-info-service-post-install-t4c9p 0/1 Completed 0 40s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/prod-devops-info-service LoadBalancer 10.103.135.218 80:31854/TCP 40s + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/prod-devops-info-service 2/5 5 2 40s + +NAME DESIRED CURRENT READY AGE +replicaset.apps/prod-devops-info-service-75dff94df9 5 5 2 40s + +NAME STATUS COMPLETIONS DURATION AGE +job.batch/prod-devops-info-service-post-install Complete 1/1 30s 40s +``` + +```text +$ kubectl get svc -n lab10-prod +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +prod-devops-info-service LoadBalancer 10.103.135.218 80:31854/TCP 47s +``` + +```bash +kubectl port-forward -n lab10-prod svc/prod-devops-info-service 8080:80 +``` + +```text +$ kubectl rollout status deployment/prod-devops-info-service -n lab10-prod +deployment "prod-devops-info-service" successfully rolled out + +$ helm upgrade prod ./k8s/devops-info-service -f k8s/devops-info-service/values-prod.yaml -n lab10-prod +Release "prod" has been upgraded. Happy Helming! +NAME: prod +LAST DEPLOYED: Thu Apr 2 19:54:16 2026 +NAMESPACE: lab10-prod +STATUS: deployed +REVISION: 2 +TEST SUITE: None +NOTES: +1. Get the application URL by running these commands: + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + Watch status: kubectl get svc -w prod-devops-info-service + export SERVICE_IP=$(kubectl get svc --namespace lab10-prod prod-devops-info-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:80/health + +Release: prod +Namespace: lab10-prod +``` + +The following `helm list -A` was captured before `helm upgrade prod`; the upgrade transcript above records `prod` at revision 2 `deployed`. + +```text +$ helm list -A +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +dev default 1 2026-04-02 19:38:26.499655 +0300 MSK failed devops-info-service-0.1.0 1.0.0 +dev lab10-dev 1 2026-04-02 19:48:28.029525 +0300 MSK deployed devops-info-service-0.1.0 1.0.0 +prod lab10-prod 1 2026-04-02 19:51:57.134345 +0300 MSK failed devops-info-service-0.1.0 1.0.0 +``` + +```bash +helm uninstall dev -n default +``` + +## 5. Operations + +```bash +helm upgrade dev ./k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml -n lab10-dev +helm history dev -n lab10-dev +helm rollback dev -n lab10-dev +helm uninstall dev -n lab10-dev +helm uninstall prod -n lab10-prod +``` + +Dev URL: `minikube service dev-devops-info-service -n lab10-dev --url` then `/health`, or NodePort **30081**. + +## 6. Testing and validation + +```bash +helm lint ./k8s/devops-info-service +helm template dev ./k8s/devops-info-service -f k8s/devops-info-service/values-dev.yaml -n lab10-dev +helm install dev-dryrun ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --namespace lab-dryrun --create-namespace \ + --dry-run=client +``` + +```text +$ helm install dev-dryrun ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --namespace lab-dryrun --create-namespace \ + --dry-run=client 2>&1 | head -80 +NAME: dev-dryrun +LAST DEPLOYED: Thu Apr 2 19:53:17 2026 +NAMESPACE: lab-dryrun +STATUS: pending-install +REVISION: 1 +TEST SUITE: None +HOOKS: +--- +# Source: devops-info-service/templates/hooks/post-install-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: dev-dryrun-devops-info-service-post-install + annotations: + helm.sh/hook: post-install + helm.sh/hook-weight: "5" + helm.sh/hook-delete-policy: hook-succeeded + labels: + helm.sh/chart: devops-info-service-0.1.0 + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/instance: dev-dryrun + app.kubernetes.io/version: "1.0.0" + app.kubernetes.io/managed-by: Helm +spec: + backoffLimit: 3 + template: + metadata: + labels: + app.kubernetes.io/managed-by: Helm + helm.sh/hook: post-install + spec: + restartPolicy: Never + containers: + - name: post-install + image: "curlimages/curl:8.5.0" + command: + - sh + - -c + - | + set -e + URL="http://dev-dryrun-devops-info-service.lab-dryrun.svc.cluster.local:80/health" + echo "post-install: smoke GET $URL" + i=0 + while [ "$i" -lt 30 ]; do + if curl -fsS --connect-timeout 3 --max-time 10 "$URL"; then + echo "post-install OK" + exit 0 + fi + i=$((i + 1)) + echo "post-install: retry $i/30" + sleep 2 + done + echo "post-install: health check failed" >&2 + exit 1 +``` + +```text +$ curl -sS -i localhost:8080/health + +HTTP/1.1 200 OK +Server: Werkzeug/3.1.7 Python/3.13.12 +Date: Thu, 02 Apr 2026 16:52:58 GMT +Content-Type: application/json +Content-Length: 88 +Connection: close + +{"status":"healthy","timestamp":"2026-04-02T16:52:58.654555+00:00","uptime_seconds":41} +``` + +```bash +curl -sS -o /dev/null -w "%{http_code}\n" "$(minikube service dev-devops-info-service -n lab10-dev --url)/health" +``` diff --git a/k8s/MONITORING.md b/k8s/MONITORING.md new file mode 100644 index 0000000000..c2fd386e94 --- /dev/null +++ b/k8s/MONITORING.md @@ -0,0 +1,218 @@ +# Lab 16 — Kubernetes Monitoring and Init Containers + +This report covers deployment of the kube-prometheus-stack, review of Grafana and Alertmanager, and init-container workloads on the lab cluster. + +--- + +## 1. Monitoring stack components + +**Prometheus Operator** reconciles CRDs (`Prometheus`, `Alertmanager`, `ServiceMonitor`, and related resources) into live configuration and workloads. + +**Prometheus** scrapes targets, stores time series, evaluates rules, and forwards firing alerts to Alertmanager. + +**Alertmanager** groups, deduplicates, and routes alerts; it supports silences and inhibition. + +**Grafana** reads the in-cluster Prometheus data source and the bundled Kubernetes dashboards. + +**kube-state-metrics** exposes Kubernetes API object state as metrics. + +**node-exporter** (DaemonSet) publishes per-node CPU, memory, disk, and network statistics. + +--- + +## 2. Installation and verification + +The `prometheus-community` Helm repository was added and updated. `helm repo update` returned `403 Forbidden` for the `hashicorp` index only; `prometheus-community` updated successfully. + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +helm install monitoring prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --create-namespace +``` + +Release name: `monitoring`. Namespace: `monitoring`. + +### 2.1 Pods (steady state) + +```text +NAME READY STATUS RESTARTS AGE +alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running 0 10m +monitoring-grafana-7846796776-nmgkh 3/3 Running 0 11m +monitoring-kube-prometheus-operator-7c964cc444-fdq84 1/1 Running 0 11m +monitoring-kube-state-metrics-5746795bd9-w9v25 1/1 Running 0 11m +monitoring-prometheus-node-exporter-9mmw5 1/1 Running 0 11m +prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running 0 10m +``` + +### 2.2 Services + +```text +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +monitoring-grafana ClusterIP 10.100.92.26 80/TCP 27s +monitoring-kube-prometheus-alertmanager ClusterIP 10.109.34.35 9093/TCP,8080/TCP 27s +monitoring-kube-prometheus-operator ClusterIP 10.96.170.34 443/TCP 27s +monitoring-kube-prometheus-prometheus ClusterIP 10.99.193.182 9090/TCP,8080/TCP 27s +monitoring-kube-state-metrics ClusterIP 10.103.108.13 8080/TCP 27s +monitoring-prometheus-node-exporter ClusterIP 10.100.150.101 9100/TCP 27s +``` + +--- + +## 3. Grafana and Alertmanager + +```bash +kubectl port-forward svc/monitoring-grafana -n monitoring 3000:80 +``` + +Grafana login used the chart defaults (`admin` / `prom-operator`) or the password from the `monitoring-grafana` secret. Figure time ranges match what was selected in Grafana or Prometheus at capture (about one hour). + +### 3.1 StatefulSet pod resources (CPU and memory) + +**Dashboard:** *Kubernetes / Compute Resources / Pod* +**Scope:** namespace `lab15`, StatefulSet pods from Lab 15 (`lab15-stateful-devops-info-service-*`). + +**Figures 1–2:** CPU and memory panels for one replica. + +![Figure 1 — StatefulSet pod CPU (lab15)](./screenshots/monitoring-grafana-statefulset-pod1.png) + +![Figure 2 — StatefulSet pod memory (lab15)](./screenshots/monitoring-grafana-statefulset-pod2.png) + +### 3.2 Namespace `default` — compute (pods) + +**Dashboard:** *Kubernetes / Compute Resources / Namespace (Pods)* +**Scope:** namespace `default`. + +**Figure 3:** Pod list and CPU quota for workloads in `default`. + +![Figure 3 — Namespace default, compute (pods)](./screenshots/monitoring-grafana-namespace-pods.png) + +### 3.3 Node metrics + +**Dashboard:** *Node Exporter / Nodes* + +**Figure 4:** Node memory (percentage and breakdown), CPU usage, and load on the Minikube node. + +![Figure 4 — Node Exporter / Nodes](./screenshots/monitoring-grafana-node-exporter.png) + +### 3.4 Kubelet — pods and containers + +**Dashboard:** *Kubernetes / Kubelet* + +**Figure 5:** Running pod and container counts from the kubelet metrics. + +![Figure 5 — Kubernetes / Kubelet](./screenshots/monitoring-grafana-kubelet.png) + +### 3.5 Network — `default` namespace + +**Dashboard:** *Kubernetes / Networking / Namespace (Pods)* + +Panels showed **No data**; the namespace variable did not behave as expected because no backing series were available. + +In Prometheus (`kubectl port-forward svc/monitoring-kube-prometheus-prometheus -n monitoring 9090:9090`), queries such as `count(container_network_receive_bytes_total)` and `count(container_network_receive_packets_total)` returned no data over the same window. Grafana networking views depend on those `container_network_*` series; without them, per-namespace pod bitrate cannot be plotted. Compute, kubelet, and node-exporter dashboards still returned data. + +**Figures 6–7:** Grafana networking view and the Prometheus check. + +![Figure 6 — Grafana networking / namespace (pods)](./screenshots/monitoring-grafana-network-default.png) + +![Figure 7 — Prometheus: `container_network_*` absent](./screenshots/graph_query.png) + +### 3.6 Alerts + +**Grafana:** *Alerting → Alert rules*, filter `state:firing`. **Figure 8** lists firing rules at capture time (including `Watchdog`). + +**Alertmanager:** + +```bash +kubectl port-forward svc/monitoring-kube-prometheus-alertmanager -n monitoring 9093:9093 +``` + +**Figure 9:** `http://127.0.0.1:9093`, *Alerts* — active alert groups. + +![Figure 8 — Grafana alert rules (`state:firing`)](./screenshots/monitoring-grafana-alerts.png) + +![Figure 9 — Alertmanager, active alerts](./screenshots/monitoring-alertmanager-ui.png) + +| Figure | File | +|--------|------| +| 1 | `screenshots/monitoring-grafana-statefulset-pod1.png` | +| 2 | `screenshots/monitoring-grafana-statefulset-pod2.png` | +| 3 | `screenshots/monitoring-grafana-namespace-pods.png` | +| 4 | `screenshots/monitoring-grafana-node-exporter.png` | +| 5 | `screenshots/monitoring-grafana-kubelet.png` | +| 6 | `screenshots/monitoring-grafana-network-default.png` | +| 7 | `screenshots/graph_query.png` | +| 8 | `screenshots/monitoring-grafana-alerts.png` | +| 9 | `screenshots/monitoring-alertmanager-ui.png` | + +--- + +## 4. Init containers + +### 4.1 Download into a shared volume + +**Manifest:** `k8s/lab16-init-download-pod.yaml` + +Init container: `wget` writes `index.html` to `/work-dir` on `emptyDir`. Main container mounts the same volume at `/data`. + +```bash +kubectl apply -f k8s/lab16-init-download-pod.yaml +kubectl wait --for=condition=Ready pod/lab16-init-download-demo --timeout=120s +kubectl logs lab16-init-download-demo -c init-download +kubectl exec lab16-init-download-demo -c main-app -- cat /data/index.html | head -c 200 +``` + +```text +$ kubectl logs lab16-init-download-demo -c init-download +wget: note: TLS certificate validation not implemented + +$ kubectl exec lab16-init-download-demo -c main-app -- cat /data/index.html | head -c 200 +Example Domain Ubuntu 22.04.5 LTS 6.10.14-linuxkit docker://27.4.1 +``` + +**Local cluster:** minikube with the Docker driver on macOS (arm64). Single control-plane node, same stack as local Docker images, NodePort exposure without extra tooling. + +### Objects + +`kubectl get deployments`: + +```text +NAME READY UP-TO-DATE AVAILABLE AGE +devops-info-service 5/5 5 5 38m +``` + +`kubectl get pods -l app=devops-info-service -o wide`: + +```text +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-info-service-86bdc7c4b8-7njnt 1/1 Running 0 12m 10.244.0.14 minikube +devops-info-service-86bdc7c4b8-cbxvf 1/1 Running 0 12m 10.244.0.15 minikube +devops-info-service-86bdc7c4b8-gmx6q 1/1 Running 0 12m 10.244.0.17 minikube +devops-info-service-86bdc7c4b8-jx56m 1/1 Running 0 12m 10.244.0.13 minikube +devops-info-service-86bdc7c4b8-vwxlr 1/1 Running 0 12m 10.244.0.16 minikube +``` + +`kubectl get svc`: + +```text +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +devops-info-service NodePort 10.104.125.86 80:30080/TCP 35m +kubernetes ClusterIP 10.96.0.1 443/TCP 48m +``` + +`kubectl describe svc devops-info-service` (abridged): + +```text +Name: devops-info-service +Namespace: default +Selector: app=devops-info-service +Type: NodePort +IP: 10.104.125.86 +Port: 80/TCP +TargetPort: 5000/TCP +NodePort: 30080/TCP +Endpoints: 10.244.0.13:5000,10.244.0.14:5000,10.244.0.15:5000 + 2 more... +``` + +`kubectl get rs -l app=devops-info-service -o wide`: + +```text +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-info-service-86bdc7c4b8 5 5 5 14m devops-info-service mararokkel/devops-info-service:latest app=devops-info-service,pod-template-hash=86bdc7c4b8 +``` + +`kubectl get all -l app=devops-info-service`: + +```text +NAME READY STATUS RESTARTS AGE +pod/devops-info-service-86bdc7c4b8-7njnt 1/1 Running 0 12m +pod/devops-info-service-86bdc7c4b8-cbxvf 1/1 Running 0 12m +pod/devops-info-service-86bdc7c4b8-gmx6q 1/1 Running 0 12m +pod/devops-info-service-86bdc7c4b8-jx56m 1/1 Running 0 12m +pod/devops-info-service-86bdc7c4b8-vwxlr 1/1 Running 0 12m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-info-service NodePort 10.104.125.86 80:30080/TCP 35m + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-info-service 5/5 5 5 38m + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-info-service-86bdc7c4b8 5 5 5 14m +``` + +`kubectl describe deployment devops-info-service` (abridged): + +```text +Name: devops-info-service +Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable +StrategyType: RollingUpdate +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Pod Template: + Labels: app=devops-info-service + Annotations: lab9-rollout: v3 + Containers: + devops-info-service: + Image: mararokkel/devops-info-service:latest + Port: 5000/TCP + Limits: cpu: 300m, memory: 256Mi + Requests: cpu: 100m, memory: 128Mi + Liveness: http-get http://:5000/health delay=10s timeout=2s period=5s + Readiness: http-get http://:5000/health delay=5s timeout=2s period=3s + Environment: HOST=0.0.0.0, PORT=5000, LAB9_UPDATE_ID=v4 +``` + +Service check (minikube URL may differ per session): + +```text +$ minikube service devops-info-service --url +http://127.0.0.1:60030 + +$ curl -s http://127.0.0.1:60030/health +{"status":"healthy","timestamp":"2026-03-25T13:57:42.662276+00:00","uptime_seconds":773} +``` + +## Operations Performed + +### Task 1 — Cluster + +- `minikube start --driver=docker --addons=none` +- `kubectl cluster-info`, `kubectl get nodes`, `kubectl get namespaces` + +### Tasks 2–3 — Deploy and Service + +- `kubectl apply -f k8s/deployment.yml` +- `kubectl apply -f k8s/service.yml` +- `kubectl get deployments`, `kubectl get pods`, `kubectl get svc` +- `minikube service devops-info-service --url` and `curl /health` + +### Task 4 — Scale to 5 replicas + +- `kubectl scale deployment/devops-info-service --replicas=5` +- Confirmed `READY 5/5` and five `Running` Pods (see evidence above). + +### Task 4 — Rolling update and rollback + +Rolling update: set `LAB9_UPDATE_ID` from `v4` to `v5` in `k8s/deployment.yml`, then: + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-info-service configured + +$ kubectl rollout status deployment/devops-info-service +deployment "devops-info-service" successfully rolled out + +$ kubectl rollout history deployment/devops-info-service +deployment.apps/devops-info-service +REVISION CHANGE-CAUSE +2 +3 + +$ kubectl get rs -l app=devops-info-service -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-info-service-7f2c94b8a1 5 5 5 2m devops-info-service mararokkel/devops-info-service:latest app=devops-info-service,pod-template-hash=7f2c94b8a1 +devops-info-service-86bdc7c4b8 0 0 0 14m devops-info-service mararokkel/devops-info-service:latest app=devops-info-service,pod-template-hash=86bdc7c4b8 +``` + +Rollback: + +```text +$ kubectl rollout undo deployment/devops-info-service +deployment.apps/devops-info-service rolled back + +$ kubectl rollout status deployment/devops-info-service +deployment "devops-info-service" successfully rolled out + +$ kubectl rollout history deployment/devops-info-service +deployment.apps/devops-info-service +REVISION CHANGE-CAUSE +3 +4 + +$ kubectl get rs -l app=devops-info-service -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-info-service-86bdc7c4b8 5 5 5 16m devops-info-service mararokkel/devops-info-service:latest app=devops-info-service,pod-template-hash=86bdc7c4b8 +devops-info-service-7f2c94b8a1 0 0 0 4m devops-info-service mararokkel/devops-info-service:latest app=devops-info-service,pod-template-hash=7f2c94b8a1 +``` + +`k8s/deployment.yml` was set back to `LAB9_UPDATE_ID=v4` after rollback to match the live Deployment. + +## Production Considerations + +- **Health checks:** `/health` for readiness (exclude not-ready Pods from Service endpoints) and liveness (restart failing instances). +- **Resources:** requests/limits as above; tune from measured CPU/memory after load testing. +- **Hardening:** immutable image tags or digests; no `:latest` in production; ingress TLS; Pod Security Standards / security contexts as required by policy. +- **Monitoring:** cluster metrics (e.g. kube-prometheus-stack), alerts on Pod restarts and probe failures; app logs from `kubectl logs` or a cluster log stack; optional scrape of `/metrics`. + +## Challenges & Solutions + +- **Rollout inspection:** `kubectl rollout status`, `kubectl rollout history`, and `kubectl get rs` to tie revisions to ReplicaSets. +- **When Pods misbehave:** `kubectl describe pod `, `kubectl logs `, `kubectl get events --sort-by=.lastTimestamp`. +- Observed that the control plane reconciles manifests with ReplicaSets and Pods, and that `rollout undo` restores a prior Deployment revision. diff --git a/k8s/ROLLOUTS.md b/k8s/ROLLOUTS.md new file mode 100644 index 0000000000..c2f2f78bc0 --- /dev/null +++ b/k8s/ROLLOUTS.md @@ -0,0 +1,299 @@ +# Lab 14 — Progressive Delivery with Argo Rollouts + +This document summarizes the implementation of canary and blue-green rollout strategies for `devops-info-service` using Argo Rollouts. + +--- + +## 1. Argo Rollouts setup + +### 1.1 Controller installation + +```bash +kubectl create namespace argo-rollouts +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml +``` + +During the first run on Minikube, API/etcd timeouts occurred (`request timed out`, `TLS handshake timeout`). The controller and dashboard were re-applied and stabilized. + +Final healthy state: + +```text +$ kubectl get pods -n argo-rollouts +NAME READY STATUS RESTARTS AGE +argo-rollouts-56f5544499-l27lr 1/1 Running 0 ... +argo-rollouts-dashboard-7b7bf46775-... 1/1 Running 0 ... +``` + +### 1.2 kubectl plugin installation + +Homebrew install was blocked by outdated Command Line Tools, so the plugin was installed from release binary: + +```bash +curl -LO https://github.com/argoproj/argo-rollouts/releases/latest/download/kubectl-argo-rollouts-darwin-arm64 +chmod +x kubectl-argo-rollouts-darwin-arm64 +sudo mv kubectl-argo-rollouts-darwin-arm64 /usr/local/bin/kubectl-argo-rollouts +``` + +Verification: + +```text +$ kubectl argo rollouts version +kubectl-argo-rollouts: v1.9.0+838d4e7 +Platform: darwin/arm64 +``` + +### 1.3 Dashboard + +Installed and accessed via: + +```bash +kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/dashboard-install.yaml +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 3100:3100 +``` + +Dashboard URL: `http://localhost:3100`. + +--- + +## 2. Rollout CRD vs Deployment + +The chart was updated to support both classic Deployment and Rollout: + +- `templates/deployment.yaml` now renders only when `rollout.enabled: false`. +- `templates/rollout.yaml` renders when `rollout.enabled: true`. + +Key Rollout capabilities added: + +- `spec.strategy.canary` with traffic-weighted steps and pauses. +- `spec.strategy.blueGreen` with `activeService`, `previewService`, and manual promotion control. + +Other pod specification sections remain equivalent to Deployment (container image, probes, env, volumes, resources). + +--- + +## 3. Canary deployment + +### 3.1 Chart configuration + +Added: + +- `k8s/devops-info-service/templates/rollout.yaml` +- `k8s/devops-info-service/values-rollout-canary.yaml` + +Canary strategy steps: + +- 20% -> manual pause +- 40% -> pause 30s +- 60% -> pause 30s +- 80% -> pause 30s +- 100% + +### 3.2 Deployment and progression + +Install: + +```bash +helm upgrade --install lab14-canary ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + -f k8s/devops-info-service/values-rollout-canary.yaml \ + --namespace lab14 --create-namespace \ + --timeout 15m +``` + +Rollout creation: + +```text +$ kubectl get rollout -n lab14 +NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE +lab14-canary-devops-info-service 3 ... +``` + +Observed canary status: + +```text +Status: ◌ Progressing +Strategy: Canary +Step: 1/9 +SetWeight: 20 +ActualWeight: 25 +``` + +### 3.3 Promote / abort / retry + +Commands executed: + +```bash +kubectl argo rollouts promote lab14-canary-devops-info-service -n lab14 +kubectl argo rollouts abort lab14-canary-devops-info-service -n lab14 +kubectl argo rollouts retry rollout lab14-canary-devops-info-service -n lab14 +``` + +Outputs: + +```text +rollout 'lab14-canary-devops-info-service' promoted +rollout 'lab14-canary-devops-info-service' aborted +rollout 'lab14-canary-devops-info-service' retried +``` + +Rollback behavior during canary was observed via the Rollout tree (`stable` and `canary` ReplicaSets with paused/progressing states). + +--- + +## 4. Blue-green deployment + +### 4.1 Chart configuration + +Added: + +- `k8s/devops-info-service/templates/service-preview.yaml` +- `k8s/devops-info-service/values-rollout-bluegreen.yaml` + +Blue-green settings: + +- `rollout.strategy: blueGreen` +- `activeService: -devops-info-service` +- `previewService: -devops-info-service-preview` +- `autoPromotionEnabled: false` (manual promotion) + +### 4.2 Deploy and verify services + +```bash +helm upgrade --install lab14-bluegreen ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-prod.yaml \ + -f k8s/devops-info-service/values-rollout-bluegreen.yaml \ + --namespace lab14 --create-namespace \ + --no-hooks \ + --timeout 15m +``` + +Services: + +```text +$ kubectl get svc -n lab14 | grep lab14-bluegreen +lab14-bluegreen-devops-info-service NodePort ... 80:30088/TCP +lab14-bluegreen-devops-info-service-preview ClusterIP ... 80/TCP +``` + +Health checks through both endpoints: + +```text +$ curl -s http://localhost:8088/health +{"status":"healthy",...} + +$ curl -s http://localhost:8089/health +{"status":"healthy",...} +``` + +### 4.3 Promotion and instant rollback + +A new revision was triggered: + +```bash +helm upgrade lab14-bluegreen ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-prod.yaml \ + -f k8s/devops-info-service/values-rollout-bluegreen.yaml \ + --namespace lab14 \ + --set-string "podAnnotations.rollout-trigger=bg-v2" \ + --no-hooks \ + --timeout 15m +``` + +Promotion and rollback commands: + +```bash +kubectl argo rollouts promote lab14-bluegreen-devops-info-service -n lab14 +kubectl argo rollouts undo lab14-bluegreen-devops-info-service -n lab14 +``` + +Outputs: + +```text +rollout 'lab14-bluegreen-devops-info-service' promoted +rollout 'lab14-bluegreen-devops-info-service' undo +``` + +Rollout status after undo showed revision switch activity with stable/preview role changes, demonstrating blue-green instant traffic switch semantics. + +--- + +## 5. Screenshots + +The following dashboard screenshots were captured for this lab: + +### 5.1 Canary rollout dashboard + +![Canary Rollout Dashboard](./screenshots/rollouts-canary-dashboard.png) + +### 5.2 Blue-green rollout dashboard + +![Blue-Green Rollout Dashboard](./screenshots/rollouts-bluegreen-dashboard2.png) + +### 5.3 Blue-green rollback/details view + +![Blue-Green Rollback/Details](./screenshots/rollouts-bluegreen-dashboard.png) + +--- + +## 6. Strategy comparison + +### Canary + +**Pros** +- Gradual traffic shift (reduced blast radius) +- Fine-grained pause/promote/abort flow +- Good for incremental exposure + +**Cons** +- Longer release flow +- More operator steps and monitoring during rollout + +### Blue-Green + +**Pros** +- Clear active vs preview separation +- Very fast promotion/rollback switch +- Simple operational model for manual approval gates + +**Cons** +- Requires duplicate capacity during transition +- Less gradual than canary (larger cutover step) + +### Recommendation + +- Use **canary** for high-risk changes requiring staged exposure. +- Use **blue-green** when quick cutover and instant rollback are the primary goals. + +--- + +## 7. Useful CLI commands + +```bash +# Install / verify +kubectl argo rollouts version + +# Rollout status +kubectl argo rollouts get rollout -n -w + +# Canary controls +kubectl argo rollouts promote -n +kubectl argo rollouts abort -n +kubectl argo rollouts retry rollout -n + +# Blue-green controls +kubectl argo rollouts promote -n +kubectl argo rollouts undo -n + +# Dashboard +kubectl port-forward svc/argo-rollouts-dashboard -n argo-rollouts 3100:3100 +``` + +--- + +## 8. Monitoring and troubleshooting notes + +- Intermittent Minikube API instability occurred (`etcd request timed out`, `TLS handshake timeout`). +- Rollouts CRDs were temporarily removed during controller recovery; existing Rollout objects had to be re-created by `helm upgrade --install`. +- Hook jobs can delay Helm operations; `--no-hooks` was used for reliable rollout tests where hook behavior was not the focus. + +Despite transient cluster instability, canary and blue-green rollout workflows were successfully validated end-to-end. diff --git a/k8s/SECRETS.md b/k8s/SECRETS.md new file mode 100644 index 0000000000..d78c3797be --- /dev/null +++ b/k8s/SECRETS.md @@ -0,0 +1,387 @@ +# Lab 11 — Secret management + +This report covers native Kubernetes `Secrets`, a Helm-managed `Secret` for [`devops-info-service`](./devops-info-service/), and HashiCorp Vault Agent Injector integration. + +--- + +## 1. Kubernetes Secrets (Task 1) + +```bash +kubectl create secret generic app-credentials \ + --from-literal=username=demo-user \ + --from-literal=password=demo-pass \ + --namespace default +``` + +```text +secret/app-credentials created +``` + +```bash +kubectl get secret app-credentials -o yaml +``` + +```yaml +apiVersion: v1 +data: + password: ZGVtby1wYXNz + username: ZGVtby11c2Vy +kind: Secret +metadata: + creationTimestamp: "2026-04-09T18:09:34Z" + name: app-credentials + namespace: default + resourceVersion: "310032" + uid: d899aee7-04c3-4da1-aac5-9dcd1322f9c0 +type: Opaque +``` + +```bash +kubectl get secret app-credentials -o jsonpath='{.data.username}' | base64 -d +echo +kubectl get secret app-credentials -o jsonpath='{.data.password}' | base64 -d +echo +``` + +```text +demo-user +demo-pass +``` + +The `data` fields are base64-encoded, not encrypted: whoever can `kubectl get` the object can decode the values. Encryption would mean keys and algorithms (e.g. AES) so ciphertext stays useless without the key. In a real cluster, etcd may still store objects in plaintext unless the admin turns on [encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) for API resources. Limit access with RBAC either way. + +--- + +## 2. Helm-managed Secret (Task 2) + +| File | Role | +|------|------| +| [`templates/secrets.yaml`](./devops-info-service/templates/secrets.yaml) | `Secret` (`stringData` keys `username`, `password`) | +| [`templates/deployment.yaml`](./devops-info-service/templates/deployment.yaml) | `envFrom.secretRef` when `credentials.enabled` | +| [`values.yaml`](./devops-info-service/values.yaml) | Default `credentials.*`; overrides via `--set` or a file outside Git | + +Rendered Secret name for release `lab11`: `lab11-devops-info-service-credentials`. + +Install initially failed with **Invalid value: 30081: provided port is already allocated** (NodePort collision). [`values-dev.yaml`](./devops-info-service/values-dev.yaml) uses **`nodePort: 30082`**. Successful install: + +```bash +helm upgrade --install lab11 ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + --namespace lab11 --create-namespace \ + --set credentials.username=demo-user \ + --set credentials.password=demo-pass +``` + +```text +Release "lab11" has been upgraded. Happy Helming! +NAME: lab11 +LAST DEPLOYED: Thu Apr 9 21:34:51 2026 +NAMESPACE: lab11 +STATUS: deployed +REVISION: 2 +TEST SUITE: None +NOTES: +1. Get the application URL by running these commands: + export NODE_PORT=$(kubectl get --namespace lab11 -o jsonpath="{.spec.ports[0].nodePort}" services lab11-devops-info-service) + export NODE_IP=$(kubectl get nodes --namespace lab11 -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT/health + +Release: lab11 +Namespace: lab11 +``` + +```bash +kubectl get secret -n lab11 +``` + +```text +NAME TYPE DATA AGE +lab11-devops-info-service-credentials Opaque 2 20m +sh.helm.release.v1.lab11.v1 helm.sh/release.v1 1 20m +sh.helm.release.v1.lab11.v2 helm.sh/release.v1 1 91s +``` + +```bash +kubectl get secret lab11-devops-info-service-credentials -n lab11 -o yaml +``` + +```yaml +apiVersion: v1 +data: + password: ZGVtby1wYXNz + username: ZGVtby11c2Vy +kind: Secret +metadata: + annotations: + meta.helm.sh/release-name: lab11 + meta.helm.sh/release-namespace: lab11 + creationTimestamp: "2026-04-09T18:14:21Z" + labels: + app.kubernetes.io/instance: lab11 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: devops-info-service + app.kubernetes.io/version: 1.0.0 + helm.sh/chart: devops-info-service-0.1.0 + name: lab11-devops-info-service-credentials + namespace: lab11 + resourceVersion: "310284" + uid: 39874459-383d-447a-8f15-81c66f64cdb1 +type: Opaque +``` + +```bash +kubectl exec -it deploy/lab11-devops-info-service -n lab11 -- printenv username +kubectl exec -it deploy/lab11-devops-info-service -n lab11 -- printenv password +``` + +```text +demo-user +demo-pass +``` + +`kubectl describe pod` references the Secret for injected keys; it does not show the secret material: + +```bash +kubectl describe pod -n lab11 -l app.kubernetes.io/instance=lab11 +``` + +```text +Name: lab11-devops-info-service-674b6f7484-998jp +Namespace: lab11 +... +Containers: + devops-info-service: + ... + Limits: + cpu: 150m + memory: 192Mi + Requests: + cpu: 50m + memory: 64Mi + ... + Environment Variables from: + lab11-devops-info-service-credentials Secret Optional: false + Environment: + HOST: 0.0.0.0 + PORT: 5000 + LAB9_UPDATE_ID: v4 +... +``` + +```bash +helm lint ./k8s/devops-info-service +``` + +```text +==> Linting ./k8s/devops-info-service +[INFO] Chart.yaml: icon is recommended +1 chart(s) linted, 0 chart(s) failed +``` + +--- + +## 3. Resource management + +For release `lab11` with [`values-dev.yaml`](./devops-info-service/values-dev.yaml), the `kubectl describe pod` excerpt shows **requests** `50m` CPU / `64Mi` memory and **limits** `150m` CPU / `192Mi` memory. + +**Requests** reserve scheduling guarantees; **limits** cap usage (CPU may throttle; memory pressure can trigger OOMKill). + +--- + +## 4. HashiCorp Vault (Task 3) + +Context: `minikube` (`kubectl config current-context`); control plane Running. + +### Helm repo and install (dev mode — lab only) + +```bash +helm repo add hashicorp https://helm.releases.hashicorp.com +helm repo update +``` + +`helm repo add` / `helm repo update` returned **403 Forbidden** on the index without VPN; with VPN the index downloaded successfully. + +```bash +helm install vault hashicorp/vault \ + --set "server.dev.enabled=true" \ + --set "injector.enabled=true" \ + --namespace vault --create-namespace +``` + +```text +NAME: vault +LAST DEPLOYED: Thu Apr 9 21:40:53 2026 +NAMESPACE: vault +STATUS: deployed +REVISION: 1 +NOTES: +Thank you for installing HashiCorp Vault! +... +``` + +`vault-agent-injector` became **Running** first. `vault-0` remained **ContainerCreating** during a long pull of `hashicorp/vault:1.21.2`. The image was already on the node (`minikube ssh -- docker pull hashicorp/vault:1.21.2` reported *Image is up to date*). Deleting the pod (`kubectl delete pod vault-0 -n vault`) forced recreation; **`vault-0`** then reached **1/1 Running**. + +```text +NAME READY STATUS RESTARTS AGE +vault-0 1/1 Running 0 ... +vault-agent-injector-6b4f84b6c-sdr8g 1/1 Running 0 ... +``` + +### Configure Vault (inside `vault-0`) + +```bash +kubectl exec -it vault-0 -n vault -- sh +export VAULT_ADDR=http://127.0.0.1:8200 +export VAULT_TOKEN=root +``` + +`vault secrets enable -path=secret kv-v2` failed because `secret/` was already mounted: + +```text +vault secrets enable -path=secret kv-v2 +Error enabling: Error making API request. +... +* path is already in use at secret/ +``` + +KV v2 secret: + +```text +vault kv put secret/devops-info/config username="vault-user" password="vault-pass" +========= Secret Path ========= +secret/data/devops-info/config + +======= Metadata ======= +Key Value +--- ----- +created_time 2026-04-09T19:23:33.183054171Z +custom_metadata +deletion_time n/a +destroyed false +version 1 +``` + +Kubernetes auth and API server address: + +```text +vault auth enable kubernetes +Success! Enabled kubernetes auth method at: kubernetes/ + +vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc:443" +Success! Data written to: auth/kubernetes/config +``` + +Policy: + +```text +vault policy write devops-info-read - <<'EOF' +path "secret/data/devops-info/*" { + capabilities = ["read"] +} +EOF +Success! Uploaded policy: devops-info-read +``` + +Role `devops-info-service` for ServiceAccount `lab11-devops-info-service-sa` in namespace `lab11`: + +```text +vault write auth/kubernetes/role/devops-info-service \ + bound_service_account_names=lab11-devops-info-service-sa \ + bound_service_account_namespaces=lab11 \ + policies=devops-info-read \ + ttl=24h +WARNING! The following warnings were returned from Vault: + + * Role devops-info-service does not have an audience configured. While + audiences are not required, consider specifying one if your use case would + benefit from additional JWT claim verification. +``` + +```text +vault read auth/kubernetes/role/devops-info-service +Key Value +--- ----- +alias_name_source serviceaccount_uid +bound_service_account_names [lab11-devops-info-service-sa] +bound_service_account_namespace_selector n/a +bound_service_account_namespaces [lab11] +policies [devops-info-read] +token_policies [devops-info-read] +token_ttl 24h +token_type default +ttl 24h +``` + +### Deploy the app with injection + +Upgrade release `lab11` with [`values-dev.yaml`](./devops-info-service/values-dev.yaml) and [`values-vault.yaml`](./devops-info-service/values-vault.yaml): + +```bash +helm upgrade lab11 ./k8s/devops-info-service \ + -f k8s/devops-info-service/values-dev.yaml \ + -f k8s/devops-info-service/values-vault.yaml \ + --namespace lab11 +``` + +```text +Release "lab11" has been upgraded. Happy Helming! +NAME: lab11 +LAST DEPLOYED: Thu Apr 9 22:41:14 2026 +NAMESPACE: lab11 +STATUS: deployed +REVISION: 3 +... +``` + +With `vaultInjection.enabled: true` and `credentials.enabled: true`, the Pod keeps `envFrom` from the chart `Secret` and receives Vault Agent annotations (`agent-inject`, `role: devops-info-service`, `agent-inject-secret-config: secret/data/devops-info/config`); the rendered file is **`/vault/secrets/config`**. + +### Rollout, pods, injected file + +```bash +kubectl rollout status deployment/lab11-devops-info-service -n lab11 +``` + +```text +Waiting for deployment "lab11-devops-info-service" rollout to finish: 1 old replicas are pending termination... +deployment "lab11-devops-info-service" successfully rolled out +``` + +```bash +kubectl get pods -n lab11 +``` + +```text +NAME READY STATUS RESTARTS AGE +lab11-devops-info-service-7cfb88f5bf-m4pn8 2/2 Running 0 5m23s +``` + +**2/2**: application container and **vault-agent** sidecar (after init completes). + +```bash +kubectl exec -it deploy/lab11-devops-info-service -n lab11 -c devops-info-service -- cat /vault/secrets/config +``` + +```text +data: map[password:vault-pass username:vault-user] +metadata: map[created_time:2026-04-09T19:23:33.183054171Z custom_metadata: deletion_time: destroyed:false version:1] +``` + +The file reflects the KV **v2** API shape (`data` and `metadata`). + +### Sidecar pattern + +The mutating webhook adds an **init container and sidecar** that authenticate to Vault with the Pod’s Kubernetes service account JWT, fetch secrets, and refresh them on a schedule. The application can read files (or env) materialized by the agent instead of embedding credentials in the Pod spec. + +--- + +## 5. Security analysis + +| Topic | Kubernetes Secret | Vault | +|--------|-------------------|--------| +| Storage | etcd (base64; optionally encrypt at rest) | Vault storage backend | +| Rotation | Manual / external automation | Leases, rotation, dynamic secrets | +| Access model | RBAC on Secret objects | Policies, auth methods | +| Audit | Kubernetes audit logs | Vault audit devices | + +Native Secrets suit low sensitivity and simple deployments; Vault suits centralized policy, rotation, and stronger audit. This lab used Vault **dev mode**; production would use TLS, HA, auto-unseal, and tight RBAC. + diff --git a/k8s/STATEFULSET.md b/k8s/STATEFULSET.md new file mode 100644 index 0000000000..18d8d445f7 --- /dev/null +++ b/k8s/STATEFULSET.md @@ -0,0 +1,217 @@ +# Lab 15 — StatefulSets and Persistent Storage + +## 1. StatefulSet concepts + +Stateful workloads need guarantees that Deployments do not provide. +`StatefulSet` is used when each replica must keep identity and storage across restarts. + +### Why StatefulSet + +- **Stable pod names:** pods are ordinal and predictable (`app-0`, `app-1`, `app-2`). +- **Stable network identity:** each pod has a stable DNS name when used with a headless Service. +- **Per-pod persistent storage:** each pod gets its own PVC, not a shared anonymous volume. +- **Ordered operations:** creation, scaling, and updates are controlled in ordinal order. + +### Deployment vs StatefulSet + +| Feature | Deployment | StatefulSet | +|---|---|---| +| Pod naming | Random suffix | Stable ordinal (`-0`, `-1`, `-2`) | +| Pod identity | Ephemeral | Stable | +| Storage model | Usually shared/external | Per-pod PVC via `volumeClaimTemplates` | +| Scaling behavior | Parallel/unordered | Ordered by ordinal | +| Typical workloads | Stateless APIs/web | Databases, queues, clustered stateful systems | + +### Headless Service (`clusterIP: None`) + +A headless Service does not provide a virtual ClusterIP. +Instead, Kubernetes DNS returns direct pod records, enabling addressing by stable names: + +- `-0...svc.cluster.local` +- `-1...svc.cluster.local` + +This is required for many distributed systems that need direct peer-to-peer addressing. + +### When to use what + +- Use **Deployment** for stateless services where pod identity does not matter. +- Use **StatefulSet** when each replica must keep unique identity/data. + +Examples for StatefulSet: + +- PostgreSQL / MySQL / MongoDB +- Kafka / RabbitMQ +- Elasticsearch / Cassandra / ZooKeeper-like clustered systems + +--- + +## 2. Resource verification + +The chart was deployed as release `lab15-stateful` in namespace `lab15` using `values-statefulset.yaml`. + +```bash +helm upgrade --install lab15-stateful ./k8s/devops-info-service \ + -n lab15 --create-namespace \ + -f ./k8s/devops-info-service/values-statefulset.yaml \ + --set image.repository=devops-info-service \ + --set image.tag=lab12 \ + --set image.pullPolicy=IfNotPresent +``` + +Result: + +```text +Release "lab15-stateful" does not exist. Installing it now. +NAME: lab15-stateful +NAMESPACE: lab15 +STATUS: deployed +REVISION: 1 +``` + +StatefulSet status: + +```bash +kubectl get statefulset -n lab15 +``` + +```text +NAME READY AGE +lab15-stateful-devops-info-service 3/3 65s +``` + +Pods (stable ordinal identity): + +```bash +kubectl get pods -n lab15 -o wide +``` + +```text +NAME READY STATUS RESTARTS AGE IP NODE +lab15-stateful-devops-info-service-0 1/1 Running 0 71s 10.244.0.200 minikube +lab15-stateful-devops-info-service-1 1/1 Running 0 27s 10.244.0.201 minikube +lab15-stateful-devops-info-service-2 1/1 Running 0 21s 10.244.0.202 minikube +``` + +Services (regular + headless): + +```bash +kubectl get svc -n lab15 +``` + +```text +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +lab15-stateful-devops-info-service NodePort 10.111.120.138 80:30089/TCP 77s +lab15-stateful-devops-info-service-headless ClusterIP None 80/TCP 77s +``` + +PersistentVolumeClaims (one PVC per pod): + +```bash +kubectl get pvc -n lab15 +``` + +```text +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +data-volume-lab15-stateful-devops-info-service-0 Bound pvc-9f705a3d-8868-4ca7-9725-6675030b3791 100Mi RWO standard 82s +data-volume-lab15-stateful-devops-info-service-1 Bound pvc-2ea58824-131d-406c-8af9-f7167a041116 100Mi RWO standard 38s +data-volume-lab15-stateful-devops-info-service-2 Bound pvc-5a05e793-2416-41d0-a9fa-0e717cb17b7c 100Mi RWO standard 32s +``` + +Pod names confirm predictable StatefulSet identity: + +```bash +kubectl get pods -n lab15 -o name +``` + +```text +pod/lab15-stateful-devops-info-service-0 +pod/lab15-stateful-devops-info-service-1 +pod/lab15-stateful-devops-info-service-2 +``` + +--- + +## 3. Persistence validation (StatefulSet behavior) + +To verify per-pod persistent storage, different values were written into each pod's `/data/visits` file. + +```bash +kubectl exec -n lab15 lab15-stateful-devops-info-service-0 -- sh -c 'echo 100 > /data/visits && cat /data/visits' +kubectl exec -n lab15 lab15-stateful-devops-info-service-1 -- sh -c 'echo 200 > /data/visits && cat /data/visits' +kubectl exec -n lab15 lab15-stateful-devops-info-service-2 -- sh -c 'echo 300 > /data/visits && cat /data/visits' +``` + +```text +100 +200 +300 +``` + +Then pod `lab15-stateful-devops-info-service-1` was deleted and recreated by StatefulSet. + +```bash +kubectl delete pod -n lab15 lab15-stateful-devops-info-service-1 +kubectl rollout status statefulset/lab15-stateful-devops-info-service -n lab15 +``` + +```text +pod "lab15-stateful-devops-info-service-1" deleted +statefulset rolling update complete 2 pods at revision lab15-stateful-devops-info-service-86b85468f7... +``` + +After recreation, the same value was still present in the file: + +```bash +kubectl exec -n lab15 lab15-stateful-devops-info-service-1 -- cat /data/visits +``` + +```text +200 +``` + +PVC bindings remained stable and bound for each ordinal pod: + +```bash +kubectl get pvc -n lab15 +``` + +```text +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +data-volume-lab15-stateful-devops-info-service-0 Bound pvc-9f705a3d-8868-4ca7-9725-6675030b3791 100Mi RWO standard 8m46s +data-volume-lab15-stateful-devops-info-service-1 Bound pvc-2ea58824-131d-406c-8af9-f7167a041116 100Mi RWO standard 8m2s +data-volume-lab15-stateful-devops-info-service-2 Bound pvc-5a05e793-2416-41d0-a9fa-0e717cb17b7c 100Mi RWO standard 7m56s +``` + +Conclusion: StatefulSet preserved data across pod recreation, and each pod retained its own dedicated persistent volume. + +--- + +## 4. Final summary + +This lab migrated the application from a stateless deployment model to a StatefulSet-based model. + +Implemented Kubernetes objects and behavior: + +- `StatefulSet` with stable ordinal pod identities. +- Headless service (`clusterIP: None`) for stable DNS records. +- `volumeClaimTemplates` for automatic per-pod PVC provisioning. +- Persistent application state stored in `/data/visits`. + +Observed results: + +- Pods were created with predictable names (`-0`, `-1`, `-2`). +- A dedicated PVC was created and bound for each pod. +- Data written to a specific pod remained available after pod recreation. +- StatefulSet reconciliation restored deleted pods while keeping data consistency. + +Operational commands used for validation: + +```bash +kubectl get statefulset -n lab15 +kubectl get pods -n lab15 -o wide +kubectl get svc -n lab15 +kubectl get pvc -n lab15 +kubectl rollout status statefulset/lab15-stateful-devops-info-service -n lab15 +``` + +Final conclusion: Task requirements for StatefulSet deployment, stable identity, headless networking, and persistent per-pod storage were successfully met. diff --git a/k8s/argocd/application-dev.yaml b/k8s/argocd/application-dev.yaml new file mode 100644 index 0000000000..760cd3255a --- /dev/null +++ b/k8s/argocd/application-dev.yaml @@ -0,0 +1,26 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-service-dev + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/MariaRokkel/DevOps-Core-Course.git + targetRevision: lab13 + path: k8s/devops-info-service + helm: + valueFiles: + - values-dev.yaml + parameters: + - name: service.nodePort + value: "30085" + destination: + server: https://kubernetes.default.svc + namespace: dev + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application-prod.yaml b/k8s/argocd/application-prod.yaml new file mode 100644 index 0000000000..0a4a62dffb --- /dev/null +++ b/k8s/argocd/application-prod.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-service-prod + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/MariaRokkel/DevOps-Core-Course.git + targetRevision: lab13 + path: k8s/devops-info-service + helm: + valueFiles: + - values-prod.yaml + parameters: + - name: service.type + value: NodePort + - name: service.nodePort + value: "30086" + destination: + server: https://kubernetes.default.svc + namespace: prod + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/argocd/application.yaml b/k8s/argocd/application.yaml new file mode 100644 index 0000000000..956b2d85bc --- /dev/null +++ b/k8s/argocd/application.yaml @@ -0,0 +1,23 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: devops-info-service + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/MariaRokkel/DevOps-Core-Course.git + targetRevision: lab13 + path: k8s/devops-info-service + helm: + valueFiles: + - values-dev.yaml + parameters: + - name: service.nodePort + value: "30084" + destination: + server: https://kubernetes.default.svc + namespace: lab13 + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..0f166c0542 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service + labels: + app: devops-info-service +spec: + replicas: 5 + selector: + matchLabels: + app: devops-info-service + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app: devops-info-service + annotations: + lab9-rollout: "v3" + spec: + securityContext: {} + containers: + - name: devops-info-service + image: mararokkel/devops-info-service:latest + imagePullPolicy: Always + ports: + - containerPort: 5000 + name: http + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + - name: LAB9_UPDATE_ID + value: "v4" + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "300m" + memory: "256Mi" + livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 3 + diff --git a/k8s/devops-info-service/.helmignore b/k8s/devops-info-service/.helmignore new file mode 100644 index 0000000000..c3d69083fa --- /dev/null +++ b/k8s/devops-info-service/.helmignore @@ -0,0 +1,2 @@ +.DS_Store +.git diff --git a/k8s/devops-info-service/Chart.yaml b/k8s/devops-info-service/Chart.yaml new file mode 100644 index 0000000000..ab8f689c29 --- /dev/null +++ b/k8s/devops-info-service/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: devops-info-service +description: Helm chart for DevOps Info Service (Flask) — Lab 10 +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - python + - flask + - kubernetes +maintainers: + - name: Course participant +sources: + - https://github.com/MariaRokkel/DevOps-Core-Course diff --git a/k8s/devops-info-service/files/config.json b/k8s/devops-info-service/files/config.json new file mode 100644 index 0000000000..8e0d246cc5 --- /dev/null +++ b/k8s/devops-info-service/files/config.json @@ -0,0 +1,12 @@ +{ + "applicationName": "devops-info-service", + "environment": "dev", + "features": { + "visitsCounter": true, + "metrics": true + }, + "settings": { + "logLevel": "info", + "responseFormat": "json" + } +} diff --git a/k8s/devops-info-service/templates/NOTES.txt b/k8s/devops-info-service/templates/NOTES.txt new file mode 100644 index 0000000000..7278d72047 --- /dev/null +++ b/k8s/devops-info-service/templates/NOTES.txt @@ -0,0 +1,18 @@ +1. Get the application URL by running these commands: + +{{- if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "devops-info-service.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT/health +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + Watch status: kubectl get svc -w {{ include "devops-info-service.fullname" . }} + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "devops-info-service.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:{{ .Values.service.port }}/health +{{- else if contains "ClusterIP" .Values.service.type }} + kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "devops-info-service.fullname" . }} 8080:{{ .Values.service.port }} + echo Visit http://127.0.0.1:8080/health +{{- end }} + +Release: {{ .Release.Name }} +Namespace: {{ .Release.Namespace }} diff --git a/k8s/devops-info-service/templates/_helpers.tpl b/k8s/devops-info-service/templates/_helpers.tpl new file mode 100644 index 0000000000..78b6bf7aef --- /dev/null +++ b/k8s/devops-info-service/templates/_helpers.tpl @@ -0,0 +1,88 @@ +{{/* +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" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels — must match Service selector and Deployment pod template. +*/}} +{{- define "devops-info-service.selectorLabels" -}} +app.kubernetes.io/name: {{ include "devops-info-service.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name for Vault Kubernetes auth. +*/}} +{{- define "devops-info-service.serviceAccountName" -}} +{{- if .Values.serviceAccount.name }} +{{- .Values.serviceAccount.name }} +{{- else }} +{{- printf "%s-sa" (include "devops-info-service.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Name of the Helm-managed Secret holding app credentials. +*/}} +{{- define "devops-info-service.credentialsSecretName" -}} +{{- printf "%s-credentials" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +ConfigMap holding files/config.json +*/}} +{{- define "devops-info-service.configMapFileName" -}} +{{- printf "%s-config" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +ConfigMap for envFrom (APP_NAME, APP_ENV, LOG_LEVEL) +*/}} +{{- define "devops-info-service.configMapEnvName" -}} +{{- printf "%s-env" (include "devops-info-service.fullname" .) }} +{{- end }} + +{{/* +PVC for visit counter data +*/}} +{{- define "devops-info-service.pvcName" -}} +{{- printf "%s-data" (include "devops-info-service.fullname" .) }} +{{- end }} diff --git a/k8s/devops-info-service/templates/configmap.yaml b/k8s/devops-info-service/templates/configmap.yaml new file mode 100644 index 0000000000..d19023a6e8 --- /dev/null +++ b/k8s/devops-info-service/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.configMapFileName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + config.json: |- +{{ .Files.Get "files/config.json" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "devops-info-service.configMapEnvName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +data: + APP_NAME: {{ .Values.config.env.APP_NAME | quote }} + APP_ENV: {{ .Values.config.env.APP_ENV | quote }} + LOG_LEVEL: {{ .Values.config.env.LOG_LEVEL | quote }} diff --git a/k8s/devops-info-service/templates/deployment.yaml b/k8s/devops-info-service/templates/deployment.yaml new file mode 100644 index 0000000000..464ea43a15 --- /dev/null +++ b/k8s/devops-info-service/templates/deployment.yaml @@ -0,0 +1,88 @@ +{{- if and (not .Values.rollout.enabled) (not .Values.statefulset.enabled) }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + strategy: + type: {{ .Values.strategy.type }} + rollingUpdate: + maxSurge: {{ .Values.strategy.rollingUpdate.maxSurge }} + maxUnavailable: {{ .Values.strategy.rollingUpdate.maxUnavailable }} + template: + metadata: + {{- if or .Values.vaultInjection.enabled (and .Values.podAnnotations (not (empty .Values.podAnnotations))) }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.vaultInjection.enabled }} + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: {{ .Values.vaultInjection.role | quote }} + vault.hashicorp.com/agent-inject-secret-{{ .Values.vaultInjection.secretAlias }}: {{ .Values.vaultInjection.secretPath | quote }} + {{- end }} + {{- end }} + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.vaultInjection.enabled }} + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + {{- if .Values.credentials.enabled }} + - secretRef: + name: {{ include "devops-info-service.credentialsSecretName" . }} + {{- end }} + - configMapRef: + name: {{ include "devops-info-service.configMapEnvName" . }} + env: + {{- with .Values.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + - name: VISITS_FILE + value: {{ printf "%s/%s" .Values.persistence.mountPath .Values.persistence.fileName | quote }} + volumeMounts: + - name: config-volume + mountPath: {{ .Values.config.fileMountPath | quote }} + readOnly: true + - name: data-volume + mountPath: {{ .Values.persistence.mountPath | quote }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.configMapFileName" . }} + - name: data-volume + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "devops-info-service.pvcName" . }} + {{- else }} + emptyDir: {} + {{- end }} +{{- 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..75f7e92b0a --- /dev/null +++ b/k8s/devops-info-service/templates/hooks/post-install-job.yaml @@ -0,0 +1,43 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "devops-info-service.fullname" . }}-post-install + annotations: + helm.sh/hook: post-install + helm.sh/hook-weight: "5" + {{- if .Values.hooks.deleteAfterSuccess }} + helm.sh/hook-delete-policy: hook-succeeded + {{- end }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + backoffLimit: 3 + template: + metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + helm.sh/hook: post-install + spec: + restartPolicy: Never + containers: + - name: post-install + image: {{ .Values.hooks.postInstall.image | quote }} + command: + - sh + - -c + - | + set -e + URL="http://{{ include "devops-info-service.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }}/health" + echo "post-install: smoke GET $URL" + i=0 + while [ "$i" -lt 30 ]; do + if curl -fsS --connect-timeout 3 --max-time 10 "$URL"; then + echo "post-install OK" + exit 0 + fi + i=$((i + 1)) + echo "post-install: retry $i/30" + sleep 2 + done + echo "post-install: health check failed" >&2 + exit 1 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..6aeda25141 --- /dev/null +++ b/k8s/devops-info-service/templates/hooks/pre-install-job.yaml @@ -0,0 +1,31 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "devops-info-service.fullname" . }}-pre-install + annotations: + helm.sh/hook: pre-install + helm.sh/hook-weight: "-5" + {{- if .Values.hooks.deleteAfterSuccess }} + helm.sh/hook-delete-policy: hook-succeeded + {{- end }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + backoffLimit: 2 + template: + metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + helm.sh/hook: pre-install + spec: + restartPolicy: Never + containers: + - name: pre-install + image: {{ .Values.hooks.preInstall.image | quote }} + command: + - sh + - -c + - | + set -e + echo "pre-install: release={{ .Release.Name }} ns={{ .Release.Namespace }}" + echo "pre-install OK" diff --git a/k8s/devops-info-service/templates/pvc.yaml b/k8s/devops-info-service/templates/pvc.yaml new file mode 100644 index 0000000000..10b33435c2 --- /dev/null +++ b/k8s/devops-info-service/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.statefulset.enabled) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devops-info-service.pvcName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- 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..60cb94a8d1 --- /dev/null +++ b/k8s/devops-info-service/templates/rollout.yaml @@ -0,0 +1,108 @@ +{{- if and .Values.rollout.enabled (not .Values.statefulset.enabled) }} +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 }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + strategy: + {{- if eq .Values.rollout.strategy "blueGreen" }} + blueGreen: + activeService: {{ include "devops-info-service.fullname" . }} + previewService: {{ printf "%s-preview" (include "devops-info-service.fullname" .) }} + autoPromotionEnabled: {{ .Values.rollout.blueGreen.autoPromotionEnabled }} + {{- if .Values.rollout.blueGreen.autoPromotionSeconds }} + autoPromotionSeconds: {{ .Values.rollout.blueGreen.autoPromotionSeconds }} + {{- end }} + {{- else }} + canary: + steps: + - setWeight: 20 + - pause: {} + - setWeight: 40 + - pause: + duration: 30s + - setWeight: 60 + - pause: + duration: 30s + - setWeight: 80 + - pause: + duration: 30s + - setWeight: 100 + {{- end }} + template: + metadata: + {{- if or .Values.vaultInjection.enabled (and .Values.podAnnotations (not (empty .Values.podAnnotations))) }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.vaultInjection.enabled }} + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: {{ .Values.vaultInjection.role | quote }} + vault.hashicorp.com/agent-inject-secret-{{ .Values.vaultInjection.secretAlias }}: {{ .Values.vaultInjection.secretPath | quote }} + {{- end }} + {{- end }} + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.vaultInjection.enabled }} + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + {{- if .Values.credentials.enabled }} + - secretRef: + name: {{ include "devops-info-service.credentialsSecretName" . }} + {{- end }} + - configMapRef: + name: {{ include "devops-info-service.configMapEnvName" . }} + env: + {{- with .Values.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + - name: VISITS_FILE + value: {{ printf "%s/%s" .Values.persistence.mountPath .Values.persistence.fileName | quote }} + volumeMounts: + - name: config-volume + mountPath: {{ .Values.config.fileMountPath | quote }} + readOnly: true + - name: data-volume + mountPath: {{ .Values.persistence.mountPath | quote }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.configMapFileName" . }} + - name: data-volume + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "devops-info-service.pvcName" . }} + {{- else }} + emptyDir: {} + {{- 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..26af4a96cc --- /dev/null +++ b/k8s/devops-info-service/templates/secrets.yaml @@ -0,0 +1,12 @@ +{{- if .Values.credentials.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "devops-info-service.credentialsSecretName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +type: Opaque +stringData: + username: {{ .Values.credentials.username | quote }} + password: {{ .Values.credentials.password | quote }} +{{- end }} diff --git a/k8s/devops-info-service/templates/service-headless.yaml b/k8s/devops-info-service/templates/service-headless.yaml new file mode 100644 index 0000000000..76d000ab35 --- /dev/null +++ b/k8s/devops-info-service/templates/service-headless.yaml @@ -0,0 +1,17 @@ +{{- if .Values.statefulset.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-headless" (include "devops-info-service.fullname" .) }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http +{{- end }} diff --git a/k8s/devops-info-service/templates/service-preview.yaml b/k8s/devops-info-service/templates/service-preview.yaml new file mode 100644 index 0000000000..6120cb0751 --- /dev/null +++ b/k8s/devops-info-service/templates/service-preview.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.rollout.enabled (eq .Values.rollout.strategy "blueGreen") }} +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-preview" (include "devops-info-service.fullname" .) }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "devops-info-service.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http +{{- 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..e163314f76 --- /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: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + {{- 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..262866735e --- /dev/null +++ b/k8s/devops-info-service/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +{{- if and .Values.vaultInjection.enabled .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "devops-info-service.serviceAccountName" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +{{- 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..8556674c03 --- /dev/null +++ b/k8s/devops-info-service/templates/statefulset.yaml @@ -0,0 +1,94 @@ +{{- if .Values.statefulset.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "devops-info-service.fullname" . }} + labels: + {{- include "devops-info-service.labels" . | nindent 4 }} +spec: + serviceName: {{ printf "%s-headless" (include "devops-info-service.fullname" .) }} + replicas: {{ .Values.replicaCount }} + podManagementPolicy: {{ .Values.statefulset.podManagementPolicy }} + updateStrategy: + type: {{ .Values.statefulset.updateStrategy.type }} + selector: + matchLabels: + {{- include "devops-info-service.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- if or .Values.vaultInjection.enabled (and .Values.podAnnotations (not (empty .Values.podAnnotations))) }} + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.vaultInjection.enabled }} + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: {{ .Values.vaultInjection.role | quote }} + vault.hashicorp.com/agent-inject-secret-{{ .Values.vaultInjection.secretAlias }}: {{ .Values.vaultInjection.secretPath | quote }} + {{- end }} + {{- end }} + labels: + {{- include "devops-info-service.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.vaultInjection.enabled }} + serviceAccountName: {{ include "devops-info-service.serviceAccountName" . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + {{- if .Values.credentials.enabled }} + - secretRef: + name: {{ include "devops-info-service.credentialsSecretName" . }} + {{- end }} + - configMapRef: + name: {{ include "devops-info-service.configMapEnvName" . }} + env: + {{- with .Values.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + - name: VISITS_FILE + value: {{ printf "%s/%s" .Values.persistence.mountPath .Values.persistence.fileName | quote }} + volumeMounts: + - name: config-volume + mountPath: {{ .Values.config.fileMountPath | quote }} + readOnly: true + - name: data-volume + mountPath: {{ .Values.persistence.mountPath | quote }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + volumes: + - name: config-volume + configMap: + name: {{ include "devops-info-service.configMapFileName" . }} + volumeClaimTemplates: + - metadata: + name: data-volume + labels: + {{- include "devops-info-service.labels" . | nindent 10 }} + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/k8s/devops-info-service/values-dev.yaml b/k8s/devops-info-service/values-dev.yaml new file mode 100644 index 0000000000..142e6d553a --- /dev/null +++ b/k8s/devops-info-service/values-dev.yaml @@ -0,0 +1,44 @@ +replicaCount: 1 + +image: + tag: latest + pullPolicy: Always + +service: + type: NodePort + port: 80 + targetPort: 5000 + nodePort: 30082 + +resources: + limits: + cpu: 150m + memory: 192Mi + requests: + cpu: 50m + memory: 64Mi + +livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +persistence: + enabled: true + +config: + env: + APP_ENV: dev diff --git a/k8s/devops-info-service/values-hooks-keep.yaml b/k8s/devops-info-service/values-hooks-keep.yaml new file mode 100644 index 0000000000..1932c77686 --- /dev/null +++ b/k8s/devops-info-service/values-hooks-keep.yaml @@ -0,0 +1,2 @@ +hooks: + deleteAfterSuccess: false diff --git a/k8s/devops-info-service/values-prod.yaml b/k8s/devops-info-service/values-prod.yaml new file mode 100644 index 0000000000..514ecff2b5 --- /dev/null +++ b/k8s/devops-info-service/values-prod.yaml @@ -0,0 +1,44 @@ +replicaCount: 5 + +image: + tag: latest + pullPolicy: Always + +service: + type: LoadBalancer + port: 80 + targetPort: 5000 + nodePort: null + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi + +livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 3 + +persistence: + enabled: false + +config: + env: + APP_ENV: prod diff --git a/k8s/devops-info-service/values-rollout-bluegreen.yaml b/k8s/devops-info-service/values-rollout-bluegreen.yaml new file mode 100644 index 0000000000..eceeff6040 --- /dev/null +++ b/k8s/devops-info-service/values-rollout-bluegreen.yaml @@ -0,0 +1,15 @@ +rollout: + enabled: true + strategy: blueGreen + blueGreen: + autoPromotionEnabled: false + +replicaCount: 3 + +image: + tag: latest + pullPolicy: Always + +service: + type: NodePort + nodePort: 30088 diff --git a/k8s/devops-info-service/values-rollout-canary.yaml b/k8s/devops-info-service/values-rollout-canary.yaml new file mode 100644 index 0000000000..65e4e6a9eb --- /dev/null +++ b/k8s/devops-info-service/values-rollout-canary.yaml @@ -0,0 +1,13 @@ +rollout: + enabled: true + strategy: canary + +replicaCount: 3 + +image: + tag: latest + pullPolicy: Always + +service: + type: NodePort + nodePort: 30087 diff --git a/k8s/devops-info-service/values-statefulset.yaml b/k8s/devops-info-service/values-statefulset.yaml new file mode 100644 index 0000000000..93786a0bc7 --- /dev/null +++ b/k8s/devops-info-service/values-statefulset.yaml @@ -0,0 +1,21 @@ +statefulset: + enabled: true + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + +rollout: + enabled: false + +replicaCount: 3 + +service: + type: NodePort + nodePort: 30089 + +persistence: + enabled: true + size: 100Mi + storageClass: "" + mountPath: /data + fileName: visits diff --git a/k8s/devops-info-service/values-vault.yaml b/k8s/devops-info-service/values-vault.yaml new file mode 100644 index 0000000000..b22c06a854 --- /dev/null +++ b/k8s/devops-info-service/values-vault.yaml @@ -0,0 +1,5 @@ +vaultInjection: + enabled: true + +credentials: + enabled: true diff --git a/k8s/devops-info-service/values.yaml b/k8s/devops-info-service/values.yaml new file mode 100644 index 0000000000..895f4b1797 --- /dev/null +++ b/k8s/devops-info-service/values.yaml @@ -0,0 +1,111 @@ +replicaCount: 3 + +image: + repository: mararokkel/devops-info-service + tag: latest + pullPolicy: Always + +nameOverride: "" +fullnameOverride: "" + +credentials: + enabled: true + username: "lab-user" + password: "lab-password-placeholder" + +serviceAccount: + create: true + name: "" + +vaultInjection: + enabled: false + role: devops-info-service + secretPath: secret/data/devops-info/config + secretAlias: config + +config: + fileMountPath: /config + env: + APP_NAME: devops-info-service + APP_ENV: dev + LOG_LEVEL: info + +persistence: + enabled: false + size: 100Mi + storageClass: "" + mountPath: /data + fileName: visits + +statefulset: + enabled: false + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + +rollout: + enabled: false + strategy: canary + blueGreen: + autoPromotionEnabled: false + autoPromotionSeconds: null + +podAnnotations: {} + +podLabels: {} + +podSecurityContext: + fsGroup: 1000 + +service: + type: NodePort + port: 80 + targetPort: 5000 + nodePort: 30080 + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + - name: LAB9_UPDATE_ID + value: "v4" + +livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 2 + failureThreshold: 3 + +hooks: + deleteAfterSuccess: true + preInstall: + image: busybox:1.36 + postInstall: + image: curlimages/curl:8.5.0 diff --git a/k8s/lab16-init-download-pod.yaml b/k8s/lab16-init-download-pod.yaml new file mode 100644 index 0000000000..8c5a00d950 --- /dev/null +++ b/k8s/lab16-init-download-pod.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Pod +metadata: + name: lab16-init-download-demo + labels: + app: lab16-init-download +spec: + initContainers: + - name: init-download + image: busybox:1.36 + command: + - sh + - -c + - wget -q -O /work-dir/index.html https://example.com + volumeMounts: + - name: workdir + mountPath: /work-dir + containers: + - name: main-app + image: busybox:1.36 + command: ["sh", "-c", "sleep 3600"] + volumeMounts: + - name: workdir + mountPath: /data + volumes: + - name: workdir + emptyDir: {} diff --git a/k8s/lab16-init-waitfor.yaml b/k8s/lab16-init-waitfor.yaml new file mode 100644 index 0000000000..17eedb36af --- /dev/null +++ b/k8s/lab16-init-waitfor.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: Service +metadata: + name: lab16-wait-backend + labels: + app: lab16-wait-backend +spec: + selector: + app: lab16-wait-backend + ports: + - name: http + port: 80 + targetPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lab16-wait-backend + labels: + app: lab16-wait-backend +spec: + replicas: 1 + selector: + matchLabels: + app: lab16-wait-backend + template: + metadata: + labels: + app: lab16-wait-backend + spec: + containers: + - name: nginx + image: nginx:1.25-alpine + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Pod +metadata: + name: lab16-init-wait-demo + labels: + app: lab16-init-wait-demo +spec: + initContainers: + - name: wait-for-service + image: busybox:1.36 + command: + - sh + - -c + - | + until nslookup lab16-wait-backend.default.svc.cluster.local; do + echo waiting for lab16-wait-backend... + sleep 2 + done + containers: + - name: main-app + image: busybox:1.36 + command: ["sh", "-c", "echo main started && sleep 3600"] diff --git a/k8s/screenshots/argocd-applications-list.png b/k8s/screenshots/argocd-applications-list.png new file mode 100644 index 0000000000..714e736215 Binary files /dev/null and b/k8s/screenshots/argocd-applications-list.png differ diff --git a/k8s/screenshots/argocd-dev-details.png b/k8s/screenshots/argocd-dev-details.png new file mode 100644 index 0000000000..ef9f8bd86d Binary files /dev/null and b/k8s/screenshots/argocd-dev-details.png differ diff --git a/k8s/screenshots/argocd-prod-details.png b/k8s/screenshots/argocd-prod-details.png new file mode 100644 index 0000000000..f76116f814 Binary files /dev/null and b/k8s/screenshots/argocd-prod-details.png differ diff --git a/k8s/screenshots/graph_query.png b/k8s/screenshots/graph_query.png new file mode 100644 index 0000000000..ab120cd0c5 Binary files /dev/null and b/k8s/screenshots/graph_query.png differ diff --git a/k8s/screenshots/monitoring-alertmanager-ui.png b/k8s/screenshots/monitoring-alertmanager-ui.png new file mode 100644 index 0000000000..d22473c0b4 Binary files /dev/null and b/k8s/screenshots/monitoring-alertmanager-ui.png differ diff --git a/k8s/screenshots/monitoring-grafana-alerts.png b/k8s/screenshots/monitoring-grafana-alerts.png new file mode 100644 index 0000000000..5b8228aa0a Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-alerts.png differ diff --git a/k8s/screenshots/monitoring-grafana-kubelet.png b/k8s/screenshots/monitoring-grafana-kubelet.png new file mode 100644 index 0000000000..2df170cb29 Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-kubelet.png differ diff --git a/k8s/screenshots/monitoring-grafana-namespace-pods.png b/k8s/screenshots/monitoring-grafana-namespace-pods.png new file mode 100644 index 0000000000..3e114961d5 Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-namespace-pods.png differ diff --git a/k8s/screenshots/monitoring-grafana-network-default.png b/k8s/screenshots/monitoring-grafana-network-default.png new file mode 100644 index 0000000000..f03cf843f0 Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-network-default.png differ diff --git a/k8s/screenshots/monitoring-grafana-node-exporter.png b/k8s/screenshots/monitoring-grafana-node-exporter.png new file mode 100644 index 0000000000..8ac4fca382 Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-node-exporter.png differ diff --git a/k8s/screenshots/monitoring-grafana-statefulset-pod1.png b/k8s/screenshots/monitoring-grafana-statefulset-pod1.png new file mode 100644 index 0000000000..dd6776618d Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-statefulset-pod1.png differ diff --git a/k8s/screenshots/monitoring-grafana-statefulset-pod2.png b/k8s/screenshots/monitoring-grafana-statefulset-pod2.png new file mode 100644 index 0000000000..02fae1a4ec Binary files /dev/null and b/k8s/screenshots/monitoring-grafana-statefulset-pod2.png differ diff --git a/k8s/screenshots/rollouts-bluegreen-dashboard.png b/k8s/screenshots/rollouts-bluegreen-dashboard.png new file mode 100644 index 0000000000..00c47c7c61 Binary files /dev/null and b/k8s/screenshots/rollouts-bluegreen-dashboard.png differ diff --git a/k8s/screenshots/rollouts-bluegreen-dashboard2.png b/k8s/screenshots/rollouts-bluegreen-dashboard2.png new file mode 100644 index 0000000000..69aa406042 Binary files /dev/null and b/k8s/screenshots/rollouts-bluegreen-dashboard2.png differ diff --git a/k8s/screenshots/rollouts-canary-dashboard.png b/k8s/screenshots/rollouts-canary-dashboard.png new file mode 100644 index 0000000000..a6ec1ed74f Binary files /dev/null and b/k8s/screenshots/rollouts-canary-dashboard.png differ diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..b828802ab8 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service + labels: + app: devops-info-service +spec: + type: NodePort + selector: + app: devops-info-service + ports: + - protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30080 + diff --git a/labs/lab18/README.md b/labs/lab18/README.md new file mode 100644 index 0000000000..08033626c2 --- /dev/null +++ b/labs/lab18/README.md @@ -0,0 +1,54 @@ +# Lab 18 — Reproducible builds (Nix) + +Application copy and Nix expressions live under **`app_python/`** (same DevOps Info Service as Labs 1–2). + +## Quick commands (from `labs/lab18/app_python/`) + +**Classic nix-build (needs `` channel or `NIX_PATH`):** + +```bash +nix-build +readlink result +./result/bin/devops-info-service +# open http://127.0.0.1:5000/health +``` + +**Flake (pins `nixpkgs` via `flake.lock`; generate lock once):** + +```bash +nix flake lock +nix build .#default +./result/bin/devops-info-service +``` + +**Docker image (needs Docker for `load`/`run`):** + +On **Linux** (or NixOS): + +```bash +nix build .#docker +docker load < result +docker run --rm -p 5001:5000 devops-info-service-nix:1.0.0 +curl -sS http://127.0.0.1:5001/health +``` + +On **macOS**, `nix build .#docker` often fails inside `dockerTools` with **`fakeroot` / `dyld` (`_fstat$INODE64`)** — this is a known limitation, not a mistake in `docker.nix`. Build the same derivation **inside Linux via Docker**: + +```bash +cd labs/lab18/app_python +chmod +x nix-docker-linux.sh +./nix-docker-linux.sh +docker load -i devops-info-service-nix.tar.gz +``` + +(`result` points into the container’s `/nix/store`; on macOS that path is often missing — the script copies **`devops-info-service-nix.tar.gz`** into this directory for `docker load`.) + +Then compare hashes / run containers as in **`submission18.md`**. + +Compare with Lab 2 (**repository root**, not `lab18/`): + +```bash +docker build -t lab2-app:lab18-compare ./app_python +``` + +Fill **`../submission18.md`** with store paths, `sha256sum` outputs, `docker history`, and screenshots under **`screenshots/`**. diff --git a/labs/lab18/app_python/.gitignore b/labs/lab18/app_python/.gitignore new file mode 100644 index 0000000000..41366528a5 --- /dev/null +++ b/labs/lab18/app_python/.gitignore @@ -0,0 +1,5 @@ +result +result-* +devops-info-service-nix.tar.gz +.direnv +.envrc diff --git a/labs/lab18/app_python/Dockerfile b/labs/lab18/app_python/Dockerfile new file mode 100644 index 0000000000..7d86391594 --- /dev/null +++ b/labs/lab18/app_python/Dockerfile @@ -0,0 +1,39 @@ +# Use specific Python version for reproducibility +FROM python:3.13-slim + +# Set working directory +WORKDIR /app + +# Healthcheck in docker-compose uses curl +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user with fixed UID/GID (matches typical fsGroup in Kubernetes) +RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser + +# Copy requirements first for better layer caching +# This layer will only rebuild if requirements.txt changes +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app.py . + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose the port the app runs on +EXPOSE 5000 + +# Set environment variables with defaults +ENV HOST=0.0.0.0 +ENV PORT=5000 +ENV DEBUG=false + +# Run the application +CMD ["python", "app.py"] diff --git a/labs/lab18/app_python/app.py b/labs/lab18/app_python/app.py new file mode 100644 index 0000000000..d3cdc8f9fe --- /dev/null +++ b/labs/lab18/app_python/app.py @@ -0,0 +1,364 @@ +""" +DevOps Info Service +Main application module + +Provides system, runtime, and request information, +as well as a health check endpoint. +""" + +import os +import socket +import platform +import logging +import json +import time +import threading +from pathlib import Path +from datetime import datetime, timezone + +from flask import Flask, jsonify, request, g + +from prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST + +# ------------------------------------------------------------------------------ +# Application setup +# ------------------------------------------------------------------------------ + +app = Flask(__name__) + +# Configuration via environment variables +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "false").lower() == "true" +VISITS_FILE = Path(os.getenv("VISITS_FILE", "/data/visits")) + +_visits_lock = threading.Lock() + +# Application start time (used for uptime calculation) +START_TIME = datetime.now(timezone.utc) + +# ------------------------------------------------------------------------------ +# Logging configuration +# ------------------------------------------------------------------------------ + +logging.basicConfig( + level=logging.INFO, + format="%(message)s" +) +logger = logging.getLogger(__name__) + +logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "startup", + "message": "DevOps Info Service starting..." +})) + +# ------------------------------------------------------------------------------ +# Helper functions +# ------------------------------------------------------------------------------ + +def get_uptime(): + """ + Calculate application uptime. + + Returns: + tuple: uptime in seconds (int), human-readable uptime (str) + """ + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + +def _normalize_endpoint_label(): + rule = getattr(request, "url_rule", None) + if rule and getattr(rule, "rule", None): + return rule.rule + return request.path + + +def _read_visits_counter(): + try: + raw = VISITS_FILE.read_text(encoding="utf-8").strip() + return int(raw) if raw else 0 + except FileNotFoundError: + return 0 + except ValueError: + logger.warning(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "WARNING", + "event": "visits_counter_invalid", + "message": f"Invalid integer in {VISITS_FILE}; treating as 0", + })) + return 0 + + +def _write_visits_counter(value: int) -> None: + VISITS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = VISITS_FILE.with_suffix(".tmp") + tmp.write_text(str(value), encoding="utf-8") + tmp.replace(VISITS_FILE) + + +def _increment_visits_counter() -> int: + with _visits_lock: + n = _read_visits_counter() + 1 + _write_visits_counter(n) + return n + + +# ------------------------------------------------------------------------------ +# Prometheus metrics +# ------------------------------------------------------------------------------ + +http_requests_total = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +http_request_duration_seconds = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], +) + +http_requests_in_progress = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed", +) + +devops_info_endpoint_calls = Counter( + "devops_info_endpoint_calls", + "DevOps Info Service endpoint calls", + ["endpoint"], +) + +devops_info_system_collection_seconds = Histogram( + "devops_info_system_collection_seconds", + "Time spent collecting system info in seconds", +) + + +def get_system_info(): + """ + Collect system information. + + Returns: + dict: system information + """ + start = time.perf_counter() + try: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + finally: + devops_info_system_collection_seconds.observe(time.perf_counter() - start) + + +# ------------------------------------------------------------------------------ +# Request instrumentation +# ------------------------------------------------------------------------------ + +@app.before_request +def _metrics_before_request(): + g._metrics_start = time.perf_counter() + g._metrics_endpoint = _normalize_endpoint_label() + + if request.path != "/metrics": + http_requests_in_progress.inc() + + +@app.after_request +def _metrics_after_request(response): + endpoint = getattr(g, "_metrics_endpoint", _normalize_endpoint_label()) + + if request.path != "/metrics": + duration = time.perf_counter() - getattr(g, "_metrics_start", time.perf_counter()) + http_requests_total.labels( + method=request.method, + endpoint=endpoint, + status_code=str(response.status_code), + ).inc() + http_request_duration_seconds.labels( + method=request.method, + endpoint=endpoint, + ).observe(duration) + http_requests_in_progress.dec() + + return response + + +@app.teardown_request +def _metrics_teardown_request(error): + if request.path == "/metrics": + return + + if error is not None: + try: + http_requests_in_progress.dec() + except ValueError: + pass + +# ------------------------------------------------------------------------------ +# Routes +# ------------------------------------------------------------------------------ + +@app.route("/", methods=["GET"]) +def index(): + """ + Main endpoint returning service, system, runtime, and request information. + """ + logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "request", + "endpoint": "index", + "method": request.method, + "path": request.path, + "client_ip": request.remote_addr, + "status_code": 200, + "user_agent": request.headers.get("User-Agent"), + })) + + uptime_seconds, uptime_human = get_uptime() + visits_count = _increment_visits_counter() + devops_info_endpoint_calls.labels(endpoint="/").inc() + + response = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime_seconds, + "uptime_human": uptime_human, + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + }, + "visits": { + "count": visits_count, + "file": str(VISITS_FILE), + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + {"path": "/visits", "method": "GET", "description": "Visits counter"}, + ], + } + + return jsonify(response) + + +@app.route("/health", methods=["GET"]) +def health(): + """ + Health check endpoint. + """ + uptime_seconds, _ = get_uptime() + devops_info_endpoint_calls.labels(endpoint="/health").inc() + + logger.info(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "INFO", + "event": "request", + "endpoint": "health", + "method": request.method, + "path": request.path, + "client_ip": request.remote_addr, + "status_code": 200, + "user_agent": request.headers.get("User-Agent"), + })) + + return jsonify({ + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime_seconds, + }) + + +@app.route("/visits", methods=["GET"]) +def visits(): + devops_info_endpoint_calls.labels(endpoint="/visits").inc() + + with _visits_lock: + count = _read_visits_counter() + + return jsonify({ + "visits": count, + "file": str(VISITS_FILE), + }) + + +@app.route("/metrics", methods=["GET"]) +def metrics(): + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} + +# ------------------------------------------------------------------------------ +# Error handlers +# ------------------------------------------------------------------------------ + +@app.errorhandler(404) +def not_found(error): + """ + Handle 404 errors. + """ + logger.warning(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "WARNING", + "event": "http_error", + "error_type": "NotFound", + "path": request.path, + "method": request.method, + "status_code": 404, + "client_ip": request.remote_addr, + })) + return jsonify({ + "error": "Not Found", + "message": "Endpoint does not exist", + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + """ + Handle 500 errors. + """ + logger.error(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": "ERROR", + "event": "http_error", + "error_type": "InternalServerError", + "path": request.path, + "method": request.method, + "status_code": 500, + "client_ip": request.remote_addr, + "error": str(error), + })) + return jsonify({ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }), 500 + +# ------------------------------------------------------------------------------ +# Application entry point +# ------------------------------------------------------------------------------ + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/labs/lab18/app_python/data/.gitkeep b/labs/lab18/app_python/data/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/labs/lab18/app_python/default.nix b/labs/lab18/app_python/default.nix new file mode 100644 index 0000000000..abfdf84d05 --- /dev/null +++ b/labs/lab18/app_python/default.nix @@ -0,0 +1,37 @@ +# Lab 18 Task 1 — reproducible DevOps Info Service (same app as Lab 1). +# Uses nixpkgs-pinned Python with Flask + prometheus-client from the package set +# (not PyPI at resolve time), so the closure is fixed for a given nixpkgs revision. +{ pkgs ? import { } }: +let + pythonEnv = pkgs.python3.withPackages ( + ps: with ps; [ + flask + prometheus-client + ] + ); +in +pkgs.stdenvNoCC.mkDerivation { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/devops-info-service + cp ${./app.py} $out/share/devops-info-service/app.py + + # Default visits file: writable without extra mounts (Docker/Nix run). + makeWrapper ${pythonEnv}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/share/devops-info-service/app.py" \ + --set-default VISITS_FILE /tmp/devops-info-visits + + runHook postInstall + ''; + + meta = { + description = "DevOps Info Service (Flask) — Lab 18 Nix package"; + mainProgram = "devops-info-service"; + }; +} diff --git a/labs/lab18/app_python/docker.nix b/labs/lab18/app_python/docker.nix new file mode 100644 index 0000000000..3f4e05097e --- /dev/null +++ b/labs/lab18/app_python/docker.nix @@ -0,0 +1,25 @@ +# Lab 18 Task 2 — reproducible OCI image via dockerTools (fixed image timestamp). +{ 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" = { }; + }; + Env = [ + "HOST=0.0.0.0" + "PORT=5000" + ]; + }; + + # Required for bit-reproducible tarballs (avoid "now"). + created = "1970-01-01T00:00:01Z"; +} diff --git a/labs/lab18/app_python/flake.lock b/labs/lab18/app_python/flake.lock new file mode 100644 index 0000000000..fe08f5660f --- /dev/null +++ b/labs/lab18/app_python/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/labs/lab18/app_python/flake.nix b/labs/lab18/app_python/flake.nix new file mode 100644 index 0000000000..a676f2ef8a --- /dev/null +++ b/labs/lab18/app_python/flake.nix @@ -0,0 +1,29 @@ +{ + description = "Lab 18 — reproducible DevOps Info Service (Nix + optional dockerTools)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + inherit (nixpkgs) lib; + in + { + packages = lib.genAttrs systems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = import ./default.nix { inherit pkgs; }; + docker = import ./docker.nix { inherit pkgs; }; + } + ); + }; +} diff --git a/labs/lab18/app_python/nix-docker-linux.sh b/labs/lab18/app_python/nix-docker-linux.sh new file mode 100755 index 0000000000..604002119e --- /dev/null +++ b/labs/lab18/app_python/nix-docker-linux.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Build the Lab 18 dockerTools tarball when native `nix build .#docker` fails on macOS +# (fakeroot / dyld `_fstat$INODE64` in dockerTools). Uses Nix inside a Linux container. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker: command not found. Install Docker Desktop / Colima first." >&2 + exit 1 +fi + +echo "Repo: $REPO_ROOT" +echo "Building docker output in Linux (nixos/nix)..." + +# Use a path: flake so Nix does not need `git` for git+file evaluation (avoids nix shell + # quoting issues). +docker run --rm -i \ + -v "$REPO_ROOT:/repo" \ + -w /repo/labs/lab18/app_python \ + nixos/nix:latest \ + bash -euxo pipefail -s <<'REMOTE' +export NIX_CONFIG="extra-experimental-features = nix-command flakes" +nix --version +# Absolute path + fragment; # is safe inside "..." for bash. +here="/repo/labs/lab18/app_python" +sys=$(nix eval --impure --raw --expr builtins.currentSystem) +nix build --print-build-logs --accept-flake-config \ + "path:${here}#packages.${sys}.docker" +# Copy tarball onto the bind mount: `result` points at /nix/store/... inside the +# container; that path usually does not exist on the macOS host, so `docker load < result` breaks. +out="$(readlink -f result)" +install -m644 "$out" "/repo/labs/lab18/app_python/devops-info-service-nix.tar.gz" +REMOTE + +echo "Done." +echo " Symlink (may be broken on macOS host): $SCRIPT_DIR/result" +echo " Host tarball for docker load: $SCRIPT_DIR/devops-info-service-nix.tar.gz" +ls -la "$SCRIPT_DIR/result" "$SCRIPT_DIR/devops-info-service-nix.tar.gz" 2>/dev/null || true diff --git a/labs/lab18/app_python/requirements.txt b/labs/lab18/app_python/requirements.txt new file mode 100644 index 0000000000..46c776bf8d --- /dev/null +++ b/labs/lab18/app_python/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.1.0 +prometheus-client==0.23.1 diff --git a/labs/lab18/screenshots/.gitkeep b/labs/lab18/screenshots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/labs/lab18/screenshots/docker-both-health.png b/labs/lab18/screenshots/docker-both-health.png new file mode 100644 index 0000000000..02b1b5b19c Binary files /dev/null and b/labs/lab18/screenshots/docker-both-health.png differ diff --git a/labs/lab18/screenshots/nix-run-health.png b/labs/lab18/screenshots/nix-run-health.png new file mode 100644 index 0000000000..c0d7b23c94 Binary files /dev/null and b/labs/lab18/screenshots/nix-run-health.png differ diff --git a/labs/submission18.md b/labs/submission18.md new file mode 100644 index 0000000000..220f222c77 --- /dev/null +++ b/labs/submission18.md @@ -0,0 +1,192 @@ +# Lab 18 — Submission: Reproducible builds with Nix + +**Merge request:** branch **`lab18`** into the course repository default branch. +**Author:** Rokkel Maria CBS-01 +**Environment:** macOS (Darwin); **Determinate Nix** on host — `nix (Determinate Nix 3.20.0) 2.34.6`; Linux build uses **`nixos/nix:latest`** → `nix (Nix) 2.34.7`. + +--- + +## 1. Task 1 — Reproducible Python app (Lab 1 revisited) + +### 1.1 Nix installation and sanity check + +```text +$ nix --version +nix (Determinate Nix 3.20.0) 2.34.6 + +$ nix run nixpkgs#hello +Hello, world! +``` + +### 1.2 Lab layout + +- Application sources: **`labs/lab18/app_python/`** (`app.py`, `requirements.txt`, same service as Lab 1). +- Nix derivation: **`labs/lab18/app_python/default.nix`**. + +### 1.3 Derivation overview + +The derivation uses **`python3.withPackages`** with **`flask`** and **`prometheus-client`** from the pinned **nixpkgs** closure (not `pip` at build time). The wrapper binary **`devops-info-service`** runs `app.py` with **`VISITS_FILE`** defaulting to **`/tmp/devops-info-visits`** so the service runs without a bind-mounted **`/data`**. Full expression: **`labs/lab18/app_python/default.nix`** in the repository. + +### 1.4 Store path and rebuild evidence + +From **`labs/lab18/app_python/`**, the application package was built with **`nix build .#default`** (flake). The store path below is the **`devops-info-service-1.0.0`** derivation output. + +| Step | Command | `readlink -f result` / notes | +|------|---------|------------------------------| +| First build | `nix build .#default` | `/nix/store/va7wv7crvlxyhx62wwbzc51h0a86pr8x-devops-info-service-1.0.0` | +| Rebuild after removing symlink | `rm -f result && nix build .#default` | Same path (binary cache hit; no inputs changed) | + +`nix-hash` on the output was not recorded on the host when the **`result`** symlink pointed only at container-local **`/nix/store`** paths; the store-path table above is the primary reproducibility evidence for Task 1. + +### 1.5 pip vs Nix (limitations of `requirements.txt`) + +Compared to **`pip install -r requirements.txt`** in a venv: + +- **`requirements.txt`** pins direct deps; transitive deps still float unless fully locked (e.g. `pip-tools`, strict hashes). +- **Nix / nixpkgs** fixes the entire dependency graph for a given revision. + +| Aspect | Lab 1 (`pip` + `venv`) | Lab 18 (Nix derivation) | +|--------|------------------------|-------------------------| +| Python runtime | Whatever the machine / image uses | From nixpkgs | +| Dependency graph | Resolved at `pip install` time | Fixed at nixpkgs revision | +| Binary cache | No standard shared cache | `cache.nixos.org` for identical store paths | +| Store path / hashing | N/A | Content-addressed `/nix/store/-…` | + +### 1.6 Nix store path format + +Example output path: **`/nix/store/va7wv7crvlxyhx62wwbzc51h0a86pr8x-devops-info-service-1.0.0`**. The **`va7wv7crv…`** prefix is the **content hash** of all fixed inputs (source nar, dependency drvs, builder script, flags). The suffix **`devops-info-service-1.0.0`** is **`pname-version`**. Same inputs ⇒ same hash ⇒ same path (binary cache safe). + +### 1.7 Screenshots (Task 1) + +Nix-built service with **`PORT=5001`** (port 5000 busy on macOS); **`GET /health`**. + +![Nix-built service: `PORT=5001` and `GET /health`](lab18/screenshots/nix-run-health.png) + +--- + +## 2. Task 2 — Reproducible Docker image (`dockerTools`) + +### 2.1 Lab 2 Dockerfile reference + +Images for comparison were built from the repository root using **`app_python/Dockerfile`**. A parallel copy exists under **`labs/lab18/app_python/Dockerfile`** for documentation only; all **`docker build`** commands below use **`./app_python`**. + +Two successive **`docker build`** runs produce different image IDs and different **`docker save`** digests (section 2.3) even when layers are cached, because BuildKit adds **per-build attestation** metadata. **`docker inspect … --format '{{.Created}}'`** is another non-reproducibility signal when builds are not identical; with a fully cached graph, **`Created`** can look close in time, but the saved tar still differs. + +### 2.2 Nix-built image + +Expression: **`labs/lab18/app_python/docker.nix`**. + +- **`buildLayeredImage`**: layered store paths for smaller diffs. +- **`created = "1970-01-01T00:00:01Z"`**: avoids time-based tarball drift. +- **`contents`**: the Task 1 derivation only (minimal closure). + +On this macOS host, **`nix build .#docker`** fails inside **`dockerTools`** (**`fakeroot`** / **`dyld`**, `_fstat$INODE64`). The image was therefore built with **`./nix-docker-linux.sh`**: Nix runs in a **Linux** container with the repo bind-mounted at **`/repo`**, using a **`path:`** flake reference so evaluation does not depend on **`git`** inside the container. The script copies **`devops-info-service-nix.tar.gz`** into **`labs/lab18/app_python/`** because the **`result`** symlink often targets a **`/nix/store`** path that exists only inside that container, not on the macOS host. + +Commands used after a successful build: + +```bash +cd labs/lab18/app_python +sha256sum devops-info-service-nix.tar.gz +docker load -i devops-info-service-nix.tar.gz +docker run --rm -d -p 5001:5000 --name nix-lab18 devops-info-service-nix:1.0.0 +curl -sS http://127.0.0.1:5001/health +docker stop nix-lab18 +``` + +Two tarball checksums were taken around a **`rm devops-info-service-nix.tar.gz`** and a full **`./nix-docker-linux.sh`** rebuild: + +| Build | `sha256sum devops-info-service-nix.tar.gz` | +|-------|--------------------------------------------| +| Before `rm` + rebuild | `ab101797fbc04600800b03208f934837d607b22938ed514352acfaf86879acc9` | +| After `./nix-docker-linux.sh` again | `e8932c8d6d58f4ca067e35f6ded3fc3c920532e105ff237071b1f372bebd1707` | + +The digests differ because the **path flake** source is the entire **`labs/lab18/app_python/`** directory; removing the previous tarball (and any other change under that path) changes the **narHash** of the flake input, so the **`devops-info-service-1.0.0`** intermediate store path changes as well. With a **bit-identical** source tree between runs, two consecutive **`./nix-docker-linux.sh`** invocations produce the **same** tarball hash (same inputs → same Nix store output). + +On **Linux** or **WSL**, **`nix build .#docker`** followed by **`docker load < result`** is the direct workflow when **`dockerTools`** runs natively. + +### 2.3 Lab 2 image non-reproducibility (tar hash) + +```bash +docker build -t lab2-app:test1 ./app_python +docker save lab2-app:test1 | shasum -a 256 +docker build -t lab2-app:test2 ./app_python +docker save lab2-app:test2 | shasum -a 256 +``` + +| Image | `docker save \| shasum -a 256` | +|-------|--------------------------------| +| `lab2-app:test1` | `460599dc3f49a57656d45a83aa5db33a4f5295ecc5f5454a1ad42429423dba20` | +| `lab2-app:test2` | `4b60642f8dfd9be6242fb40493849e84789d6331847d84b3fde1e3009b19055e` | + +BuildKit exported a **different attestation manifest** per build (`exporting attestation manifest sha256:…`), so the saved tar digest changes even when application layers are cached. + +### 2.4 Image size and `docker history` + +```bash +docker images | grep -E 'lab2-app|devops-info-service-nix' +docker history lab2-app:test1 +docker history devops-info-service-nix:1.0.0 +``` + +| Metric | Lab 2 Dockerfile | Lab 18 Nix `dockerTools` | +|--------|------------------|--------------------------| +| Reported image size (`docker images`) | **~241 MB** (`lab2-app:test1`) | **~433 MB** (`devops-info-service-nix:1.0.0` — includes full closure as reported by Docker Desktop) | +| Reproducible tarball / digest | No — `docker save` differs run-to-run (attestation / metadata) | Yes **when flake inputs + local path are unchanged** — fixed `created` in `docker.nix` | +| Base image | `python:3.13-slim` + `apt` | None (store paths only) | + +**`docker history`:** Lab 2 shows **BuildKit** steps with **relative `CREATED` times** (e.g. “5 minutes ago”) and a Debian base layer. Nix image shows **content-addressed store path layers** with **`CREATED` N/A** and fixed epoch-style metadata in the image config (`56 years ago` is Docker’s display of the **1970-01-01** reproducibility timestamp). + +### 2.5 Side-by-side runtime + +Port **5000** on macOS was busy (system service); Lab 2 was mapped to **5002**, Nix image to **5001**. + +| Endpoint | Lab 2 container | Nix container | +|----------|-----------------|---------------| +| `GET /health` | `http://127.0.0.1:5002/health` | `http://127.0.0.1:5001/health` | + +```json +{"status":"healthy","timestamp":"2026-05-14T21:21:59.446048+00:00","uptime_seconds":13} +``` + +```json +{"status":"healthy","timestamp":"2026-05-14T21:22:07.151182+00:00","uptime_seconds":14} +``` + +### 2.6 Screenshots (Task 2) + +Lab 2 container (**host port 5002**) and Nix image (**5001**); both **`GET /health`**. + +![Lab 2 on port 5002 and Nix image on port 5001: `GET /health`](lab18/screenshots/docker-both-health.png) + +### 2.7 Analysis + +**Why traditional Dockerfiles are not bit-for-bit reproducible:** image config timestamps, layer metadata, registry tag drift (`python:3.13-slim` moves), non-deterministic package mirrors (`apt`, unpinned `pip`), and build-time `ARG`/`LABEL` usage. + +**Redoing Lab 2 with Nix:** pin **`nixpkgs`** in **`flake.lock`**, build a **`dockerTools`** tarball with a fixed **`created`** timestamp, and treat Docker as transport for that artifact instead of using **`Dockerfile`** + **`pip`** as the resolver. + +**Where reproducibility matters:** CI image digest signing, incident rollback to a byte-identical artifact, supply-chain audits, and avoiding environment drift between developer laptops and automation. + +--- + +## 3. Flakes vs Helm pinning (Lab 10 bonus tie-in) + +| Idea | Helm / Kubernetes (Lab 10) | Nix Flakes | +|------|------------------------------|------------| +| What is pinned | Chart version + `values.yaml` image tags | `flake.lock` inputs (`nixpkgs`, etc.) | +| Drift source | Upstream chart semver, mutable `:latest` tags | Input URL + locked revision only | +| Rollback | Helm release revision / pinned values | Git revert lock + rebuild | + +The course Helm chart **`k8s/devops-info-service/`** pins application images and tunables in **`values.yaml`** and chart version; that is version control at the **Kubernetes delivery** layer. **`flake.lock`** pins the **nixpkgs** (and other) input revisions at the **build** layer. Both reduce drift; Nix additionally fixes the entire compiled dependency graph for the Worker code before an image exists. + +--- + +## 4. Flake lock + +The submission repository includes **`labs/lab18/app_python/flake.lock`**. Locked **`nixpkgs`** revision: **`50ab793786d9de88ee30ec4e4c24fb4236fc2674`** (input **`github:NixOS/nixpkgs/nixos-24.11`**). + +--- + +## 5. Reflection + +- **How would Nix have helped in Lab 1 from day one?** One pinned **nixpkgs** revision would have fixed Python, Flask, and all transitive libraries at build time, with the same store paths across laptops and CI—no “works with my venv” drift. +- **Biggest surprise building the Nix Docker image vs Lab 2?** Native **`dockerTools`** failed on macOS (**fakeroot** / **dyld**); building inside Linux via **`nix-docker-linux.sh`** worked. Also, **`docker images`** size for the Nix image was **larger** than the slim Dockerfile image here, while the **lab narrative** often stresses smaller images—closure size vs slim multi-stage images depends on what you ship. diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..70bec6fd8b --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,152 @@ +services: + prometheus: + image: prom/prometheus:v3.9.0 + ports: + - "9090:9090" + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=15d" + - "--storage.tsdb.retention.size=10GB" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 512M + + loki: + image: grafana/loki:3.0.0 + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 512M + + promtail: + image: grafana/promtail:3.0.0 + command: -config.file=/etc/promtail/config.yml + ports: + - "9080:9080" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - logging + depends_on: + - loki + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9080/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M + + grafana: + image: grafana/grafana:12.3.1 + ports: + - "3000:3000" + environment: + # Production-like settings; admin password is provided via .env + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD} + GF_SECURITY_ALLOW_EMBEDDING: "true" + GF_METRICS_ENABLED: "true" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + networks: + - logging + depends_on: + loki: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M + + app-python: + build: + context: ../app_python + image: devops-info-service:lab08 + container_name: devops-python + ports: + - "8000:5000" + networks: + - logging + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.25" + memory: 256M + +volumes: + prometheus-data: + loki-data: + grafana-data: + +networks: + logging: + driver: bridge + diff --git a/monitoring/docs/LAB07.md b/monitoring/docs/LAB07.md new file mode 100644 index 0000000000..d68d6464e7 --- /dev/null +++ b/monitoring/docs/LAB07.md @@ -0,0 +1,217 @@ +## Lab 7 — Observability & Logging with Loki Stack + +### Architecture + +- **Loki 3.0**: central log storage using TSDB + filesystem backend. +- **Promtail 3.0**: log agent that discovers Docker containers and ships logs to Loki. +- **Grafana 12.3**: UI for querying and visualising logs and metrics via LogQL. +- **Applications**: containers with labels `logging=promtail`, `app=...`; their Docker logs are scraped by Promtail and stored in Loki. + +Log flow: container → Docker logs → Promtail (Docker SD + label filter) → Loki (TSDB with 7‑day retention) → Grafana (LogQL queries and dashboards). + +### Setup Guide + +1. Go to the `monitoring` directory: + ```bash + cd monitoring + ``` +2. Start the stack: + ```bash + docker compose up -d + docker compose ps + ``` +3. Verify that all services are healthy: + ```bash + curl http://localhost:3100/ready # Loki + curl http://localhost:9080/targets # Promtail targets + curl http://localhost:3000/api/health # Grafana + ``` +4. Open Grafana at `http://localhost:3000/`, log in as `admin` with the password from `.env` (`GF_SECURITY_ADMIN_PASSWORD`), add a Loki data source with URL `http://loki:3100` and click **Save & Test**. + +### Loki Configuration (config.yml) + +Key snippets: + +```yaml +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + 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 + +limits_config: + retention_period: 168h # 7 days +``` + +- **TSDB + schema v13**: recommended Loki 3.0 schema with fast queries. +- **filesystem object_store**: simple backend for a single‑node setup. +- **7‑day retention**: logs are kept for 168 hours. +- **Compactor** (configured in `config.yml`) periodically removes data older than the retention period. + +### Promtail Configuration (config.yml) + +Key snippets: + +```yaml +server: + http_listen_port: 9080 + +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"] + relabel_configs: + - source_labels: ["__meta_docker_container_name"] + target_label: container + regex: "/(.*)" + replacement: "$1" + - source_labels: ["__meta_docker_container_label_app"] + target_label: app +``` + +- **Docker service discovery**: Promtail talks to the Docker API on `unix:///var/run/docker.sock`. +- **filters**: only containers with label `logging=promtail` are scraped. +- **relabel_configs**: `__meta_docker_container_name` and `__meta_docker_container_label_app` are converted into the `container` and `app` labels that are later used in LogQL. + +### Application Logging (JSON) + +The Python app uses the standard `logging` module, but emits **JSON‑encoded log lines** with rich context: + +- Core fields: `timestamp`, `level`, `message` / `event`. +- HTTP context: `method`, `path`, `status_code`, `client_ip`, `user_agent`. +- Events: service `startup`, request handling for `/` and `/health`, 404 and 500 errors. + +Example LogQL queries for these JSON logs: + +- All logs from the Python app: + ```logql + {service_name="devops-python"} + ``` +- Only errors: + ```logql + {service_name="devops-python"} |= "ERROR" + ``` +- Parse JSON and filter by method: + ```logql + {service_name="devops-python"} | json | method="GET" + ``` + +The screenshot below shows Loki Explore with logs from the `devops-python` container and other services, queried via: + +![Loki Explore view for devops-python](explore.png) + +### Dashboard & LogQL + +The following LogQL patterns are used for the dashboard: + +- Stream selection by service: + ```logql + {service_name="devops-python"} + ``` +- Text filter for errors: + ```logql + {service_name="devops-python"} |= "ERROR" + ``` +- Metrics from logs (request rate): + ```logql + sum by (service_name) (rate({service_name="devops-python"}[1m])) + ``` +- Log volume for the service: + ```logql + count_over_time({service_name="devops-python"}[5m]) + ``` + +The following screenshot shows Loki Explore with a text filter on HTTP method `GET` for the `devops-python` service: + +![Loki Explore view filtered by GET](explore_GET.png) + +Recommended dashboard panels: + +1. **Application logs (devops-python)** — Logs panel, query: + ```logql + {service_name="devops-python"} + ``` +2. **Request rate** — Time series panel, query: + ```logql + sum by (service_name) (rate({service_name="devops-python"}[1m])) + ``` +3. **Error logs** — Logs panel, query: + ```logql + {service_name="devops-python"} |= "ERROR" + ``` +4. **Log volume** — Pie chart panel, query: + ```logql + count_over_time({service_name="devops-python"}[5m]) + ``` + The screenshot below shows the final Grafana dashboard with all four panels and real log data from the `devops-python` service: + + ![Grafana Loki dashboard for devops-python](dashboard.png) + +### Production Config & Security + +- **Resource limits** are configured for all services in `docker-compose.yml` via `deploy.resources.limits` and `deploy.resources.reservations`. +- **Grafana**: + - `GF_AUTH_ANONYMOUS_ENABLED=false` disables anonymous access. + - `GF_SECURITY_ADMIN_PASSWORD` is provided via `.env` and is not committed to git. + - For local development anonymous access can be temporarily enabled, but this must be disabled in production. +- **Loki** uses a 7‑day retention period and the compactor removes old chunks accordingly. + +### Testing & Verification + +- Start the stack: + ```bash + cd monitoring + docker compose up -d + docker compose ps + ``` +- Generate application logs: + ```bash + for i in {1..20}; do curl http://localhost:8000/; done + for i in {1..20}; do curl http://localhost:8000/health; done + ``` +- In Grafana (Explore, Loki data source), run: + - `{service_name="devops-python"}` + - `{service_name="devops-python"} |= "GET"` + - `{service_name="devops-python"} |= "ERROR"` + +### Research Answers + +- **How is Loki different from Elasticsearch?** + Loki indexes only **labels** (metadata) instead of full log text and stores the raw log lines cheaply in object storage or filesystem. This makes it much more cost‑efficient and resource‑friendly for log workloads than Elasticsearch, which indexes the entire document body. + +- **What are log labels and why do they matter?** + Labels are key–value metadata attached to log streams (for example `app`, `container`, `namespace`, `job`). Queries in LogQL select streams by labels and aggregate over them, so good label design enables fast, efficient queries and flexible dashboards. + +- **How does Promtail discover containers?** + Promtail supports multiple discovery mechanisms such as Docker service discovery (`docker_sd_configs`) and Kubernetes API. In this lab it uses the Docker API on the Unix socket and applies a label filter so that only containers with `logging=promtail` are scraped. + +### Challenges & Solutions + +- **Loki 3.0 configuration errors**: the initial Loki config used deprecated `shared_store` fields and missed the required `compactor.delete_request_store`, which caused startup failures. The configuration was fixed by switching fully to TSDB settings and explicitly setting `delete_request_store: filesystem`. +- **Grafana admin password confusion**: the container started with the default `admin/admin` credentials, while the `.env` value was not yet applied. The issue was resolved by logging in with the default password, setting a new admin password that matches the `.env` value, and restarting the Grafana container. +- **Empty queries in Explore**: some LogQL queries initially returned no data because of wrong label names (`app` vs `service_name`) and an incorrect time range. Using the Label browser to discover real labels and increasing the time range to the last few hours fixed the problem and made the logs visible. + + diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..8b42805d90 --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,348 @@ +## Lab 8 — Metrics & Monitoring with Prometheus + +### Architecture + +**Goal**: add application metrics and build a complete metrics monitoring stack. + +**Components**: + +- **devops-info-service (Flask)** exposes Prometheus metrics at `GET /metrics` +- **Prometheus** scrapes metrics (pull model) and stores time-series in TSDB +- **Grafana** visualizes Prometheus metrics with dashboards (PromQL) +- **(From Lab 7) Loki + Promtail** remain for logs, complementing metrics + +**Metric flow**: app → Prometheus (scrape) → Grafana (dashboards) + +**Diagram (logical)**: + +```mermaid +flowchart LR + A[Flask app
:5000 /metrics] -->|scrape 15s| P[Prometheus
:9090] + L[Loki
:3100] -->|scrape 15s| P + G[Grafana
:3000 /metrics] -->|scrape 15s| P + P -->|PromQL| D[Grafana Dashboards] +``` + +--- + +### Application Instrumentation + +#### Added dependencies + +- `prometheus-client==0.23.1` in `app_python/requirements.txt` + +#### Exposed endpoint + +- `GET /metrics` returns metrics in Prometheus text format. + +#### Metrics (RED method + app-specific) + +**HTTP / RED metrics** (labels kept low-cardinality: `method`, normalized `endpoint`, `status_code`): + +- **Counter** `http_requests_total{method,endpoint,status_code}` + Counts total HTTP requests (used for request rate and errors). +- **Histogram** `http_request_duration_seconds_bucket{method,endpoint,...}` + Measures latency distribution (used for p95 and heatmaps). +- **Gauge** `http_requests_in_progress` + Current number of in-flight HTTP requests. + +**App-specific metrics**: + +- **Counter** `devops_info_endpoint_calls{endpoint}` + Business-level “endpoint usage” counter for `"/"` and `"/health"`. +- **Histogram** `devops_info_system_collection_seconds` + Time spent collecting system info inside request handling. + +**Label design note**: endpoint label is normalized via Flask route rules (e.g. `"/health"`). We avoid user IDs or raw dynamic paths to prevent label cardinality explosion. + +#### Code location + +- Implementation: `app_python/app.py` + +#### Local testing (evidence) + +Run locally: + +```bash +cd app_python +pip install -r requirements.txt +python app.py +curl -s http://localhost:5000/metrics | head -n 40 +``` + +**Screenshot required**: output of `/metrics` showing `http_requests_total` and `http_request_duration_seconds`. + +Evidence (Task 1): + +![Application /metrics output](screenshots/lab08-metrics.png) + +--- + +### Prometheus Configuration + +#### Docker Compose + +Monitoring stack lives in `monitoring/docker-compose.yml`. + +Key settings: + +- Prometheus image: `prom/prometheus:v3.9.0` +- Scrape interval: `15s` +- Retention: + - `--storage.tsdb.retention.time=15d` + - `--storage.tsdb.retention.size=10GB` +- Persistent data volume: `prometheus-data:/prometheus` +- Same `logging` network as Loki/Grafana (Lab 7) + +#### Scrape targets + +Prometheus config: `monitoring/prometheus/prometheus.yml` + +Jobs: + +- `prometheus`: `localhost:9090` +- `app`: `app-python:5000` (path: `/metrics`) +- `loki`: `loki:3100` (path: `/metrics`) +- `grafana`: `grafana:3000` (path: `/metrics`) + +#### Deploy & verify (evidence) + +```bash +cd monitoring +docker compose up -d --build +``` + +If Prometheus fails to start, check logs: + +```bash +docker compose logs --tail=200 prometheus +``` + +Verification steps: + +- Prometheus UI: `http://localhost:9090` +- Targets page: `http://localhost:9090/targets` +- PromQL sanity: query `up` and confirm jobs are present + +**Screenshots required**: + +- `/targets` showing `prometheus`, `app`, `loki`, `grafana` are **UP** +- Prometheus query page showing successful `up` result + +Evidence (Task 2): + +![Prometheus /targets (all UP)](screenshots/lab08-targets.png) + +![PromQL query: up](screenshots/lab08-promql-up.png) + +--- + +### Dashboard Walkthrough (Grafana) + +#### Data source + +Add Prometheus data source: + +- **URL**: `http://prometheus:9090` +- Alternative: auto-provisioned via `monitoring/grafana/provisioning/datasources/datasource-prometheus.yml` + +#### Panels (6+ required) and PromQL + +1) **Request Rate** (time series) +Purpose: request throughput (Rate in RED) per endpoint. + +```promql +sum by (endpoint) (rate(http_requests_total[5m])) +``` + +2) **Error Rate (5xx)** (time series) +Purpose: server-side error rate (Errors in RED). + +```promql +sum(rate(http_requests_total{status_code=~"5.."}[5m])) +``` + +3) **Latency p95** (time series) +Purpose: tail latency (Duration in RED) as 95th percentile. + +```promql +histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) +``` + +4) **Latency heatmap** (heatmap) +Purpose: visualize latency distribution across histogram buckets over time. + +```promql +sum by (le) (rate(http_request_duration_seconds_bucket[5m])) +``` + +5) **Active Requests** (stat / time series) +Purpose: current concurrency / in-flight requests. + +```promql +http_requests_in_progress +``` + +6) **Status Code Distribution** (pie) +Purpose: breakdown of responses by status class (2xx/4xx/5xx). + +```promql +sum by (status_code) (rate(http_requests_total[5m])) +``` + +7) **Uptime (app target)** (stat) +Purpose: target availability from Prometheus perspective. + +```promql +up{job="app"} +``` + +**Screenshot required**: custom dashboard with live data and all panels working. + +Evidence (Task 3): + +![Grafana dashboard (Prometheus metrics)](screenshots/lab08-grafana.png) + +#### Export + +Export the dashboard JSON from Grafana and save it as: + +- `monitoring/docs/lab08-dashboard.json` +- Auto-provisioned dashboard file: `monitoring/grafana/dashboards/lab08-dashboard.json` + +--- + +### PromQL Examples (5+) + +1) **Overall RPS** +Shows total request rate across all endpoints/methods/statuses. + +```promql +sum(rate(http_requests_total[5m])) +``` + +2) **RPS by endpoint** +Shows traffic distribution and identifies the hottest endpoints. + +```promql +sum by (endpoint) (rate(http_requests_total[5m])) +``` + +3) **5xx error rate** +Shows server error rate (only responses with status_code 5xx). + +```promql +sum(rate(http_requests_total{status_code=~"5.."}[5m])) +``` + +4) **p95 latency** +Shows 95th percentile latency computed from histogram buckets. + +```promql +histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) +``` + +5) **Top endpoints by usage (raw counter increase)** +Shows the most-used endpoints over the last hour by counter growth. + +```promql +topk(5, sum by (endpoint) (increase(http_requests_total[1h]))) +``` + +6) **Service down detection** +Returns targets that are down (0) at the moment. + +```promql +up == 0 +``` + +--- + +### Production Setup + +#### Health checks + +- Prometheus: `/-/healthy` +- App: `/health` +- Promtail: `/ready` + +Healthchecks are configured in `monitoring/docker-compose.yml`. + +#### Resource limits + +Configured via `deploy.resources.limits`: + +- Prometheus: **1 CPU / 1G** +- Loki (Lab 7): **1 CPU / 1G** +- Grafana: **0.5 CPU / 512M** +- App: **0.5 CPU / 256M** + +#### Data retention & persistence + +- Prometheus retention: **15d** or **10GB** +- Persistent volumes: + - `prometheus-data` + - `loki-data` + - `grafana-data` + +Persistence test: + +```bash +cd monitoring +docker compose down +docker compose up -d +``` + +Expected: dashboards still exist (Grafana volume) and Prometheus keeps data (Prometheus volume). + +**Evidence required**: + +- `screenshots/lab08-compose-ps.png` showing all services **healthy** +- proof that Grafana dashboard persists after restart: + - `screenshots/before.png` (dashboard exists before restart) + - `screenshots/after.png` (dashboard exists after restart) + +Evidence (Task 4): + +![docker compose ps (all healthy)](screenshots/lab08-compose-ps.png) + +![Grafana dashboard before restart](screenshots/before.png) + +![Grafana dashboard after restart](screenshots/after.png) + +--- + +### Testing Results + +Generate load: + +```bash +for i in {1..50}; do curl -s http://localhost:8000/ >/dev/null; done +for i in {1..50}; do curl -s http://localhost:8000/health >/dev/null; done +``` + +Required screenshots to attach (store next to this file or in a dedicated folder): + +- `screenshots/lab08-targets.png` (Prometheus targets all UP) +- `screenshots/lab08-promql-up.png` (query `up`) +- `screenshots/lab08-metrics.png` (`/metrics` output) +- `screenshots/lab08-grafana.png` (custom Grafana dashboard with panels) +- `screenshots/lab08-compose-ps.png` (`docker compose ps` all healthy) +- `screenshots/before.png` (Grafana dashboard before restart) +- `screenshots/after.png` (Grafana dashboard after restart) + +--- + +### Challenges & Solutions + +- **Docker image vs local code**: the stack originally ran a prebuilt app image, so changes in `app_python/app.py` wouldn’t be reflected. + **Fix**: switched `app-python` service to `build: ../app_python` so instrumentation is included. + +--- + +### Metrics vs Logs (Lab 7) + +- **Logs** answer “what exactly happened” (context, errors, traces of events). +- **Metrics** answer “how much/how often/how long” (rates, error ratios, latency distributions). +- Together: use metrics to detect and quantify issues (RED), then pivot to logs in Loki to investigate root cause. + diff --git a/monitoring/docs/dashboard.png b/monitoring/docs/dashboard.png new file mode 100644 index 0000000000..7e97fffb95 Binary files /dev/null and b/monitoring/docs/dashboard.png differ diff --git a/monitoring/docs/explore.png b/monitoring/docs/explore.png new file mode 100644 index 0000000000..4d525003bf Binary files /dev/null and b/monitoring/docs/explore.png differ diff --git a/monitoring/docs/explore_GET.png b/monitoring/docs/explore_GET.png new file mode 100644 index 0000000000..592409f5ca Binary files /dev/null and b/monitoring/docs/explore_GET.png differ diff --git a/monitoring/docs/lab08-dashboard.json b/monitoring/docs/lab08-dashboard.json new file mode 100644 index 0000000000..7befd30a8e --- /dev/null +++ b/monitoring/docs/lab08-dashboard.json @@ -0,0 +1,308 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum by (endpoint) (rate(http_requests_total[5m]))", + "legendFormat": "{{endpoint}}", + "refId": "A" + } + ], + "title": "Request rate (req/s) by endpoint", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m]))", + "legendFormat": "5xx", + "refId": "A" + } + ], + "title": "5xx error rate (req/s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "A" + } + ], + "title": "Request latency p95 (s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "targets": [ + { + "expr": "http_requests_in_progress", + "legendFormat": "in_progress", + "refId": "A" + } + ], + "title": "Active requests (in progress)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "displayLabels": [ + "name", + "value" + ], + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total[5m]))", + "legendFormat": "{{status_code}}", + "refId": "A" + } + ], + "title": "Status code distribution (req/s)", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "up{job=\"app\"}", + "legendFormat": "app", + "refId": "A" + } + ], + "title": "App target up", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 24 + }, + "id": 7, + "targets": [ + { + "expr": "sum by (le) (rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Request duration heatmap (bucket rate)", + "type": "heatmap" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "lab08", + "prometheus" + ], + "templating": { + "list": [ + { + "current": { + "text": "Prometheus", + "value": "Prometheus" + }, + "hide": 0, + "label": "Prometheus datasource", + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "type": "datasource" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Lab 08 — App Metrics (Prometheus)", + "uid": "lab08-app-metrics", + "version": 1, + "weekStart": "" +} + diff --git a/monitoring/docs/screenshots/after.png b/monitoring/docs/screenshots/after.png new file mode 100644 index 0000000000..696797f24b Binary files /dev/null and b/monitoring/docs/screenshots/after.png differ diff --git a/monitoring/docs/screenshots/before.png b/monitoring/docs/screenshots/before.png new file mode 100644 index 0000000000..bd82d7cde9 Binary files /dev/null and b/monitoring/docs/screenshots/before.png differ diff --git a/monitoring/docs/screenshots/lab08-compose-ps.png b/monitoring/docs/screenshots/lab08-compose-ps.png new file mode 100644 index 0000000000..4f14a6617c Binary files /dev/null and b/monitoring/docs/screenshots/lab08-compose-ps.png differ diff --git a/monitoring/docs/screenshots/lab08-grafana.png b/monitoring/docs/screenshots/lab08-grafana.png new file mode 100644 index 0000000000..3df4218955 Binary files /dev/null and b/monitoring/docs/screenshots/lab08-grafana.png differ diff --git a/monitoring/docs/screenshots/lab08-metrics.png b/monitoring/docs/screenshots/lab08-metrics.png new file mode 100644 index 0000000000..b9ef6c4e06 Binary files /dev/null and b/monitoring/docs/screenshots/lab08-metrics.png differ diff --git a/monitoring/docs/screenshots/lab08-promql-up.png b/monitoring/docs/screenshots/lab08-promql-up.png new file mode 100644 index 0000000000..9647611522 Binary files /dev/null and b/monitoring/docs/screenshots/lab08-promql-up.png differ diff --git a/monitoring/docs/screenshots/lab08-targets.png b/monitoring/docs/screenshots/lab08-targets.png new file mode 100644 index 0000000000..c252085897 Binary files /dev/null and b/monitoring/docs/screenshots/lab08-targets.png differ diff --git a/monitoring/grafana/dashboards/lab08-dashboard.json b/monitoring/grafana/dashboards/lab08-dashboard.json new file mode 100644 index 0000000000..7befd30a8e --- /dev/null +++ b/monitoring/grafana/dashboards/lab08-dashboard.json @@ -0,0 +1,308 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum by (endpoint) (rate(http_requests_total[5m]))", + "legendFormat": "{{endpoint}}", + "refId": "A" + } + ], + "title": "Request rate (req/s) by endpoint", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m]))", + "legendFormat": "5xx", + "refId": "A" + } + ], + "title": "5xx error rate (req/s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "A" + } + ], + "title": "Request latency p95 (s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "targets": [ + { + "expr": "http_requests_in_progress", + "legendFormat": "in_progress", + "refId": "A" + } + ], + "title": "Active requests (in progress)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "displayLabels": [ + "name", + "value" + ], + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "expr": "sum by (status_code) (rate(http_requests_total[5m]))", + "legendFormat": "{{status_code}}", + "refId": "A" + } + ], + "title": "Status code distribution (req/s)", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "up{job=\"app\"}", + "legendFormat": "app", + "refId": "A" + } + ], + "title": "App target up", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 24 + }, + "id": 7, + "targets": [ + { + "expr": "sum by (le) (rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Request duration heatmap (bucket rate)", + "type": "heatmap" + } + ], + "refresh": "10s", + "schemaVersion": 41, + "tags": [ + "lab08", + "prometheus" + ], + "templating": { + "list": [ + { + "current": { + "text": "Prometheus", + "value": "Prometheus" + }, + "hide": 0, + "label": "Prometheus datasource", + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "type": "datasource" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Lab 08 — App Metrics (Prometheus)", + "uid": "lab08-app-metrics", + "version": 1, + "weekStart": "" +} + diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000000..0c5317b2ef --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: "Lab08" + orgId: 1 + folder: "Lab08" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource-prometheus.yml b/monitoring/grafana/provisioning/datasources/datasource-prometheus.yml new file mode 100644 index 0000000000..1a57b69c8a --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource-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..2cd982762b --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,42 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 0 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +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 + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 168h + +compactor: + working_directory: /loki/compactor + retention_enabled: true + delete_request_store: filesystem + diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..f94d0b2f19 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,23 @@ +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..a100f9a410 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,26 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/promtail-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"] + relabel_configs: + - source_labels: ["__meta_docker_container_name"] + target_label: container + regex: "/(.*)" + replacement: "$1" + - source_labels: ["__meta_docker_container_label_app"] + target_label: app + diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..f089367005 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,2 @@ +name: lab04-infra +runtime: python diff --git a/pulumi/README.md b/pulumi/README.md new file mode 100644 index 0000000000..9cfa79c47b --- /dev/null +++ b/pulumi/README.md @@ -0,0 +1,31 @@ +# Pulumi (lab04) + +Same infrastructure as Terraform: VM on Yandex Cloud with network, subnet, security group. + +## Setup + +```bash +cd pulumi +python -m venv venv +source venv/bin/activate # or venv\Scripts\activate on Windows +pip install -r requirements.txt +``` + +## Config + +```bash +pulumi config set cloud_id +pulumi config set folder_id +pulumi config set zone ru-central1-a +pulumi config set my_ip /32 +``` + +Auth: `export YC_TOKEN="$(yc iam create-token)"` + +## Run + +```bash +pulumi preview +pulumi up +pulumi stack output +``` diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..eb6b713131 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,69 @@ +from pathlib import Path + +import pulumi +import pulumi_yandex as yandex + +config = pulumi.Config() +cloud_id = config.require("cloud_id") +folder_id = config.require("folder_id") +zone = config.get("zone") or "ru-central1-a" +my_ip = config.get("my_ip") or "0.0.0.0/0" +ssh_key_path = config.get("ssh_public_key_path") or str(Path.home() / ".ssh" / "id_rsa.pub") +ssh_user = config.get("ssh_user") or "ubuntu" + +ssh_public_key = Path(ssh_key_path).expanduser().read_text().strip() + +network = yandex.VpcNetwork( + "lab04-network", + name="lab04-network", + folder_id=folder_id, +) + +subnet = yandex.VpcSubnet( + "lab04-subnet", + name="lab04-subnet", + zone=zone, + network_id=network.id, + v4_cidr_blocks=["10.0.1.0/24"], + folder_id=folder_id, +) + +image = yandex.get_compute_image(family="ubuntu-2204-lts") + +vm = yandex.ComputeInstance( + "lab04-vm", + name="lab04-vm", + platform_id="standard-v2", + zone=zone, + folder_id=folder_id, + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + core_fraction=20, + memory=1, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id=image.id, + size=10, + ), + ), + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + ), + ], + metadata={ + "ssh-keys": f"{ssh_user}:{ssh_public_key}", + }, + labels={ + "environment": "lab04", + "managed-by": "pulumi", + }, +) + +public_ip = vm.network_interfaces[0].nat_ip_address +pulumi.export("vm_public_ip", public_ip) +pulumi.export("vm_private_ip", vm.network_interfaces[0].ip_address) +pulumi.export("vm_id", vm.id) +pulumi.export("ssh_command", pulumi.Output.concat("ssh ", ssh_user, "@", public_ip)) diff --git a/pulumi/__pycache__/__main__.cpython-312.pyc b/pulumi/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 0000000000..c63db3ec61 Binary files /dev/null and b/pulumi/__pycache__/__main__.cpython-312.pyc differ diff --git a/pulumi/__pycache__/__main__.cpython-313.pyc b/pulumi/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000..33e36a77b3 Binary files /dev/null and b/pulumi/__pycache__/__main__.cpython-313.pyc differ diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..eca873a6e2 --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,3 @@ +pulumi>=3.0.0 +pulumi-yandex>=0.13.0 +setuptools<70.0.0 diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000000..914b60133e --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,31 @@ +# Terraform state files +*.tfstate +*.tfstate.* +*.tfstate.backup + +# Terraform directories +.terraform/ +.terraform.lock.hcl + +# Variable files with secrets +terraform.tfvars +*.tfvars +*.tfvars.json + +# Override files +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.auto.tfvars +*.auto.tfvars.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..680a7105c7 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,15 @@ +# Terraform (lab04) + +Каталог содержит конфигурацию Terraform для развёртывания одной виртуальной машины в Yandex Cloud: + +- сеть и подсеть; +- security group с портами 22, 80 и 5000; +- VM Ubuntu с публичным IP и SSH‑доступом по ключу. + +Основные файлы: + +- `providers.tf` — провайдер Yandex; +- `variables.tf` — входные параметры; +- `main.tf` — ресурсы; +- `outputs.tf` — выходные значения; +- `terraform.tfvars` — реальные значения переменных (не добавлять в git). diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..c12b7a87bf --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,88 @@ +data "yandex_compute_image" "ubuntu" { + family = var.vm_image_family +} + +resource "yandex_vpc_network" "network" { + name = var.network_name +} + +resource "yandex_vpc_subnet" "subnet" { + name = var.subnet_name + zone = var.yandex_zone + network_id = yandex_vpc_network.network.id + v4_cidr_blocks = [var.subnet_cidr] +} + +resource "yandex_vpc_security_group" "sg" { + name = "lab04-security-group" + network_id = yandex_vpc_network.network.id + + ingress { + description = "SSH" + protocol = "TCP" + from_port = 22 + to_port = 22 + v4_cidr_blocks = [var.my_ip] + } + + ingress { + description = "HTTP" + protocol = "TCP" + from_port = 80 + to_port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Custom port 5000" + protocol = "TCP" + from_port = 5000 + to_port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All outgoing traffic" + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +locals { + ssh_public_key = file(pathexpand(var.ssh_public_key_path)) +} + +resource "yandex_compute_instance" "vm" { + name = var.vm_name + platform_id = "standard-v2" + zone = var.yandex_zone + + resources { + cores = var.vm_cores + core_fraction = var.vm_core_fraction + memory = var.vm_memory + } + + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = var.vm_disk_size + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.subnet.id + nat = true + security_group_ids = [yandex_vpc_security_group.sg.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${local.ssh_public_key}" + } + + labels = { + environment = "lab04" + managed-by = "terraform" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..69479a4c58 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "vm_public_ip" { + description = "Public IP address of the virtual machine" + value = yandex_compute_instance.vm.network_interface[0].nat_ip_address +} + +output "vm_private_ip" { + description = "Private IP address of the virtual machine" + value = yandex_compute_instance.vm.network_interface[0].ip_address +} + +output "ssh_command" { + description = "SSH command to connect to the VM" + value = "ssh ${var.ssh_user}@${yandex_compute_instance.vm.network_interface[0].nat_ip_address}" +} + +output "vm_id" { + description = "ID of the created virtual machine" + value = yandex_compute_instance.vm.id +} + +output "vm_name" { + description = "Name of the virtual machine" + value = yandex_compute_instance.vm.name +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000000..100990ef2f --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.100" + } + } +} + +provider "yandex" { + cloud_id = var.yandex_cloud_id + folder_id = var.yandex_folder_id + zone = var.yandex_zone +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..397c669322 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,87 @@ +variable "yandex_cloud_id" { + description = "Yandex Cloud ID" + type = string +} + +variable "yandex_folder_id" { + description = "Yandex Cloud Folder ID" + type = string +} + +variable "yandex_zone" { + description = "Yandex Cloud zone (e.g., ru-central1-a)" + type = string + default = "ru-central1-a" +} + +variable "vm_name" { + description = "Name of the virtual machine" + type = string + default = "lab04-vm" +} + +variable "vm_cores" { + description = "Number of CPU cores" + type = number + default = 2 +} + +variable "vm_core_fraction" { + description = "CPU core fraction (20% for free tier)" + type = number + default = 20 +} + +variable "vm_memory" { + description = "Memory in GB" + type = number + default = 1 +} + +variable "vm_disk_size" { + description = "Boot disk size in GB" + type = number + default = 10 +} + +variable "vm_image_family" { + description = "OS image family (e.g., ubuntu-2204-lts)" + type = string + default = "ubuntu-2204-lts" +} + +variable "network_name" { + description = "Name of the VPC network" + type = string + default = "lab04-network" +} + +variable "subnet_name" { + description = "Name of the subnet" + type = string + default = "lab04-subnet" +} + +variable "subnet_cidr" { + description = "CIDR block for subnet" + type = string + default = "10.0.1.0/24" +} + +variable "my_ip" { + description = "IP address for SSH access" + type = string + default = "0.0.0.0/0" +} + +variable "ssh_public_key_path" { + description = "Path to your SSH public key file" + type = string + default = "~/.ssh/id_rsa.pub" +} + +variable "ssh_user" { + description = "SSH username (usually 'ubuntu' for Ubuntu images)" + type = string + default = "ubuntu" +}