From 387b46267344b3e0d98889450f1783e8a4e1c251 Mon Sep 17 00:00:00 2001 From: Brazing Technology Date: Wed, 29 Apr 2026 14:52:01 +0530 Subject: [PATCH] update qubernetees --- .claude/settings.local.json | 33 + .../plans/2026-04-29-k8s-taskapp-plan.md | 1364 +++++++++++++++++ .../specs/2026-04-29-k8s-taskapp-design.md | 276 ++++ frontend/style.css | 144 +- 4 files changed, 1774 insertions(+), 43 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/superpowers/plans/2026-04-29-k8s-taskapp-plan.md create mode 100644 docs/superpowers/specs/2026-04-29-k8s-taskapp-design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..830d2c8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,33 @@ +{ + "permissions": { + "allow": [ + "Bash(cp \"../cloud assiagment/backend/app.py\" backend/)", + "Bash(cp \"../cloud assiagment/backend/requirements.txt\" backend/)", + "Bash(cp \"../cloud assiagment/frontend/index.html\" frontend/)", + "Bash(cp \"../cloud assiagment/frontend/style.css\" frontend/)", + "Bash(cp \"../cloud assiagment/frontend/app.js\" frontend/)", + "Bash(python -m py_compile backend/app.py)", + "Bash(command -v kubectl)", + "Bash(kubectl version *)", + "Bash(kubectl apply *)", + "Bash(python -c \"import yaml,sys; list\\(yaml.safe_load_all\\(open\\('namespace.yaml'\\)\\)\\)\")", + "Bash(python -c \"import yaml,sys; list\\(yaml.safe_load_all\\(open\\('statefulset.yaml'\\)\\)\\)\")", + "Bash(python -c \"import yaml,sys; list\\(yaml.safe_load_all\\(open\\('service.yaml'\\)\\)\\)\")", + "Bash(python -c \"import yaml,sys; list\\(yaml.safe_load_all\\(open\\('deployment.yaml'\\)\\)\\)\")", + "Bash(chmod +x prepare-app.sh)", + "Bash(bash -n prepare-app.sh)", + "Bash(bash -n start-app.sh)", + "Bash(bash -n stop-app.sh)", + "Bash(python -c 'import yaml,sys; docs=list\\(yaml.safe_load_all\\(open\\('\\\\''__TRACKED_VAR__'\\\\''\\)\\)\\); print\\(f'\\\\'' {len\\(docs\\)} document\\(s\\):'\\\\''\\); [print\\(f'\\\\'' [{i}] kind={d.get\\(\"kind\"\\)}, name={d.get\\(\"metadata\",{}\\).get\\(\"name\"\\)}'\\\\''\\) for i,d in enumerate\\(docs\\) if d]')", + "Bash(docker version *)", + "Bash(minikube status *)", + "Bash(scoop install *)", + "Bash(minikube version *)", + "Bash(\"/c/Program Files/Docker/Docker/Docker Desktop.exe\")", + "Bash(./prepare-app.sh)", + "Bash(./start-app.sh)", + "Bash(./stop-app.sh)", + "Bash(minikube stop *)" + ] + } +} diff --git a/docs/superpowers/plans/2026-04-29-k8s-taskapp-plan.md b/docs/superpowers/plans/2026-04-29-k8s-taskapp-plan.md new file mode 100644 index 0000000..a340992 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-k8s-taskapp-plan.md @@ -0,0 +1,1364 @@ +# Task Manager on Kubernetes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the existing 3-tier Task Manager (Nginx + Flask + Postgres) from `cloud assiagment/` to Kubernetes on minikube, satisfying every course-assignment requirement (Namespace, Deployment ×2, StatefulSet+PV+PVC, Services, lifecycle scripts, README). + +**Architecture:** All resources in namespace `taskapp`. Stateless tiers (web, api) as Deployments with 2 replicas. Postgres as a 1-replica StatefulSet bound to a manually-defined hostPath PV via a PVC. Web exposed via NodePort 30080 (browser access through `minikube service`). DB password in a Secret; `nginx.conf` in a ConfigMap. + +**Tech Stack:** Kubernetes 1.28+, kubectl, minikube, Docker, Nginx Alpine, Python 3.12 + Flask + Gunicorn, PostgreSQL 15, bash. + +**Spec reference:** `docs/superpowers/specs/2026-04-29-k8s-taskapp-design.md` — read it before starting if you're a fresh agent. + +**Source-of-truth files copied from first assignment:** `C:\Users\gigis\DEVapps\cloud assiagment\backend\*`, `…\frontend\*`. The first assignment is a complete, working Docker version of the same app. + +**Working directory:** `C:\Users\gigis\DEVapps\qubernetees\` (Windows host; bash via Git Bash). Use Unix-style forward-slash paths in scripts. + +**Note on git:** the working directory is NOT a git repo. Decision: do `git init` once at the start of Task 1; commit at the end of each task. This gives a clean history for the oral evaluation. + +**Note on cluster availability:** the agent executing this plan may or may not have minikube installed and running. The plan's verification commands assume minikube is available. If `minikube status` fails, the agent should report this back to the user rather than try to install minikube — that's the user's environment to set up. End-to-end checks (Task 13) MAY be deferred to the user if the cluster isn't reachable in the executor's environment. + +--- + +## File structure (target end state) + +``` +qubernetees/ +├── README.md +├── prepare-app.sh +├── start-app.sh +├── stop-app.sh +├── namespace.yaml +├── statefulset.yaml +├── deployment.yaml +├── service.yaml +├── nginx.conf +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app.py +└── frontend/ + ├── Dockerfile + ├── index.html + ├── style.css + └── app.js +``` + +Total: 8 root files + 2 subdirectories with 4+3 files = 15 files. The plan creates each one in dependency order. + +--- + +## Task 1: Initialize repo and copy frontend/backend source from first assignment + +**Files:** +- Create: `qubernetees/.gitignore` +- Copy: `cloud assiagment/backend/{app.py,requirements.txt}` → `qubernetees/backend/` +- Copy: `cloud assiagment/frontend/{index.html,style.css,app.js}` → `qubernetees/frontend/` + +- [ ] **Step 1: Initialize git repo** + +```bash +cd "C:/Users/gigis/DEVapps/qubernetees" +git init +git config user.email "bvilborg@brazing-technology.com" +git config user.name "Brazing Technology" +``` + +- [ ] **Step 2: Create `.gitignore`** + +Content: +``` +*.log +.DS_Store +Thumbs.db +.idea/ +.vscode/ +__pycache__/ +*.pyc +.env +``` + +- [ ] **Step 3: Create directories and copy source files** + +```bash +mkdir -p backend frontend +cp "../cloud assiagment/backend/app.py" backend/ +cp "../cloud assiagment/backend/requirements.txt" backend/ +cp "../cloud assiagment/frontend/index.html" frontend/ +cp "../cloud assiagment/frontend/style.css" frontend/ +cp "../cloud assiagment/frontend/app.js" frontend/ +``` + +- [ ] **Step 4: Verify all files copied** + +```bash +ls -la backend/ frontend/ +``` +Expected: `backend/app.py`, `backend/requirements.txt`, `frontend/index.html`, `frontend/style.css`, `frontend/app.js` all present and non-empty. + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore backend/ frontend/ +git commit -m "chore: initial repo, copy source from assignment 1" +``` + +--- + +## Task 2: Add `/api/health` endpoint to backend + +The readiness probe needs a meaningful health check. Add `GET /api/health` that returns 200 only if the DB is reachable. + +**Files:** +- Modify: `qubernetees/backend/app.py` + +- [ ] **Step 1: Read existing `app.py`** + +```bash +cat backend/app.py +``` +Confirmed pattern (already verified during planning): the file defines `get_db()` returning a `psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)`. DB_CONFIG reads `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` from env. Use the same helper. + +- [ ] **Step 2: Add the health endpoint** + +Place the new route **after the `delete_task` route** (line ~105) and **before** the `if __name__ == "__main__":` block (line ~108). Insert this verbatim: + +```python +@app.route("/api/health", methods=["GET"]) +def health(): + try: + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT 1") + cur.fetchone() + cur.close() + conn.close() + return jsonify({"status": "ok"}), 200 + except Exception as exc: + return jsonify({"status": "unhealthy", "error": str(exc)}), 503 +``` + +- [ ] **Step 3: Verify Python syntax** + +```bash +python -m py_compile backend/app.py +``` +Expected: no output (success). If syntax error, fix and re-run. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app.py +git commit -m "feat(backend): add /api/health endpoint for k8s readiness probe" +``` + +--- + +## Task 3: Create backend Dockerfile + +**Files:** +- Create: `qubernetees/backend/Dockerfile` + +- [ ] **Step 1: Write Dockerfile** + +Content: +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +# Run init_db() once (creates the tasks table) then start gunicorn. +# Same pattern as the first assignment's Dockerfile. +CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && gunicorn --bind 0.0.0.0:5000 --workers 2 app:app"] +``` + +If the first assignment's `requirements.txt` already includes `gunicorn`, this works as-is. Otherwise add `gunicorn` to `backend/requirements.txt`. + +- [ ] **Step 2: Verify gunicorn is in requirements** + +```bash +grep -i gunicorn backend/requirements.txt +``` +If not present: +```bash +echo "gunicorn==22.0.0" >> backend/requirements.txt +``` + +- [ ] **Step 3: Lint Dockerfile syntax** + +```bash +docker build --no-cache -t taskapp-api:lint-check backend/ 2>&1 | tail -5 +``` +Expected: build completes successfully (or fails only if the Docker daemon isn't running — that's OK, we'll build for real in Task 11). The point is that `docker` accepts the Dockerfile syntax. + +If Docker daemon isn't running, skip the build and move on; the syntax will be checked when `prepare-app.sh` runs in Task 13. + +- [ ] **Step 4: Commit** + +```bash +git add backend/Dockerfile backend/requirements.txt +git commit -m "feat(backend): add Dockerfile for taskapp-api image" +``` + +--- + +## Task 4: Create frontend Dockerfile and nginx.conf source file + +**Files:** +- Create: `qubernetees/frontend/Dockerfile` +- Create: `qubernetees/nginx.conf` (root-level — used as ConfigMap source) + +- [ ] **Step 1: Write `frontend/Dockerfile`** + +Content: +```dockerfile +FROM nginx:alpine + +# Static assets +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ + +# nginx.conf is provided at runtime via ConfigMap mount. +# We do NOT copy it into the image; the default nginx.conf in the image +# would be used if the ConfigMap mount is missing. + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +- [ ] **Step 2: Write root-level `nginx.conf` (ConfigMap source)** + +Content: +```nginx +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + large_client_header_buffers 4 32k; + + upstream api_backend { + server api:5000; + } + + server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } +} +``` + +Notes: +- `upstream api_backend { server api:5000; }` — `api` is the K8s Service name, resolved via cluster DNS. +- `large_client_header_buffers` carried over from assignment 1 (handles large cookies; harmless if unused). +- This file is the **source** for the ConfigMap; the manifest in `namespace.yaml` will inline its contents. + +- [ ] **Step 3: Verify nginx.conf syntax (optional, only if nginx CLI available)** + +```bash +docker run --rm -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t +``` +Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`. If Docker isn't available, skip — `kubectl apply` won't catch nginx-syntax errors anyway; the pod readiness probe will. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/Dockerfile nginx.conf +git commit -m "feat(frontend): add Dockerfile and nginx.conf for ConfigMap" +``` + +--- + +## Task 5: Write `namespace.yaml` (Namespace + Secret + ConfigMap) + +**Files:** +- Create: `qubernetees/namespace.yaml` + +- [ ] **Step 1: Generate base64 values for the Secret** + +```bash +echo -n "taskapp" | base64 # POSTGRES_DB +echo -n "taskuser" | base64 # POSTGRES_USER +echo -n "taskpass" | base64 # POSTGRES_PASSWORD +``` +Expected output: +- `dGFza2FwcA==` +- `dGFza3VzZXI=` +- `dGFza3Bhc3M=` + +(These are weak demo credentials. The README will note this.) + +- [ ] **Step 2: Inline the contents of `nginx.conf` into the ConfigMap** + +The ConfigMap embeds `nginx.conf` as a multi-line YAML string. Use `|` block style (preserves newlines, strips trailing). The ConfigMap key name is `nginx.conf` (matches the `subPath` in the Deployment). + +- [ ] **Step 3: Write `namespace.yaml`** + +Content: +```yaml +# 1. Namespace — every other object lives in this namespace +apiVersion: v1 +kind: Namespace +metadata: + name: taskapp + labels: + app: taskapp + +--- +# 2. Secret — DB credentials, consumed by both Postgres and Flask +apiVersion: v1 +kind: Secret +metadata: + name: db-credentials + namespace: taskapp +type: Opaque +data: + POSTGRES_DB: dGFza2FwcA== + POSTGRES_USER: dGFza3VzZXI= + POSTGRES_PASSWORD: dGFza3Bhc3M= + +--- +# 3. ConfigMap — nginx.conf for the web tier +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config + namespace: taskapp +data: + nginx.conf: | + worker_processes 1; + + events { + worker_connections 1024; + } + + http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + large_client_header_buffers 4 32k; + + upstream api_backend { + server api:5000; + } + + server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } + } +``` + +- [ ] **Step 4: Validate YAML with kubectl dry-run** + +```bash +kubectl apply -f namespace.yaml --dry-run=client -o yaml >/dev/null +``` +Expected: no errors. (Output suppressed; we just want to confirm the manifest parses and is valid.) + +If kubectl isn't available locally, fall back to a YAML linter: +```bash +python -c "import yaml,sys; list(yaml.safe_load_all(open('namespace.yaml')))" +``` +Expected: no exception. + +- [ ] **Step 5: Commit** + +```bash +git add namespace.yaml +git commit -m "feat(k8s): namespace, secret, configmap manifests" +``` + +--- + +## Task 6: Write `statefulset.yaml` (PV + PVC + StatefulSet) + +**Files:** +- Create: `qubernetees/statefulset.yaml` + +- [ ] **Step 1: Write `statefulset.yaml`** + +Content: +```yaml +# 1. PersistentVolume — cluster-scoped, hostPath on minikube node +apiVersion: v1 +kind: PersistentVolume +metadata: + name: db-pv + labels: + app: db +spec: + capacity: + storage: 1Gi + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + storageClassName: manual + hostPath: + path: /mnt/data/taskapp-db + type: DirectoryOrCreate + +--- +# 2. PersistentVolumeClaim — namespaced, binds to db-pv +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: db-pvc + namespace: taskapp +spec: + accessModes: + - ReadWriteOnce + storageClassName: manual + resources: + requests: + storage: 1Gi + selector: + matchLabels: + app: db + +--- +# 3. StatefulSet — Postgres +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: db + namespace: taskapp +spec: + serviceName: db # references the headless Service in service.yaml + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: postgres + image: postgres:15 + ports: + - containerPort: 5432 + name: postgres + envFrom: + - secretRef: + name: db-credentials + env: + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: db-storage + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + exec: + command: + - sh + - -c + - 'pg_isready -U "$POSTGRES_USER"' + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + livenessProbe: + exec: + command: + - sh + - -c + - 'pg_isready -U "$POSTGRES_USER"' + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + volumes: + - name: db-storage + persistentVolumeClaim: + claimName: db-pvc +``` + +Design notes (DO NOT include in the file): +- `PGDATA` is set to a subdir of the mount because Postgres refuses to initialize a non-empty data dir, and `lost+found` etc. on hostPath can trigger that. +- `volumeClaimTemplates` is intentionally NOT used — we want one explicit PVC bound to one explicit PV (assignment requires "PV and PVC objects"). +- The PVC selector `matchLabels: app: db` is what binds it to `db-pv` (which has matching labels). Static binding. + +- [ ] **Step 2: Validate YAML** + +```bash +kubectl apply -f statefulset.yaml --dry-run=client --validate=false -o yaml >/dev/null +``` +(`--validate=false` skips the cluster-side schema check, which would require a real cluster connection. Client-side parsing still runs.) + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add statefulset.yaml +git commit -m "feat(k8s): PV, PVC, StatefulSet for postgres" +``` + +--- + +## Task 7: Write `service.yaml` (web + api + db Services) + +**Files:** +- Create: `qubernetees/service.yaml` + +- [ ] **Step 1: Write `service.yaml`** + +Content: +```yaml +# 1. web Service — NodePort, exposes the Nginx frontend to the host +apiVersion: v1 +kind: Service +metadata: + name: web + namespace: taskapp + labels: + app: web +spec: + type: NodePort + selector: + app: web + ports: + - name: http + port: 80 + targetPort: 80 + nodePort: 30080 + +--- +# 2. api Service — ClusterIP, internal-only access for Flask +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: taskapp + labels: + app: api +spec: + type: ClusterIP + selector: + app: api + ports: + - name: http + port: 5000 + targetPort: 5000 + +--- +# 3. db Service — headless (clusterIP: None), pairs with the StatefulSet +apiVersion: v1 +kind: Service +metadata: + name: db + namespace: taskapp + labels: + app: db +spec: + clusterIP: None + selector: + app: db + ports: + - name: postgres + port: 5432 + targetPort: 5432 +``` + +- [ ] **Step 2: Validate YAML** + +```bash +kubectl apply -f service.yaml --dry-run=client --validate=false -o yaml >/dev/null +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add service.yaml +git commit -m "feat(k8s): web NodePort, api ClusterIP, db headless services" +``` + +--- + +## Task 8: Write `deployment.yaml` (api + web Deployments) + +**Files:** +- Create: `qubernetees/deployment.yaml` + +- [ ] **Step 1: Write `deployment.yaml`** + +Content: +```yaml +# 1. api Deployment — Flask backend +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: taskapp + labels: + app: api +spec: + replicas: 2 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: taskapp-api:v1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5000 + name: http + env: + - name: DB_HOST + value: "db" + - name: DB_PORT + value: "5432" + - name: DB_NAME + valueFrom: + secretKeyRef: + name: db-credentials + key: POSTGRES_DB + - name: DB_USER + valueFrom: + secretKeyRef: + name: db-credentials + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: POSTGRES_PASSWORD + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + readinessProbe: + httpGet: + path: /api/health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /api/health + port: 5000 + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + +--- +# 2. web Deployment — Nginx frontend +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: taskapp + labels: + app: web +spec: + replicas: 2 + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: taskapp-web:v1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + readOnly: true + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 100m + memory: 128Mi + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 1 + periodSeconds: 5 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 15 + volumes: + - name: nginx-config + configMap: + name: nginx-config + items: + - key: nginx.conf + path: nginx.conf +``` + +Design notes (DO NOT include in file): +- The Flask app (`app.py`) reads `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — not `POSTGRES_*`. So we map each Secret key to the right env name explicitly via `valueFrom: secretKeyRef`. (Important: `$(POSTGRES_DB)` substitution in the `env:` block does NOT work for vars sourced via `envFrom`; only prior `env:` entries can be referenced. So inline-reading from the Secret per-var is the correct pattern here.) +- `imagePullPolicy: IfNotPresent` is critical — local minikube images would otherwise be re-pulled from a registry that doesn't have them. +- The `failureThreshold: 6` on the api readiness probe means the api pod gets up to 30 s (6×5s) of DB unavailability before being marked unready. Useful during cold start when the DB is still initializing. +- `subPath: nginx.conf` mounts a single file (not a directory) over `/etc/nginx/nginx.conf`. Without `subPath`, the entire ConfigMap directory would be mounted at that path, breaking nginx. + +- [ ] **Step 2: Validate YAML** + +```bash +kubectl apply -f deployment.yaml --dry-run=client --validate=false -o yaml >/dev/null +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add deployment.yaml +git commit -m "feat(k8s): api and web Deployments with probes and resources" +``` + +--- + +## Task 9: Write `prepare-app.sh` + +**Files:** +- Create: `qubernetees/prepare-app.sh` (executable) + +- [ ] **Step 1: Write `prepare-app.sh`** + +Content: +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "[prepare-app] Preparing Task Manager for Kubernetes..." + +# 1. Ensure minikube is running +if ! minikube status >/dev/null 2>&1; then + echo "[prepare-app] minikube is not running. Starting it..." + minikube start +else + echo "[prepare-app] minikube is already running." +fi + +# 2. Point local docker at minikube's daemon so images land inside the cluster +echo "[prepare-app] Switching docker context to minikube..." +eval "$(minikube -p minikube docker-env)" + +# 3. Build the two custom images +echo "[prepare-app] Building taskapp-api:v1..." +docker build -t taskapp-api:v1 backend/ + +echo "[prepare-app] Building taskapp-web:v1..." +docker build -t taskapp-web:v1 frontend/ + +# 4. Create the hostPath directory on the minikube node +echo "[prepare-app] Creating /mnt/data/taskapp-db on the minikube node..." +minikube ssh -- "sudo mkdir -p /mnt/data/taskapp-db && sudo chmod 777 /mnt/data/taskapp-db" + +# 5. Apply the namespace (so the PVC has a place to live) and the StatefulSet stack. +# The namespace must exist before namespaced objects (PVC, StatefulSet) can be created, +# so we apply and then poll for it to exist before continuing. +echo "[prepare-app] Creating Namespace, Secret, ConfigMap..." +kubectl apply -f namespace.yaml + +echo "[prepare-app] Waiting for taskapp namespace to be available..." +for _ in $(seq 1 20); do + if kubectl get namespace taskapp >/dev/null 2>&1; then + break + fi + sleep 1 +done + +echo "[prepare-app] Creating PV, PVC, StatefulSet..." +kubectl apply -f statefulset.yaml + +echo "[prepare-app] App prepared." +echo "[prepare-app] Run ./start-app.sh to bring up the rest." +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x prepare-app.sh +``` + +- [ ] **Step 3: Verify bash syntax** + +```bash +bash -n prepare-app.sh +``` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add prepare-app.sh +git commit -m "feat(scripts): prepare-app.sh — build images, create PV" +``` + +--- + +## Task 10: Write `start-app.sh` + +**Files:** +- Create: `qubernetees/start-app.sh` (executable) + +- [ ] **Step 1: Write `start-app.sh`** + +Content: +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "[start-app] Starting Task Manager on Kubernetes..." + +# Note: this script assumes ./prepare-app.sh has already been run successfully +# (images built into minikube, hostPath dir created, PV applied). Re-applying +# the prepare-stage manifests below is idempotent and safe, but does NOT build +# images or create the hostPath dir. Without prepare-app.sh, pods will fail with +# ImagePullBackOff. + +echo "[start-app] Applying namespace.yaml..." +kubectl apply -f namespace.yaml + +echo "[start-app] Applying statefulset.yaml..." +kubectl apply -f statefulset.yaml + +echo "[start-app] Applying service.yaml..." +kubectl apply -f service.yaml + +echo "[start-app] Applying deployment.yaml..." +kubectl apply -f deployment.yaml + +# Wait for everything to become ready +echo "[start-app] Waiting for db StatefulSet..." +kubectl -n taskapp rollout status statefulset/db --timeout=180s + +echo "[start-app] Waiting for api Deployment..." +kubectl -n taskapp rollout status deployment/api --timeout=180s + +echo "[start-app] Waiting for web Deployment..." +kubectl -n taskapp rollout status deployment/web --timeout=180s + +echo "" +echo "[start-app] App is running!" +echo "[start-app] Opening browser at the web service URL..." +minikube service web -n taskapp +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x start-app.sh +``` + +- [ ] **Step 3: Verify bash syntax** + +```bash +bash -n start-app.sh +``` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add start-app.sh +git commit -m "feat(scripts): start-app.sh — apply all manifests, open browser" +``` + +--- + +## Task 11: Write `stop-app.sh` + +**Files:** +- Create: `qubernetees/stop-app.sh` (executable) + +- [ ] **Step 1: Write `stop-app.sh`** + +Content: +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "[stop-app] Removing Task Manager from Kubernetes..." + +# Reverse order: Deployments and Services first, then StatefulSet+PV+PVC, then Namespace +kubectl delete -f deployment.yaml --ignore-not-found +kubectl delete -f service.yaml --ignore-not-found +kubectl delete -f statefulset.yaml --ignore-not-found +kubectl delete -f namespace.yaml --ignore-not-found + +# The hostPath directory on the minikube node (/mnt/data/taskapp-db) is intentionally +# NOT removed: PV ReclaimPolicy is Retain, so the data survives even after the PV +# object is deleted. To wipe it manually, run: +# minikube ssh -- sudo rm -rf /mnt/data/taskapp-db + +echo "[stop-app] App stopped and removed." +echo "[stop-app] (Persistent data on minikube node is retained at /mnt/data/taskapp-db.)" +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x stop-app.sh +``` + +- [ ] **Step 3: Verify bash syntax** + +```bash +bash -n stop-app.sh +``` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add stop-app.sh +git commit -m "feat(scripts): stop-app.sh — full teardown" +``` + +--- + +## Task 12: Write `README.md` + +**Files:** +- Create: `qubernetees/README.md` + +- [ ] **Step 1: Write `README.md`** + +Use this exact structure (the assignment grades documentation completeness): + +```markdown +# Task Manager on Kubernetes + +A web-based task manager (Nginx + Flask + PostgreSQL) deployed to a Kubernetes cluster (minikube). Migrated from the Docker Compose version in the first assignment. + +## What the application does + +A simple CRUD task manager. Users can: + +- Add a new task by typing a title and pressing Enter. +- Mark a task as completed by toggling its checkbox. +- Delete a task by clicking the X button. +- See all tasks sorted by creation date. + +The frontend is a static page served by Nginx. The backend is a Flask REST API. Tasks are stored in PostgreSQL and survive pod restarts thanks to a hostPath PersistentVolume. + +## Prerequisites + +- **minikube** (any recent version, tested with 1.32+) +- **kubectl** (any version compatible with the minikube cluster) +- **Docker** CLI (used by `prepare-app.sh` to build images directly into minikube's docker daemon) +- **bash** (Linux/macOS native; Git Bash on Windows) + +## Containers used + +| Container | Image | Description | +|-----------|-------|-------------| +| `web` | `taskapp-web:v1` (built locally from `nginx:alpine`) | Serves static HTML/CSS/JS and reverse-proxies `/api/*` to the `api` Service. `nginx.conf` is mounted from a ConfigMap. | +| `api` | `taskapp-api:v1` (built locally from `python:3.12-slim`) | Flask REST API on Gunicorn (2 workers). Exposes `GET/POST /api/tasks`, `PUT /api/tasks/:id`, `DELETE /api/tasks/:id`, `GET /api/health`. | +| `db` | `postgres:15` (Docker Hub) | Stores `tasks` table. Data lives at `/var/lib/postgresql/data` on a PersistentVolume backed by hostPath. | + +## Kubernetes objects + +| Object | Name | Description | +|--------|------|-------------| +| Namespace | `taskapp` | Isolates every other resource. | +| Secret | `db-credentials` | Holds `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`. Consumed by both Postgres and Flask via `envFrom`. | +| ConfigMap | `nginx-config` | Holds `nginx.conf`. Mounted into the `web` container at `/etc/nginx/nginx.conf` via `subPath`. | +| PersistentVolume | `db-pv` | 1 Gi, `hostPath: /mnt/data/taskapp-db` on the minikube node. ReclaimPolicy `Retain`. StorageClass `manual`. | +| PersistentVolumeClaim | `db-pvc` | 1 Gi, `ReadWriteOnce`, binds to `db-pv` via label selector. Mounted by the StatefulSet. | +| StatefulSet | `db` | 1 replica, `postgres:15`. Uses the PVC. Has liveness and readiness probes (`pg_isready`). | +| Deployment | `api` | 2 replicas, custom Flask image. Probes hit `GET /api/health` (which checks DB reachability). | +| Deployment | `web` | 2 replicas, custom Nginx image. Probes hit `GET /`. | +| Service | `web` | NodePort 30080 → 80. The only externally reachable Service. | +| Service | `api` | ClusterIP, port 5000. Internal only. | +| Service | `db` | Headless (`clusterIP: None`), port 5432. Internal only. Pairs with the StatefulSet for stable DNS. | + +## Virtual networks + +The cluster's CNI plugin handles all pod-to-pod traffic. Service discovery is via Kubernetes DNS (`kube-dns` / `coredns`): + +- Inside the namespace, every Service is reachable by short name: `web`, `api`, `db`. +- The `web` Service is the only one exposed outside the cluster (NodePort 30080). +- The `db` Service is **headless** — it returns the pod IPs directly instead of load-balancing. This is the canonical pattern for StatefulSets and gives `db-0` a stable DNS identity (`db-0.db.taskapp.svc.cluster.local`). + +## Named volumes + +| Volume | Mount | Backed by | Reclaim policy | +|--------|-------|-----------|----------------| +| `db-pv` (PVC `db-pvc`) | `/var/lib/postgresql/data` (in `db-0`) | hostPath `/mnt/data/taskapp-db` on the minikube node | Retain | + +`Retain` means deleting the PVC does not automatically wipe the underlying directory — the data survives even a full `stop-app.sh` and is reattached on the next `prepare-app.sh + start-app.sh`. To wipe it manually: + +```bash +minikube ssh -- sudo rm -rf /mnt/data/taskapp-db +``` + +## Container configuration performed + +- **`web`** — built from `nginx:alpine` plus the static frontend files. The `nginx.conf` is supplied at runtime via the `nginx-config` ConfigMap, mounted with `subPath: nginx.conf` so only that single file is overlaid (not the whole config directory). This means the proxy rule (`proxy_pass http://api_backend;`) and `large_client_header_buffers` setting can be edited by changing the ConfigMap and reloading, without rebuilding the image. +- **`api`** — built from `python:3.12-slim`, installs `libpq-dev` and `gcc` for the `psycopg2` build, then `pip install` of `requirements.txt` (Flask + Gunicorn + psycopg2). Runs Gunicorn with 2 workers on port 5000. Reads DB credentials from env vars sourced from the `db-credentials` Secret. +- **`db`** — official `postgres:15` image, unmodified. Env (DB name, user, password) sourced from the same Secret. `PGDATA` is set to `/var/lib/postgresql/data/pgdata` (a subdir) because Postgres refuses to initialize a non-empty data directory and hostPath dirs occasionally have stray entries. + +Resource requests/limits are set on every container so the cluster scheduler can place pods predictably; values are conservative and tested against a 4 GiB minikube VM. + +## Usage + +### Prepare the application + +Builds images directly into minikube's docker daemon, creates the hostPath directory on the node, and applies Namespace + Secret + ConfigMap + PV + PVC + StatefulSet. + +```bash +./prepare-app.sh +``` + +### Start the application + +Applies the Deployments and Services. (Re-applies prepare-stage resources idempotently, so it's safe to run on its own too.) + +```bash +./start-app.sh +``` + +The script then opens your default browser at `http://:30080`. + +### Pause / stop the application + +`stop-app.sh` removes every Kubernetes object (Deployments, Services, StatefulSet, PVC, PV, Namespace, Secret, ConfigMap): + +```bash +./stop-app.sh +``` + +The hostPath data on the minikube node is **retained** (PV ReclaimPolicy = `Retain`). To resume from where you left off, run `./prepare-app.sh && ./start-app.sh` again. + +### Delete everything (including data) + +```bash +./stop-app.sh +minikube ssh -- sudo rm -rf /mnt/data/taskapp-db +``` + +## Viewing the application in a web browser + +After `./start-app.sh` finishes, run: + +```bash +minikube service web -n taskapp +``` + +This opens the browser at the right URL automatically. Alternatively, navigate manually: + +```bash +echo "http://$(minikube ip):30080" +``` + +You'll see the Task Manager interface where you can add, complete, and delete tasks. + +## API endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check (200 if DB reachable, 503 otherwise). Used by readiness probe. | +| GET | `/api/tasks` | List all tasks. | +| POST | `/api/tasks` | Create a task. Body: `{"title": "..."}`. | +| PUT | `/api/tasks/:id` | Toggle completion. | +| DELETE | `/api/tasks/:id` | Delete a task. | + +## Project structure + +``` +qubernetees/ +├── README.md # this file +├── prepare-app.sh # build images, create PV, apply prepare-stage manifests +├── start-app.sh # apply Deployments + Services, open browser +├── stop-app.sh # full teardown +├── namespace.yaml # Namespace + Secret + ConfigMap +├── statefulset.yaml # PV + PVC + StatefulSet +├── deployment.yaml # api + web Deployments +├── service.yaml # api + web + db Services +├── nginx.conf # source of the ConfigMap (kept for readability) +├── backend/ # Flask REST API +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app.py # + /api/health endpoint +└── frontend/ # static HTML/CSS/JS + ├── Dockerfile + ├── index.html + ├── style.css + └── app.js +``` + +## Sources + +- First assignment (Docker version of the same app) — code copied from `cloud assiagment/` +- [Kubernetes documentation](https://kubernetes.io/docs/) +- [Kubernetes StatefulSet docs](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) +- [Postgres official Docker image](https://hub.docker.com/_/postgres) +- [minikube documentation](https://minikube.sigs.k8s.io/docs/) + +## Use of Artificial Intelligence + +This Kubernetes deployment was designed and implemented with the assistance of **Claude** (Anthropic), an AI assistant. Claude was used for: + +- Designing the K8s object topology (Namespace, Deployments, StatefulSet, PV, PVC, Services). +- Authoring the manifest files, lifecycle scripts, and this documentation. +- Reviewing the design against the assignment requirements. + +**AI agent used:** Claude Opus 4.7 (Anthropic) via Claude Code CLI. + +The application logic itself (Flask backend, frontend) was carried over from the first assignment. +``` + +- [ ] **Step 2: Validate that markdown renders cleanly** + +```bash +# Lightweight check — confirm no merge markers, no broken yaml fences +grep -nE '<<<<<<<|>>>>>>>|```\s*$' README.md && echo "BAD" || echo "OK" +``` +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README covering app, K8s objects, networks, volumes, lifecycle" +``` + +--- + +## Task 13: End-to-end functional verification + +**Goal:** prove the whole stack works from clean slate to browser-served task manager. + +**Prerequisites:** working minikube cluster, working `kubectl`, working `docker`. If any of these is unavailable in the executor's environment: **skip Task 13 entirely and proceed to Task 14**. Task 14's offline dry-run validation (manifest schema) is the no-cluster fallback — it will catch YAML and structural errors but not image-build, scheduling, or runtime issues. Surface this clearly to the user when handing off so they know the live verification is still pending on their side. + +- [ ] **Step 1: Verify cluster reachable** + +```bash +kubectl version --client +minikube status +docker version +``` +Expected: all three commands succeed. If `minikube status` shows "Stopped" or fails, run `minikube start` first. + +- [ ] **Step 2: Run prepare-app.sh** + +```bash +cd "C:/Users/gigis/DEVapps/qubernetees" +./prepare-app.sh +``` +Expected last line: `[prepare-app] Run ./start-app.sh to bring up the rest.` + +If this fails, read the error carefully: +- "Cannot connect to the Docker daemon" → `minikube start` again to refresh docker-env. +- "no matches for kind X" → re-check that all YAMLs apply cleanly with `kubectl apply --dry-run=client -f `. +- Image build failures → fix the Dockerfile in the relevant subdir. + +- [ ] **Step 3: Run start-app.sh** + +```bash +./start-app.sh +``` +Expected: +- All four `kubectl apply` lines succeed. +- All three `rollout status` lines end with "successfully rolled out". +- A browser opens (or a URL is printed). + +- [ ] **Step 4: Confirm pods are healthy** + +```bash +kubectl -n taskapp get pods +``` +Expected: 5 pods (`db-0`, 2× `api-…`, 2× `web-…`), all `Running`, all `1/1` ready. + +- [ ] **Step 5: Smoke-test the API directly via Service URL** + +```bash +URL=$(minikube service web -n taskapp --url) +curl -sf "$URL/api/health" +``` +Expected: `{"status":"ok"}` with HTTP 200. + +- [ ] **Step 6: Smoke-test task creation** + +```bash +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"title":"k8s smoke test task"}' \ + "$URL/api/tasks" +curl -s "$URL/api/tasks" +``` +Expected: the second `curl` returns a JSON array containing the newly-created task (with `id`, `title="k8s smoke test task"`, `completed=false`, `created_at`). + +- [ ] **Step 7: Open the UI manually in a browser** + +```bash +echo "$URL" +``` +Open that URL. Verify visually: input box accepts text, Enter creates a task, checkbox toggles completion, X deletes a task, list persists across page reloads. + +- [ ] **Step 8: Verify data persistence across stop/start** + +```bash +./stop-app.sh +./prepare-app.sh +./start-app.sh +URL=$(minikube service web -n taskapp --url) +curl -s "$URL/api/tasks" +``` +Expected: the task created in Step 6 is still present (PV `Retain` reclaim policy preserved the data dir). + +- [ ] **Step 9: Final teardown for clean evaluation state** + +```bash +./stop-app.sh +``` +(Optional: `minikube ssh -- sudo rm -rf /mnt/data/taskapp-db` to wipe the persisted data too.) + +- [ ] **Step 10: Commit verification log (optional)** + +If you captured terminal output to a file like `verification.log`, do NOT commit it (it pollutes the submission). Just confirm verification passed. + +--- + +## Task 14: Final review and submission readiness + +- [ ] **Step 1: Confirm all required files are present** + +```bash +ls -la prepare-app.sh start-app.sh stop-app.sh \ + deployment.yaml service.yaml statefulset.yaml namespace.yaml \ + README.md nginx.conf +ls -la backend/Dockerfile frontend/Dockerfile +``` +Expected: every file listed exists and is non-empty. The three `.sh` files have `x` executable bits. + +- [ ] **Step 2: Spot-check that scripts pass syntax** + +```bash +bash -n prepare-app.sh && bash -n start-app.sh && bash -n stop-app.sh +``` +Expected: no output, exit 0. + +- [ ] **Step 3: Spot-check that all YAML parses** + +```bash +for f in namespace.yaml statefulset.yaml service.yaml deployment.yaml; do + echo "--- $f ---" + kubectl apply -f "$f" --dry-run=client --validate=false -o yaml >/dev/null && echo OK +done +``` +Expected: four `OK` lines. + +- [ ] **Step 4: Show git log to confirm clean history** + +```bash +git log --oneline +``` +Expected: roughly one commit per task (12-14 commits, descriptive messages). + +- [ ] **Step 5: Hand off to user** + +Tell the user: +- "Implementation complete. End-to-end verification: ." +- "All required files present in `qubernetees/`. Ready for upload to `zkt25/z2`." +- Quote the URL the app served at, if available, so they can sanity-check before submission. + +--- + +## Total estimate + +| Task | What | Estimate | +|------|------|----------| +| 1 | Init repo + copy source | 5 min | +| 2 | Add /api/health | 5 min | +| 3 | Backend Dockerfile | 5 min | +| 4 | Frontend Dockerfile + nginx.conf | 10 min | +| 5 | namespace.yaml | 10 min | +| 6 | statefulset.yaml | 15 min | +| 7 | service.yaml | 5 min | +| 8 | deployment.yaml | 15 min | +| 9 | prepare-app.sh | 10 min | +| 10 | start-app.sh | 10 min | +| 11 | stop-app.sh | 5 min | +| 12 | README.md | 20 min | +| 13 | E2E verification | 15 min | +| 14 | Final review | 5 min | +| **Total** | | **~2h15m** | diff --git a/docs/superpowers/specs/2026-04-29-k8s-taskapp-design.md b/docs/superpowers/specs/2026-04-29-k8s-taskapp-design.md new file mode 100644 index 0000000..91279eb --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-k8s-taskapp-design.md @@ -0,0 +1,276 @@ +# Design — Task Manager on Kubernetes (zkt25 / z2) + +**Date:** 2026-04-29 +**Author:** Brazing Technology (bvilborg@brazing-technology.com), with Claude Opus 4.7 +**Course assignment:** Task 2 — Kubernetes (due 2026-03-31, late submission) +**Source application:** First assignment — `cloud assiagment/` (Docker-based 3-tier task manager) + +## 1. Goal + +Migrate the existing 3-tier Task Manager application from Docker Compose to Kubernetes, satisfying every requirement of the course assignment: + +- ≥ 1 `Namespace`; all other objects belong to it. +- ≥ 1 `Deployment`. +- ≥ 1 `StatefulSet` with its `PersistentVolume` and `PersistentVolumeClaim`. +- ≥ 1 `Service`. +- Mandatory files at repo root: `start-app.sh`, `stop-app.sh`, `prepare-app.sh`, `deployment.yaml`, `service.yaml`, `statefulset.yaml`, `Dockerfile(s)`, `README.md`. +- Documentation covering: app description, containers, K8s objects, networks/volumes, container configuration, lifecycle instructions, web access instructions. + +## 2. Target environment + +- **Cluster:** minikube (local, Windows host) +- **kubectl:** assumed installed and configured (matches assignment wording) +- **Docker:** images built directly into minikube's docker daemon via `eval $(minikube -p minikube docker-env)` — no registry, no `minikube image load` step + +This choice is portable: the same scripts and YAML run unchanged on any minikube install (Linux/Mac/Windows). The graders need only `minikube start && ./prepare-app.sh && ./start-app.sh`. + +## 3. Application (unchanged from assignment 1) + +A Task Manager web app: + +- **Frontend** — Nginx serving static HTML/CSS/JS, reverse-proxying `/api/*` to the backend. +- **Backend** — Flask (Python) REST API on Gunicorn, CRUD on `tasks` (id, title, completed, created_at). Auto-creates the table on startup. +- **Database** — PostgreSQL 15. + +Endpoints: `GET/POST /api/tasks`, `PUT /api/tasks/:id`, `DELETE /api/tasks/:id`. **New:** `GET /api/health` (returns 200 if DB reachable) — used by the readiness probe. + +## 4. Architecture + +``` +Browser ─► minikube service ─► NodePort 30080 + │ + ▼ + ┌──────────────────────┐ + │ Service: web (NodePort)│ + └──────────┬───────────┘ + │ + ┌───────▼────────┐ + │ Deployment: │ + │ web (nginx, 2) │ + └───────┬────────┘ + │ /api/* (proxy_pass http://api:5000) + ┌───────▼────────┐ + │ Service: api │ (ClusterIP) + └───────┬────────┘ + │ + ┌───────▼────────┐ + │ Deployment: │ + │ api (flask, 2) │ + └───────┬────────┘ + │ TCP 5432 (db.taskapp.svc:5432) + ┌───────▼────────┐ + │ Service: db │ (headless, ClusterIP None) + └───────┬────────┘ + │ + ┌───────▼────────┐ + │ StatefulSet: │ + │ db (postgres,1)│── PVC ◄── PV (hostPath, 1Gi) + └────────────────┘ +``` + +## 5. Kubernetes object inventory + +| # | Object | Name | Purpose | +|---|--------|------|---------| +| 1 | Namespace | `taskapp` | Isolates all resources | +| 2 | Secret | `db-credentials` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | +| 3 | ConfigMap | `nginx-config` | Holds `nginx.conf` (mounted at `/etc/nginx/nginx.conf`) | +| 4 | PersistentVolume | `db-pv` | 1 Gi, `hostPath: /mnt/data/taskapp-db`, `Retain` reclaim, `manual` storageClass | +| 5 | PersistentVolumeClaim | `db-pvc` | Binds to `db-pv`; consumed by StatefulSet pod | +| 6 | StatefulSet | `db` | 1 replica, `postgres:15`, mounts PVC at `/var/lib/postgresql/data`, env from Secret, `pg_isready` probes | +| 7 | Service | `db` | Headless (`clusterIP: None`), TCP 5432 | +| 8 | Deployment | `api` | 2 replicas, `taskapp-api:v1`, env from Secret, `GET /api/health` probes, resource requests/limits | +| 9 | Service | `api` | ClusterIP, TCP 5000 | +| 10 | Deployment | `web` | 2 replicas, `taskapp-web:v1`, ConfigMap-mounted nginx.conf, `GET /` readiness, resource requests/limits | +| 11 | Service | `web` | NodePort 30080 → 80 | + +**Required by assignment:** Namespace ✓, Deployment ✓ (web, api), StatefulSet+PV+PVC ✓, Service ✓ (web, api, db). +**Engineering polish (item B from brainstorming):** Secret, ConfigMap, probes, resource requests/limits, multi-replica stateless tier. + +## 6. Networking + +- **Cluster DNS** — every Service is reachable inside the namespace by its short name: `web`, `api`, `db`. Across namespaces it would be `.taskapp.svc.cluster.local`. +- **Pod-to-pod** — handled by the CNI; no manual config. +- **Headless Service for `db`** — pairs with the StatefulSet so `db-0.db.taskapp.svc.cluster.local` is a stable DNS name. kube-proxy does not load-balance headless Services; clients connect directly to a pod. +- **External access** — only `web` is exposed (NodePort 30080). `api` and `db` are ClusterIP-only and not reachable from outside the cluster. +- **Web access for the user** — `minikube service web -n taskapp` opens the browser at the right URL automatically; alternative is `minikube ip` + `:30080`. + +## 7. Storage + +- **PV** — `db-pv`, 1 Gi, `hostPath: /mnt/data/taskapp-db` on the minikube node. Reclaim policy `Retain` so deleting the PVC does **not** wipe the underlying directory. StorageClass `manual` (matches the PVC's `storageClassName`). +- **PVC** — `db-pvc`, requests 1 Gi `ReadWriteOnce`, storageClassName `manual`. The StatefulSet pod's volume mount references the PVC by name (claim ref, not a volumeClaimTemplate, since we want a pre-bound static PV). +- **Why hostPath, not a StorageClass-driven dynamic PV** — assignment explicitly requires PV and PVC objects. Static provisioning is the textbook fit. (Note: in production, dynamic provisioning is preferred.) +- **Initialization** — `prepare-app.sh` runs `minikube ssh -- sudo mkdir -p /mnt/data/taskapp-db && sudo chmod 777 …` so the directory exists with permissions before the PV binds. + +## 8. Configuration & secrets + +- **DB password** — stored in Secret `db-credentials` (base64-encoded in YAML). Both Postgres (`POSTGRES_PASSWORD`) and Flask (`DB_PASSWORD`) read it via `envFrom: secretRef`. +- **DB host/port for Flask** — plain env vars in the Deployment manifest (`DB_HOST=db`, `DB_PORT=5432`); not secret. +- **`nginx.conf`** — held in ConfigMap `nginx-config`, mounted into the web container at `/etc/nginx/nginx.conf` (single-file mount via `subPath`). Tweaking the proxy block does not require rebuilding the image. + +## 9. Container configuration + +- **`taskapp-web` (Nginx)** — built from `nginx:alpine` + the static frontend files. The default `nginx.conf` is replaced at runtime by the ConfigMap mount. Listens on 80. +- **`taskapp-api` (Flask)** — built from `python:3.12-slim` + Flask + Gunicorn (2 workers). Reads DB credentials from env (Secret-sourced), creates the `tasks` table on startup if missing. Listens on 5000. +- **`postgres:15`** — official image, unmodified. Env from Secret. Volume mount at `/var/lib/postgresql/data`. Liveness and readiness use `pg_isready -U $POSTGRES_USER`. + +## 10. File layout (repo root) + +``` +qubernetees/ +├── README.md # documentation (assignment-required) +├── prepare-app.sh # build images into minikube; create PV directory +├── start-app.sh # kubectl apply in dependency order; wait for rollouts; open browser +├── stop-app.sh # kubectl delete (full teardown — assignment wording) +├── namespace.yaml # Namespace + Secret + ConfigMap +├── statefulset.yaml # PV + PVC + StatefulSet (no Service — see §10 note) +├── deployment.yaml # api + web Deployments +├── service.yaml # ALL Services: web (NodePort) + api (ClusterIP) + db (headless) +├── nginx.conf # source for the ConfigMap +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app.py # + new /api/health endpoint +└── frontend/ + ├── Dockerfile + ├── index.html + ├── style.css + └── app.js +``` + +**Note on `service.yaml`:** the assignment maps file → object type strictly (`service.yaml` is "configuration file for object type Service"), so **all three Services** live in `service.yaml`: `web` (NodePort), `api` (ClusterIP), `db` (headless ClusterIP). The headless-service-next-to-its-StatefulSet pattern is idiomatic but ignored here in favor of the assignment's prescribed file layout. + +**Note on `statefulset.yaml`:** holds PV + PVC + StatefulSet only. The `db` Service is in `service.yaml`. Object dependency at apply time (Service must exist before the StatefulSet pod starts so DNS resolves) is handled by ordering inside `start-app.sh`. + +## 11. Lifecycle scripts + +### `prepare-app.sh` + +Per the assignment, this script "compiles images **and creates permanent volumes**". So image builds **and** PV creation happen here. The PV is cluster-scoped (no namespace prerequisite), so it can be applied before `start-app.sh` runs. + +``` +1. minikube status (or `minikube start` if not running) +2. eval "$(minikube -p minikube docker-env)" +3. docker build -t taskapp-api:v1 backend/ +4. docker build -t taskapp-web:v1 frontend/ +5. minikube ssh -- "sudo mkdir -p /mnt/data/taskapp-db && sudo chmod 777 /mnt/data/taskapp-db" +6. kubectl apply -f statefulset.yaml --dry-run=client -o yaml \ + | kubectl apply -f - # NO — see decision below +6. kubectl apply -f statefulset.yaml # creates PV (cluster-scoped) — PVC and StatefulSet + # are namespaced and will fail-soft if NS missing, + # but we keep this here for "creating permanent volumes" + # per assignment wording +7. echo "App prepared." +``` + +**Apply-strategy decision:** `statefulset.yaml` contains the cluster-scoped PV plus the namespaced PVC and StatefulSet. Running `kubectl apply -f statefulset.yaml` before the namespace exists would fail on the namespaced objects. Two clean options: + +- **(a)** Split the PV into its own file (e.g., `pv.yaml`) so `prepare-app.sh` applies only the PV. Cleaner, but adds an extra file beyond the assignment's mandatory set. +- **(b)** Apply `namespace.yaml` first inside `prepare-app.sh`, then apply `statefulset.yaml`. The namespace+PV live after prepare; PVC and StatefulSet are also created in prepare; `start-app.sh` then applies only `deployment.yaml` and `service.yaml`. + +We pick **(b)**. It satisfies the assignment wording ("creating permanent volumes" — PV+PVC are both created in prepare), and `start-app.sh` still satisfies "create all Kubernetes objects" since `kubectl apply` is idempotent and re-running it on already-existing PV/PVC/StatefulSet is a no-op (resources are reconciled, not duplicated). + +Final `prepare-app.sh`: +``` +1. minikube status (or `minikube start` if not running) +2. eval "$(minikube -p minikube docker-env)" +3. docker build -t taskapp-api:v1 backend/ +4. docker build -t taskapp-web:v1 frontend/ +5. minikube ssh -- "sudo mkdir -p /mnt/data/taskapp-db && sudo chmod 777 /mnt/data/taskapp-db" +6. kubectl apply -f namespace.yaml # Namespace + Secret + ConfigMap (prereq for PVC) +7. kubectl apply -f statefulset.yaml # PV + PVC + StatefulSet +8. echo "App prepared." +``` + +### `start-app.sh` + +Per assignment: "commands for kubectl to create all Kubernetes objects". Re-applies everything (idempotent). Resources already created by `prepare-app.sh` are reconciled with no side effect. +``` +1. kubectl apply -f namespace.yaml # idempotent +2. kubectl apply -f statefulset.yaml # idempotent +3. kubectl apply -f service.yaml # web + api + db Services +4. kubectl apply -f deployment.yaml # web + api Deployments +5. kubectl -n taskapp rollout status statefulset/db +6. kubectl -n taskapp rollout status deployment/api +7. kubectl -n taskapp rollout status deployment/web +8. echo "App is running." +9. minikube service web -n taskapp # opens browser at http://:30080 +``` + +Note: `service.yaml` is applied *before* `deployment.yaml` so the api and db Services exist before pods try to resolve them. + +### `stop-app.sh` +Full teardown (matches assignment wording "drop the created Kubernetes objects"): +``` +1. kubectl delete -f service.yaml --ignore-not-found +2. kubectl delete -f deployment.yaml --ignore-not-found +3. kubectl delete -f statefulset.yaml --ignore-not-found # also deletes PV+PVC+db Service +4. kubectl delete -f namespace.yaml --ignore-not-found # also deletes Secret+ConfigMap +5. echo "App stopped and removed." +``` + +The hostPath data on the node remains (`Retain` reclaim policy). It can be wiped manually with `minikube ssh -- sudo rm -rf /mnt/data/taskapp-db` if desired — that command is documented in the README, not in any script. + +## 12. Health checks + +| Container | Liveness | Readiness | Initial delay | +|-----------|----------|-----------|---------------| +| `db` | `exec: pg_isready -U $POSTGRES_USER` | same | 10 s / 5 s | +| `api` | `httpGet: /api/health :5000` | same | 5 s / 5 s | +| `web` | `httpGet: / :80` | same | 2 s / 1 s | + +The `api` `/api/health` endpoint executes `SELECT 1` against the DB and returns 200 only if it succeeds — this means rolling updates wait for real DB reachability, not just Flask startup. + +## 13. Resource limits + +| Container | requests cpu / mem | limits cpu / mem | +|-----------|--------------------|------------------| +| `db` | 100m / 128Mi | 500m / 512Mi | +| `api` | 50m / 64Mi | 250m / 256Mi | +| `web` | 25m / 32Mi | 100m / 128Mi | + +Conservative; fits comfortably in a 4 GiB minikube VM with overhead for system pods. + +## 14. Documentation (README.md) + +The README covers, in order: + +1. What the application does (one-paragraph plus screenshots-optional). +2. Containers used (web/api/db) — short description each. +3. Kubernetes objects (the table from §5, with one-line "what it does" for each). +4. Virtual networks — cluster DNS, the four Services, headless Service rationale. +5. Named volumes — the PV, the PVC, hostPath path, reclaim policy. +6. Container configuration performed (env vars, ConfigMap mount, image build context). +7. Instructions: prepare → start → web access → stop. +8. How to view in browser (`minikube service web -n taskapp`). +9. Sources (assignment 1 + Kubernetes docs). +10. Use of AI (Claude Opus 4.7, Anthropic — disclosed per academic-integrity convention from assignment 1). + +## 15. Out of scope (YAGNI) + +- Ingress, HPA, NetworkPolicy, PodDisruptionBudget — not required, no demo value. +- TLS — assignment doesn't ask, browser access is over plain HTTP on a NodePort. +- Multiple DB replicas / streaming replication — out of scope for the assignment. +- CI/CD, Helm chart, Kustomize overlays — over-engineering for a single-environment school project. + +## 16. Risks & mitigations + +| Risk | Mitigation | +|------|-----------| +| Image not visible to minikube | `prepare-app.sh` runs `eval $(minikube docker-env)` before `docker build` so the image lands in minikube's daemon. | +| PV directory missing on node | `prepare-app.sh` creates `/mnt/data/taskapp-db` via `minikube ssh`. | +| `api` starts before `db` is reachable | Readiness probe on `/api/health` includes a DB ping; rolling update waits. App also tolerates and retries on connect failure at boot. | +| User runs `start-app.sh` twice | All operations are idempotent (`kubectl apply`); script is safe to re-run. | +| User stops then starts → data lost? | PV reclaim policy is `Retain`, so the underlying hostPath dir survives. On re-create the same PV is bound by the same PVC selector. | + +## 17. Oral evaluation talking points + +(For grading; not for the README.) + +1. *Why StatefulSet for Postgres, not Deployment?* — stable pod identity (`db-0`), stable storage, ordered startup; Postgres can't tolerate two pods racing on the same data dir. +2. *Why headless Service for `db`?* — gives StatefulSet pods stable DNS; kube-proxy does not load-balance, which is what stateful clients want. +3. *Why a Secret instead of plain env in YAML?* — separate object, base64-encoded, can be replaced with sealed-secrets / vault; the YAML can be committed without the password (production direction). +4. *Why a ConfigMap for `nginx.conf`?* — separates config from image; tweaking the proxy block does not require a rebuild. +5. *Why two replicas on `web` and `api`?* — stateless = horizontally scalable; demonstrates that Deployment ≠ "one pod". +6. *Why `Retain` on the PV?* — deleting the PVC won't wipe the underlying directory; safer default; allows operator review before reuse. +7. *Why static PV (not StorageClass dynamic provisioning)?* — assignment explicitly asks for PV+PVC objects; static is the textbook match. In production we'd use a StorageClass. diff --git a/frontend/style.css b/frontend/style.css index 2bb64a3..48de1a1 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1,120 +1,178 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap'); + * { margin: 0; padding: 0; box-sizing: border-box; + font-family: 'Outfit', sans-serif; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f0f2f5; - color: #1a1a2e; + background: #f4f5f8; + color: #111; min-height: 100vh; display: flex; justify-content: center; - padding-top: 60px; + align-items: flex-start; + padding: 40px 16px; } .container { width: 100%; - max-width: 520px; - padding: 0 16px; + max-width: 420px; + background: #f4f5f8; + position: relative; } h1 { - font-size: 28px; + font-size: 26px; font-weight: 700; - margin-bottom: 24px; - text-align: center; + margin-bottom: 32px; + color: #111; + display: flex; + align-items: center; + gap: 8px; +} + +h1::after { + content: '👋'; + font-size: 24px; } #task-form { display: flex; - gap: 8px; - margin-bottom: 24px; + flex-direction: column; + gap: 16px; + margin-bottom: 40px; } #task-input { - flex: 1; - padding: 12px 16px; - border: 2px solid #ddd; - border-radius: 8px; - font-size: 16px; + width: 100%; + padding: 18px 20px; + border: 1.5px solid #111; + border-radius: 16px; + font-size: 15px; + font-weight: 500; outline: none; - transition: border-color 0.2s; + background: #fff; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(0,0,0,0.02); +} + +#task-input::placeholder { + color: #999; + font-weight: 400; } #task-input:focus { - border-color: #4a6cf7; + box-shadow: 0 6px 16px rgba(0,0,0,0.06); + transform: translateY(-1px); } #task-form button { - padding: 12px 24px; - background: #4a6cf7; + padding: 18px 24px; + background: #F06A59; color: #fff; border: none; - border-radius: 8px; + border-radius: 16px; font-size: 16px; font-weight: 600; cursor: pointer; - transition: background 0.2s; + transition: all 0.2s ease; + box-shadow: 0 8px 20px rgba(240, 106, 89, 0.25); } #task-form button:hover { - background: #3a5ce5; + background: #e55c4b; + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(240, 106, 89, 0.35); } #task-list { list-style: none; + display: flex; + flex-direction: column; + gap: 16px; } .task-item { display: flex; align-items: center; - gap: 12px; - padding: 14px 16px; + gap: 16px; + padding: 20px; background: #fff; - border-radius: 8px; - margin-bottom: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - transition: opacity 0.2s; + border: 1.5px solid #111; + border-radius: 20px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; +} + +.task-item:hover { + transform: translateY(-3px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); +} + +.task-item.completed { + opacity: 0.6; + background: #fdfdfd; + border-color: #ddd; + box-shadow: none; +} + +.task-item.completed:hover { + transform: none; } .task-item.completed .task-title { text-decoration: line-through; - opacity: 0.5; + color: #888; } .task-checkbox { - width: 20px; - height: 20px; + width: 22px; + height: 22px; cursor: pointer; - accent-color: #4a6cf7; + accent-color: #F06A59; + border-radius: 6px; } .task-title { flex: 1; font-size: 16px; + font-weight: 600; + color: #111; + word-break: break-word; } .task-delete { - background: none; + background: #111; + color: #fff; border: none; - color: #e74c3c; - font-size: 18px; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 16px; + font-weight: bold; cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - opacity: 0.6; - transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; } .task-delete:hover { - opacity: 1; + background: #F06A59; + transform: scale(1.1) rotate(90deg); } .empty { text-align: center; color: #888; - font-size: 14px; - margin-top: 16px; + font-size: 15px; + font-weight: 500; + margin-top: 32px; + padding: 32px 24px; + border: 1.5px dashed #ccc; + border-radius: 20px; + background: rgba(255, 255, 255, 0.5); }