diff --git a/sk1/.gitignore b/sk1/.gitignore new file mode 100644 index 0000000..7ce229f --- /dev/null +++ b/sk1/.gitignore @@ -0,0 +1,14 @@ +.env +*.pem +*.key +node_modules/ +backups/ + +# LaTeX build artifacts +*.aux +*.log +*.out +*.toc +*.fdb_latexmk +*.fls +*.synctex.gz diff --git a/sk1/README.md b/sk1/README.md new file mode 100644 index 0000000..3a0f3e1 --- /dev/null +++ b/sk1/README.md @@ -0,0 +1,116 @@ +# Task Manager β Cloud Deployment (SK1) + +A full-stack task management web application deployed to **Microsoft Azure** using Docker Compose. + +## ποΈ Architecture + + + +| Container | Image | Port | Purpose | +|-----------|-------|------|---------| +| **Caddy** | caddy:2-alpine | 443, 80 | Reverse proxy + automatic HTTPS (Let's Encrypt) | +| **Frontend** | nginx:alpine | 80 | Static files + API reverse proxy | +| **API** | node:20-alpine | 3000 | Express REST API (CRUD) | +| **PostgreSQL** | postgres:16-alpine | 5432 | Persistent relational database | +| **Redis** | redis:7-alpine | 6379 | In-memory cache | + +## π Quick Deploy + +```bash +# 1. Login to Azure (requires Azure for Students subscription) +az login + +# 2. Clone and configure +git clone git@git.kemt.fei.tuke.sk:gs699he/zkt26.git +cd zkt26/sk1 +cp env.example .env +nano .env # Set POSTGRES_PASSWORD, optionally change DNS_LABEL + +# 3. Deploy (creates VM, installs Docker, starts containers) +./prepare-app.sh + +# 4. Visit: https://taskmanager-gs699he.westeurope.cloudapp.azure.com +``` + +## ποΈ Remove (after exam) + +```bash +./remove-app.sh # Deletes entire resource group (VM, IP, disk) +``` + +## πΎ Backup & Logs + +```bash +./backup-db.sh # Download database dump +./view-logs.sh # All container logs +./view-logs.sh --api -f # Follow API logs in real-time +``` + +## π Project Structure + +``` +sk1/ +βββ api/ # Node.js REST API +β βββ Dockerfile +β βββ server.js # Express + CRUD routes +β βββ db.js # PostgreSQL pool +β βββ package.json +βββ frontend/ # Web UI +β βββ Dockerfile +β βββ nginx.conf +β βββ public/ (index.html, style.css, app.js) +βββ db/init.sql # Database schema +βββ caddy/Caddyfile # HTTPS proxy config +βββ docs/ +β βββ architecture.png # Architecture diagram +β βββ documentation.tex # LaTeX source +β βββ documentation.pdf # 9-page PDF report +βββ docker-compose.yaml # 5-service orchestration +βββ cloud-init.yaml # VM bootstrap (Docker install) +βββ prepare-app.sh # Azure deployment script +βββ remove-app.sh # Azure teardown script +βββ backup-db.sh # Database backup +βββ view-logs.sh # Access log viewer +βββ env.example # Environment template +βββ .gitignore +βββ README.md +``` + +## βοΈ Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `POSTGRES_PASSWORD` | Database password | *(required)* | +| `AZURE_RESOURCE_GROUP` | Azure resource group | taskmanager-rg | +| `AZURE_LOCATION` | Azure region | westeurope | +| `AZURE_VM_SIZE` | VM size | Standard_B1s | +| `AZURE_DNS_LABEL` | DNS label for URL | taskmanager-gs699he | + +## π° Cost (Azure for Students) + +| Resource | Monthly Cost | +|----------|-------------| +| VM (Standard_B1s) | ~$7.59 | +| Public IP | ~$3.00 | +| Disk (30 GB) | ~$1.20 | +| TLS (Let's Encrypt) | Free | +| **Total** | **~$11.79/mo** | + +> π‘ Azure for Students gives $100 free credit β enough for ~8 months. + +## π Security + +- HTTPS enforced (Caddy + Let's Encrypt) +- Databases on isolated Docker network (`backend-net`) +- Secrets via `.env` (gitignored) +- Security headers in Nginx +- Auto-restart on failure + +## π€ AI Declaration + +- **Google Antigravity (Gemini)**: Scripts, Docker config, LaTeX docs, diagram +- All code reviewed and adapted by student + +--- + +*Technical University of KoΕ‘ice β KEMT FEI β Cloud Technologies β 2026* diff --git a/sk1/api/Dockerfile b/sk1/api/Dockerfile new file mode 100644 index 0000000..06f182d --- /dev/null +++ b/sk1/api/Dockerfile @@ -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"] diff --git a/sk1/api/db.js b/sk1/api/db.js new file mode 100644 index 0000000..670ca9e --- /dev/null +++ b/sk1/api/db.js @@ -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; diff --git a/sk1/api/package.json b/sk1/api/package.json new file mode 100644 index 0000000..9dda847 --- /dev/null +++ b/sk1/api/package.json @@ -0,0 +1,15 @@ +{ + "name": "taskmanager-api", + "version": "1.0.0", + "description": "REST API for Task Manager - Cloud 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" + } +} diff --git a/sk1/api/server.js b/sk1/api/server.js new file mode 100644 index 0000000..f8f9352 --- /dev/null +++ b/sk1/api/server.js @@ -0,0 +1,173 @@ +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()); + +// Request logging middleware +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log( + `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms - ${req.ip}` + ); + }); + next(); +}); + +// 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(); diff --git a/sk1/backup-db.sh b/sk1/backup-db.sh new file mode 100755 index 0000000..3406491 --- /dev/null +++ b/sk1/backup-db.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# ============================================================================= +# backup-db.sh β Backup PostgreSQL from the Azure VM +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env" + +LOC="${AZURE_LOCATION:-westeurope}" +DNS="${AZURE_DNS_LABEL:-taskmanager-gs699he}" +FQDN="${DNS}.${LOC}.cloudapp.azure.com" +SSH_KEY="$HOME/.ssh/sk1_taskmanager" +ADMIN="azureuser" + +BACKUP_DIR="$SCRIPT_DIR/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="taskmanager_backup_${TIMESTAMP}.sql" + +echo "=============================================" +echo " Database Backup β Task Manager" +echo " Host: $FQDN" +echo "=============================================" + +mkdir -p "$BACKUP_DIR" + +echo " Creating database dump..." +ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" "$ADMIN@$FQDN" \ + "sudo docker exec taskapp-postgres pg_dump -U ${POSTGRES_USER:-taskuser} ${POSTGRES_DB:-taskmanager}" \ + > "$BACKUP_DIR/$BACKUP_FILE" + +if [ -s "$BACKUP_DIR/$BACKUP_FILE" ]; then + SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1) + echo " β Saved: $BACKUP_DIR/$BACKUP_FILE ($SIZE)" +else + echo " β Backup failed" + rm -f "$BACKUP_DIR/$BACKUP_FILE" + exit 1 +fi +echo "=============================================" diff --git a/sk1/caddy/Caddyfile b/sk1/caddy/Caddyfile new file mode 100644 index 0000000..28135eb --- /dev/null +++ b/sk1/caddy/Caddyfile @@ -0,0 +1,21 @@ +{$DOMAIN_NAME} { + # Reverse proxy to Nginx frontend + reverse_proxy frontend:80 + + # Access logging + log { + output file /data/access.log { + roll_size 10mb + roll_keep 5 + } + format json + } + + # Security headers + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + } +} diff --git a/sk1/cloud-init.yaml b/sk1/cloud-init.yaml new file mode 100644 index 0000000..0441e2a --- /dev/null +++ b/sk1/cloud-init.yaml @@ -0,0 +1,14 @@ +#cloud-config +package_update: true +packages: + - ca-certificates + - curl + - gnupg + - lsb-release + +runcmd: + - curl -fsSL https://get.docker.com | sh + - systemctl enable docker + - systemctl start docker + - usermod -aG docker azureuser + - mkdir -p /opt/taskmanager diff --git a/sk1/db/init.sql b/sk1/db/init.sql new file mode 100644 index 0000000..abc445d --- /dev/null +++ b/sk1/db/init.sql @@ -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 deployed to the cloud. 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); diff --git a/sk1/docker-compose.yaml b/sk1/docker-compose.yaml new file mode 100644 index 0000000..606ffe8 --- /dev/null +++ b/sk1/docker-compose.yaml @@ -0,0 +1,125 @@ +services: + # ========================================================== + # Caddy β Reverse Proxy with Automatic HTTPS + # Provisions Let's Encrypt TLS certificates automatically. + # ========================================================== + caddy: + image: caddy:2-alpine + container_name: taskapp-caddy + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + - access_logs:/data + environment: + - DOMAIN_NAME=${DOMAIN_NAME:-localhost} + depends_on: + - frontend + networks: + - frontend-net + + # ========================================================== + # Frontend β Nginx serving static files + reverse proxy to API + # ========================================================== + frontend: + build: ./frontend + container_name: taskapp-frontend + restart: always + expose: + - "80" + depends_on: + - api + networks: + - frontend-net + + # ========================================================== + # API β Node.js Express REST Backend + # ========================================================== + api: + build: ./api + container_name: taskapp-api + restart: always + expose: + - "3000" + environment: + - PORT=3000 + - POSTGRES_HOST=${POSTGRES_HOST:-postgres} + - POSTGRES_PORT=${POSTGRES_PORT:-5432} + - POSTGRES_DB=${POSTGRES_DB:-taskmanager} + - POSTGRES_USER=${POSTGRES_USER:-taskuser} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-taskpass} + - REDIS_HOST=${REDIS_HOST:-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - frontend-net + - backend-net + + # ========================================================== + # PostgreSQL β Primary persistent database + # ========================================================== + postgres: + image: postgres:16-alpine + container_name: taskapp-postgres + restart: always + environment: + - POSTGRES_DB=${POSTGRES_DB:-taskmanager} + - POSTGRES_USER=${POSTGRES_USER:-taskuser} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-taskpass} + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskuser} -d ${POSTGRES_DB:-taskmanager}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - backend-net + + # ========================================================== + # Redis β In-memory cache for fast API responses + # ========================================================== + redis: + image: redis:7-alpine + container_name: taskapp-redis + restart: always + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend-net + +# Named volumes for data persistence +volumes: + pgdata: + name: taskapp-pgdata + redisdata: + name: taskapp-redisdata + caddy_data: + name: taskapp-caddy-data + caddy_config: + name: taskapp-caddy-config + access_logs: + name: taskapp-access-logs + +# Virtual networks for service isolation +networks: + frontend-net: + name: taskapp-frontend-net + backend-net: + name: taskapp-backend-net diff --git a/sk1/docs/architecture.png b/sk1/docs/architecture.png new file mode 100644 index 0000000..baa90ed Binary files /dev/null and b/sk1/docs/architecture.png differ diff --git a/sk1/docs/documentation.pdf b/sk1/docs/documentation.pdf new file mode 100644 index 0000000..21c8d17 Binary files /dev/null and b/sk1/docs/documentation.pdf differ diff --git a/sk1/docs/documentation.tex b/sk1/docs/documentation.tex new file mode 100644 index 0000000..02909a6 --- /dev/null +++ b/sk1/docs/documentation.tex @@ -0,0 +1,361 @@ +\documentclass[12pt,a4paper]{article} + +% ============================================================ +% Packages +% ============================================================ +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage[margin=2.5cm]{geometry} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{xcolor} +\usepackage{listings} +\usepackage{booktabs} +\usepackage{enumitem} +\usepackage{fancyhdr} +\usepackage{titlesec} +\usepackage{float} +\usepackage{longtable} +\usepackage{caption} +\usepackage{textcomp} + +\setlength{\headheight}{14pt} +\addtolength{\topmargin}{-2pt} + +% ============================================================ +% Colors and Styling +% ============================================================ +\definecolor{azureblue}{HTML}{0078D4} +\definecolor{codegray}{HTML}{F5F5F7} +\definecolor{textdark}{HTML}{1D1D1F} +\definecolor{linkblue}{HTML}{0071E3} + +\hypersetup{ + colorlinks=true, + linkcolor=azureblue, + urlcolor=linkblue, + citecolor=azureblue, + pdftitle={Task Manager -- Cloud Deployment Documentation}, + pdfauthor={Gopi Suvanam} +} + +\lstset{ + backgroundcolor=\color{codegray}, + basicstyle=\ttfamily\small, + breaklines=true, + frame=single, + rulecolor=\color{gray!30}, + captionpos=b, + tabsize=2, + showstringspaces=false, + keywordstyle=\color{azureblue}\bfseries, + commentstyle=\color{gray}, + stringstyle=\color{green!50!black} +} + +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{\small\textcolor{gray}{Task Manager -- Cloud Deployment}} +\fancyhead[R]{\small\textcolor{gray}{SK1 Final Exam}} +\fancyfoot[C]{\thepage} +\renewcommand{\headrulewidth}{0.4pt} + +\titleformat{\section}{\Large\bfseries\color{azureblue}}{}{0em}{}[\vspace{-0.5em}\textcolor{azureblue}{\rule{\textwidth}{0.5pt}}] +\titleformat{\subsection}{\large\bfseries\color{textdark}}{}{0em}{} + +% ============================================================ +\begin{document} +% ============================================================ + +% ---- Title Page ------------------------------------------- +\begin{titlepage} +\centering +\vspace*{3cm} +{\Huge\bfseries\color{azureblue} Task Manager}\\[0.5cm] +{\LARGE Cloud Deployment Documentation}\\[2cm] + +\begin{tabular}{rl} + \textbf{Course:} & Cloud Technologies / Web Application Deployment \\ + \textbf{Student:} & Gopi Suvanam \\ + \textbf{Login:} & gs699he \\ + \textbf{Date:} & \today \\ + \textbf{Cloud:} & Microsoft Azure (Azure for Students) \\ + \textbf{Repository:} & \url{https://git.kemt.fei.tuke.sk/gs699he/zkt25} \\ +\end{tabular} + +\vspace{3cm} +\includegraphics[width=0.65\textwidth]{architecture.png} +\vfill +{\small Technical University of Ko\v{s}ice -- KEMT FEI} +\end{titlepage} + +\tableofcontents +\newpage + +% ============================================================= +\section{Application Overview} +% ============================================================= + +The \textbf{Task Manager} is a full-stack web application for creating, reading, updating, and deleting (CRUD) tasks. It is deployed to \textbf{Microsoft Azure} using Docker Compose on an Azure Virtual Machine. + +\subsection{Key Features} +\begin{itemize}[nosep] + \item RESTful API with full CRUD operations + \item Apple-inspired premium UI with glassmorphism + \item Redis caching (30s TTL) for API optimization + \item PostgreSQL database with persistent volume and backup scripts + \item Automatic HTTPS via Caddy + Let's Encrypt + \item Fully automated deployment via shell scripts + \item Auto-restart on container failure +\end{itemize} + +% ============================================================= +\section{Architecture} +% ============================================================= + +The application uses a \textbf{5-container microservices architecture} on an Azure VM, orchestrated by Docker Compose. + +\begin{figure}[H] + \centering + \includegraphics[width=0.72\textwidth]{architecture.png} + \caption{Architecture -- 5 Docker containers on Azure VM} + \label{fig:architecture} +\end{figure} + +\subsection{Container Descriptions} + +\begin{longtable}{@{}p{2.8cm}p{3cm}p{7.7cm}@{}} +\toprule +\textbf{Container} & \textbf{Image} & \textbf{Purpose} \\ +\midrule +\endhead +Caddy & caddy:2-alpine & Reverse proxy with automatic HTTPS/TLS via Let's Encrypt. Ports 80, 443. \\[6pt] +Frontend & nginx:alpine & Serves static HTML/CSS/JS, proxies \texttt{/api/} to backend. \\[6pt] +API & node:20-alpine & Express.js REST API with CRUD endpoints. \\[6pt] +PostgreSQL & postgres:16-alpine & Persistent database with Docker named volume. \\[6pt] +Redis & redis:7-alpine & In-memory cache with AOF persistence. \\ +\bottomrule +\end{longtable} + +\subsection{Network Isolation} + +\begin{itemize}[nosep] + \item \texttt{frontend-net} -- Caddy, Frontend, API + \item \texttt{backend-net} -- API, PostgreSQL, Redis +\end{itemize} + +Database and cache are \textbf{not accessible} from the internet. Only the API bridges both networks. + +% ============================================================= +\section{Azure Services Used} +% ============================================================= + +\begin{longtable}{@{}p{4.5cm}p{9cm}@{}} +\toprule +\textbf{Service} & \textbf{Usage} \\ +\midrule +\endhead +Azure VM (Standard\_B1s) & Ubuntu 24.04, 1 vCPU, 1 GB RAM. Runs Docker. \\[6pt] +Public IP + DNS & \texttt{*.westeurope.cloudapp.azure.com} for HTTPS. \\[6pt] +Azure for Students & \$100 free credit, no credit card required. \\[6pt] +Let's Encrypt & Free TLS certificates via Caddy (auto-renewal). \\[6pt] +cloud-init & Installs Docker on first VM boot automatically. \\ +\bottomrule +\end{longtable} + +% ============================================================= +\section{File Descriptions} +% ============================================================= + +\begin{longtable}{@{}p{4.5cm}p{9cm}@{}} +\toprule +\textbf{File} & \textbf{Description} \\ +\midrule +\endhead +\texttt{docker-compose.yaml} & 5 services, 2 networks, persistent volumes. \\[4pt] +\texttt{prepare-app.sh} & Creates Azure VM, installs Docker, deploys app. \\[4pt] +\texttt{remove-app.sh} & Deletes Azure resource group (all resources). \\[4pt] +\texttt{backup-db.sh} & SSH + pg\_dump for database backups. \\[4pt] +\texttt{view-logs.sh} & Retrieves container logs via SSH. \\[4pt] +\texttt{cloud-init.yaml} & Auto-installs Docker on VM first boot. \\[4pt] +\texttt{env.example} & Template for secrets (copied to .env). \\[4pt] +\texttt{caddy/Caddyfile} & HTTPS reverse proxy configuration. \\[4pt] +\texttt{frontend/} & Nginx Dockerfile, config, static files. \\[4pt] +\texttt{api/} & Node.js Dockerfile, Express server, DB pool. \\[4pt] +\texttt{db/init.sql} & Database schema and sample data. \\[4pt] +\texttt{docs/} & This documentation + architecture diagram. \\ +\bottomrule +\end{longtable} + +% ============================================================= +\section{Configuration} +% ============================================================= + +All settings via environment variables in \texttt{.env} (gitignored). + +\begin{longtable}{@{}p{4.5cm}p{4.5cm}p{4.5cm}@{}} +\toprule +\textbf{Variable} & \textbf{Description} & \textbf{Default} \\ +\midrule +\endhead +\texttt{POSTGRES\_PASSWORD} & Database password & \textit{(required)} \\ +\texttt{AZURE\_RESOURCE\_GROUP} & Resource group name & taskmanager-rg \\ +\texttt{AZURE\_LOCATION} & Azure region & westeurope \\ +\texttt{AZURE\_VM\_SIZE} & VM size & Standard\_B1s \\ +\texttt{AZURE\_DNS\_LABEL} & DNS subdomain & taskmanager-gs699he \\ +\bottomrule +\end{longtable} + +\subsection{Security} +\begin{itemize}[nosep] + \item \texttt{.env} in \texttt{.gitignore} -- secrets never in Git + \item Databases on isolated Docker network + \item HTTPS enforced by Caddy (HTTP $\rightarrow$ HTTPS redirect) + \item Security headers in Nginx + \item SSH key authentication (no password login) +\end{itemize} + +% ============================================================= +\section{Deployment Instructions} +% ============================================================= + +\subsection{Prerequisites} +\begin{enumerate}[nosep] + \item Azure CLI: \texttt{curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash} + \item Azure for Students subscription activated + \item \texttt{az login} completed +\end{enumerate} + +\subsection{Deploy} +\begin{lstlisting}[language=bash] +# Clone and configure +git clone git@git.kemt.fei.tuke.sk:gs699he/zkt25.git +cd zkt25/sk1 +cp env.example .env +nano .env # Set POSTGRES_PASSWORD + +# Deploy to Azure +./prepare-app.sh +# Creates: Resource Group -> VM -> Docker -> Containers + +# Access: +# https://taskmanager-gs699he.westeurope.cloudapp.azure.com +\end{lstlisting} + +\subsection{Remove (after exam)} +\begin{lstlisting}[language=bash] +./remove-app.sh +# Deletes entire resource group (VM, IP, disk, NSG) +\end{lstlisting} + +% ============================================================= +\section{Data Persistence and Backups} +% ============================================================= + +\subsection{Volumes} +\begin{itemize}[nosep] + \item \texttt{taskapp-pgdata} -- PostgreSQL data + \item \texttt{taskapp-redisdata} -- Redis AOF + \item \texttt{taskapp-caddy-data} -- TLS certificates +\end{itemize} + +\subsection{Backup} +\begin{lstlisting}[language=bash] +./backup-db.sh +# Output: backups/taskmanager_backup_20260519_020000.sql +\end{lstlisting} + +\subsection{Auto-Restart} +All containers: \texttt{restart: always}. Docker starts on boot via systemd. + +% ============================================================= +\section{Access Logs} +% ============================================================= + +\begin{lstlisting}[language=bash] +./view-logs.sh # All logs +./view-logs.sh --caddy # HTTPS proxy logs +./view-logs.sh --api -f # Follow API logs real-time +\end{lstlisting} + +Log sources: +\begin{itemize}[nosep] + \item \textbf{Caddy}: JSON -- client IP, status, TLS version + \item \textbf{API}: \texttt{METHOD /path STATUS duration} + \item \textbf{Nginx}: Combined log format +\end{itemize} + +% ============================================================= +\section{Cost Analysis} +% ============================================================= + +\subsection{Azure for Students -- Monthly Costs} + +\begin{longtable}{@{}p{5cm}p{5cm}r@{}} +\toprule +\textbf{Resource} & \textbf{Configuration} & \textbf{Monthly} \\ +\midrule +\endhead +Azure VM & Standard\_B1s (1 vCPU, 1 GB) & \$7.59 \\ +Public IP & Static IPv4 & \$3.00 \\ +OS Disk & 30 GB Premium SSD & \$1.20 \\ +TLS Certificate & Let's Encrypt (free) & \$0.00 \\ +\midrule +\textbf{Total} & & \textbf{\$11.79} \\ +\textbf{Annual} & & \textbf{\$141.48} \\ +\bottomrule +\end{longtable} + +Azure for Students provides \$100 free credit -- enough for approximately 8 months of operation. + +\subsection{Comparison} +\begin{longtable}{@{}p{4cm}p{5cm}r@{}} +\toprule +\textbf{Provider} & \textbf{Plan} & \textbf{Monthly} \\ +\midrule +\endhead +Azure (Students) & B1s + \$100 credit & \$11.79 \\ +AWS & t3.micro (free 12mo) & \$8.50 \\ +DigitalOcean & Basic Droplet 2 GB & \$12.00 \\ +Hostinger & KVM 1 (4 GB) & \$5.99 \\ +\bottomrule +\end{longtable} + +% ============================================================= +\section{AI Usage Declaration} +% ============================================================= + +\begin{itemize}[nosep] + \item \textbf{Google Antigravity (Gemini)}: Generated scripts, Docker Compose, Nginx/Caddy config, and this documentation. + \item \textbf{Architecture diagram}: AI-generated from technical specification. +\end{itemize} + +All content reviewed, tested, and adapted. Student understands all components. + +% ============================================================= +\section{Exam Defense Preparation} +% ============================================================= + +\textbf{Q: How does HTTPS work?}\\ +A: Caddy uses the ACME protocol to automatically get a free Let's Encrypt TLS certificate for the Azure DNS hostname. It renews every 90 days. + +\textbf{Q: What if a container crashes?}\\ +A: \texttt{restart: always} in docker-compose.yaml. Docker auto-restarts failed containers. Docker starts on boot via systemd. + +\textbf{Q: How are secrets managed?}\\ +A: \texttt{.env} file (gitignored). Docker Compose injects vars into containers. \texttt{env.example} has template without values. + +\textbf{Q: How do you back up the database?}\\ +A: \texttt{backup-db.sh} runs \texttt{pg\_dump} via SSH inside the PostgreSQL container. SQL dump saved locally. + +\textbf{Q: Can deployment be reproduced?}\\ +A: Yes. \texttt{prepare-app.sh} creates all Azure resources from scratch via CLI. \texttt{remove-app.sh} deletes everything. No web UI needed. + +\textbf{Q: Why 5 containers?}\\ +A: Separation of concerns. Caddy handles HTTPS, Nginx serves static files, Node.js handles API logic, PostgreSQL stores data, Redis caches queries. Each can be scaled independently. + +\textbf{Q: How does caching work?}\\ +A: Redis caches \texttt{GET /api/tasks} for 30 seconds. Write operations invalidate the cache. + +\end{document} diff --git a/sk1/env.example b/sk1/env.example new file mode 100644 index 0000000..1c6de0a --- /dev/null +++ b/sk1/env.example @@ -0,0 +1,23 @@ +# ============================================= +# Environment Variables for Task Manager +# Copy this file to .env and fill in your values +# DO NOT COMMIT .env TO GIT +# ============================================= + +# ---- PostgreSQL ---- +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=taskmanager +POSTGRES_USER=taskuser +POSTGRES_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD + +# ---- Redis ---- +REDIS_HOST=redis +REDIS_PORT=6379 + +# ---- Azure Configuration (used by prepare-app.sh) ---- +AZURE_RESOURCE_GROUP=taskmanager-rg +AZURE_LOCATION=westeurope +AZURE_VM_NAME=taskmanager-vm +AZURE_VM_SIZE=Standard_B1s +AZURE_DNS_LABEL=taskmanager-gs699he diff --git a/sk1/frontend/Dockerfile b/sk1/frontend/Dockerfile new file mode 100644 index 0000000..0301567 --- /dev/null +++ b/sk1/frontend/Dockerfile @@ -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;"] diff --git a/sk1/frontend/nginx.conf b/sk1/frontend/nginx.conf new file mode 100644 index 0000000..d135188 --- /dev/null +++ b/sk1/frontend/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name _; + + # Access log format with timestamps and client IPs + access_log /var/log/nginx/access.log combined; + + # 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 + location /api/ { + proxy_pass http://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; +} diff --git a/sk1/frontend/public/app.js b/sk1/frontend/public/app.js new file mode 100644 index 0000000..1b61040 --- /dev/null +++ b/sk1/frontend/public/app.js @@ -0,0 +1,234 @@ +// ===== Task Manager Frontend Application ===== + +const API_BASE = '/api'; +let tasks = []; +let currentFilter = 'all'; + +// ===== DOM Elements ===== +const taskList = document.getElementById('tasks-container'); +const addTaskForm = document.getElementById('task-form'); +const taskTitleInput = document.getElementById('title'); +const taskDescInput = document.getElementById('description'); +const filterTabs = document.querySelectorAll('.filter-tab'); +const toast = document.getElementById('toast'); +const totalCountEl = document.getElementById('total-tasks-count'); + +// ===== 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 || []; + 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; + + if (totalCountEl) totalCountEl.textContent = total; + + if (filtered.length === 0) { + const msg = tasks.length > 0 + ? `No ${currentFilter} tasks.` + : 'No tasks yet. Add your first task above!'; + + taskList.innerHTML = ` +
${msg}
+Unable to connect to the server.
+