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"