1365 lines
40 KiB
Markdown
1365 lines
40 KiB
Markdown
# 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://<minikube-ip>: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 <file>`.
|
||
- 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: <PASSED / DEFERRED — describe>."
|
||
- "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** |
|