diff --git a/.env.azure.example b/.env.azure.example new file mode 100644 index 0000000..311763a --- /dev/null +++ b/.env.azure.example @@ -0,0 +1,19 @@ + +RG_NAME=zkt-guestbook-rg +LOCATION=polandcentral +CONTAINERAPPS_ENV=zkt-guestbook-env + +ACR_NAME=acrzktguestbook12345 + +FRONTEND_APP=zkt-frontend +BACKEND_APP=zkt-backend + +# PostgreSQL Flexible Server. +PG_SERVER=zkt-pg-guestbook-12345 +PG_DB=guestbook +PG_ADMIN=guestbookadmin +PG_PASSWORD=hesloheslo + +# Lokálne tagy obrazov v ACR +FRONTEND_IMAGE=zkt-frontend:latest +BACKEND_IMAGE=zkt-backend:latest diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..a5b28d2 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,7 @@ +LOCAL_POSTGRES_DB=guestbook +LOCAL_POSTGRES_USER=guestbook_user +LOCAL_POSTGRES_PASSWORD=hesloheslo +LOCAL_DB_HOST=db +LOCAL_DB_PORT=5432 +LOCAL_APP_PORT=5000 +LOCAL_DB_SSLMODE=prefer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f953f14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env.azure +.env.local +backups/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0cb84f2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..760a458 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,125 @@ +import os +import time +from datetime import datetime + +import psycopg2 +from flask import Flask, jsonify, request + +app = Flask(__name__) + +DB_CONFIG = { + "host": os.getenv("DB_HOST", "db"), + "port": int(os.getenv("DB_PORT", "5432")), + "dbname": os.getenv("DB_NAME", "guestbook"), + "user": os.getenv("DB_USER", "guestbook_user"), + "password": os.getenv("DB_PASSWORD", ""), + + "sslmode": os.getenv("DB_SSLMODE", "prefer"), +} + +APP_PORT = int(os.getenv("APP_PORT", "5000")) + + +def get_connection(): + return psycopg2.connect(**DB_CONFIG) + + +def init_db(retries: int = 20, delay: int = 2) -> None: + for attempt in range(1, retries + 1): + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + author VARCHAR(100) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() + print("Database initialized.") + return + except Exception as exc: + print(f"Database not ready yet (attempt {attempt}/{retries}): {exc}") + time.sleep(delay) + raise RuntimeError("Could not initialize the database.") + + +@app.route("/api/health", methods=["GET"]) +def health(): + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + return jsonify({"status": "ok", "database": "connected"}), 200 + except Exception as exc: + return jsonify({"status": "error", "database": str(exc)}), 500 + + +@app.route("/api/messages", methods=["GET"]) +def get_messages(): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, author, content, created_at + FROM messages + ORDER BY created_at DESC, id DESC + """ + ) + rows = cur.fetchall() + + data = [ + { + "id": row[0], + "author": row[1], + "content": row[2], + "created_at": row[3].isoformat(), + } + for row in rows + ] + return jsonify(data), 200 + + +@app.route("/api/messages", methods=["POST"]) +def add_message(): + payload = request.get_json(silent=True) or {} + author = (payload.get("author") or "").strip() + content = (payload.get("content") or "").strip() + + if not author or not content: + return jsonify({"error": "Fields 'author' and 'content' are required."}), 400 + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO messages (author, content, created_at) + VALUES (%s, %s, %s) + RETURNING id, author, content, created_at + """, + (author, content, datetime.utcnow()), + ) + row = cur.fetchone() + conn.commit() + + return ( + jsonify( + { + "id": row[0], + "author": row[1], + "content": row[2], + "created_at": row[3].isoformat(), + } + ), + 201, + ) + + +if __name__ == "__main__": + init_db() + app.run(host="0.0.0.0", port=APP_PORT) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e75dae1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.0 +psycopg2-binary==2.9.10 diff --git a/backup-db.sh b/backup-db.sh new file mode 100644 index 0000000..c26d7e3 --- /dev/null +++ b/backup-db.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.azure}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Chýba $ENV_FILE" + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${RG_NAME:?Missing RG_NAME}" +: "${PG_SERVER:?Missing PG_SERVER}" +: "${PG_DB:?Missing PG_DB}" +: "${PG_ADMIN:?Missing PG_ADMIN}" +: "${PG_PASSWORD:?Missing PG_PASSWORD}" + +if ! command -v pg_dump >/dev/null 2>&1; then + echo "Chýba pg_dump." + exit 1 +fi + +mkdir -p "$ROOT_DIR/backups" +OUT="$ROOT_DIR/backups/${PG_DB}_$(date +%Y%m%d_%H%M%S).sql" + +echo "Vytváram SQL zálohu databázy do: $OUT" +PGPASSWORD="$PG_PASSWORD" pg_dump \ + "host=$PG_SERVER.postgres.database.azure.com port=5432 dbname=$PG_DB user=$PG_ADMIN sslmode=require" \ + --no-owner \ + --no-privileges \ + > "$OUT" + +echo "Záloha hotová: $OUT" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..57164b1 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,65 @@ +services: + db: + image: postgres:16-alpine + container_name: zkt-db + restart: unless-stopped + env_file: + - .env.local + environment: + POSTGRES_DB: ${LOCAL_POSTGRES_DB} + POSTGRES_USER: ${LOCAL_POSTGRES_USER} + POSTGRES_PASSWORD: ${LOCAL_POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - app-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${LOCAL_POSTGRES_USER} -d ${LOCAL_POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + container_name: zkt-backend + restart: unless-stopped + env_file: + - .env.local + environment: + DB_HOST: ${LOCAL_DB_HOST} + DB_PORT: ${LOCAL_DB_PORT} + DB_NAME: ${LOCAL_POSTGRES_DB} + DB_USER: ${LOCAL_POSTGRES_USER} + DB_PASSWORD: ${LOCAL_POSTGRES_PASSWORD} + DB_SSLMODE: ${LOCAL_DB_SSLMODE} + APP_PORT: ${LOCAL_APP_PORT} + depends_on: + db: + condition: service_healthy + networks: + - app-net + + frontend: + build: + context: ./frontend + container_name: zkt-frontend + restart: unless-stopped + depends_on: + - backend + environment: + BACKEND_URL: http://backend:5000 + ports: + - "8080:80" + networks: + - app-net + +networks: + app-net: + external: true + name: zkt_app_net + +volumes: + pgdata: + external: true + name: zkt_pgdata diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..773398d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:1.27-alpine +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY index.html /usr/share/nginx/html/index.html + +ENV BACKEND_URL=http://backend:5000 + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ec71ab --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,185 @@ + + + + + + ZKT Guestbook + + + +
+

ZKT Guestbook

+ +
Kontrolujem stav backendu...
+ +
+ + + +
+ +

Správy

+
+
+ + + + diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 0000000..c13bbcf --- /dev/null +++ b/frontend/nginx.conf.template @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log warn; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass ${BACKEND_URL}/api/; + proxy_http_version 1.1; + proxy_set_header Host $proxy_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; + } +} diff --git a/prepare-app.sh b/prepare-app.sh new file mode 100644 index 0000000..352821d --- /dev/null +++ b/prepare-app.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.azure}" + +log() { + echo "==> $*" +} + +fail() { + echo "Chyba: $*" >&2 + exit 1 +} + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Chýba $ENV_FILE" + echo "uprav ACR_NAME, PG_SERVER a PG_PASSWORD." + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${RG_NAME:?Missing RG_NAME}" +: "${LOCATION:?Missing LOCATION}" +: "${CONTAINERAPPS_ENV:?Missing CONTAINERAPPS_ENV}" +: "${ACR_NAME:?Missing ACR_NAME}" +: "${FRONTEND_APP:?Missing FRONTEND_APP}" +: "${BACKEND_APP:?Missing BACKEND_APP}" +: "${PG_SERVER:?Missing PG_SERVER}" +: "${PG_DB:?Missing PG_DB}" +: "${PG_ADMIN:?Missing PG_ADMIN}" +: "${PG_PASSWORD:?Missing PG_PASSWORD}" +: "${FRONTEND_IMAGE:=zkt-frontend:latest}" +: "${BACKEND_IMAGE:=zkt-backend:latest}" + + +log "Kontrola Azure Container Apps rozsirenia a resource providerov" +az extension add --name containerapp --upgrade --yes >/dev/null +az provider register --namespace Microsoft.App --wait >/dev/null +az provider register --namespace Microsoft.ContainerRegistry --wait >/dev/null +az provider register --namespace Microsoft.DBforPostgreSQL --wait >/dev/null +az provider register --namespace Microsoft.OperationalInsights --wait >/dev/null + +log "Vytvaram alebo kontrolujem resource group: $RG_NAME ($LOCATION)" +az group create --name "$RG_NAME" --location "$LOCATION" >/dev/null + +if ! az acr show --name "$ACR_NAME" --resource-group "$RG_NAME" >/dev/null 2>&1; then + log "Vytvaram Azure Container Registry: $ACR_NAME" + az acr create \ + --resource-group "$RG_NAME" \ + --name "$ACR_NAME" \ + --sku Basic \ + --admin-enabled true >/dev/null +else + log "ACR uz existuje: $ACR_NAME" + az acr update \ + --name "$ACR_NAME" \ + --resource-group "$RG_NAME" \ + --admin-enabled true >/dev/null +fi + +ACR_LOGIN_SERVER="$(az acr show --name "$ACR_NAME" --resource-group "$RG_NAME" --query loginServer -o tsv)" +ACR_PASSWORD="$(az acr credential show --name "$ACR_NAME" --resource-group "$RG_NAME" --query 'passwords[0].value' -o tsv)" + +log "Build a push backend image do ACR" +az acr build --registry "$ACR_NAME" --image "$BACKEND_IMAGE" "$ROOT_DIR/backend" + +log "Build a push frontend image do ACR" +az acr build --registry "$ACR_NAME" --image "$FRONTEND_IMAGE" "$ROOT_DIR/frontend" + +if ! az postgres flexible-server show --resource-group "$RG_NAME" --name "$PG_SERVER" >/dev/null 2>&1; then + log "Vytvaram Azure Database for PostgreSQL Flexible Server: $PG_SERVER" + az postgres flexible-server create \ + --resource-group "$RG_NAME" \ + --name "$PG_SERVER" \ + --location "$LOCATION" \ + --admin-user "$PG_ADMIN" \ + --admin-password "$PG_PASSWORD" \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --storage-size 32 \ + --version 16 \ + --public-access 0.0.0.0 \ + --backup-retention 7 \ + --yes >/dev/null +else + log "PostgreSQL server už existuje: $PG_SERVER" +fi + +log "Nastavujem firewall pravidlo pre Azure služby k PostgreSQL" +az postgres flexible-server firewall-rule create \ + --resource-group "$RG_NAME" \ + --name "$PG_SERVER" \ + --rule-name allowazureservices \ + --start-ip-address 0.0.0.0 \ + --end-ip-address 0.0.0.0 >/dev/null 2>&1 || true + +log "Kontrolujem alebo vytvaram databazu: $PG_DB" +DB_READY="false" +for i in {1..18}; do + if az postgres flexible-server db show \ + --resource-group "$RG_NAME" \ + --server-name "$PG_SERVER" \ + --database-name "$PG_DB" >/dev/null 2>&1; then + log "Databaza uz existuje: $PG_DB" + DB_READY="true" + break + fi + + if az postgres flexible-server db create \ + --resource-group "$RG_NAME" \ + --server-name "$PG_SERVER" \ + --database-name "$PG_DB" >/dev/null 2>&1; then + log "Databaza vytvorena: $PG_DB" + DB_READY="true" + break + fi + + log "PostgreSQL ešte nemusi byt pripraveny, cakam a skusam znova ($i/18)..." + sleep 10 +done + +if [[ "$DB_READY" != "true" ]]; then + fail "Nepodarilo sa vytvorit alebo overit databazu $PG_DB. Skontroluj PostgreSQL server a firewall." +fi + +if ! az containerapp env show --name "$CONTAINERAPPS_ENV" --resource-group "$RG_NAME" >/dev/null 2>&1; then + log "Vytvaram Container Apps environment: $CONTAINERAPPS_ENV" + az containerapp env create \ + --name "$CONTAINERAPPS_ENV" \ + --resource-group "$RG_NAME" \ + --location "$LOCATION" >/dev/null +else + log "Container Apps environment už existuje: $CONTAINERAPPS_ENV" +fi + +delete_containerapp_if_exists() { + local app_name="$1" + + if az containerapp show --name "$app_name" --resource-group "$RG_NAME" >/dev/null 2>&1; then + log "Mažem existujúcu Container App: $app_name" + az containerapp delete --name "$app_name" --resource-group "$RG_NAME" --yes >/dev/null + fi +} + +log "Nasadzujem backend ako internu Container App" +delete_containerapp_if_exists "$BACKEND_APP" +az containerapp create \ + --name "$BACKEND_APP" \ + --resource-group "$RG_NAME" \ + --environment "$CONTAINERAPPS_ENV" \ + --image "$ACR_LOGIN_SERVER/$BACKEND_IMAGE" \ + --target-port 5000 \ + --ingress internal \ + --transport auto \ + --registry-server "$ACR_LOGIN_SERVER" \ + --registry-username "$ACR_NAME" \ + --registry-password "$ACR_PASSWORD" \ + --min-replicas 1 \ + --max-replicas 1 \ + --cpu 0.25 \ + --memory 0.5Gi \ + --secrets db-password="$PG_PASSWORD" \ + --env-vars \ + DB_HOST="$PG_SERVER.postgres.database.azure.com" \ + DB_PORT=5432 \ + DB_NAME="$PG_DB" \ + DB_USER="$PG_ADMIN" \ + DB_PASSWORD=secretref:db-password \ + DB_SSLMODE=require \ + APP_PORT=5000 >/dev/null + +log "Nasadzujem frontend ako verejnú Container App" +delete_containerapp_if_exists "$FRONTEND_APP" +az containerapp create \ + --name "$FRONTEND_APP" \ + --resource-group "$RG_NAME" \ + --environment "$CONTAINERAPPS_ENV" \ + --image "$ACR_LOGIN_SERVER/$FRONTEND_IMAGE" \ + --target-port 80 \ + --ingress external \ + --transport auto \ + --registry-server "$ACR_LOGIN_SERVER" \ + --registry-username "$ACR_NAME" \ + --registry-password "$ACR_PASSWORD" \ + --min-replicas 1 \ + --max-replicas 1 \ + --cpu 0.25 \ + --memory 0.5Gi \ + --env-vars BACKEND_URL="http://$BACKEND_APP" >/dev/null + +FRONTEND_FQDN="$(az containerapp show --name "$FRONTEND_APP" --resource-group "$RG_NAME" --query 'properties.configuration.ingress.fqdn' -o tsv)" + +cat </dev/null 2>&1; then + echo "Chýba Azure CLI." + exit 1 +fi + +if ! az account show >/dev/null 2>&1; then + echo "Nie si prihlaseny v Azure CLI." + exit 1 +fi + +echo "Odstranujem celu resource group: $RG_NAME" +az group delete --name "$RG_NAME" --yes --no-wait + +echo "Odstranovanie bolo spustene. overenie cez: az group exists --name $RG_NAME" diff --git a/show-logs.sh b/show-logs.sh new file mode 100644 index 0000000..f4861d4 --- /dev/null +++ b/show-logs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.azure}" +TAIL="${TAIL:-100}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Chýba $ENV_FILE" + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${RG_NAME:?Missing RG_NAME}" +: "${FRONTEND_APP:?Missing FRONTEND_APP}" +: "${BACKEND_APP:?Missing BACKEND_APP}" + +echo "==> Frontend logy" +az containerapp logs show --name "$FRONTEND_APP" --resource-group "$RG_NAME" --tail "$TAIL" + +echo + echo "==> Backend logy" +az containerapp logs show --name "$BACKEND_APP" --resource-group "$RG_NAME" --tail "$TAIL" diff --git a/start-app.sh b/start-app.sh new file mode 100644 index 0000000..53d2947 --- /dev/null +++ b/start-app.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-local.sh" diff --git a/stop-app.sh b/stop-app.sh new file mode 100644 index 0000000..ccae8d9 --- /dev/null +++ b/stop-app.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/stop-local.sh"