skuska
This commit is contained in:
parent
a40f28696a
commit
15f4373858
6
skuska/.gitignore
vendored
Normal file
6
skuska/.gitignore
vendored
Normal 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
144
skuska/README.md
Normal 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
11
skuska/backend/Dockerfile
Normal 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
234
skuska/backend/server.js
Normal 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
33
skuska/backup-db.sh
Normal 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"
|
||||
9
skuska/frontend/Dockerfile
Normal file
9
skuska/frontend/Dockerfile
Normal 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
472
skuska/frontend/index.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
loadEntries();
|
||||
setInterval(loadStatus, 15000);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
skuska/frontend/nginx.conf
Normal file
20
skuska/frontend/nginx.conf
Normal 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
23
skuska/logs-app.sh
Normal 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
|
||||
8
skuska/postgres/Dockerfile
Normal file
8
skuska/postgres/Dockerfile
Normal 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"]
|
||||
36
skuska/postgres/start-postgres.sh
Normal file
36
skuska/postgres/start-postgres.sh
Normal 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
401
skuska/prepare-app.sh
Normal 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
35
skuska/remove-app.sh
Normal 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
35
skuska/start-app.sh
Normal 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
38
skuska/stop-app.sh
Normal 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."
|
||||
Loading…
Reference in New Issue
Block a user