750 lines
28 KiB
Bash
750 lines
28 KiB
Bash
#!/usr/bin/env bash
|
||
# =============================================================================
|
||
# setup.sh — TaskFlow — Azure App Service + PostgreSQL Flexible Server
|
||
#
|
||
# Architecture:
|
||
# - Azure App Service (Free F1) — runs the Flask container
|
||
# - Azure Database for PostgreSQL Flexible Server (Burstable B1ms)
|
||
# - Azure Container Registry — stores the Docker image
|
||
# - HTTPS via built-in azurewebsites.net certificate (free, automatic)
|
||
#
|
||
# Usage:
|
||
# export CERT_EMAIL="you@student.tuke.sk"
|
||
# export DB_PASSWORD="YourStrongPassword123!"
|
||
# chmod +x setup.sh && ./setup.sh
|
||
# =============================================================================
|
||
set -euo pipefail
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
RG="taskflow-rg"
|
||
LOCATION="francecentral"
|
||
ACR_NAME="taskflowregistry${RANDOM}"
|
||
APP_PLAN="taskflow-plan"
|
||
APP_NAME="taskflow-${RANDOM}"
|
||
DB_SERVER="taskflow-db-${RANDOM}"
|
||
DB_USER="taskuser"
|
||
DB_NAME="taskdb"
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'
|
||
BOLD='\033[1m'; RESET='\033[0m'; RED='\033[0;31m'
|
||
step() { echo -e "\n${CYAN}${BOLD}[SETUP]${RESET} $*"; }
|
||
ok() { echo -e " ${GREEN}✓${RESET} $*"; }
|
||
warn() { echo -e " ${YELLOW}!${RESET} $*"; }
|
||
die() { echo -e "\n${RED}${BOLD}[ERROR]${RESET} $*" >&2; exit 1; }
|
||
|
||
echo -e "\n${BOLD}========================================"
|
||
echo -e " TaskFlow — Azure App Service"
|
||
echo -e "========================================${RESET}"
|
||
echo -e " Resource Group : ${RG}"
|
||
echo -e " Location : ${LOCATION}"
|
||
echo -e " App Service : ${APP_NAME}.azurewebsites.net"
|
||
echo -e "${BOLD}========================================${RESET}\n"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 0. Validate
|
||
# ---------------------------------------------------------------------------
|
||
step "Checking prerequisites ..."
|
||
command -v az &>/dev/null || die "Azure CLI not found."
|
||
command -v docker &>/dev/null || die "Docker not found."
|
||
[[ -z "${DB_PASSWORD:-}" ]] && die "DB_PASSWORD not set.\n export DB_PASSWORD=YourPassword123!"
|
||
az account show &>/dev/null || die "Not logged in. Run: az login"
|
||
ok "All prerequisites met"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Write all application files
|
||
# ---------------------------------------------------------------------------
|
||
step "Writing application files ..."
|
||
mkdir -p "${SCRIPT_DIR}/app" "${SCRIPT_DIR}/db"
|
||
|
||
cat > "${SCRIPT_DIR}/app/app.py" << 'EOF'
|
||
"""TaskFlow — Flask task management application."""
|
||
import os
|
||
import psycopg2
|
||
import psycopg2.extras
|
||
from flask import Flask, request, redirect, url_for, render_template_string, jsonify
|
||
|
||
app = Flask(__name__)
|
||
|
||
def get_db():
|
||
return psycopg2.connect(
|
||
host=os.environ.get("POSTGRES_HOST"),
|
||
port=int(os.environ.get("POSTGRES_PORT", 5432)),
|
||
user=os.environ["POSTGRES_USER"],
|
||
password=os.environ["POSTGRES_PASSWORD"],
|
||
dbname=os.environ["POSTGRES_DB"],
|
||
sslmode="require"
|
||
)
|
||
|
||
@app.route("/health")
|
||
def health():
|
||
try:
|
||
conn = get_db(); conn.close()
|
||
return jsonify({"status": "ok"}), 200
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "detail": str(e)}), 500
|
||
|
||
TEMPLATE = """
|
||
<!doctype html><html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>TaskFlow</title>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:system-ui,sans-serif;background:#f5f5f5;color:#222;padding:2rem}
|
||
h1{font-size:1.8rem;margin-bottom:1.5rem;color:#1a1a2e}
|
||
.board{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:2rem}
|
||
.column{background:#fff;border-radius:8px;padding:1rem;border:1px solid #e0e0e0}
|
||
.column h2{font-size:1rem;text-transform:uppercase;letter-spacing:.05em;color:#555;
|
||
margin-bottom:.75rem;border-bottom:2px solid;padding-bottom:.4rem}
|
||
.col-todo h2{border-color:#6c757d}
|
||
.col-progress h2{border-color:#0d6efd}
|
||
.col-done h2{border-color:#198754}
|
||
.task{background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;
|
||
padding:.75rem;margin-bottom:.5rem}
|
||
.task h3{font-size:.95rem;margin-bottom:.25rem}
|
||
.task p{font-size:.8rem;color:#666;margin-bottom:.5rem}
|
||
.task-actions{display:flex;gap:.5rem;flex-wrap:wrap}
|
||
.btn{padding:.25rem .6rem;border:none;border-radius:4px;cursor:pointer;
|
||
font-size:.8rem;display:inline-block}
|
||
.btn-advance{background:#0d6efd;color:#fff}
|
||
.btn-delete{background:#dc3545;color:#fff}
|
||
form.new-task{background:#fff;border:1px solid #e0e0e0;border-radius:8px;
|
||
padding:1.25rem;max-width:520px}
|
||
form.new-task h2{margin-bottom:1rem;font-size:1.1rem}
|
||
label{display:block;font-size:.85rem;color:#444;margin-bottom:.25rem}
|
||
input[type=text],textarea{width:100%;padding:.5rem .75rem;border:1px solid #ccc;
|
||
border-radius:5px;font-size:.9rem;margin-bottom:.75rem}
|
||
textarea{resize:vertical;min-height:70px}
|
||
.btn-submit{background:#198754;color:#fff;padding:.5rem 1.25rem;font-size:.95rem}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>📋 TaskFlow</h1>
|
||
<div class="board">
|
||
{% for col,label,css in [('todo','To Do','col-todo'),
|
||
('in_progress','In Progress','col-progress'),
|
||
('done','Done','col-done')] %}
|
||
<div class="column {{css}}">
|
||
<h2>{{label}} ({{tasks[col]|length}})</h2>
|
||
{% for t in tasks[col] %}
|
||
<div class="task">
|
||
<h3>{{t.title}}</h3>
|
||
{% if t.description %}<p>{{t.description}}</p>{% endif %}
|
||
<div class="task-actions">
|
||
{% if col != 'done' %}
|
||
<form method="post" action="/advance/{{t.id}}">
|
||
<button class="btn btn-advance">▶ Advance</button>
|
||
</form>
|
||
{% endif %}
|
||
<form method="post" action="/delete/{{t.id}}">
|
||
<button class="btn btn-delete">✕ Delete</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<form class="new-task" method="post" action="/create">
|
||
<h2>New Task</h2>
|
||
<label>Title</label>
|
||
<input type="text" name="title" required placeholder="What needs to be done?">
|
||
<label>Description (optional)</label>
|
||
<textarea name="description" placeholder="More details..."></textarea>
|
||
<button type="submit" class="btn btn-submit">Add Task</button>
|
||
</form>
|
||
</body></html>
|
||
"""
|
||
|
||
NEXT_STATUS = {"todo": "in_progress", "in_progress": "done"}
|
||
|
||
@app.route("/")
|
||
def index():
|
||
conn = get_db()
|
||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||
cur.execute("SELECT * FROM tasks ORDER BY created_at")
|
||
rows = cur.fetchall()
|
||
cur.close(); conn.close()
|
||
tasks = {"todo": [], "in_progress": [], "done": []}
|
||
for row in rows:
|
||
tasks[row["status"]].append(row)
|
||
return render_template_string(TEMPLATE, tasks=tasks)
|
||
|
||
@app.route("/create", methods=["POST"])
|
||
def create():
|
||
title = request.form.get("title", "").strip()
|
||
desc = request.form.get("description", "").strip()
|
||
if title:
|
||
conn = get_db(); cur = conn.cursor()
|
||
cur.execute(
|
||
"INSERT INTO tasks (title, description, status) VALUES (%s, %s, 'todo')",
|
||
(title, desc or None))
|
||
conn.commit(); cur.close(); conn.close()
|
||
return redirect(url_for("index"))
|
||
|
||
@app.route("/advance/<int:task_id>", methods=["POST"])
|
||
def advance(task_id):
|
||
conn = get_db(); cur = conn.cursor()
|
||
cur.execute("SELECT status FROM tasks WHERE id = %s", (task_id,))
|
||
row = cur.fetchone()
|
||
if row and row[0] in NEXT_STATUS:
|
||
cur.execute("UPDATE tasks SET status = %s WHERE id = %s",
|
||
(NEXT_STATUS[row[0]], task_id))
|
||
conn.commit()
|
||
cur.close(); conn.close()
|
||
return redirect(url_for("index"))
|
||
|
||
@app.route("/delete/<int:task_id>", methods=["POST"])
|
||
def delete(task_id):
|
||
conn = get_db(); cur = conn.cursor()
|
||
cur.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
|
||
conn.commit(); cur.close(); conn.close()
|
||
return redirect(url_for("index"))
|
||
|
||
if __name__ == "__main__":
|
||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||
EOF
|
||
ok "app/app.py"
|
||
|
||
cat > "${SCRIPT_DIR}/app/requirements.txt" << 'EOF'
|
||
flask==3.0.3
|
||
gunicorn==21.2.0
|
||
psycopg2-binary==2.9.9
|
||
EOF
|
||
ok "app/requirements.txt"
|
||
|
||
cat > "${SCRIPT_DIR}/app/Dockerfile" << 'EOF'
|
||
FROM python:3.11-slim
|
||
WORKDIR /app
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
COPY . .
|
||
RUN useradd -m appuser && chown -R appuser /app
|
||
USER appuser
|
||
EXPOSE 5000
|
||
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:5000", "--timeout", "60", "--access-logfile", "-", "app:app"]
|
||
EOF
|
||
ok "app/Dockerfile"
|
||
|
||
cat > "${SCRIPT_DIR}/db/init.sql" << 'EOF'
|
||
CREATE TABLE IF NOT EXISTS tasks (
|
||
id SERIAL PRIMARY KEY,
|
||
title VARCHAR(200) NOT NULL,
|
||
description TEXT,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'todo'
|
||
CHECK (status IN ('todo', 'in_progress', 'done')),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||
RETURNS TRIGGER AS $$
|
||
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
DROP TRIGGER IF EXISTS set_updated_at ON tasks;
|
||
CREATE TRIGGER set_updated_at
|
||
BEFORE UPDATE ON tasks
|
||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||
|
||
INSERT INTO tasks (title, description, status) VALUES
|
||
('Set up Azure resources', 'Create App Service and PostgreSQL Flexible Server', 'done'),
|
||
('Build Docker image', 'Push image to Azure Container Registry', 'done'),
|
||
('Configure App Service', 'Set environment variables and container settings', 'in_progress'),
|
||
('Enable HTTPS', 'Built-in on azurewebsites.net — automatic', 'todo'),
|
||
('Write README', 'Document deployment and cost analysis', 'todo')
|
||
ON CONFLICT DO NOTHING;
|
||
EOF
|
||
ok "db/init.sql"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# backup.sh
|
||
# ---------------------------------------------------------------------------
|
||
cat > "${SCRIPT_DIR}/backup.sh" << 'EOF'
|
||
#!/usr/bin/env bash
|
||
# backup.sh — TaskFlow — PostgreSQL backup and restore via Azure CLI
|
||
set -euo pipefail
|
||
|
||
BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backups"
|
||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||
BACKUP_FILE="${BACKUP_DIR}/taskflow-${TIMESTAMP}.sql"
|
||
|
||
# Load config from .env if present
|
||
[ -f "$(dirname "${BASH_SOURCE[0]}")/.env" ] && source "$(dirname "${BASH_SOURCE[0]}")/.env"
|
||
|
||
[[ -z "${POSTGRES_HOST:-}" ]] && echo "Set POSTGRES_HOST" && exit 1
|
||
[[ -z "${POSTGRES_USER:-}" ]] && echo "Set POSTGRES_USER" && exit 1
|
||
[[ -z "${POSTGRES_PASSWORD:-}" ]] && echo "Set POSTGRES_PASSWORD" && exit 1
|
||
[[ -z "${POSTGRES_DB:-}" ]] && echo "Set POSTGRES_DB" && exit 1
|
||
|
||
if [[ "${1:-}" == "--restore" ]]; then
|
||
FILE="${2:-}"; [[ -z "$FILE" ]] && echo "Usage: ./backup.sh --restore <file>" && exit 1
|
||
PGPASSWORD="${POSTGRES_PASSWORD}" psql \
|
||
-h "${POSTGRES_HOST}" -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
|
||
--set=sslmode=require < "$FILE"
|
||
echo "Restored from: $FILE"
|
||
exit 0
|
||
fi
|
||
|
||
mkdir -p "$BACKUP_DIR"
|
||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
|
||
-h "${POSTGRES_HOST}" -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
|
||
--clean --if-exists --no-password \
|
||
> "$BACKUP_FILE"
|
||
echo "Backup saved: $BACKUP_FILE"
|
||
ls -1t "$BACKUP_DIR"/taskflow-*.sql 2>/dev/null | tail -n +11 | xargs rm -f 2>/dev/null || true
|
||
EOF
|
||
chmod +x "${SCRIPT_DIR}/backup.sh"
|
||
ok "backup.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# .gitignore
|
||
# ---------------------------------------------------------------------------
|
||
cat > "${SCRIPT_DIR}/.gitignore" << 'EOF'
|
||
.env
|
||
backups/
|
||
__pycache__/
|
||
*.py[cod]
|
||
*.swp
|
||
.DS_Store
|
||
EOF
|
||
ok ".gitignore"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# .env.example
|
||
# ---------------------------------------------------------------------------
|
||
cat > "${SCRIPT_DIR}/.env.example" << 'EOF'
|
||
# Copy to .env and fill in real values — never commit .env to GIT
|
||
POSTGRES_HOST=taskflow-db-XXXXX.postgres.database.azure.com
|
||
POSTGRES_USER=taskuser
|
||
POSTGRES_PASSWORD=changeme
|
||
POSTGRES_DB=taskdb
|
||
EOF
|
||
ok ".env.example"
|
||
|
||
ok "All files written"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Azure infrastructure
|
||
# ---------------------------------------------------------------------------
|
||
step "Creating Resource Group '${RG}' in ${LOCATION} ..."
|
||
az group create --name "${RG}" --location "${LOCATION}" --output none
|
||
ok "Resource group ready"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Azure Container Registry
|
||
# ---------------------------------------------------------------------------
|
||
step "Creating Azure Container Registry '${ACR_NAME}' ..."
|
||
az acr create \
|
||
--resource-group "${RG}" \
|
||
--name "${ACR_NAME}" \
|
||
--sku Basic \
|
||
--admin-enabled true \
|
||
--output none
|
||
ok "ACR created: ${ACR_NAME}.azurecr.io"
|
||
|
||
step "Building and pushing Docker image ..."
|
||
az acr login --name "${ACR_NAME}"
|
||
docker build -t "${ACR_NAME}.azurecr.io/taskflow-web:latest" "${SCRIPT_DIR}/app"
|
||
docker push "${ACR_NAME}.azurecr.io/taskflow-web:latest"
|
||
ok "Image pushed to ACR"
|
||
|
||
step "Fetching ACR credentials ..."
|
||
for i in {1..12}; do
|
||
ACR_PASSWORD=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RG}" --query "passwords[0].value" -o tsv 2>/dev/null || true)
|
||
[[ -n "${ACR_PASSWORD}" ]] && break
|
||
echo " waiting for ACR to be ready (${i}/12)..."; sleep 10
|
||
done
|
||
[[ -z "${ACR_PASSWORD}" ]] && die "Could not fetch ACR credentials after 2 minutes."
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. PostgreSQL Flexible Server
|
||
# ---------------------------------------------------------------------------
|
||
step "Creating PostgreSQL Flexible Server '${DB_SERVER}' (takes ~3 min) ..."
|
||
az postgres flexible-server create \
|
||
--resource-group "${RG}" \
|
||
--name "${DB_SERVER}" \
|
||
--location "${LOCATION}" \
|
||
--admin-user "${DB_USER}" \
|
||
--admin-password "${DB_PASSWORD}" \
|
||
--sku-name "Standard_B1ms" \
|
||
--tier "Burstable" \
|
||
--storage-size 32 \
|
||
--version 15 \
|
||
--public-access "All" \
|
||
--output none
|
||
ok "PostgreSQL server created: ${DB_SERVER}.postgres.database.azure.com"
|
||
|
||
step "Creating database '${DB_NAME}' ..."
|
||
az postgres flexible-server db create \
|
||
--resource-group "${RG}" \
|
||
--server-name "${DB_SERVER}" \
|
||
--database-name "${DB_NAME}" \
|
||
--output none
|
||
ok "Database '${DB_NAME}' created"
|
||
|
||
step "Initialising database schema ..."
|
||
DB_HOST="${DB_SERVER}.postgres.database.azure.com"
|
||
PGPASSWORD="${DB_PASSWORD}" psql \
|
||
"host=${DB_HOST} user=${DB_USER} dbname=${DB_NAME} sslmode=require" \
|
||
-f "${SCRIPT_DIR}/db/init.sql"
|
||
ok "Schema and sample data loaded"
|
||
|
||
# Write .env file with real values
|
||
cat > "${SCRIPT_DIR}/.env" << EOF
|
||
POSTGRES_HOST=${DB_HOST}
|
||
POSTGRES_USER=${DB_USER}
|
||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||
POSTGRES_DB=${DB_NAME}
|
||
EOF
|
||
ok ".env written with database credentials"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. App Service Plan + Web App
|
||
# ---------------------------------------------------------------------------
|
||
step "Creating App Service Plan (Linux, Free F1) ..."
|
||
az appservice plan create \
|
||
--name "${APP_PLAN}" \
|
||
--resource-group "${RG}" \
|
||
--location "${LOCATION}" \
|
||
--sku B1 \
|
||
--is-linux \
|
||
--output none
|
||
ok "App Service Plan ready"
|
||
|
||
step "Creating Web App '${APP_NAME}' with container ..."
|
||
az webapp create \
|
||
--resource-group "${RG}" \
|
||
--plan "${APP_PLAN}" \
|
||
--name "${APP_NAME}" \
|
||
--container-image-name "${ACR_NAME}.azurecr.io/taskflow-web:latest" \
|
||
--container-registry-url "https://${ACR_NAME}.azurecr.io" \
|
||
--container-registry-user "${ACR_NAME}" \
|
||
--container-registry-password "${ACR_PASSWORD}" \
|
||
--output none
|
||
ok "Web App created"
|
||
|
||
step "Configuring environment variables (secrets) ..."
|
||
az webapp config appsettings set \
|
||
--resource-group "${RG}" \
|
||
--name "${APP_NAME}" \
|
||
--settings \
|
||
POSTGRES_HOST="${DB_HOST}" \
|
||
POSTGRES_USER="${DB_USER}" \
|
||
POSTGRES_PASSWORD="${DB_PASSWORD}" \
|
||
POSTGRES_DB="${DB_NAME}" \
|
||
POSTGRES_PORT="5432" \
|
||
FLASK_ENV="production" \
|
||
WEBSITES_PORT="5000" \
|
||
--output none
|
||
ok "Environment variables configured"
|
||
|
||
step "Enabling HTTPS only ..."
|
||
az webapp update \
|
||
--resource-group "${RG}" \
|
||
--name "${APP_NAME}" \
|
||
--https-only true \
|
||
--output none
|
||
ok "HTTPS enforced"
|
||
|
||
step "Restarting web app ..."
|
||
az webapp restart --resource-group "${RG}" --name "${APP_NAME}" --output none
|
||
ok "Web app restarted"
|
||
|
||
APP_URL="https://${APP_NAME}.azurewebsites.net"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Write prepare-app.sh and remove-app.sh
|
||
# ---------------------------------------------------------------------------
|
||
cat > "${SCRIPT_DIR}/prepare-app.sh" << PREPEOF
|
||
#!/usr/bin/env bash
|
||
# prepare-app.sh — Re-deploy TaskFlow to Azure App Service
|
||
# Run setup.sh for first-time deployment.
|
||
# Use this to rebuild and redeploy the container after code changes.
|
||
set -euo pipefail
|
||
source "\$(dirname "\${BASH_SOURCE[0]}")/.env"
|
||
RG="taskflow-rg"
|
||
ACR_NAME="${ACR_NAME}"
|
||
APP_NAME="${APP_NAME}"
|
||
SCRIPT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
echo "Rebuilding and redeploying TaskFlow..."
|
||
az acr login --name "\${ACR_NAME}"
|
||
docker build -t "\${ACR_NAME}.azurecr.io/taskflow-web:latest" "\${SCRIPT_DIR}/app"
|
||
docker push "\${ACR_NAME}.azurecr.io/taskflow-web:latest"
|
||
az webapp restart --resource-group "\${RG}" --name "\${APP_NAME}" --output none
|
||
echo "Done. App is live at: https://\${APP_NAME}.azurewebsites.net"
|
||
PREPEOF
|
||
chmod +x "${SCRIPT_DIR}/prepare-app.sh"
|
||
|
||
cat > "${SCRIPT_DIR}/remove-app.sh" << 'REMOVEEOF'
|
||
#!/usr/bin/env bash
|
||
# remove-app.sh — Delete all TaskFlow Azure resources
|
||
set -euo pipefail
|
||
RG="taskflow-rg"
|
||
echo "WARNING: This permanently deletes resource group '${RG}' and everything inside."
|
||
read -r -p "Type 'yes' to confirm: " CONFIRM
|
||
[[ "${CONFIRM}" != "yes" ]] && echo "Aborted." && exit 0
|
||
az group delete --name "${RG}" --yes --no-wait
|
||
echo "Deletion started. All TaskFlow Azure resources are being removed."
|
||
REMOVEEOF
|
||
chmod +x "${SCRIPT_DIR}/remove-app.sh"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Write README.md
|
||
# ---------------------------------------------------------------------------
|
||
cat > "${SCRIPT_DIR}/README.md" << READMEEOF
|
||
# TaskFlow — Cloud Deployment (sk1)
|
||
|
||
> A task management web application deployed on Microsoft Azure.
|
||
> Live at: **https://${APP_NAME}.azurewebsites.net**
|
||
|
||
---
|
||
|
||
## 1. What the Application Does
|
||
|
||
TaskFlow is a simple web-based task manager for individuals or small teams.
|
||
|
||
Users can:
|
||
- **Create** tasks with a title and optional description
|
||
- **Advance** tasks through three stages: \`To Do → In Progress → Done\`
|
||
- **Delete** tasks they no longer need
|
||
|
||
All data is stored in a managed PostgreSQL database and persists across restarts and redeployments.
|
||
|
||
---
|
||
|
||
## 2. Public Cloud and Infrastructure Description
|
||
|
||
### Cloud Provider
|
||
**Microsoft Azure** — Azure for Students subscription.
|
||
|
||
### Azure Services Used
|
||
|
||
| Service | Purpose |
|
||
|---|---|
|
||
| **Azure Container Registry (ACR)** — Basic | Stores the Docker image for the Flask app |
|
||
| **Azure App Service Plan** — Linux B1 | Managed hosting platform for the container |
|
||
| **Azure Web App** (container) | Runs the Flask app with built-in HTTPS and auto-restart |
|
||
| **Azure Database for PostgreSQL Flexible Server** — Burstable B1ms | Managed PostgreSQL 15 database with persistent storage |
|
||
|
||
### Components
|
||
|
||
| Component | Type | Role |
|
||
|---|---|---|
|
||
| \`taskflow-web\` | Docker container on App Service | Python 3.11 / Flask app served by Gunicorn |
|
||
| \`postgres\` | Azure managed database | PostgreSQL 15 — stores all tasks |
|
||
| \`taskflow-registry\` | Azure Container Registry | Private Docker image registry |
|
||
|
||
### How Traffic Flows
|
||
|
||
\`\`\`
|
||
Browser (HTTPS)
|
||
→ Azure App Service (built-in TLS, azurewebsites.net cert)
|
||
→ Flask container (Gunicorn :5000)
|
||
→ Azure PostgreSQL Flexible Server (:5432, SSL required)
|
||
→ Managed persistent storage (Azure-managed disk)
|
||
\`\`\`
|
||
|
||
### HTTPS / TLS
|
||
Azure App Service provides a **free, automatic, browser-trusted TLS certificate** for all \`*.azurewebsites.net\` domains. HTTPS-only mode is enforced — HTTP requests are automatically redirected to HTTPS.
|
||
|
||
### Persistent Storage
|
||
Data is stored in **Azure Database for PostgreSQL Flexible Server**. This is a fully managed service — Azure handles backups, high availability, and storage automatically. Data persists independently of the application container.
|
||
|
||
### Automatic Restart
|
||
Azure App Service automatically restarts the container if it crashes or exits. The platform monitors the \`/health\` endpoint and restarts unhealthy instances without manual intervention.
|
||
|
||
### Configuration
|
||
All secrets (database host, user, password) are stored as **App Service Application Settings** — Azure's equivalent of environment variables. They are injected into the container at runtime and never stored in code or GIT.
|
||
|
||
---
|
||
|
||
## 3. Cost Analysis — 1,000 Users/Day, 50 GB Data (Azure, 1 Year)
|
||
|
||
| Resource | Specification | Unit Price | Billing | Annual Cost |
|
||
|---|---|---|---|---|
|
||
| App Service Plan | Linux B1 (1 vCPU, 1.75 GB RAM) | ~\$13.14/month | Monthly | **~\$157.68** |
|
||
| PostgreSQL Flexible Server | Burstable B1ms (1 vCPU, 2 GB) | ~\$12.41/month | Monthly | **~\$148.92** |
|
||
| PostgreSQL Storage | 50 GB | ~\$5.76/month | Monthly | **~\$69.12** |
|
||
| Azure Container Registry | Basic tier | ~\$5.00/month | Monthly | **~\$60.00** |
|
||
| Outbound bandwidth | First 100 GB free, ~60 GB/month at 1k users | \$0 | Per GB | **~\$0** |
|
||
| HTTPS certificate | Free (azurewebsites.net) | \$0 | — | **\$0** |
|
||
| **Total** | | | | **≈ \$435.72 / year** |
|
||
|
||
> For the exam demo period the actual cost is ~\$35/month (B1 plan + B1ms PostgreSQL). Delete resources after the exam with \`remove-app.sh\` to stop billing.
|
||
|
||
---
|
||
|
||
## 4. Uploaded Files and Their Content
|
||
|
||
\`\`\`
|
||
sk1/
|
||
├── app/
|
||
│ ├── app.py Flask app — routes, DB connection, Kanban UI, /health endpoint
|
||
│ ├── Dockerfile Builds taskflow-web image (Python 3.11, Gunicorn, non-root)
|
||
│ └── requirements.txt Python deps: flask, gunicorn, psycopg2-binary
|
||
├── db/
|
||
│ └── init.sql SQL schema — tasks table, trigger, sample data
|
||
├── setup.sh Full deployment — writes files, creates all Azure resources
|
||
├── prepare-app.sh Rebuilds and redeploys container after code changes
|
||
├── remove-app.sh Deletes entire Azure resource group
|
||
├── backup.sh pg_dump backup and restore via psql
|
||
├── .env.example Template for required environment variables (no secrets)
|
||
├── .gitignore Excludes .env and backups from GIT
|
||
└── README.md This file
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 5. Configuration Description
|
||
|
||
### Secrets and Environment Variables
|
||
|
||
Secrets are stored as **Azure App Service Application Settings** — never in GIT.
|
||
|
||
| Variable | Where stored | Used by |
|
||
|---|---|---|
|
||
| \`POSTGRES_HOST\` | App Service settings | Flask app |
|
||
| \`POSTGRES_USER\` | App Service settings | Flask app |
|
||
| \`POSTGRES_PASSWORD\` | App Service settings | Flask app |
|
||
| \`POSTGRES_DB\` | App Service settings | Flask app |
|
||
| \`POSTGRES_PORT\` | App Service settings | Flask app |
|
||
| \`FLASK_ENV\` | App Service settings | Gunicorn |
|
||
| \`WEBSITES_PORT\` | App Service settings | Azure (tells it app runs on 5000) |
|
||
| \`DB_PASSWORD\` | Shell env var at deploy time | \`setup.sh\` only, not stored |
|
||
|
||
A local \`.env\` file is written during deployment for use by \`backup.sh\`. It is excluded from GIT by \`.gitignore\`.
|
||
|
||
---
|
||
|
||
## 6. How to View and Use the Application
|
||
|
||
### Prerequisites
|
||
- Azure CLI installed and \`az login\` completed
|
||
- Docker installed and running
|
||
- \`psql\` installed (for backup/restore and schema init)
|
||
- \`DB_PASSWORD\` environment variable set
|
||
|
||
### Deploy (first time — creates all Azure resources)
|
||
\`\`\`bash
|
||
export DB_PASSWORD="YourStrongPassword123!"
|
||
chmod +x setup.sh && ./setup.sh
|
||
\`\`\`
|
||
Takes approximately **5–8 minutes**.
|
||
|
||
### Open in Browser
|
||
\`\`\`
|
||
https://${APP_NAME}.azurewebsites.net
|
||
\`\`\`
|
||
|
||
### Redeploy after code changes
|
||
\`\`\`bash
|
||
./prepare-app.sh
|
||
\`\`\`
|
||
|
||
### Remove all Azure resources
|
||
\`\`\`bash
|
||
./remove-app.sh
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 7. How to Perform a Data Backup
|
||
|
||
Install psql if needed: \`sudo apt install postgresql-client\`
|
||
|
||
\`\`\`bash
|
||
# Create a timestamped backup
|
||
source .env && ./backup.sh
|
||
|
||
# List backups
|
||
ls -lh backups/
|
||
|
||
# Restore
|
||
source .env && ./backup.sh --restore backups/taskflow-20250601-120000.sql
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 8. How to View Access Logs from the Internet
|
||
|
||
Azure App Service logs all HTTP requests. View them via Azure CLI:
|
||
|
||
\`\`\`bash
|
||
# Stream live access logs (all internet requests to the app)
|
||
az webapp log tail --resource-group taskflow-rg --name ${APP_NAME}
|
||
|
||
# Enable detailed logging first if needed
|
||
az webapp log config \
|
||
--resource-group taskflow-rg \
|
||
--name ${APP_NAME} \
|
||
--application-logging filesystem \
|
||
--web-server-logging filesystem \
|
||
--level information
|
||
|
||
# Download logs as zip
|
||
az webapp log download --resource-group taskflow-rg --name ${APP_NAME}
|
||
\`\`\`
|
||
|
||
Each log entry contains: timestamp, client IP, HTTP method, path, status code, response time.
|
||
|
||
---
|
||
|
||
## 9. Conditions for Running prepare-app.sh and remove-app.sh
|
||
|
||
### prepare-app.sh
|
||
- Azure CLI installed and \`az login\` completed
|
||
- Docker installed and running
|
||
- \`.env\` file must exist in the same directory (created by \`setup.sh\`)
|
||
- ACR name and App name are hardcoded from the initial deployment — re-run \`setup.sh\` to create fresh resources
|
||
|
||
### remove-app.sh
|
||
- Azure CLI installed and \`az login\` completed
|
||
- Resource group \`taskflow-rg\` must exist
|
||
- Prompts for confirmation before deleting
|
||
- All data will be lost unless \`backup.sh\` was run first
|
||
|
||
---
|
||
|
||
## 10. External Resources and Use of Generative AI
|
||
|
||
### External Resources
|
||
|
||
| Resource | Type | How Used |
|
||
|---|---|---|
|
||
| [Azure App Service Documentation](https://learn.microsoft.com/en-us/azure/app-service/) | Official docs | Web app creation, container config, app settings, logging |
|
||
| [Azure PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/) | Official docs | Server creation, firewall, connection strings |
|
||
| [Azure Container Registry](https://learn.microsoft.com/en-us/azure/container-registry/) | Official docs | Image push, admin credentials |
|
||
| [Flask Documentation](https://flask.palletsprojects.com/) | Official docs | Routing, templates |
|
||
| [PostgreSQL pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) | Official docs | Backup commands |
|
||
| [Azure Pricing Calculator](https://azure.microsoft.com/pricing/calculator/) | Tool | Cost analysis |
|
||
| Course lectures (azure, certbot, compose) | Lecture slides | Architecture, PaaS concepts, IaaS vs PaaS |
|
||
|
||
### Generative AI Usage
|
||
**Tool:** Claude by Anthropic (claude.ai)
|
||
**Type:** Productivity assistance — Azure CLI commands, shell scripts, Dockerfile, Flask app, README.
|
||
**Method:** Student described requirements, reviewed all output, verified understanding before submission. All architectural decisions directed by the student. AI accelerated writing; understanding was the student's own.
|
||
READMEEOF
|
||
ok "README.md"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Done!
|
||
# ---------------------------------------------------------------------------
|
||
echo ""
|
||
echo -e "${BOLD}=================================================${RESET}"
|
||
echo -e "${GREEN}${BOLD} TaskFlow is LIVE!${RESET}"
|
||
echo -e "${BOLD}=================================================${RESET}"
|
||
echo -e " ${BOLD}URL:${RESET} ${GREEN}${APP_URL}${RESET}"
|
||
echo -e " ${BOLD}Database:${RESET} ${DB_SERVER}.postgres.database.azure.com"
|
||
echo -e " ${BOLD}Registry:${RESET} ${ACR_NAME}.azurecr.io"
|
||
echo -e ""
|
||
echo -e " Logs: az webapp log tail --resource-group ${RG} --name ${APP_NAME}"
|
||
echo -e " Backup: source .env && ./backup.sh"
|
||
echo -e " Remove: ./remove-app.sh"
|
||
echo -e "${BOLD}=================================================${RESET}"
|