This commit is contained in:
root 2026-05-11 20:59:41 +02:00
parent 883f62b717
commit 25fa79c48c
16 changed files with 764 additions and 0 deletions

19
.env.azure.example Normal file
View File

@ -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

7
.env.local.example Normal file
View File

@ -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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env.azure
.env.local
backups/
*.log

12
backend/Dockerfile Normal file
View File

@ -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"]

125
backend/app.py Normal file
View File

@ -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)

2
backend/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask==3.1.0
psycopg2-binary==2.9.10

38
backup-db.sh Normal file
View File

@ -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"

65
docker-compose.yaml Normal file
View File

@ -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

7
frontend/Dockerfile Normal file
View File

@ -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

185
frontend/index.html Normal file
View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZKT Guestbook</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f4f7fb;
color: #1f2937;
margin: 0;
padding: 0;
}
.container {
max-width: 900px;
margin: 40px auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
padding: 32px;
}
h1 {
margin-top: 0;
}
p.note {
color: #4b5563;
margin-bottom: 24px;
}
form {
display: grid;
gap: 12px;
margin-bottom: 28px;
}
input, textarea, button {
font: inherit;
padding: 12px;
border-radius: 10px;
border: 1px solid #d1d5db;
}
textarea {
min-height: 100px;
resize: vertical;
}
button {
background: #2563eb;
color: white;
border: none;
cursor: pointer;
font-weight: bold;
}
button:hover {
opacity: 0.95;
}
.status {
margin-bottom: 18px;
padding: 10px 12px;
border-radius: 10px;
background: #eef2ff;
}
.messages {
display: grid;
gap: 12px;
}
.message {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 14px;
background: #fafafa;
}
.meta {
font-size: 0.9rem;
color: #6b7280;
margin-bottom: 8px;
}
.empty {
color: #6b7280;
font-style: italic;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="container">
<h1>ZKT Guestbook</h1>
<div id="status" class="status">Kontrolujem stav backendu...</div>
<form id="messageForm">
<input id="author" type="text" placeholder="Tvoje meno" required>
<textarea id="content" placeholder="Napíš správu..." required></textarea>
<button type="submit">Pridať správu</button>
</form>
<h2>Správy</h2>
<div id="messages" class="messages"></div>
</div>
<script>
const statusEl = document.getElementById('status');
const messagesEl = document.getElementById('messages');
const form = document.getElementById('messageForm');
async function checkHealth() {
try {
const response = await fetch('/api/health');
const data = await response.json();
if (response.ok) {
statusEl.textContent = `Backend je dostupný. Databáza: ${data.database}`;
} else {
statusEl.textContent = 'Backend nie je dostupný.';
}
} catch (error) {
statusEl.textContent = 'Nepodarilo sa spojiť s backendom.';
}
}
async function loadMessages() {
try {
const response = await fetch('/api/messages');
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
messagesEl.innerHTML = '<div class="empty">Zatiaľ tu nie sú žiadne správy.</div>';
return;
}
messagesEl.innerHTML = data.map(msg => {
const date = new Date(msg.created_at).toLocaleString('sk-SK');
return `
<div class="message">
<div class="meta"><strong>${escapeHtml(msg.author)}</strong> • ${date}</div>
<div>${escapeHtml(msg.content)}</div>
</div>
`;
}).join('');
} catch (error) {
messagesEl.innerHTML = '<div class="empty">Nepodarilo sa načítať správy.</div>';
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const author = document.getElementById('author').value.trim();
const content = document.getElementById('content').value.trim();
if (!author || !content) {
return;
}
const response = await fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ author, content })
});
if (response.ok) {
form.reset();
await loadMessages();
} else {
alert('Správu sa nepodarilo uložiť.');
}
});
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
checkHealth();
loadMessages();
</script>
</body>
</html>

View File

@ -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;
}
}

212
prepare-app.sh Normal file
View File

@ -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 <<MSG
Hotovo. Aplikacia je dostupna cez HTTPS:
https://$FRONTEND_FQDN
Health check:
https://$FRONTEND_FQDN/api/health
Test v terminali:
curl -i https://$FRONTEND_FQDN/api/health
Ak frontend stale hlasi problem s backendom, teba skontrolovat logy:
az containerapp logs show --name $BACKEND_APP --resource-group $RG_NAME --follow
az containerapp logs show --name $FRONTEND_APP --resource-group $RG_NAME --follow
MSG

32
remove-app.sh Normal file
View File

@ -0,0 +1,32 @@
#!/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}"
if ! command -v az >/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"

27
show-logs.sh Normal file
View File

@ -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"

3
start-app.sh Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start-local.sh"

3
stop-app.sh Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/stop-local.sh"