This commit is contained in:
Bohdan Kapliuk 2026-05-13 19:50:55 +03:00
parent a40f28696a
commit 15f4373858
15 changed files with 1505 additions and 0 deletions

6
skuska/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.skuska.env
backup-*.sql
backup-*.json
secret.yaml
.backend-containerapp.yaml
.skuska-containerapp.yaml

144
skuska/README.md Normal file
View File

@ -0,0 +1,144 @@
# Skuska - webova aplikacia v Azure
Projekt nasadzuje jednoduchu aplikaciu `Skuska Guestbook` do verejneho klaudu Microsoft Azure. Pouzivatel zada meno a kratku spravu, frontend ju odosle na backend API a backend ju ulozi do PostgreSQL databazy. Ulozene zaznamy sa zobrazia naspat v prehliadaci spolu s poctom zaznamov a stavom backendu/databazy.
## Pouzite cloudove sluzby
- Azure Container Apps - spustenie aplikacie s verejnym HTTPS endpointom.
- Azure Container Registry - ulozenie Docker obrazov frontendu a backendu.
- Azure Storage Account a Azure Files - trvaly zvazok pripojeny do `postgres` kontajnera na `/backup` pre SQL zalohy PostgreSQL.
- Azure Resource Group - spolocna skupina vsetkych zdrojov, aby sa aplikacia dala jednoducho odstranit.
Nasadenie pouziva jeden Container Apps Environment a jednu Container App s tromi kontajnermi. Azure Container Apps zabezpecuje HTTPS ingress, automaticke restartovanie kontajnerov a zobrazenie logov cez Azure CLI.
## Komponenty aplikacie
- `frontend` - Nginx kontajner so statickym HTML/CSS/JavaScript rozhranim a proxy `/api/*` na backend.
- `backend` - Node.js/Express API, ktore poskytuje endpointy `/save`, `/entries`, `/status`, `/ready` a `/health`.
- `postgres` - PostgreSQL kontajner s automatickym SQL backupom do Azure Files volume `/backup`.
Vsetky tri kontajnery bezia v jednej Container App, preto medzi sebou komunikuju cez `localhost` a interne porty. Verejne dostupny je iba frontend na HTTPS. Frontend posiela API poziadavky na `/api/*`, Nginx ich presmeruje na backend a backend zapisuje guestbook zaznamy do PostgreSQL.
PostgreSQL data directory nie je priamo ulozeny na Azure Files, pretoze PostgreSQL vyzaduje Unix permissions, ktore Azure Files cez SMB nepodporuje spolahlivo. Azure Files sa preto pouziva ako trvaly backup volume `/backup`. Postgres kontajner automaticky vytvara `pg_dump` backup do suboru `/backup/latest.sql` a pri novom starte ho pouzije na obnovu databazy. Takto data preziju restart alebo znovuvytvorenie kontajnera.
## Odovzdane subory
- `prepare-app.sh` - vytvori Azure resource group, ACR, Storage Account, Azure Files share, Container Apps Environment a jednu Container App s tromi kontajnermi.
- `remove-app.sh` - odstrani celu Azure resource group so vsetkymi zdrojmi aplikacie.
- `backup-db.sh` - stiahne aktualny SQL backup PostgreSQL databazy z Azure Files.
- `logs-app.sh` - zobrazi posledne logy kontajnerov `frontend`, `backend` a `postgres`.
- `start-app.sh` - zapne verejny ingress a nastavi minimalny pocet replik aplikacie na 1.
- `stop-app.sh` - vytvori finalny backup, vypne verejny ingress a nastavi minimalny pocet replik na 0.
- `backend/server.js` - zdrojovy kod backend API.
- `backend/Dockerfile` - Docker obraz backendu.
- `frontend/index.html` - zdrojovy kod frontend rozhrania.
- `frontend/nginx.conf` - Nginx konfiguracia, ktora proxyuje `/api/*` na backend kontajner.
- `frontend/Dockerfile` - Docker obraz frontendu.
- `.gitignore` - ignoruje lokalne secrets a backupy.
## Konfiguracia
Cloud konfiguracia je nastavitelna cez premenne prostredia:
- `APP_PREFIX` - prefix nazvov zdrojov, predvolene `skuska`.
- `LOCATION` - Azure region, predvolene `norwayeast`.
- `RESOURCE_GROUP` - nazov resource group, predvolene `skuska-rg`.
- `CONTAINER_ENV` - nazov Container Apps Environment, predvolene `skuska-env`.
- `CONTAINER_ENV_RESOURCE_GROUP` - resource group existujuceho environmentu, ak sa pouziva uz vytvoreny environment.
- `APP_NAME` - nazov Container App, predvolene `skuska-app`.
- `ALLOWED_LOCATIONS` - zoznam regionov povolenych politikou subscription, predvolene `norwayeast`.
- `DB_NAME` - nazov databazy, predvolene `appdb`.
- `DB_USER` - PostgreSQL pouzivatel, predvolene `appuser`.
- `DB_PASSWORD` - heslo databazy. Ak nie je nastavene, skript ho vygeneruje.
Skript ulozi lokalne nasadzovacie hodnoty do `.skuska.env`. Tento subor obsahuje citlive udaje a nesmie byt odovzdany do GITu.
## Spustenie v Azure
Podmienky spustenia:
- aktivne Azure konto a subscription;
- nainstalovany Azure CLI;
- nainstalovany Docker;
- prihlasenie cez `az login`;
- shell prostredie Bash.
Prikazy:
```bash
chmod +x prepare-app.sh remove-app.sh backup-db.sh logs-app.sh
az login
./prepare-app.sh
```
Ak uz v subscription existuje Container Apps Environment a dalsi sa neda vytvorit, pouzite existujuci:
```bash
az containerapp env list -o table
CONTAINER_ENV=nazov-env CONTAINER_ENV_RESOURCE_GROUP=resource-group-env ./prepare-app.sh
```
Na konci skript vypise verejnu HTTPS adresu aplikacie, napriklad:
```text
URL: https://skuska-app.example.azurecontainerapps.io
```
Tuto URL treba otvorit vo webovom prehliadaci.
## Funkcie aplikacie
- pridanie guestbook zaznamu s menom a spravou;
- zobrazenie zoznamu ulozenych zaznamov;
- pocitadlo ulozenych zaznamov;
- indikacia stavu backendu a databazy;
- manualne obnovenie zoznamu;
- vymazanie jedneho zaznamu;
- vymazanie vsetkych zaznamov.
## Odstranenie aplikacie
Po skuske je potrebne odstranit zdroje, aby nevznikali dalsie naklady:
```bash
./remove-app.sh
```
Skript sa najprv pokusi vytvorit finalny backup databazy cez `backup-db.sh` a potom odstrani celu Azure resource group, ktora bola vytvorena pre aplikaciu.
## Spustenie a pozastavenie
Aplikaciu je mozne pozastavit bez odstranenia zdrojov:
```bash
./stop-app.sh
```
Skript pred pozastavenim vytvori finalny backup databazy, vypne verejny ingress a nastavi minimalny pocet replik na 0. Aplikaciu je mozne znova zapnut:
```bash
./start-app.sh
```
## Zaloha dat
Manualny SQL backup databazy:
```bash
./backup-db.sh
```
PostgreSQL kontajner automaticky aktualizuje subor `latest.sql` na Azure Files. Skript `backup-db.sh` tento subor stiahne lokalne ako `backup-appdb-DATUM.sql`. Tento subor sa neodosiela do GITu.
## Logy a pristupy z internetu
Logy kontajnerov:
```bash
./logs-app.sh
```
Pristupy z internetu su v logoch kontajnera `frontend`, pretoze Nginx prijima verejne HTTPS poziadavky cez Azure Container Apps ingress.
- Generativny model (GPT 5-5) bol pouzity na upravu skriptov, kontrolu poziadaviek a pripravu dokumentacie.

11
skuska/backend/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:18-alpine
WORKDIR /app
COPY server.js .
RUN npm init -y && npm install express pg cors
EXPOSE 3000
CMD ["node", "server.js"]

234
skuska/backend/server.js Normal file
View File

@ -0,0 +1,234 @@
const express = require("express");
const cors = require("cors");
const { Pool } = require("pg");
const fs = require("fs/promises");
const path = require("path");
const app = express();
const port = Number(process.env.PORT || 3000);
const dataFile = process.env.DATA_FILE;
let databaseReady = false;
let databaseInitError = null;
app.use(cors());
app.use(express.json());
const pool = dataFile
? null
: new Pool({
host: process.env.DB_HOST || "postgres-service",
user: process.env.DB_USER || "user",
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || "mydb",
port: Number(process.env.DB_PORT || 5432),
ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : false
});
async function ensureDataFile() {
await fs.mkdir(path.dirname(dataFile), { recursive: true });
try {
await fs.access(dataFile);
} catch (_error) {
await fs.writeFile(dataFile, "[]\n", "utf8");
}
}
async function readUsersFromFile() {
await ensureDataFile();
const content = await fs.readFile(dataFile, "utf8");
return JSON.parse(content || "[]");
}
async function saveUserToFile(name) {
const users = await readUsersFromFile();
const nextId = users.length > 0 ? Math.max(...users.map((user) => user.id || 0)) + 1 : 1;
users.push({ id: nextId, name });
await fs.writeFile(dataFile, `${JSON.stringify(users, null, 2)}\n`, "utf8");
}
async function prepareDatabase() {
if (dataFile) {
await ensureDataFile();
return;
}
await pool.query(`
CREATE TABLE IF NOT EXISTS guestbook_entries (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
}
async function waitForDatabase(maxAttempts = 120, delayMs = 5000) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await prepareDatabase();
console.log(`Database is ready after attempt ${attempt}.`);
databaseReady = true;
databaseInitError = null;
return;
} catch (error) {
databaseReady = false;
databaseInitError = error;
console.error(`Database is not ready yet (attempt ${attempt}/${maxAttempts}):`, error.message);
if (attempt === maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
app.get("/health", async (_req, res) => {
try {
if (dataFile) {
await ensureDataFile();
} else {
await pool.query("SELECT 1");
}
res.json({ status: "ok" });
} catch (error) {
console.error("Healthcheck failed:", error);
res.status(500).json({ status: "error" });
}
});
app.get("/ready", (_req, res) => {
if (databaseReady) {
return res.json({ status: "ready" });
}
return res.status(503).json({
status: "starting",
error: databaseInitError ? databaseInitError.message : "Database is not ready yet"
});
});
app.get("/status", (_req, res) => {
res.json({
backend: "online",
database: databaseReady ? "ready" : "starting"
});
});
app.post("/save", async (req, res) => {
if (!databaseReady) {
return res.status(503).send("Database is still starting");
}
const name = (req.body.name || "").trim();
const message = (req.body.message || "").trim();
if (!name) {
return res.status(400).send("Name is required");
}
if (!message) {
return res.status(400).send("Message is required");
}
try {
if (dataFile) {
await saveUserToFile(name);
} else {
await pool.query("INSERT INTO guestbook_entries(name, message) VALUES($1, $2)", [name, message]);
}
return res.send(`Saved: ${name}`);
} catch (error) {
console.error("Insert failed:", error);
return res.status(500).send("Error");
}
});
app.get("/users", async (_req, res) => {
if (!databaseReady) {
return res.status(503).json([]);
}
try {
if (dataFile) {
const users = await readUsersFromFile();
return res.json(users);
}
const result = await pool.query(`
SELECT id, name, message, created_at
FROM guestbook_entries
ORDER BY created_at DESC, id DESC
`);
return res.json(result.rows);
} catch (error) {
console.error("Select failed:", error);
return res.status(500).send("Error");
}
});
app.get("/entries", async (_req, res) => {
if (!databaseReady) {
return res.status(503).json([]);
}
try {
const result = await pool.query(`
SELECT id, name, message, created_at
FROM guestbook_entries
ORDER BY created_at DESC, id DESC
`);
return res.json(result.rows);
} catch (error) {
console.error("Select entries failed:", error);
return res.status(500).send("Error");
}
});
app.delete("/entries/:id", async (req, res) => {
if (!databaseReady) {
return res.status(503).send("Database is still starting");
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).send("Invalid entry id");
}
try {
await pool.query("DELETE FROM guestbook_entries WHERE id = $1", [id]);
return res.send("Deleted");
} catch (error) {
console.error("Delete entry failed:", error);
return res.status(500).send("Error");
}
});
app.delete("/entries", async (_req, res) => {
if (!databaseReady) {
return res.status(503).send("Database is still starting");
}
try {
await pool.query("DELETE FROM guestbook_entries");
return res.send("Cleared");
} catch (error) {
console.error("Clear entries failed:", error);
return res.status(500).send("Error");
}
});
app.listen(port, () => {
console.log(`Backend running on port ${port}`);
});
waitForDatabase()
.catch((error) => {
console.error("Database initialization failed:", error);
});

33
skuska/backup-db.sh Normal file
View File

@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
ENV_FILE=".skuska.env"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE was not found. Run prepare-app.sh first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
BACKUP_FILE="backup-${DB_NAME}-$(date +%Y%m%d-%H%M%S).sql"
TMP_DIR="$(mktemp -d)"
STORAGE_KEY="$(az storage account keys list \
--resource-group "$RESOURCE_GROUP" \
--account-name "$STORAGE_ACCOUNT" \
--query "[0].value" \
-o tsv)"
az storage file download \
--account-name "$STORAGE_ACCOUNT" \
--account-key "$STORAGE_KEY" \
--share-name "$STORAGE_SHARE" \
--path latest.sql \
--dest "$TMP_DIR" >/dev/null
mv "$TMP_DIR/latest.sql" "$BACKUP_FILE"
rmdir "$TMP_DIR"
echo "Backup saved to $BACKUP_FILE"

View File

@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
ARG API_BASE_URL=/api
RUN sed -i "s|__API_BASE_URL__|${API_BASE_URL}|g" /usr/share/nginx/html/index.html
EXPOSE 80

472
skuska/frontend/index.html Normal file
View File

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skuska Guestbook</title>
<style>
:root {
color-scheme: light;
--bg: #eef3ee;
--panel: #fffdf8;
--text: #17202a;
--muted: #5c6a72;
--accent: #2f7d66;
--accent-dark: #205846;
--danger: #b24a3b;
--border: #d8dfd6;
--soft: #edf5f0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", Arial, sans-serif;
color: var(--text);
background:
linear-gradient(135deg, rgba(47, 125, 102, 0.14), transparent 34%),
linear-gradient(180deg, #f8faf7 0%, var(--bg) 100%);
padding: 28px;
}
main {
width: min(920px, 100%);
margin: 0 auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(34, 56, 48, 0.12);
overflow: hidden;
}
header {
padding: 28px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
flex-wrap: wrap;
}
h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 6vw, 3rem);
letter-spacing: 0;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.45;
}
.badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.badge {
border: 1px solid var(--border);
background: var(--soft);
border-radius: 999px;
padding: 8px 12px;
color: var(--accent-dark);
font-size: 0.9rem;
font-weight: 700;
white-space: nowrap;
}
.badge.offline {
background: #fff0ed;
color: var(--danger);
}
.content {
padding: 28px;
display: grid;
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
gap: 24px;
}
form,
.entries-panel {
min-width: 0;
}
label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-weight: 700;
font-size: 0.92rem;
}
input,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
padding: 13px 14px;
font: inherit;
color: var(--text);
background: #fff;
}
textarea {
min-height: 132px;
resize: vertical;
}
.field + .field {
margin-top: 16px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 18px;
}
button {
border: 0;
border-radius: 10px;
padding: 12px 15px;
background: var(--accent);
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s ease, background 0.15s ease;
}
button:hover {
background: var(--accent-dark);
transform: translateY(-1px);
}
button.secondary {
background: #dde9e3;
color: var(--accent-dark);
}
button.danger {
background: #f3d8d3;
color: var(--danger);
}
button.icon {
width: 38px;
height: 38px;
padding: 0;
display: grid;
place-items: center;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.counter {
color: var(--accent-dark);
font-weight: 800;
}
.status {
min-height: 24px;
margin-top: 16px;
color: var(--accent-dark);
font-weight: 700;
}
.entries {
display: grid;
gap: 10px;
}
.entry {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
background: #fff;
}
.entry-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.entry-name {
font-weight: 800;
}
.entry-time {
color: var(--muted);
font-size: 0.86rem;
}
.entry-message {
color: #2e3a42;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.empty {
border: 1px dashed var(--border);
border-radius: 10px;
padding: 20px;
color: var(--muted);
background: rgba(255, 255, 255, 0.55);
}
@media (max-width: 760px) {
body {
padding: 14px;
}
header,
.content {
padding: 20px;
}
.content {
grid-template-columns: 1fr;
}
.badges {
justify-content: flex-start;
}
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Skuska Guestbook</h1>
<p>Write a short entry and store it through the backend API in PostgreSQL.</p>
</div>
<div class="badges">
<span id="backendBadge" class="badge offline">Backend: checking</span>
<span id="databaseBadge" class="badge offline">Database: checking</span>
</div>
</header>
<section class="content">
<form onsubmit="event.preventDefault(); saveEntry();">
<div class="field">
<label for="name">Name</label>
<input id="name" maxlength="80" placeholder="Enter your name" autocomplete="name">
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" maxlength="500" placeholder="Leave a short message"></textarea>
</div>
<div class="actions">
<button type="submit">Save entry</button>
<button class="secondary" type="button" onclick="loadEntries()">Refresh</button>
<button class="danger" type="button" onclick="clearEntries()">Clear all</button>
</div>
<div id="status" class="status"></div>
</form>
<section class="entries-panel">
<div class="panel-head">
<div class="counter" id="counter">Saved entries: 0</div>
<button class="secondary icon" type="button" onclick="loadEntries()" title="Refresh entries"></button>
</div>
<div id="entries" class="entries"></div>
</section>
</section>
</main>
<script>
const configuredApiBaseUrl = "__API_BASE_URL__";
const apiBaseUrl = configuredApiBaseUrl.startsWith("__") ? "/api" : configuredApiBaseUrl;
function apiUrl(path) {
return `${apiBaseUrl}${path}`;
}
function setStatus(message) {
document.getElementById("status").textContent = message;
}
function setBadge(id, label, ok) {
const badge = document.getElementById(id);
badge.textContent = label;
badge.classList.toggle("offline", !ok);
}
function formatTime(value) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
async function loadStatus() {
try {
const res = await fetch(apiUrl("/status"));
const data = await res.json();
setBadge("backendBadge", "Backend: online", data.backend === "online");
setBadge("databaseBadge", `Database: ${data.database}`, data.database === "ready");
} catch (error) {
console.error(error);
setBadge("backendBadge", "Backend: offline", false);
setBadge("databaseBadge", "Database: unknown", false);
}
}
async function loadEntries() {
const entriesEl = document.getElementById("entries");
const counter = document.getElementById("counter");
try {
setStatus("Loading entries...");
await loadStatus();
const res = await fetch(apiUrl("/entries"));
if (res.status === 503) {
entriesEl.innerHTML = '<div class="empty">Backend is starting. Please try again shortly.</div>';
setStatus("Backend is starting. Please try again shortly.");
return;
}
const data = await res.json();
counter.textContent = `Saved entries: ${data.length}`;
if (data.length === 0) {
entriesEl.innerHTML = '<div class="empty">No guestbook entries yet.</div>';
setStatus("Entries loaded successfully.");
return;
}
entriesEl.innerHTML = data.map((entry) => `
<article class="entry">
<div class="entry-head">
<div>
<div class="entry-name">${escapeHtml(entry.name)}</div>
<div class="entry-time">${escapeHtml(formatTime(entry.created_at))}</div>
</div>
<button class="danger icon" type="button" onclick="deleteEntry(${entry.id})" title="Delete entry">×</button>
</div>
<div class="entry-message">${escapeHtml(entry.message)}</div>
</article>
`).join("");
setStatus("Entries loaded successfully.");
} catch (error) {
console.error(error);
setStatus("Cannot connect to backend.");
}
}
async function saveEntry() {
const nameInput = document.getElementById("name");
const messageInput = document.getElementById("message");
const name = nameInput.value.trim();
const message = messageInput.value.trim();
if (!name || !message) {
setStatus("Please enter name and message.");
return;
}
try {
setStatus("Saving entry...");
const res = await fetch(apiUrl("/save"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name, message })
});
if (res.status === 503) {
setStatus("Backend is starting. Please try again shortly.");
return;
}
if (!res.ok) {
setStatus("Saving failed.");
return;
}
nameInput.value = "";
messageInput.value = "";
await loadEntries();
} catch (error) {
console.error(error);
setStatus("Saving failed.");
}
}
async function deleteEntry(id) {
try {
setStatus("Deleting entry...");
const res = await fetch(apiUrl(`/entries/${id}`), { method: "DELETE" });
if (!res.ok) {
setStatus("Deleting failed.");
return;
}
await loadEntries();
} catch (error) {
console.error(error);
setStatus("Deleting failed.");
}
}
async function clearEntries() {
try {
setStatus("Clearing entries...");
const res = await fetch(apiUrl("/entries"), { method: "DELETE" });
if (!res.ok) {
setStatus("Clearing failed.");
return;
}
await loadEntries();
} catch (error) {
console.error(error);
setStatus("Clearing failed.");
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
window.onload = () => {
loadEntries();
setInterval(loadStatus, 15000);
};
</script>
</body>
</html>

View File

@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3000/;
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;
}
}

23
skuska/logs-app.sh Normal file
View File

@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
ENV_FILE=".skuska.env"
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
RESOURCE_GROUP="${RESOURCE_GROUP:-skuska-rg}"
APP_NAME="${APP_NAME:-skuska-app}"
echo "Frontend logs:"
az containerapp logs show --resource-group "$RESOURCE_GROUP" --name "$APP_NAME" --container frontend --tail 50
echo
echo "Backend logs:"
az containerapp logs show --resource-group "$RESOURCE_GROUP" --name "$APP_NAME" --container backend --tail 50
echo
echo "Postgres logs:"
az containerapp logs show --resource-group "$RESOURCE_GROUP" --name "$APP_NAME" --container postgres --tail 50

View File

@ -0,0 +1,8 @@
FROM postgres:15-alpine
COPY start-postgres.sh /usr/local/bin/start-postgres.sh
RUN chmod +x /usr/local/bin/start-postgres.sh
ENTRYPOINT ["start-postgres.sh"]
CMD ["postgres"]

View File

@ -0,0 +1,36 @@
#!/bin/sh
set -eu
export PGDATA="${PGDATA:-/tmp/postgres-data}"
BACKUP_DIR="${BACKUP_DIR:-/backup}"
LATEST_BACKUP="$BACKUP_DIR/latest.sql"
INIT_DIR="/docker-entrypoint-initdb.d"
mkdir -p "$BACKUP_DIR" "$INIT_DIR"
if [ ! -s "$PGDATA/PG_VERSION" ] && [ -s "$LATEST_BACKUP" ]; then
echo "Found persistent backup at $LATEST_BACKUP, preparing restore on first init."
cp "$LATEST_BACKUP" "$INIT_DIR/001-restore.sql"
fi
docker-entrypoint.sh "$@" &
postgres_pid="$!"
until pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do
echo "Waiting for PostgreSQL before backup loop..."
sleep 5
done
while kill -0 "$postgres_pid" 2>/dev/null; do
if PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" "$POSTGRES_DB" > "$BACKUP_DIR/latest.sql.tmp"; then
mv "$BACKUP_DIR/latest.sql.tmp" "$LATEST_BACKUP"
echo "Persistent backup updated at $LATEST_BACKUP"
else
echo "Persistent backup failed"
rm -f "$BACKUP_DIR/latest.sql.tmp"
fi
sleep "${BACKUP_INTERVAL_SECONDS:-60}"
done
wait "$postgres_pid"

401
skuska/prepare-app.sh Normal file
View File

@ -0,0 +1,401 @@
#!/bin/bash
set -euo pipefail
REQUESTED_LOCATION="${LOCATION:-}"
REQUESTED_RESOURCE_GROUP="${RESOURCE_GROUP:-}"
APP_PREFIX="${APP_PREFIX:-skuska}"
LOCATION="${LOCATION:-norwayeast}"
RESOURCE_GROUP="${RESOURCE_GROUP:-${APP_PREFIX}-rg}"
CONTAINER_ENV_RESOURCE_GROUP="${CONTAINER_ENV_RESOURCE_GROUP:-$RESOURCE_GROUP}"
ALLOWED_LOCATIONS="${ALLOWED_LOCATIONS:-norwayeast}"
ENV_FILE=".skuska.env"
if ! command -v az >/dev/null 2>&1; then
echo "Azure CLI is required. Install it and run: az login"
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required to build and push container images."
exit 1
fi
if ! command -v openssl >/dev/null 2>&1; then
echo "openssl is required to generate DB_PASSWORD."
exit 1
fi
az account show >/dev/null
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
if [ -n "$REQUESTED_LOCATION" ]; then
LOCATION="$REQUESTED_LOCATION"
fi
if [ -n "$REQUESTED_RESOURCE_GROUP" ]; then
RESOURCE_GROUP="$REQUESTED_RESOURCE_GROUP"
fi
CONTAINER_ENV_RESOURCE_GROUP="${CONTAINER_ENV_RESOURCE_GROUP:-$RESOURCE_GROUP}"
SUFFIX="${SUFFIX:-$(date +%s | tail -c 7)}"
ACR_NAME="${ACR_NAME:-${APP_PREFIX}acr${SUFFIX}}"
CONTAINER_ENV="${CONTAINER_ENV:-${APP_PREFIX}-env}"
APP_NAME="${APP_NAME:-${APP_PREFIX}-app}"
STORAGE_ACCOUNT="${STORAGE_ACCOUNT:-${APP_PREFIX}st${SUFFIX}}"
STORAGE_ACCOUNT="$(echo "$STORAGE_ACCOUNT" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9' | cut -c1-24)"
STORAGE_SHARE="${STORAGE_SHARE:-pgbackup}"
STORAGE_MOUNT_NAME="${STORAGE_MOUNT_NAME:-postgres-backup-storage}"
DB_NAME="${DB_NAME:-appdb}"
DB_USER="${DB_USER:-appuser}"
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24)}"
cat > "$ENV_FILE" <<EOF
APP_PREFIX=$APP_PREFIX
LOCATION=$LOCATION
ALLOWED_LOCATIONS="$ALLOWED_LOCATIONS"
RESOURCE_GROUP=$RESOURCE_GROUP
CONTAINER_ENV_RESOURCE_GROUP=$CONTAINER_ENV_RESOURCE_GROUP
SUFFIX=$SUFFIX
ACR_NAME=$ACR_NAME
CONTAINER_ENV=$CONTAINER_ENV
APP_NAME=$APP_NAME
STORAGE_ACCOUNT=$STORAGE_ACCOUNT
STORAGE_SHARE=$STORAGE_SHARE
STORAGE_MOUNT_NAME=$STORAGE_MOUNT_NAME
DB_NAME=$DB_NAME
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
EOF
wait_for_provider() {
local namespace="$1"
local state=""
echo "Registering Azure provider: $namespace"
az provider register --namespace "$namespace" >/dev/null
for _ in $(seq 1 60); do
state="$(az provider show --namespace "$namespace" --query registrationState -o tsv 2>/dev/null || true)"
echo " $namespace state: ${state:-unknown}"
if [ "$state" = "Registered" ]; then
return 0
fi
sleep 10
done
echo "Provider $namespace was not registered after 10 minutes."
exit 1
}
wait_for_acr() {
local state=""
echo "Waiting for Azure Container Registry: $ACR_NAME"
for _ in $(seq 1 60); do
state="$(az acr show \
--resource-group "$RESOURCE_GROUP" \
--name "$ACR_NAME" \
--query provisioningState \
-o tsv 2>/dev/null || true)"
echo " $ACR_NAME state: ${state:-not found yet}"
if [ "$state" = "Succeeded" ]; then
return 0
fi
if [ "$state" = "Failed" ]; then
echo "Azure Container Registry provisioning failed."
exit 1
fi
sleep 10
done
echo "Azure Container Registry was not ready after 10 minutes."
exit 1
}
get_acr_credentials() {
for _ in $(seq 1 30); do
ACR_LOGIN_SERVER="$(az acr show --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" --query loginServer -o tsv 2>/dev/null || true)"
ACR_USERNAME="$(az acr credential show --name "$ACR_NAME" --query username -o tsv 2>/dev/null || true)"
ACR_PASSWORD="$(az acr credential show --name "$ACR_NAME" --query passwords[0].value -o tsv 2>/dev/null || true)"
if [ -n "$ACR_LOGIN_SERVER" ] && [ -n "$ACR_USERNAME" ] && [ -n "$ACR_PASSWORD" ]; then
return 0
fi
echo " ACR credentials are not ready yet..."
sleep 10
done
echo "Could not read Azure Container Registry credentials."
exit 1
}
yaml_quote() {
printf "'%s'" "$(printf "%s" "$1" | sed "s/'/''/g")"
}
ensure_container_env() {
local existing_env=""
local candidate=""
local candidates="${CONTAINER_ENV_LOCATIONS:-$ALLOWED_LOCATIONS}"
if az containerapp env show \
--resource-group "$CONTAINER_ENV_RESOURCE_GROUP" \
--name "$CONTAINER_ENV" >/dev/null 2>&1; then
echo "Using existing Container Apps environment: $CONTAINER_ENV in $CONTAINER_ENV_RESOURCE_GROUP"
return 0
fi
existing_env="$(az containerapp env list \
--query "[?name=='$CONTAINER_ENV'] | [0].resourceGroup" \
-o tsv 2>/dev/null || true)"
if [ -n "$existing_env" ] && [ "$existing_env" != "None" ]; then
CONTAINER_ENV_RESOURCE_GROUP="$existing_env"
echo "Found existing Container Apps environment: $CONTAINER_ENV in $CONTAINER_ENV_RESOURCE_GROUP"
return 0
fi
echo "Creating one Container Apps environment: $CONTAINER_ENV"
for candidate in $candidates; do
echo " Trying Container Apps environment region: $candidate"
if az containerapp env create \
--resource-group "$CONTAINER_ENV_RESOURCE_GROUP" \
--name "$CONTAINER_ENV" \
--location "$candidate" >/dev/null 2>&1; then
LOCATION="$candidate"
echo " Created Container Apps environment in $LOCATION"
return 0
fi
done
echo
echo "Could not create Container Apps environment in tested regions."
echo "Your subscription likely blocks Container Apps environments or reached the regional quota."
echo "List existing environments with:"
echo " az containerapp env list -o table"
echo
echo "If Azure Portal shows an existing environment, rerun with:"
echo " CONTAINER_ENV=<existing-env-name> CONTAINER_ENV_RESOURCE_GROUP=<existing-env-rg> ./prepare-app.sh"
echo
echo "You can also provide your own region list:"
echo " CONTAINER_ENV_LOCATIONS=\"region1 region2 region3\" ./prepare-app.sh"
exit 1
}
echo "Installing/updating Azure Container Apps extension..."
az extension add --name containerapp --upgrade >/dev/null
wait_for_provider "Microsoft.App"
wait_for_provider "Microsoft.OperationalInsights"
wait_for_provider "Microsoft.ContainerRegistry"
wait_for_provider "Microsoft.Storage"
EXISTING_RESOURCE_GROUP_LOCATION="$(az group show --name "$RESOURCE_GROUP" --query location -o tsv 2>/dev/null || true)"
if [ -n "$EXISTING_RESOURCE_GROUP_LOCATION" ]; then
LOCATION="$EXISTING_RESOURCE_GROUP_LOCATION"
echo "Using existing resource group $RESOURCE_GROUP in $LOCATION..."
else
echo "Creating resource group in $LOCATION..."
az group create --name "$RESOURCE_GROUP" --location "$LOCATION" >/dev/null
fi
if [ "$CONTAINER_ENV_RESOURCE_GROUP" != "$RESOURCE_GROUP" ]; then
az group show --name "$CONTAINER_ENV_RESOURCE_GROUP" >/dev/null
fi
echo "Creating Azure Container Registry..."
if ! az acr show --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" >/dev/null 2>&1; then
az acr create \
--resource-group "$RESOURCE_GROUP" \
--name "$ACR_NAME" \
--sku Basic \
--admin-enabled true >/dev/null
fi
wait_for_acr
az acr update --resource-group "$RESOURCE_GROUP" --name "$ACR_NAME" --admin-enabled true >/dev/null
get_acr_credentials
echo "Logging in to Azure Container Registry..."
az acr login --name "$ACR_NAME" >/dev/null
echo "Building and pushing backend image..."
docker build -t "$ACR_LOGIN_SERVER/${APP_PREFIX}-backend:latest" ./backend
docker push "$ACR_LOGIN_SERVER/${APP_PREFIX}-backend:latest"
echo "Building and pushing frontend image..."
docker build --build-arg API_BASE_URL=/api -t "$ACR_LOGIN_SERVER/${APP_PREFIX}-frontend:latest" ./frontend
docker push "$ACR_LOGIN_SERVER/${APP_PREFIX}-frontend:latest"
echo "Building and pushing postgres image..."
docker build -t "$ACR_LOGIN_SERVER/${APP_PREFIX}-postgres:latest" ./postgres
docker push "$ACR_LOGIN_SERVER/${APP_PREFIX}-postgres:latest"
ensure_container_env
CONTAINER_ENV_ID="$(az containerapp env show \
--resource-group "$CONTAINER_ENV_RESOURCE_GROUP" \
--name "$CONTAINER_ENV" \
--query id -o tsv)"
echo "Creating Azure Storage Account for persistent PostgreSQL backups..."
if ! az storage account show --resource-group "$RESOURCE_GROUP" --name "$STORAGE_ACCOUNT" >/dev/null 2>&1; then
az storage account create \
--resource-group "$RESOURCE_GROUP" \
--name "$STORAGE_ACCOUNT" \
--location "$LOCATION" \
--sku Standard_LRS \
--kind StorageV2 \
--min-tls-version TLS1_2 >/dev/null
fi
STORAGE_KEY="$(az storage account keys list \
--resource-group "$RESOURCE_GROUP" \
--account-name "$STORAGE_ACCOUNT" \
--query "[0].value" \
-o tsv)"
echo "Creating Azure Files share for persistent PostgreSQL backups..."
az storage share create \
--account-name "$STORAGE_ACCOUNT" \
--account-key "$STORAGE_KEY" \
--name "$STORAGE_SHARE" \
--quota 5 >/dev/null
echo "Connecting Azure Files share to Container Apps environment..."
az containerapp env storage set \
--resource-group "$CONTAINER_ENV_RESOURCE_GROUP" \
--name "$CONTAINER_ENV" \
--storage-name "$STORAGE_MOUNT_NAME" \
--storage-type AzureFile \
--azure-file-account-name "$STORAGE_ACCOUNT" \
--azure-file-account-key "$STORAGE_KEY" \
--azure-file-share-name "$STORAGE_SHARE" \
--access-mode ReadWrite >/dev/null
APP_YAML=".skuska-containerapp.yaml"
REGISTRY_PASSWORD_YAML="$(yaml_quote "$ACR_PASSWORD")"
DB_PASSWORD_YAML="$(yaml_quote "$DB_PASSWORD")"
cat > "$APP_YAML" <<EOF
properties:
managedEnvironmentId: $CONTAINER_ENV_ID
configuration:
activeRevisionsMode: Single
secrets:
- name: registry-password
value: $REGISTRY_PASSWORD_YAML
- name: db-password
value: $DB_PASSWORD_YAML
registries:
- server: $ACR_LOGIN_SERVER
username: $ACR_USERNAME
passwordSecretRef: registry-password
ingress:
external: true
targetPort: 80
transport: auto
allowInsecure: false
template:
containers:
- name: frontend
image: $ACR_LOGIN_SERVER/${APP_PREFIX}-frontend:latest
resources:
cpu: 0.25
memory: 0.5Gi
- name: backend
image: $ACR_LOGIN_SERVER/${APP_PREFIX}-backend:latest
env:
- name: DB_HOST
value: 127.0.0.1
- name: DB_PORT
value: "5432"
- name: DB_USER
value: $DB_USER
- name: DB_PASSWORD
secretRef: db-password
- name: DB_NAME
value: $DB_NAME
- name: DB_SSL
value: "false"
resources:
cpu: 0.5
memory: 1Gi
- name: postgres
image: $ACR_LOGIN_SERVER/${APP_PREFIX}-postgres:latest
env:
- name: POSTGRES_USER
value: $DB_USER
- name: POSTGRES_PASSWORD
secretRef: db-password
- name: POSTGRES_DB
value: $DB_NAME
- name: PGDATA
value: /tmp/postgres-data
- name: BACKUP_DIR
value: /backup
- name: BACKUP_INTERVAL_SECONDS
value: "60"
resources:
cpu: 0.5
memory: 1Gi
volumeMounts:
- volumeName: postgres-backup
mountPath: /backup
scale:
minReplicas: 1
maxReplicas: 1
volumes:
- name: postgres-backup
storageType: AzureFile
storageName: $STORAGE_MOUNT_NAME
EOF
echo "Deploying one Container App with three containers: frontend, backend, postgres..."
az containerapp delete --resource-group "$RESOURCE_GROUP" --name "$APP_NAME" --yes >/dev/null 2>&1 || true
az containerapp create \
--resource-group "$RESOURCE_GROUP" \
--name "$APP_NAME" \
--environment "$CONTAINER_ENV_ID" \
--yaml "$APP_YAML" >/dev/null
APP_FQDN="$(az containerapp show --resource-group "$RESOURCE_GROUP" --name "$APP_NAME" --query properties.configuration.ingress.fqdn -o tsv)"
rm -f "$APP_YAML"
grep -v -E '^(LOCATION|ACR_LOGIN_SERVER|APP_URL|CONTAINER_ENV_RESOURCE_GROUP)=' "$ENV_FILE" > "${ENV_FILE}.tmp"
mv "${ENV_FILE}.tmp" "$ENV_FILE"
cat >> "$ENV_FILE" <<EOF
LOCATION=$LOCATION
CONTAINER_ENV_RESOURCE_GROUP=$CONTAINER_ENV_RESOURCE_GROUP
ACR_LOGIN_SERVER=$ACR_LOGIN_SERVER
APP_URL=https://$APP_FQDN
EOF
echo
echo "Application is ready:"
echo "URL: https://$APP_FQDN"
echo "Container App: $APP_NAME"
echo "Containers: frontend, backend, postgres"
echo "Environment: $CONTAINER_ENV in $CONTAINER_ENV_RESOURCE_GROUP"
echo "Persistent backup volume: Azure Files share $STORAGE_SHARE mounted to postgres at /backup"
echo
echo "Local deployment values were saved to $ENV_FILE. Do not commit this file."

35
skuska/remove-app.sh Normal file
View File

@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
ENV_FILE=".skuska.env"
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
RESOURCE_GROUP="${RESOURCE_GROUP:-skuska-rg}"
if ! command -v az >/dev/null 2>&1; then
echo "Azure CLI is required."
exit 1
fi
az account show >/dev/null
if [ -x "./backup-db.sh" ] && [ -f "$ENV_FILE" ]; then
echo "Creating final database backup before removing Azure resources..."
if ./backup-db.sh; then
echo "Final database backup created."
else
echo "WARNING: Final database backup failed. Resources will still be removed."
fi
else
echo "Skipping final backup because backup-db.sh or $ENV_FILE was not found."
fi
echo "Deleting Azure resource group: $RESOURCE_GROUP"
az group delete --name "$RESOURCE_GROUP" --yes --no-wait
echo "Delete started. Azure will remove all application resources in this resource group."

35
skuska/start-app.sh Normal file
View File

@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
ENV_FILE=".skuska.env"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE was not found. Run prepare-app.sh first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if ! command -v az >/dev/null 2>&1; then
echo "Azure CLI is required."
exit 1
fi
az account show >/dev/null
az containerapp update \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--min-replicas 1 \
--max-replicas 1 >/dev/null
az containerapp ingress enable \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--type external \
--target-port 80 \
--transport auto >/dev/null
echo "Application started."
echo "URL: ${APP_URL:-run ./prepare-app.sh to refresh APP_URL}"

38
skuska/stop-app.sh Normal file
View File

@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
ENV_FILE=".skuska.env"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE was not found. Run prepare-app.sh first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if ! command -v az >/dev/null 2>&1; then
echo "Azure CLI is required."
exit 1
fi
az account show >/dev/null
echo "Creating final database backup before stopping the app..."
if [ -x "./backup-db.sh" ]; then
./backup-db.sh || echo "WARNING: Backup failed, continuing with stop."
fi
az containerapp update \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--min-replicas 0 \
--max-replicas 1 >/dev/null
az containerapp ingress disable \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" >/dev/null
echo "Application stopped."
echo "Minimum replicas set to 0 and public ingress disabled."
echo "The app can be started again with ./start-app.sh."