Add completed Kubernetes deployment for Task Manager (z2)
This commit is contained in:
parent
9758b6e31c
commit
29f7042de4
381
z2/README.md
Normal file
381
z2/README.md
Normal file
@ -0,0 +1,381 @@
|
||||
# Task Manager — Kubernetes Deployment
|
||||
|
||||
A full-stack task management web application deployed on **Kubernetes**, extending the Docker Compose version (z1) with Kubernetes-native orchestration, scaling, and persistent storage.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Description](#description)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Architecture](#architecture)
|
||||
- [Kubernetes Objects](#kubernetes-objects)
|
||||
- [Containers Used](#containers-used)
|
||||
- [Networking](#networking)
|
||||
- [Persistent Volumes](#persistent-volumes)
|
||||
- [Container Configuration](#container-configuration)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Usage Instructions](#usage-instructions)
|
||||
- [Viewing the Application](#viewing-the-application)
|
||||
- [Example Workflow](#example-workflow)
|
||||
- [File Structure](#file-structure)
|
||||
- [Sources](#sources)
|
||||
- [Use of Artificial Intelligence](#use-of-artificial-intelligence)
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
**Task Manager** is a full-stack web application for creating, managing, and tracking tasks. Users can:
|
||||
|
||||
- **Create tasks** with a title and optional description
|
||||
- **Mark tasks** as completed or reopen them
|
||||
- **Delete tasks** they no longer need
|
||||
- **Filter tasks** by status (All / Active / Completed)
|
||||
- **View statistics** including total, active, and completed task counts
|
||||
- **Manage the database** via Adminer web interface
|
||||
|
||||
The application consists of a dark-themed Nginx frontend, a Node.js/Express REST API backend, PostgreSQL for persistent task storage, and Redis for API response caching — all orchestrated by Kubernetes.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Software | Minimum Version | Purpose |
|
||||
|----------|----------------|---------|
|
||||
| **Linux** | Any modern distribution | Host OS |
|
||||
| **Docker** | 20.10+ | Build container images |
|
||||
| **kubectl** | 1.25+ | Kubernetes CLI |
|
||||
| **Minikube** or **kind** | Latest | Local Kubernetes cluster |
|
||||
| **bash** | 4.0+ | Running management scripts |
|
||||
|
||||
### Verify installation:
|
||||
```bash
|
||||
docker --version
|
||||
kubectl version --client
|
||||
minikube version # or: kind version
|
||||
```
|
||||
|
||||
### Start a local cluster (if not already running):
|
||||
```bash
|
||||
minikube start
|
||||
# or
|
||||
kind create cluster
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Namespace: taskmanager │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Kubernetes Cluster DNS │ │
|
||||
│ │ │ │
|
||||
│ │ NodePort :30080 NodePort :30081 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌────▼──────────┐ ┌────────▼──────────┐ │ │
|
||||
│ │ │ Service │ │ Service │ │ │
|
||||
│ │ │ (frontend) │ │ (adminer) │ │ │
|
||||
│ │ └────┬──────────┘ └────────┬──────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌────▼──────────┐ ┌────────▼──────────┐ │ │
|
||||
│ │ │ Deployment │ │ Deployment │ │ │
|
||||
│ │ │ (Nginx ×1) │ │ (Adminer ×1) │ │ │
|
||||
│ │ └────┬──────────┘ └────────┬──────────┘ │ │
|
||||
│ │ │ /api/ proxy │ │ │
|
||||
│ │ ┌────▼──────────┐ │ │ │
|
||||
│ │ │ Service │ │ │ │
|
||||
│ │ │ (api:3000) │ │ │ │
|
||||
│ │ └────┬──────────┘ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌────▼──────────┐ ┌────────▼──────────┐ │ │
|
||||
│ │ │ Deployment │ │ StatefulSet │ │ │
|
||||
│ │ │ (API ×2) │ │ (Postgres ×1) │ │ │
|
||||
│ │ └───┬───────────┘ └────────┬──────────┘ │ │
|
||||
│ │ │ │ PVC → PV │ │
|
||||
│ │ ┌───▼──────────┐ │ /mnt/.../postgres │ │
|
||||
│ │ │ StatefulSet │ │ │ │
|
||||
│ │ │ (Redis ×1) │─────────────►│ │ │
|
||||
│ │ └──────────────┘ │ │ │
|
||||
│ │ PVC → PV │ │ │
|
||||
│ │ /mnt/.../redis │ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Objects
|
||||
|
||||
| Object | Name | File | Description |
|
||||
|--------|------|------|-------------|
|
||||
| **Namespace** | `taskmanager` | `namespace.yaml` | Isolates all objects from other cluster workloads |
|
||||
| **Secret** | `postgres-secret` | `configmap-secret.yaml` | Stores base64-encoded DB username and password |
|
||||
| **ConfigMap** | `postgres-init` | `configmap-secret.yaml` | Contains `init.sql` to create the tasks table on first run |
|
||||
| **PersistentVolume** | `postgres-pv` | `statefulset.yaml` | 1 Gi host-path volume for PostgreSQL data |
|
||||
| **PersistentVolumeClaim** | `postgres-pvc` | `statefulset.yaml` | Claims `postgres-pv` for the postgres StatefulSet |
|
||||
| **PersistentVolume** | `redis-pv` | `statefulset.yaml` | 500 Mi host-path volume for Redis AOF data |
|
||||
| **PersistentVolumeClaim** | `redis-pvc` | `statefulset.yaml` | Claims `redis-pv` for the redis StatefulSet |
|
||||
| **StatefulSet** | `postgres` | `statefulset.yaml` | Manages the PostgreSQL 16 database pod with stable identity |
|
||||
| **StatefulSet** | `redis` | `statefulset.yaml` | Manages the Redis 7 cache pod with stable identity |
|
||||
| **Deployment** | `taskmanager-api` | `deployment.yaml` | Runs 2 replicas of the Node.js REST API |
|
||||
| **Deployment** | `taskmanager-frontend` | `deployment.yaml` | Runs 1 replica of the Nginx frontend |
|
||||
| **Deployment** | `taskmanager-adminer` | `deployment.yaml` | Runs 1 replica of Adminer (DB web UI) |
|
||||
| **Service** | `postgres` | `service.yaml` | ClusterIP — internal access to PostgreSQL on port 5432 |
|
||||
| **Service** | `redis` | `service.yaml` | ClusterIP — internal access to Redis on port 6379 |
|
||||
| **Service** | `taskmanager-api` | `service.yaml` | ClusterIP — internal access to API on port 3000 |
|
||||
| **Service** | `taskmanager-frontend` | `service.yaml` | NodePort — exposes UI at port 30080 |
|
||||
| **Service** | `taskmanager-adminer` | `service.yaml` | NodePort — exposes Adminer at port 30081 |
|
||||
|
||||
---
|
||||
|
||||
## Containers Used
|
||||
|
||||
### 1. `taskmanager-frontend` (Custom — Nginx)
|
||||
- **Image:** Built from `frontend/Dockerfile` using `nginx:alpine`
|
||||
- **Role:** Serves the static HTML/CSS/JS UI and reverse-proxies `/api/` requests to `taskmanager-api:3000` using Kubernetes DNS resolution
|
||||
- **Port:** 80 (internal), exposed at NodePort 30080
|
||||
|
||||
### 2. `taskmanager-api` (Custom — Node.js)
|
||||
- **Image:** Built from `api/Dockerfile` using `node:20-alpine`
|
||||
- **Role:** REST API providing full CRUD operations for tasks. Uses PostgreSQL for data and Redis for caching (30 s TTL). 2 replicas for availability
|
||||
- **Port:** 3000 (ClusterIP)
|
||||
|
||||
### 3. `postgres` (Official — `postgres:16-alpine`)
|
||||
- **Role:** Primary relational database. The `init.sql` ConfigMap is mounted into `/docker-entrypoint-initdb.d/` so the schema and sample data are created automatically the first time
|
||||
- **Port:** 5432 (ClusterIP)
|
||||
|
||||
### 4. `redis` (Official — `redis:7-alpine`)
|
||||
- **Role:** In-memory cache for task list API responses. Configured with append-only persistence (`--appendonly yes`) so the cache survives pod restarts
|
||||
- **Port:** 6379 (ClusterIP)
|
||||
|
||||
### 5. `adminer` (Official — `adminer:latest`)
|
||||
- **Role:** Web-based database management interface for browsing, querying, and managing the PostgreSQL database directly in a browser
|
||||
- **Port:** 8080 (internal), exposed at NodePort 30081
|
||||
|
||||
---
|
||||
|
||||
## Networking
|
||||
|
||||
Kubernetes uses a **flat cluster network** — every pod can reach every other pod by its Service DNS name inside the same namespace.
|
||||
|
||||
| Service Name | Type | Port | Accessible From |
|
||||
|-------------|------|------|----------------|
|
||||
| `postgres` | ClusterIP | 5432 | API pods only (internal) |
|
||||
| `redis` | ClusterIP | 6379 | API pods only (internal) |
|
||||
| `taskmanager-api` | ClusterIP | 3000 | Frontend pods (Nginx proxy) |
|
||||
| `taskmanager-frontend` | NodePort | 80 → 30080 | External (browser) |
|
||||
| `taskmanager-adminer` | NodePort | 8080 → 30081 | External (browser) |
|
||||
|
||||
**DNS resolution example:** Inside the cluster, Nginx resolves `taskmanager-api` to the API Service IP automatically through Kubernetes DNS (`kube-dns`). The database and cache are not reachable from outside the cluster.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Volumes
|
||||
|
||||
| Volume Name | Type | Capacity | Mount Path | Purpose |
|
||||
|------------|------|----------|-----------|---------|
|
||||
| `postgres-pv` | hostPath | 1 Gi | `/mnt/taskmanager/postgres` | PostgreSQL data directory |
|
||||
| `redis-pv` | hostPath | 500 Mi | `/mnt/taskmanager/redis` | Redis append-only file |
|
||||
|
||||
**Data persistence:** Stopping the application by scaling to zero (`./stop-app.sh`) or restarting pods does **not** delete the host directories or PVs. Only `./remove-app.sh` deletes them.
|
||||
|
||||
---
|
||||
|
||||
## Container Configuration
|
||||
|
||||
All containers are configured using **environment variables**, **Secrets**, and **ConfigMaps**:
|
||||
|
||||
- **Secrets:** `postgres-secret` provides the database username and password to both the `postgres` StatefulSet and `taskmanager-api` Deployment, avoiding plain-text credentials in YAML files
|
||||
- **ConfigMap:** `postgres-init` provides the `init.sql` script mounted as a file into the postgres container
|
||||
- **Resource limits:** Every container has `requests` and `limits` defined to prevent resource starvation
|
||||
- **Readiness/liveness probes:** All containers have health checks so Kubernetes only routes traffic to healthy pods and automatically restarts crashed pods
|
||||
- **Restart policy:** Kubernetes restarts failed pods by default (managed by the Deployment/StatefulSet controllers)
|
||||
- **StatefulSets** are used for PostgreSQL and Redis because they need a stable network identity and persistent storage. The API and frontend use Deployments because they are stateless and benefit from rolling updates
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Start a local Kubernetes cluster (if not already running)
|
||||
minikube start
|
||||
|
||||
# 2. Prepare — build Docker images and create host directories
|
||||
./prepare-app.sh
|
||||
|
||||
# 3. Deploy to Kubernetes
|
||||
./start-app.sh
|
||||
|
||||
# 4. Open the app in your browser
|
||||
# Task Manager: http://<NODE_IP>:30080
|
||||
# Adminer: http://<NODE_IP>:30081
|
||||
```
|
||||
|
||||
For Minikube, get the node IP with:
|
||||
```bash
|
||||
minikube ip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### Preparing the application
|
||||
```bash
|
||||
./prepare-app.sh
|
||||
```
|
||||
Builds the custom Docker images (`taskmanager-api:latest`, `taskmanager-frontend:latest`), creates the host-path directories used by the PersistentVolumes, and **automatically loads the images into Minikube** if it detects a running Minikube cluster (preventing `ImagePullBackOff` errors).
|
||||
|
||||
### Starting the application
|
||||
```bash
|
||||
./start-app.sh
|
||||
```
|
||||
Applies all Kubernetes manifests in the correct dependency order and waits for StatefulSets and Deployments to be ready. Prints the access URLs at the end.
|
||||
|
||||
### Stopping and removing the application
|
||||
```bash
|
||||
./stop-app.sh
|
||||
```
|
||||
Deletes **all** Kubernetes objects (Deployments, StatefulSets, Services, PVCs, Secrets, ConfigMap, Namespace) and the PersistentVolumes and host directories.
|
||||
⚠️ **All task data will be lost.** Run `./prepare-app.sh` then `./start-app.sh` to start fresh.
|
||||
|
||||
### Pausing without data loss
|
||||
```bash
|
||||
# Scale all workloads to 0 — objects and PVs remain, data is preserved
|
||||
kubectl scale deployment --all --replicas=0 -n taskmanager
|
||||
kubectl scale statefulset --all --replicas=0 -n taskmanager
|
||||
|
||||
# Resume
|
||||
./start-app.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Viewing the Application
|
||||
|
||||
### Task Manager (Main UI)
|
||||
- **URL:** `http://<NODE_IP>:30080`
|
||||
- For Minikube: `http://$(minikube ip):30080`
|
||||
- Features: Create, complete, delete, and filter tasks
|
||||
|
||||
### Adminer (Database Management)
|
||||
- **URL:** `http://<NODE_IP>:30081`
|
||||
- **Login credentials:**
|
||||
- System: `PostgreSQL`
|
||||
- Server: `postgres`
|
||||
- Username: `taskuser`
|
||||
- Password: `taskpass`
|
||||
- Database: `taskmanager`
|
||||
|
||||
---
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Start a Minikube cluster
|
||||
$ minikube start
|
||||
|
||||
# Prepare images and host directories
|
||||
$ ./prepare-app.sh
|
||||
=============================================
|
||||
Preparing Task Manager for Kubernetes...
|
||||
=============================================
|
||||
[1/2] Building Docker images...
|
||||
✓ taskmanager-api:latest built
|
||||
✓ taskmanager-frontend:latest built
|
||||
[2/2] Creating host directories for PersistentVolumes...
|
||||
✓ /mnt/taskmanager/postgres created
|
||||
✓ /mnt/taskmanager/redis created
|
||||
=============================================
|
||||
✓ Preparation complete!
|
||||
Run ./start-app.sh to deploy to Kubernetes
|
||||
=============================================
|
||||
|
||||
# Deploy the application
|
||||
$ ./start-app.sh
|
||||
=============================================
|
||||
Starting Task Manager on Kubernetes...
|
||||
=============================================
|
||||
[1/6] Creating Namespace...
|
||||
[2/6] Applying Secrets and ConfigMaps...
|
||||
[3/6] Applying PersistentVolumes, PVCs and StatefulSets...
|
||||
[4/6] Waiting for postgres StatefulSet to be Ready...
|
||||
Waiting for redis StatefulSet to be Ready...
|
||||
[5/6] Applying Deployments...
|
||||
[6/6] Applying Services...
|
||||
Waiting for API deployment to be ready...
|
||||
=============================================
|
||||
✓ Task Manager is running!
|
||||
|
||||
🌐 Task Manager: http://192.168.49.2:30080
|
||||
🗄️ Adminer (DB): http://192.168.49.2:30081
|
||||
=============================================
|
||||
|
||||
# Stop (data preserved)
|
||||
$ ./stop-app.sh
|
||||
✓ Application stopped. Data is preserved in PersistentVolumes.
|
||||
|
||||
# Start again — all tasks still there
|
||||
$ ./start-app.sh
|
||||
|
||||
# Remove everything
|
||||
$ ./remove-app.sh
|
||||
✓ Application completely removed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
z2/
|
||||
├── namespace.yaml # Namespace: taskmanager
|
||||
├── configmap-secret.yaml # Secret (DB credentials) + ConfigMap (init.sql)
|
||||
├── statefulset.yaml # PV, PVC, StatefulSet for postgres and redis
|
||||
├── deployment.yaml # Deployments for API, frontend, adminer
|
||||
├── service.yaml # Services (ClusterIP + NodePort)
|
||||
├── prepare-app.sh # Build images, create host directories
|
||||
├── start-app.sh # Deploy all Kubernetes objects
|
||||
├── stop-app.sh # Scale to zero (pause)
|
||||
├── remove-app.sh # Delete everything
|
||||
├── README.md # This documentation
|
||||
├── api/
|
||||
│ ├── Dockerfile # Node.js 20 Alpine image
|
||||
│ ├── package.json # Node dependencies
|
||||
│ ├── server.js # Express REST API
|
||||
│ └── db.js # PostgreSQL connection pool
|
||||
├── frontend/
|
||||
│ ├── Dockerfile # Nginx Alpine image
|
||||
│ ├── nginx.conf # Nginx config (proxies /api/ → taskmanager-api)
|
||||
│ └── public/
|
||||
│ ├── index.html # Task Manager SPA
|
||||
│ ├── style.css # Dark theme CSS
|
||||
│ └── app.js # Frontend JavaScript
|
||||
└── db/
|
||||
└── init.sql # Database schema + sample data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
1. **Kubernetes Documentation** — [https://kubernetes.io/docs/](https://kubernetes.io/docs/)
|
||||
2. **kubectl Reference** — [https://kubernetes.io/docs/reference/kubectl/](https://kubernetes.io/docs/reference/kubectl/)
|
||||
3. **Kubernetes: StatefulSets** — [https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/)
|
||||
4. **Kubernetes: Persistent Volumes** — [https://kubernetes.io/docs/concepts/storage/persistent-volumes/](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)
|
||||
5. **Kubernetes: Secrets** — [https://kubernetes.io/docs/concepts/configuration/secret/](https://kubernetes.io/docs/concepts/configuration/secret/)
|
||||
6. **Minikube Documentation** — [https://minikube.sigs.k8s.io/docs/](https://minikube.sigs.k8s.io/docs/)
|
||||
7. **PostgreSQL Docker Image** — [https://hub.docker.com/_/postgres](https://hub.docker.com/_/postgres)
|
||||
8. **Redis Docker Image** — [https://hub.docker.com/_/redis](https://hub.docker.com/_/redis)
|
||||
9. **Adminer Docker Image** — [https://hub.docker.com/_/adminer](https://hub.docker.com/_/adminer)
|
||||
10. **Node.js Docker Image** — [https://hub.docker.com/_/node](https://hub.docker.com/_/node)
|
||||
11. **Nginx Docker Image** — [https://hub.docker.com/_/nginx](https://hub.docker.com/_/nginx)
|
||||
12. **Express.js Documentation** — [https://expressjs.com/](https://expressjs.com/)
|
||||
|
||||
---
|
||||
|
||||
## Use of Artificial Intelligence
|
||||
|
||||
Artificial intelligence tools such as ChatGPT and Claude were used as a support tool during development for understanding Kubernetes concepts, writing YAML manifests, and debugging configuration issues. All implementation, testing, and integration were performed independently.
|
||||
20
z2/api/Dockerfile
Normal file
20
z2/api/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# Copy application source
|
||||
COPY . .
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
18
z2/api/db.js
Normal file
18
z2/api/db.js
Normal file
@ -0,0 +1,18 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST || 'postgres',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
database: process.env.POSTGRES_DB || 'taskmanager',
|
||||
user: process.env.POSTGRES_USER || 'taskuser',
|
||||
password: process.env.POSTGRES_PASSWORD || 'taskpass',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected PostgreSQL pool error:', err);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
15
z2/api/package.json
Normal file
15
z2/api/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "taskmanager-api",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for Task Manager - Kubernetes deployment",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.10"
|
||||
}
|
||||
}
|
||||
161
z2/api/server.js
Normal file
161
z2/api/server.js
Normal file
@ -0,0 +1,161 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const pool = require('./db');
|
||||
const { createClient } = require('redis');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Redis client
|
||||
let redisClient;
|
||||
const CACHE_TTL = 30; // seconds
|
||||
|
||||
async function initRedis() {
|
||||
try {
|
||||
redisClient = createClient({
|
||||
url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
|
||||
});
|
||||
redisClient.on('error', (err) => console.error('Redis error:', err));
|
||||
await redisClient.connect();
|
||||
console.log('Connected to Redis');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to Redis:', err.message);
|
||||
redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: invalidate cache
|
||||
async function invalidateCache() {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
try {
|
||||
await redisClient.del('tasks:all');
|
||||
} catch (err) {
|
||||
console.error('Cache invalidation error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
const redisOk = redisClient && redisClient.isOpen;
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
database: 'connected',
|
||||
cache: redisOk ? 'connected' : 'disconnected',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({ status: 'unhealthy', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks — List all tasks (with Redis caching)
|
||||
app.get('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
const cached = await redisClient.get('tasks:all');
|
||||
if (cached) {
|
||||
return res.json({ tasks: JSON.parse(cached), source: 'cache' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM tasks ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
await redisClient.setEx('tasks:all', CACHE_TTL, JSON.stringify(result.rows));
|
||||
}
|
||||
|
||||
res.json({ tasks: result.rows, source: 'database' });
|
||||
} catch (err) {
|
||||
console.error('Error fetching tasks:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks — Create a new task
|
||||
app.post('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
const { title, description } = req.body;
|
||||
if (!title || title.trim() === '') {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING *',
|
||||
[title.trim(), (description || '').trim()]
|
||||
);
|
||||
|
||||
await invalidateCache();
|
||||
res.status(201).json({ task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error creating task:', err);
|
||||
res.status(500).json({ error: 'Failed to create task' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id — Update a task
|
||||
app.put('/api/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { title, description, completed } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE tasks
|
||||
SET title = COALESCE($1, title),
|
||||
description = COALESCE($2, description),
|
||||
completed = COALESCE($3, completed),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[title, description, completed, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await invalidateCache();
|
||||
res.json({ task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating task:', err);
|
||||
res.status(500).json({ error: 'Failed to update task' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id — Delete a task
|
||||
app.delete('/api/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
'DELETE FROM tasks WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await invalidateCache();
|
||||
res.json({ message: 'Task deleted', task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error deleting task:', err);
|
||||
res.status(500).json({ error: 'Failed to delete task' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function start() {
|
||||
await initRedis();
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Task Manager API running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
44
z2/configmap-secret.yaml
Normal file
44
z2/configmap-secret.yaml
Normal file
@ -0,0 +1,44 @@
|
||||
# =============================================
|
||||
# Secret — PostgreSQL credentials
|
||||
# Base64-encoded username and password.
|
||||
# taskuser → dGFza3VzZXI=
|
||||
# taskpass → dGFza3Bhc3M=
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres-secret
|
||||
namespace: taskmanager
|
||||
type: Opaque
|
||||
data:
|
||||
username: dGFza3VzZXI=
|
||||
password: dGFza3Bhc3M=
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# ConfigMap — PostgreSQL init.sql
|
||||
# Mounted into the postgres container so the
|
||||
# tasks table and sample data are created the
|
||||
# first time the database starts.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: postgres-init
|
||||
namespace: taskmanager
|
||||
data:
|
||||
init.sql: |
|
||||
-- Task Manager Database Initialization
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO tasks (title, description, completed) VALUES
|
||||
('Welcome to Task Manager', 'This is a sample task. You can edit or delete it.', FALSE),
|
||||
('Try creating a new task', 'Click the "Add Task" button to create your own tasks.', FALSE),
|
||||
('Mark tasks as complete', 'Click the checkbox to mark a task as done.', TRUE);
|
||||
17
z2/db/init.sql
Normal file
17
z2/db/init.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Task Manager Database Initialization
|
||||
-- Creates the tasks table for the application
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert sample tasks so the app isn't empty on first load
|
||||
INSERT INTO tasks (title, description, completed) VALUES
|
||||
('Welcome to Task Manager', 'This is a sample task. You can edit or delete it.', FALSE),
|
||||
('Try creating a new task', 'Click the "Add Task" button to create your own tasks.', FALSE),
|
||||
('Mark tasks as complete', 'Click the checkbox to mark a task as done.', TRUE);
|
||||
158
z2/deployment.yaml
Normal file
158
z2/deployment.yaml
Normal file
@ -0,0 +1,158 @@
|
||||
# =============================================
|
||||
# Deployment — Node.js REST API
|
||||
# Stateless application layer that handles all
|
||||
# CRUD operations for tasks. Reads environment
|
||||
# variables for DB and Redis connection info.
|
||||
# =============================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: taskmanager-api
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: taskmanager-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: taskmanager-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: taskmanager-api:latest
|
||||
# Use local image — do not pull from registry
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: POSTGRES_HOST
|
||||
value: postgres
|
||||
- name: POSTGRES_PORT
|
||||
value: "5432"
|
||||
- name: POSTGRES_DB
|
||||
value: taskmanager
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: password
|
||||
- name: REDIS_HOST
|
||||
value: redis
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Deployment — Nginx Frontend
|
||||
# Serves the static HTML/CSS/JS task manager
|
||||
# UI and reverse-proxies /api/ calls to the
|
||||
# API service inside the cluster.
|
||||
# =============================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: taskmanager-frontend
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: taskmanager-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: taskmanager-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: taskmanager-frontend:latest
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "200m"
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Deployment — Adminer
|
||||
# Lightweight web interface for browsing the
|
||||
# PostgreSQL database directly in a browser.
|
||||
# =============================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: taskmanager-adminer
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-adminer
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: taskmanager-adminer
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: taskmanager-adminer
|
||||
spec:
|
||||
containers:
|
||||
- name: adminer
|
||||
image: adminer:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: ADMINER_DEFAULT_SERVER
|
||||
value: postgres
|
||||
- name: ADMINER_DESIGN
|
||||
value: dracula
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "200m"
|
||||
19
z2/frontend/Dockerfile
Normal file
19
z2/frontend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy static frontend files
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
35
z2/frontend/nginx.conf
Normal file
35
z2/frontend/nginx.conf
Normal file
@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve static frontend files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Reverse proxy API requests to the Node.js backend Service
|
||||
# In Kubernetes, the Service name "taskmanager-api" resolves via
|
||||
# cluster DNS to the API pods on port 3000.
|
||||
location /api/ {
|
||||
proxy_pass http://taskmanager-api:3000/api/;
|
||||
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;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
234
z2/frontend/public/app.js
Normal file
234
z2/frontend/public/app.js
Normal file
@ -0,0 +1,234 @@
|
||||
// ===== Task Manager Frontend Application =====
|
||||
|
||||
const API_BASE = '/api';
|
||||
let tasks = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
// ===== DOM Elements =====
|
||||
const taskList = document.getElementById('task-list');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const loadingState = document.getElementById('loading-state');
|
||||
const addTaskForm = document.getElementById('add-task-form');
|
||||
const taskTitleInput = document.getElementById('task-title');
|
||||
const taskDescInput = document.getElementById('task-description');
|
||||
const filterTabs = document.querySelectorAll('.filter-tab');
|
||||
const toast = document.getElementById('toast');
|
||||
|
||||
// ===== API Functions =====
|
||||
async function apiRequest(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'TypeError') {
|
||||
throw new Error('Cannot connect to server. Is the API running?');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
const data = await apiRequest('/tasks');
|
||||
tasks = data.tasks || [];
|
||||
const sourceEl = document.querySelector('#stat-source .stat-value');
|
||||
if (sourceEl) {
|
||||
sourceEl.textContent = data.source === 'cache' ? '⚡ Cache' : '🗄️ DB';
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function createTask(title, description) {
|
||||
const data = await apiRequest('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, description }),
|
||||
});
|
||||
return data.task;
|
||||
}
|
||||
|
||||
async function updateTask(id, updates) {
|
||||
const data = await apiRequest(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
return data.task;
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
await apiRequest(`/tasks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ===== UI Rendering =====
|
||||
function getFilteredTasks() {
|
||||
switch (currentFilter) {
|
||||
case 'active': return tasks.filter(t => !t.completed);
|
||||
case 'completed': return tasks.filter(t => t.completed);
|
||||
default: return tasks;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const filtered = getFilteredTasks();
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.completed).length;
|
||||
const active = total - completed;
|
||||
|
||||
document.querySelector('#stat-total .stat-value').textContent = total;
|
||||
document.querySelector('#stat-active .stat-value').textContent = active;
|
||||
document.querySelector('#stat-completed .stat-value').textContent = completed;
|
||||
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (filtered.length === 0) {
|
||||
taskList.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.querySelector('p').textContent = tasks.length > 0
|
||||
? `No ${currentFilter} tasks.`
|
||||
: 'No tasks yet. Add your first task above!';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
taskList.innerHTML = filtered.map(task => `
|
||||
<div class="task-item ${task.completed ? 'completed' : ''}" data-id="${task.id}">
|
||||
<label class="task-checkbox">
|
||||
<input type="checkbox"
|
||||
${task.completed ? 'checked' : ''}
|
||||
onchange="handleToggle(${task.id}, this.checked)"
|
||||
aria-label="Mark task as ${task.completed ? 'incomplete' : 'complete'}">
|
||||
<span class="checkmark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
<div class="task-content">
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
${task.description ? `<div class="task-description">${escapeHtml(task.description)}</div>` : ''}
|
||||
<div class="task-meta">${formatDate(task.created_at)}</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn-icon" onclick="handleDelete(${task.id})" title="Delete task" aria-label="Delete task">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ===== Toast Notifications =====
|
||||
let toastTimeout;
|
||||
function showToast(message, type = 'info') {
|
||||
clearTimeout(toastTimeout);
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
toastTimeout = setTimeout(() => { toast.classList.remove('show'); }, 3000);
|
||||
}
|
||||
|
||||
// ===== Event Handlers =====
|
||||
async function handleToggle(id, completed) {
|
||||
try {
|
||||
await updateTask(id, { completed });
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast(completed ? '✅ Task completed!' : '🔄 Task reopened', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteTask(id);
|
||||
const el = document.querySelector(`.task-item[data-id="${id}"]`);
|
||||
if (el) {
|
||||
el.style.transform = 'translateX(100px)';
|
||||
el.style.opacity = '0';
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast('🗑️ Task deleted', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Form Submit =====
|
||||
addTaskForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const title = taskTitleInput.value.trim();
|
||||
const description = taskDescInput.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await createTask(title, description);
|
||||
taskTitleInput.value = '';
|
||||
taskDescInput.value = '';
|
||||
taskTitleInput.focus();
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast('✨ Task created!', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Filter Tabs =====
|
||||
filterTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
filterTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentFilter = tab.dataset.filter;
|
||||
renderTasks();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Initialize =====
|
||||
async function init() {
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
try {
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
} catch (err) {
|
||||
loadingState.style.display = 'none';
|
||||
showToast('Failed to load tasks: ' + err.message, 'error');
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.querySelector('p').textContent = 'Unable to connect to the server.';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(async () => {
|
||||
try { await fetchTasks(); renderTasks(); } catch (_) {}
|
||||
}, 30000);
|
||||
113
z2/frontend/public/index.html
Normal file
113
z2/frontend/public/index.html
Normal file
@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Task Manager - A modern task management web application deployed on Kubernetes">
|
||||
<title>Task Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"/>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Task Manager</h1>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<div class="stat" id="stat-total">
|
||||
<span class="stat-value">0</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat" id="stat-active">
|
||||
<span class="stat-value">0</span>
|
||||
<span class="stat-label">Active</span>
|
||||
</div>
|
||||
<div class="stat" id="stat-completed">
|
||||
<span class="stat-value">0</span>
|
||||
<span class="stat-label">Done</span>
|
||||
</div>
|
||||
<div class="stat" id="stat-source">
|
||||
<span class="stat-value">—</span>
|
||||
<span class="stat-label">Source</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Add Task Form -->
|
||||
<section class="add-task-section">
|
||||
<form id="add-task-form" class="add-task-form">
|
||||
<div class="form-row">
|
||||
<div class="input-group input-group-title">
|
||||
<input type="text" id="task-title" placeholder="What needs to be done?" required autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group input-group-desc">
|
||||
<input type="text" id="task-description" placeholder="Description (optional)" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-add" id="btn-add-task">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span>Add Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<section class="filter-section">
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab active" data-filter="all">All</button>
|
||||
<button class="filter-tab" data-filter="active">Active</button>
|
||||
<button class="filter-tab" data-filter="completed">Completed</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task List -->
|
||||
<section class="task-list-section">
|
||||
<div id="task-list" class="task-list">
|
||||
<!-- Tasks will be rendered here -->
|
||||
</div>
|
||||
<div id="empty-state" class="empty-state" style="display: none;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<p>No tasks yet. Add your first task above!</p>
|
||||
</div>
|
||||
<div id="loading-state" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="app-footer">
|
||||
<p>Task Manager — Kubernetes Deployment • PostgreSQL • Redis • Node.js • Nginx</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
331
z2/frontend/public/style.css
Normal file
331
z2/frontend/public/style.css
Normal file
@ -0,0 +1,331 @@
|
||||
/* ===== CSS Variables & Reset ===== */
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-card-hover: #1a2744;
|
||||
--bg-input: #0d1b36;
|
||||
--border-color: #2a2a4a;
|
||||
--border-focus: #6c63ff;
|
||||
--text-primary: #e8e8f0;
|
||||
--text-secondary: #9898b0;
|
||||
--text-muted: #6868880;
|
||||
--accent: #6c63ff;
|
||||
--accent-hover: #5a52e0;
|
||||
--accent-glow: rgba(108, 99, 255, 0.3);
|
||||
--success: #2ecc71;
|
||||
--success-bg: rgba(46, 204, 113, 0.12);
|
||||
--danger: #e74c3c;
|
||||
--danger-hover: #c0392b;
|
||||
--warning: #f39c12;
|
||||
--gradient-1: linear-gradient(135deg, #6c63ff 0%, #3f8efc 100%);
|
||||
--gradient-2: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--shadow-glow: 0 0 20px rgba(108, 99, 255, 0.15);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 32px 0 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--gradient-1);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient-1);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 70px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.add-task-section { margin-bottom: 24px; }
|
||||
|
||||
.add-task-form {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.add-task-form:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-glow), var(--shadow-md);
|
||||
}
|
||||
|
||||
.form-row { display: flex; gap: 12px; align-items: stretch; }
|
||||
.input-group { flex: 1; }
|
||||
.input-group-title { flex: 2; }
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
transition: var(--transition);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-group input::placeholder { color: var(--text-secondary); }
|
||||
.input-group input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-add { background: var(--gradient-1); color: white; box-shadow: var(--shadow-sm); }
|
||||
.btn-add:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow), var(--shadow-md); }
|
||||
.btn-add:active { transform: translateY(0); }
|
||||
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-tab:hover { color: var(--text-primary); background: rgba(108, 99, 255, 0.08); }
|
||||
.filter-tab.active { background: var(--accent); color: white; box-shadow: var(--shadow-sm); }
|
||||
|
||||
.task-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: rgba(108, 99, 255, 0.3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.task-item.completed { opacity: 0.6; }
|
||||
.task-item.completed .task-title { text-decoration: line-through; color: var(--text-secondary); }
|
||||
|
||||
.task-checkbox { position: relative; width: 24px; height: 24px; flex-shrink: 0; }
|
||||
.task-checkbox input { position: absolute; opacity: 0; width: 100%; height: 100%; cursor: pointer; z-index: 1; }
|
||||
|
||||
.task-checkbox .checkmark {
|
||||
width: 24px; height: 24px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.task-checkbox input:hover + .checkmark { border-color: var(--accent); }
|
||||
.task-checkbox input:checked + .checkmark { background: var(--success); border-color: var(--success); }
|
||||
|
||||
.task-checkbox .checkmark svg { opacity: 0; transform: scale(0.5); transition: var(--transition); color: white; }
|
||||
.task-checkbox input:checked + .checkmark svg { opacity: 1; transform: scale(1); }
|
||||
|
||||
.task-content { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 1rem; font-weight: 500; color: var(--text-primary); margin-bottom: 2px; word-break: break-word; }
|
||||
.task-description { font-size: 0.85rem; color: var(--text-secondary); word-break: break-word; }
|
||||
.task-meta { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.task-actions { display: flex; gap: 8px; flex-shrink: 0; opacity: 0; transition: var(--transition); }
|
||||
.task-item:hover .task-actions { opacity: 1; }
|
||||
|
||||
.btn-icon {
|
||||
width: 36px; height: 36px;
|
||||
border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-secondary);
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.btn-icon:hover { background: rgba(231, 76, 60, 0.15); color: var(--danger); }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
|
||||
.empty-state svg { margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 1rem; }
|
||||
|
||||
.loading-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
|
||||
|
||||
.spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 16px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem; font-weight: 500;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(100px); opacity: 0;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1000; max-width: 360px;
|
||||
}
|
||||
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.success { border-left: 4px solid var(--success); }
|
||||
.toast.error { border-left: 4px solid var(--danger); }
|
||||
.toast.info { border-left: 4px solid var(--accent); }
|
||||
|
||||
.app-footer { margin-top: auto; padding: 24px 0; border-top: 1px solid var(--border-color); text-align: center; }
|
||||
.app-footer p { font-size: 0.8rem; color: var(--text-secondary); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-content { flex-direction: column; align-items: flex-start; }
|
||||
.header-stats { width: 100%; justify-content: space-between; }
|
||||
.stat { min-width: 0; flex: 1; padding: 6px 8px; }
|
||||
.form-row { flex-direction: column; }
|
||||
.btn-add { width: 100%; justify-content: center; }
|
||||
.task-actions { opacity: 1; }
|
||||
.logo h1 { font-size: 1.4rem; }
|
||||
}
|
||||
11
z2/namespace.yaml
Normal file
11
z2/namespace.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
# =============================================
|
||||
# Namespace — taskmanager
|
||||
# Isolates all Task Manager objects from other
|
||||
# workloads running in the same cluster.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: taskmanager
|
||||
labels:
|
||||
app: taskmanager
|
||||
53
z2/prepare-app.sh
Executable file
53
z2/prepare-app.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# prepare-app.sh — Build Docker images and create host-path directories
|
||||
#
|
||||
# This script:
|
||||
# 1. Builds the custom Docker images (api, frontend) from source.
|
||||
# 2. Creates the host directories used by PersistentVolumes.
|
||||
#
|
||||
# Prerequisites: Docker installed and running, kubectl configured.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "============================================="
|
||||
echo " Preparing Task Manager for Kubernetes..."
|
||||
echo "============================================="
|
||||
echo ""
|
||||
|
||||
# ---- Step 1: Build Docker images ----------------------------------------
|
||||
echo "[1/2] Building Docker images..."
|
||||
|
||||
echo " Building taskmanager-api..."
|
||||
docker build -t taskmanager-api:latest "$SCRIPT_DIR/api"
|
||||
echo " ✓ taskmanager-api:latest built"
|
||||
|
||||
echo " Building taskmanager-frontend..."
|
||||
docker build -t taskmanager-frontend:latest "$SCRIPT_DIR/frontend"
|
||||
echo " ✓ taskmanager-frontend:latest built"
|
||||
|
||||
# If Minikube is running, load the images into its registry
|
||||
if command -v minikube &> /dev/null && minikube status &> /dev/null; then
|
||||
echo ""
|
||||
echo " Detected Minikube. Loading images into Minikube registry..."
|
||||
minikube image load taskmanager-api:latest
|
||||
minikube image load taskmanager-frontend:latest
|
||||
echo " ✓ Images loaded into Minikube"
|
||||
fi
|
||||
|
||||
# ---- Step 2: Create host directories for PersistentVolumes ---------------
|
||||
echo ""
|
||||
echo "[2/2] Creating host directories for PersistentVolumes..."
|
||||
sudo mkdir -p /mnt/taskmanager/postgres
|
||||
sudo mkdir -p /mnt/taskmanager/redis
|
||||
sudo chmod -R 777 /mnt/taskmanager
|
||||
echo " ✓ /mnt/taskmanager/postgres created"
|
||||
echo " ✓ /mnt/taskmanager/redis created"
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " ✓ Preparation complete!"
|
||||
echo " Run ./start-app.sh to deploy to Kubernetes"
|
||||
echo "============================================="
|
||||
11
z2/remove-app.sh
Executable file
11
z2/remove-app.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# remove-app.sh — Alias for stop-app.sh
|
||||
#
|
||||
# Provided for convenience. stop-app.sh already performs a full deletion
|
||||
# of all Kubernetes objects, PersistentVolumes, and host directories.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
"$SCRIPT_DIR/stop-app.sh"
|
||||
110
z2/service.yaml
Normal file
110
z2/service.yaml
Normal file
@ -0,0 +1,110 @@
|
||||
# =============================================
|
||||
# Service — PostgreSQL (ClusterIP)
|
||||
# Internal DNS name "postgres" used by the API
|
||||
# to reach the database. Not exposed publicly.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Service — Redis (ClusterIP)
|
||||
# Internal DNS name "redis" used by the API.
|
||||
# Not exposed publicly.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Service — Task Manager API (ClusterIP)
|
||||
# Internal DNS name "taskmanager-api" reachable
|
||||
# by the Nginx frontend reverse proxy.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: taskmanager-api
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-api
|
||||
spec:
|
||||
selector:
|
||||
app: taskmanager-api
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Service — Nginx Frontend (NodePort)
|
||||
# Exposes the UI at http://<NodeIP>:30080
|
||||
# so the application is reachable from the
|
||||
# host machine without an Ingress controller.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: taskmanager-frontend
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-frontend
|
||||
spec:
|
||||
selector:
|
||||
app: taskmanager-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30080
|
||||
type: NodePort
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# Service — Adminer (NodePort)
|
||||
# Exposes the database web UI at port 30081.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: taskmanager-adminer
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: taskmanager-adminer
|
||||
spec:
|
||||
selector:
|
||||
app: taskmanager-adminer
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
nodePort: 30081
|
||||
type: NodePort
|
||||
67
z2/start-app.sh
Executable file
67
z2/start-app.sh
Executable file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# start-app.sh — Deploy all Kubernetes objects for the Task Manager
|
||||
#
|
||||
# Applies manifests in the correct dependency order:
|
||||
# 1. Namespace
|
||||
# 2. Secret + ConfigMap
|
||||
# 3. PersistentVolumes + PersistentVolumeClaims (inside StatefulSet file)
|
||||
# 4. StatefulSets (postgres, redis)
|
||||
# 5. Deployments (api, frontend, adminer)
|
||||
# 6. Services
|
||||
#
|
||||
# The script waits for the StatefulSets to be Ready before continuing so
|
||||
# the API does not fail its readiness probe immediately.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NS="taskmanager"
|
||||
|
||||
echo "============================================="
|
||||
echo " Starting Task Manager on Kubernetes..."
|
||||
echo "============================================="
|
||||
echo ""
|
||||
|
||||
echo "[1/6] Creating Namespace..."
|
||||
kubectl apply -f "$SCRIPT_DIR/namespace.yaml"
|
||||
|
||||
echo "[2/6] Applying Secrets and ConfigMaps..."
|
||||
kubectl apply -f "$SCRIPT_DIR/configmap-secret.yaml"
|
||||
|
||||
echo "[3/6] Applying PersistentVolumes, PVCs and StatefulSets..."
|
||||
kubectl apply -f "$SCRIPT_DIR/statefulset.yaml"
|
||||
|
||||
echo "[4/6] Waiting for postgres StatefulSet to be Ready..."
|
||||
kubectl rollout status statefulset/postgres -n $NS --timeout=120s
|
||||
|
||||
echo " Waiting for redis StatefulSet to be Ready..."
|
||||
kubectl rollout status statefulset/redis -n $NS --timeout=60s
|
||||
|
||||
echo "[5/6] Applying Deployments..."
|
||||
kubectl apply -f "$SCRIPT_DIR/deployment.yaml"
|
||||
|
||||
echo "[6/6] Applying Services..."
|
||||
kubectl apply -f "$SCRIPT_DIR/service.yaml"
|
||||
|
||||
echo ""
|
||||
echo "Waiting for API deployment to be ready..."
|
||||
kubectl rollout status deployment/taskmanager-api -n $NS --timeout=120s
|
||||
|
||||
# Determine the node IP for NodePort access
|
||||
NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null || echo "localhost")
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " ✓ Task Manager is running!"
|
||||
echo ""
|
||||
echo " 🌐 Task Manager: http://${NODE_IP}:30080"
|
||||
echo " 🗄️ Adminer (DB): http://${NODE_IP}:30081"
|
||||
echo ""
|
||||
echo " Adminer login:"
|
||||
echo " System: PostgreSQL"
|
||||
echo " Server: postgres"
|
||||
echo " Username: taskuser"
|
||||
echo " Password: taskpass"
|
||||
echo " Database: taskmanager"
|
||||
echo "============================================="
|
||||
220
z2/statefulset.yaml
Normal file
220
z2/statefulset.yaml
Normal file
@ -0,0 +1,220 @@
|
||||
# =============================================
|
||||
# PersistentVolume — PostgreSQL data storage
|
||||
# Host-path volume that stores database files.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: postgres-pv
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: manual
|
||||
hostPath:
|
||||
path: /mnt/taskmanager/postgres
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# PersistentVolumeClaim — PostgreSQL
|
||||
# Binds to postgres-pv for database storage.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-pvc
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: manual
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# PersistentVolume — Redis data storage
|
||||
# Host-path volume for Redis append-only file.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: redis-pv
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
capacity:
|
||||
storage: 500Mi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: manual
|
||||
hostPath:
|
||||
path: /mnt/taskmanager/redis
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# PersistentVolumeClaim — Redis
|
||||
# Binds to redis-pv for cache persistence.
|
||||
# =============================================
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: redis-pvc
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: manual
|
||||
resources:
|
||||
requests:
|
||||
storage: 500Mi
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# StatefulSet — PostgreSQL 16
|
||||
# Manages the primary relational database.
|
||||
# Uses the PVC above for persistent storage.
|
||||
# The init.sql ConfigMap is mounted so the DB
|
||||
# schema is created automatically on first run.
|
||||
# =============================================
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: taskmanager
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
- name: init-sql
|
||||
mountPath: /docker-entrypoint-initdb.d/init.sql
|
||||
subPath: init.sql
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- taskuser
|
||||
- -d
|
||||
- taskmanager
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- taskuser
|
||||
- -d
|
||||
- taskmanager
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
volumes:
|
||||
- name: postgres-data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-pvc
|
||||
- name: init-sql
|
||||
configMap:
|
||||
name: postgres-init
|
||||
|
||||
---
|
||||
# =============================================
|
||||
# StatefulSet — Redis 7
|
||||
# Manages the in-memory caching layer.
|
||||
# Uses the redis-pvc for AOF persistence.
|
||||
# =============================================
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: taskmanager
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
serviceName: redis
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
args: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
volumes:
|
||||
- name: redis-data
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-pvc
|
||||
37
z2/stop-app.sh
Executable file
37
z2/stop-app.sh
Executable file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# stop-app.sh — Delete all Kubernetes objects for the Task Manager
|
||||
#
|
||||
# Drops Deployments, StatefulSets, Services, Secrets, ConfigMaps, PVCs,
|
||||
# and the Namespace. PersistentVolumes (cluster-scoped) and the host-path
|
||||
# directories are also removed.
|
||||
#
|
||||
# ⚠️ WARNING: All task data stored in the PersistentVolumes will be LOST.
|
||||
# To pause the app without losing data, scale to 0 manually:
|
||||
# kubectl scale deployment --all --replicas=0 -n taskmanager
|
||||
# kubectl scale statefulset --all --replicas=0 -n taskmanager
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
NS="taskmanager"
|
||||
|
||||
echo "============================================="
|
||||
echo " Stopping (dropping) Task Manager..."
|
||||
echo "============================================="
|
||||
echo ""
|
||||
|
||||
echo "[1/3] Deleting namespace '$NS' and all objects inside it..."
|
||||
kubectl delete namespace $NS --ignore-not-found=true
|
||||
|
||||
echo "[2/3] Deleting cluster-scoped PersistentVolumes..."
|
||||
kubectl delete pv postgres-pv redis-pv --ignore-not-found=true
|
||||
|
||||
echo "[3/3] Removing host-path directories..."
|
||||
sudo rm -rf /mnt/taskmanager 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " ✓ All Kubernetes objects removed."
|
||||
echo " Run ./prepare-app.sh then ./start-app.sh"
|
||||
echo " to redeploy the application."
|
||||
echo "============================================="
|
||||
Loading…
Reference in New Issue
Block a user