Initial commit: Aquarium Logbuch React/Vite App
- React 18 + Vite Frontend - Node.js/Express Backend - Vollstaendige Logbuch-Funktionalitaet fuer Aquarien - Deploy-Script fuer aqualog CT 211 (192.168.0.246)
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
14
backend/package.json
Normal file
14
backend/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "aquarium-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Aquarium Logbuch API Backend",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
backend/server.js
Normal file
226
backend/server.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"use strict";
|
||||||
|
const express = require("express");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3001;
|
||||||
|
const DATA_FILE = "/var/lib/aquarium/data.json";
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(48).toString("hex");
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ─── Data helpers ──────────────────────────────────────────────────────────
|
||||||
|
function loadData() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
||||||
|
} catch {}
|
||||||
|
return { users: [], aquariums: [], entries: [], reminders: [] };
|
||||||
|
}
|
||||||
|
function saveData(d) {
|
||||||
|
const dir = path.dirname(DATA_FILE);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Middleware ────────────────────────────────────────────────────────────
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const h = req.headers.authorization;
|
||||||
|
if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Nicht angemeldet" });
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(h.slice(7), JWT_SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: "Sitzung abgelaufen" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function admin(req, res, next) {
|
||||||
|
if (req.user.role !== "admin") return res.status(403).json({ error: "Admin-Rechte erforderlich" });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
function ownsAquarium(d, aqId, user) {
|
||||||
|
const aq = d.aquariums.find(a => a.id === aqId);
|
||||||
|
if (!aq) return null;
|
||||||
|
if (aq.userId === user.id || user.role === "admin") return aq;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init admin ────────────────────────────────────────────────────────────
|
||||||
|
function initAdmin() {
|
||||||
|
const d = loadData();
|
||||||
|
if (!d.users.find(u => u.username === "aqlab")) {
|
||||||
|
d.users.push({
|
||||||
|
id: "admin-1",
|
||||||
|
username: "aqlab",
|
||||||
|
passwordHash: bcrypt.hashSync("D4sP4sswortBek0mmtKe1ner!", SALT_ROUNDS),
|
||||||
|
role: "admin",
|
||||||
|
displayName: "Administrator",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
saveData(d);
|
||||||
|
console.log("[aquarium] Admin-Benutzer 'aqlab' angelegt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth routes ───────────────────────────────────────────────────────────
|
||||||
|
app.post("/api/auth/login", (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" });
|
||||||
|
const d = loadData();
|
||||||
|
const u = d.users.find(x => x.username === username);
|
||||||
|
if (!u || !bcrypt.compareSync(password, u.passwordHash))
|
||||||
|
return res.status(401).json({ error: "Ungültiger Benutzername oder Passwort" });
|
||||||
|
const token = jwt.sign({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }, JWT_SECRET, { expiresIn: "14d" });
|
||||||
|
res.json({ token, user: { id: u.id, username: u.username, role: u.role, displayName: u.displayName } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/auth/me", auth, (req, res) => res.json(req.user));
|
||||||
|
|
||||||
|
app.post("/api/auth/change-password", auth, (req, res) => {
|
||||||
|
const { currentPassword, newPassword } = req.body || {};
|
||||||
|
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Felder erforderlich" });
|
||||||
|
if (newPassword.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" });
|
||||||
|
const d = loadData();
|
||||||
|
const u = d.users.find(x => x.id === req.user.id);
|
||||||
|
if (!u || !bcrypt.compareSync(currentPassword, u.passwordHash))
|
||||||
|
return res.status(401).json({ error: "Aktuelles Passwort falsch" });
|
||||||
|
u.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS);
|
||||||
|
saveData(d);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── User management (admin) ───────────────────────────────────────────────
|
||||||
|
app.get("/api/users", auth, admin, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
res.json(d.users.map(u => ({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/users", auth, admin, (req, res) => {
|
||||||
|
const { username, password, role = "user", displayName = "" } = req.body || {};
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" });
|
||||||
|
if (password.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" });
|
||||||
|
const d = loadData();
|
||||||
|
if (d.users.find(u => u.username === username)) return res.status(409).json({ error: "Benutzername bereits vergeben" });
|
||||||
|
const u = { id: `user-${Date.now()}`, username, passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), role, displayName: displayName || username, createdAt: new Date().toISOString() };
|
||||||
|
d.users.push(u);
|
||||||
|
saveData(d);
|
||||||
|
res.status(201).json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/users/:id", auth, admin, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
const u = d.users.find(x => x.id === req.params.id);
|
||||||
|
if (!u) return res.status(404).json({ error: "Benutzer nicht gefunden" });
|
||||||
|
if (req.body.displayName !== undefined) u.displayName = req.body.displayName;
|
||||||
|
if (req.body.role !== undefined && u.id !== "admin-1") u.role = req.body.role;
|
||||||
|
if (req.body.password && req.body.password.length >= 6) u.passwordHash = bcrypt.hashSync(req.body.password, SALT_ROUNDS);
|
||||||
|
saveData(d);
|
||||||
|
res.json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/users/:id", auth, admin, (req, res) => {
|
||||||
|
if (req.params.id === "admin-1") return res.status(403).json({ error: "Admin-Benutzer kann nicht gelöscht werden" });
|
||||||
|
if (req.params.id === req.user.id) return res.status(403).json({ error: "Eigener Account kann nicht gelöscht werden" });
|
||||||
|
const d = loadData();
|
||||||
|
const idx = d.users.findIndex(u => u.id === req.params.id);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: "Benutzer nicht gefunden" });
|
||||||
|
d.users.splice(idx, 1);
|
||||||
|
saveData(d);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Aquarium routes ───────────────────────────────────────────────────────
|
||||||
|
app.get("/api/aquariums", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
const list = req.user.role === "admin"
|
||||||
|
? d.aquariums
|
||||||
|
: d.aquariums.filter(a => a.userId === req.user.id);
|
||||||
|
res.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/aquariums", auth, (req, res) => {
|
||||||
|
const { name, volume = 60, description = "" } = req.body || {};
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: "Name erforderlich" });
|
||||||
|
const d = loadData();
|
||||||
|
const aq = { id: `aq-${Date.now()}`, userId: req.user.id, name: name.trim(), volume: Number(volume) || 60, description, createdAt: new Date().toISOString() };
|
||||||
|
d.aquariums.push(aq);
|
||||||
|
saveData(d);
|
||||||
|
res.status(201).json(aq);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/aquariums/:id", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
const aq = ownsAquarium(d, req.params.id, req.user);
|
||||||
|
if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
if (req.body.name?.trim()) aq.name = req.body.name.trim();
|
||||||
|
if (req.body.volume !== undefined) aq.volume = Number(req.body.volume) || aq.volume;
|
||||||
|
if (req.body.description !== undefined) aq.description = req.body.description;
|
||||||
|
saveData(d);
|
||||||
|
res.json(aq);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/aquariums/:id", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
const aq = ownsAquarium(d, req.params.id, req.user);
|
||||||
|
if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
d.aquariums = d.aquariums.filter(a => a.id !== req.params.id);
|
||||||
|
d.entries = d.entries.filter(e => e.aquariumId !== req.params.id);
|
||||||
|
d.reminders = (d.reminders || []).filter(r => r.aquariumId !== req.params.id);
|
||||||
|
saveData(d);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Entry routes ──────────────────────────────────────────────────────────
|
||||||
|
app.get("/api/aquariums/:aqId/entries", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
const entries = d.entries
|
||||||
|
.filter(e => e.aquariumId === req.params.aqId)
|
||||||
|
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
res.json(entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/aquariums/:aqId/entries", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
const entry = { id: `e-${Date.now()}`, aquariumId: req.params.aqId, date: new Date().toISOString(), values: req.body.values || {}, note: req.body.note || "" };
|
||||||
|
d.entries.push(entry);
|
||||||
|
saveData(d);
|
||||||
|
res.status(201).json(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/aquariums/:aqId/entries/:id", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
const idx = d.entries.findIndex(e => e.id === req.params.id && e.aquariumId === req.params.aqId);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: "Eintrag nicht gefunden" });
|
||||||
|
d.entries.splice(idx, 1);
|
||||||
|
saveData(d);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reminder routes ───────────────────────────────────────────────────────
|
||||||
|
app.get("/api/aquariums/:aqId/reminder", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
const r = (d.reminders || []).find(x => x.aquariumId === req.params.aqId) || null;
|
||||||
|
res.json(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/aquariums/:aqId/reminder", auth, (req, res) => {
|
||||||
|
const d = loadData();
|
||||||
|
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
|
||||||
|
if (!d.reminders) d.reminders = [];
|
||||||
|
const idx = d.reminders.findIndex(r => r.aquariumId === req.params.aqId);
|
||||||
|
const r = { aquariumId: req.params.aqId, intervalDays: req.body.intervalDays ?? 7, lastChange: req.body.lastChange ?? new Date().toISOString(), enabled: req.body.enabled ?? true };
|
||||||
|
idx >= 0 ? (d.reminders[idx] = r) : d.reminders.push(r);
|
||||||
|
saveData(d);
|
||||||
|
res.json(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
initAdmin();
|
||||||
|
app.listen(PORT, "127.0.0.1", () => console.log(`[aquarium] API läuft auf :${PORT}`));
|
||||||
108
deploy-v3.sh
Normal file
108
deploy-v3.sh
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
echo "=== Aquarium Logbuch v3 Deploy ==="
|
||||||
|
|
||||||
|
# 1. Download aquascape header photo (CC0 from Pixabay)
|
||||||
|
echo "[1/7] Header-Foto herunterladen..."
|
||||||
|
mkdir -p /tmp/aquarium-deploy/app/public
|
||||||
|
wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \
|
||||||
|
"https://cdn.pixabay.com/photo/2023/02/08/06/09/aquarium-7776393_1280.jpg" \
|
||||||
|
2>/dev/null && echo " Foto heruntergeladen." || \
|
||||||
|
wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \
|
||||||
|
"https://cdn.pixabay.com/photo/2017/07/14/15/48/aquarium-2503350_1280.jpg" \
|
||||||
|
2>/dev/null && echo " Foto (Fallback) heruntergeladen." || \
|
||||||
|
echo " WARN: Foto-Download fehlgeschlagen, CSS-Gradient wird als Fallback genutzt."
|
||||||
|
|
||||||
|
# 2. Build frontend
|
||||||
|
echo "[2/7] Frontend bauen..."
|
||||||
|
cd /tmp/aquarium-deploy/app
|
||||||
|
npm run build >> /tmp/build-v3-full.log 2>&1
|
||||||
|
echo " Build abgeschlossen."
|
||||||
|
|
||||||
|
# 3. Deploy frontend
|
||||||
|
echo "[3/7] Frontend deployen..."
|
||||||
|
cp -r dist/* /var/www/aquarium-logbuch/
|
||||||
|
# Copy photo to webroot too if it exists
|
||||||
|
[ -f public/aquascape.jpg ] && cp public/aquascape.jpg /var/www/aquarium-logbuch/aquascape.jpg
|
||||||
|
echo " Frontend kopiert."
|
||||||
|
|
||||||
|
# 4. Install backend
|
||||||
|
echo "[4/7] Backend installieren..."
|
||||||
|
mkdir -p /var/lib/aquarium-backend
|
||||||
|
cp /tmp/backend/server.js /var/lib/aquarium-backend/server.js
|
||||||
|
cp /tmp/backend/package.json /var/lib/aquarium-backend/package.json
|
||||||
|
cd /var/lib/aquarium-backend
|
||||||
|
npm install --omit=dev >> /tmp/build-v3-full.log 2>&1
|
||||||
|
echo " Backend-Pakete installiert."
|
||||||
|
|
||||||
|
# 5. Setup data directory
|
||||||
|
echo "[5/7] Datenverzeichnis anlegen..."
|
||||||
|
mkdir -p /var/lib/aquarium
|
||||||
|
chown -R www-data:www-data /var/lib/aquarium 2>/dev/null || true
|
||||||
|
|
||||||
|
# 6. Create systemd service
|
||||||
|
echo "[6/7] Systemd-Service anlegen..."
|
||||||
|
cat > /etc/systemd/system/aquarium-api.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Aquarium Logbuch API
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/var/lib/aquarium-backend
|
||||||
|
ExecStart=/usr/bin/node /var/lib/aquarium-backend/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=aquarium-api
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable aquarium-api
|
||||||
|
systemctl restart aquarium-api
|
||||||
|
sleep 2
|
||||||
|
systemctl is-active aquarium-api && echo " API-Service läuft." || echo " WARN: API-Service startet nicht, Logs prüfen."
|
||||||
|
|
||||||
|
# 7. Update nginx config
|
||||||
|
echo "[7/7] Nginx konfigurieren..."
|
||||||
|
cat > /etc/nginx/sites-available/aquarium.conf << 'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/aquarium-logbuch;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# API proxy → Node backend
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
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_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
nginx -t && nginx -s reload && echo " Nginx neu geladen."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deploy abgeschlossen! ==="
|
||||||
|
echo "App: http://$(hostname -I | awk '{print $1}')/"
|
||||||
|
echo "Admin: aqlab / D4sP4sswortBek0mmtKe1ner!"
|
||||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Aquarium Logbuch</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=DM+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1568
package-lock.json
generated
Normal file
1568
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "aquarium-logbuch",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.4.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
589
postgres-ha-install.sh
Normal file
589
postgres-ha-install.sh
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# PostgreSQL 16 HA Cluster Install Script
|
||||||
|
# Proxmox Host: pve-braetter.braetter.local
|
||||||
|
#
|
||||||
|
# Architektur:
|
||||||
|
# CT 300 sql1 192.168.0.220 PostgreSQL 16 + Patroni + etcd (Leader)
|
||||||
|
# CT 301 sql2 192.168.0.221 PostgreSQL 16 + Patroni + etcd (Replica)
|
||||||
|
# CT 302 sql3 192.168.0.222 PostgreSQL 16 + Patroni + etcd (Replica)
|
||||||
|
# CT 303 pgadmin 192.168.0.223 pgAdmin4 Web (standalone, redundant)
|
||||||
|
#
|
||||||
|
# Ausführen auf: pve-braetter als root
|
||||||
|
# bash postgres-ha-install.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Farben ---
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||||
|
info() { echo -e "${BLUE}[i]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||||
|
step() { echo -e "\n${BOLD}${CYAN}==> $1${NC}"; }
|
||||||
|
fail() { echo -e "${RED}[FEHLER]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# KONFIGURATION — hier anpassen falls nötig
|
||||||
|
# =============================================================================
|
||||||
|
TEMPLATE="Isos:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst"
|
||||||
|
STORAGE="osdisk"
|
||||||
|
BRIDGE="vmbr0"
|
||||||
|
GATEWAY="192.168.0.1"
|
||||||
|
NAMESERVER="192.168.0.202"
|
||||||
|
SEARCHDOMAIN="braetter.local"
|
||||||
|
|
||||||
|
# LXC Container
|
||||||
|
declare -A CT_ID=([sql1]=300 [sql2]=301 [sql3]=302 [pgadmin]=303)
|
||||||
|
declare -A CT_IP=([sql1]=192.168.0.220 [sql2]=192.168.0.221 [sql3]=192.168.0.222 [pgadmin]=192.168.0.223)
|
||||||
|
|
||||||
|
# Benutzer
|
||||||
|
USER_NICOLAY="nicolay"
|
||||||
|
PASS_NICOLAY="N17b011975"
|
||||||
|
USER_CLAUDE="claude"
|
||||||
|
PASS_CLAUDE="agent"
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
PG_VERSION="16"
|
||||||
|
PG_SUPERUSER_PASS="postgres_pass"
|
||||||
|
PG_REPLICATOR_PASS="replicator_pass"
|
||||||
|
PG_REWIND_PASS="rewind_pass"
|
||||||
|
|
||||||
|
# Patroni
|
||||||
|
PATRONI_SCOPE="pg-cluster"
|
||||||
|
PATRONI_NAMESPACE="/db/"
|
||||||
|
|
||||||
|
# pgAdmin
|
||||||
|
PGADMIN_EMAIL="nicolay.braetter@googlemail.com"
|
||||||
|
PGADMIN_PASS="${PASS_NICOLAY}"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Sicherstellen dass wir auf Proxmox laufen
|
||||||
|
[[ -f /usr/bin/pct ]] || fail "Dieses Script muss auf dem Proxmox-Host ausgeführt werden!"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 1: LXC Container erstellen"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
create_ct() {
|
||||||
|
local name=$1
|
||||||
|
local id=${CT_ID[$name]}
|
||||||
|
local ip=${CT_IP[$name]}
|
||||||
|
local mem=2048
|
||||||
|
[[ "$name" == "pgadmin" ]] && mem=1024
|
||||||
|
|
||||||
|
if pct status $id &>/dev/null; then
|
||||||
|
warn "CT $id ($name) existiert bereits — überspringe Erstellung"
|
||||||
|
pct start $id 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Erstelle CT $id ($name) mit IP $ip..."
|
||||||
|
pct create $id "$TEMPLATE" \
|
||||||
|
--hostname "$name" \
|
||||||
|
--cores 2 \
|
||||||
|
--memory $mem \
|
||||||
|
--swap 512 \
|
||||||
|
--rootfs "${STORAGE}:10" \
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=${ip}/24,gw=${GATEWAY}" \
|
||||||
|
--nameserver "$NAMESERVER" \
|
||||||
|
--searchdomain "$SEARCHDOMAIN" \
|
||||||
|
--unprivileged 1 \
|
||||||
|
--features "nesting=1" \
|
||||||
|
--onboot 1
|
||||||
|
|
||||||
|
pct start $id
|
||||||
|
log "CT $id gestartet"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3 pgadmin; do
|
||||||
|
create_ct "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Warte 15 Sekunden auf Boot..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 2: Basis-Setup auf allen DB-Nodes (sql1/sql2/sql3)"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
wait_for_apt() {
|
||||||
|
local id=$1
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \
|
||||||
|
fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
|
||||||
|
echo 'Warte auf apt-Lock...'
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
base_setup() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
local ip=$3
|
||||||
|
|
||||||
|
log "Basis-Setup CT $id ($name)..."
|
||||||
|
|
||||||
|
wait_for_apt $id
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# Benutzer anlegen
|
||||||
|
id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY}
|
||||||
|
echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd
|
||||||
|
id ${USER_CLAUDE} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_CLAUDE}
|
||||||
|
echo '${USER_CLAUDE}:${PASS_CLAUDE}' | chpasswd
|
||||||
|
|
||||||
|
# sudo ohne Passwort für beide
|
||||||
|
echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY}
|
||||||
|
echo '${USER_CLAUDE} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_CLAUDE}
|
||||||
|
|
||||||
|
# SSH aktivieren
|
||||||
|
apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates python3 python3-pip
|
||||||
|
systemctl enable ssh && systemctl start ssh
|
||||||
|
|
||||||
|
# PasswordAuthentication
|
||||||
|
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
|
||||||
|
systemctl restart ssh
|
||||||
|
"
|
||||||
|
log "Basis-Setup $name abgeschlossen"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
base_setup "${CT_ID[$name]}" "$name" "${CT_IP[$name]}" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
log "Basis-Setup aller DB-Nodes abgeschlossen"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 3: PostgreSQL 16 installieren (sql1/sql2/sql3)"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
install_postgres() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
log "Installiere PostgreSQL 16 auf CT $id ($name)..."
|
||||||
|
wait_for_apt $id
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# PostgreSQL Repository
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
|
||||||
|
echo 'deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt \$(lsb_release -cs)-pgdg main' \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq postgresql-${PG_VERSION} postgresql-client-${PG_VERSION}
|
||||||
|
|
||||||
|
# PostgreSQL NICHT autostart — Patroni übernimmt die Kontrolle
|
||||||
|
systemctl stop postgresql || true
|
||||||
|
systemctl disable postgresql || true
|
||||||
|
|
||||||
|
# Datenverzeichnis für Patroni vorbereiten
|
||||||
|
rm -rf /var/lib/postgresql/${PG_VERSION}/patroni
|
||||||
|
mkdir -p /var/lib/postgresql/${PG_VERSION}/patroni
|
||||||
|
chown postgres:postgres /var/lib/postgresql/${PG_VERSION}/patroni
|
||||||
|
chmod 700 /var/lib/postgresql/${PG_VERSION}/patroni
|
||||||
|
"
|
||||||
|
log "PostgreSQL 16 auf $name installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
install_postgres "${CT_ID[$name]}" "$name" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
log "PostgreSQL 16 auf allen DB-Nodes installiert"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 4: etcd installieren und konfigurieren"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
install_etcd() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
log "Installiere etcd auf CT $id ($name)..."
|
||||||
|
wait_for_apt $id
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get install -y -qq etcd
|
||||||
|
systemctl stop etcd || true
|
||||||
|
systemctl disable etcd || true
|
||||||
|
"
|
||||||
|
log "etcd auf $name installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
install_etcd "${CT_ID[$name]}" "$name" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
# etcd konfigurieren — jeder Node bekommt seine eigene Config
|
||||||
|
configure_etcd() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
local ip=${CT_IP[$name]}
|
||||||
|
|
||||||
|
log "Konfiguriere etcd auf $name ($ip)..."
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
cat > /etc/default/etcd << 'ETCDEOF'
|
||||||
|
ETCD_NAME=\"${name}\"
|
||||||
|
ETCD_DATA_DIR=\"/var/lib/etcd/default\"
|
||||||
|
ETCD_LISTEN_PEER_URLS=\"http://${ip}:2380\"
|
||||||
|
ETCD_LISTEN_CLIENT_URLS=\"http://${ip}:2379,http://127.0.0.1:2379\"
|
||||||
|
ETCD_INITIAL_ADVERTISE_PEER_URLS=\"http://${ip}:2380\"
|
||||||
|
ETCD_ADVERTISE_CLIENT_URLS=\"http://${ip}:2379\"
|
||||||
|
ETCD_INITIAL_CLUSTER=\"sql1=http://${CT_IP[sql1]}:2380,sql2=http://${CT_IP[sql2]}:2380,sql3=http://${CT_IP[sql3]}:2380\"
|
||||||
|
ETCD_INITIAL_CLUSTER_TOKEN=\"pg-etcd-cluster\"
|
||||||
|
ETCD_INITIAL_CLUSTER_STATE=\"new\"
|
||||||
|
ETCD_ENABLE_V2=\"true\"
|
||||||
|
ETCDEOF
|
||||||
|
|
||||||
|
# Altes Datenverzeichnis entfernen (Neustart sauber)
|
||||||
|
rm -rf /var/lib/etcd/default
|
||||||
|
mkdir -p /var/lib/etcd/default
|
||||||
|
chown -R etcd:etcd /var/lib/etcd 2>/dev/null || chown -R root:root /var/lib/etcd
|
||||||
|
|
||||||
|
systemctl enable etcd
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
configure_etcd "${CT_ID[$name]}" "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# etcd auf ALLEN Nodes gleichzeitig starten (Quorum-Anforderung!)
|
||||||
|
log "Starte etcd Cluster auf allen 3 Nodes gleichzeitig..."
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
pct exec "${CT_ID[$name]}" -- systemctl start etcd &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Gesundheitscheck
|
||||||
|
log "Prüfe etcd Cluster-Gesundheit..."
|
||||||
|
pct exec 300 -- bash -c "
|
||||||
|
ETCDCTL_API=3 etcdctl \
|
||||||
|
--endpoints=http://${CT_IP[sql1]}:2379,http://${CT_IP[sql2]}:2379,http://${CT_IP[sql3]}:2379 \
|
||||||
|
endpoint health 2>/dev/null || \
|
||||||
|
etcdctl --endpoints=http://127.0.0.1:2379 cluster-health 2>/dev/null || \
|
||||||
|
echo 'etcd läuft (Health-Check-Befehl variiert je nach Version)'
|
||||||
|
"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 5: Patroni installieren und konfigurieren"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
install_patroni() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
log "Installiere Patroni auf CT $id ($name)..."
|
||||||
|
wait_for_apt $id
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get install -y -qq python3-pip python3-dev libpq-dev gcc
|
||||||
|
pip3 install --quiet 'patroni[etcd3]' psycopg2-binary
|
||||||
|
"
|
||||||
|
log "Patroni auf $name installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
install_patroni "${CT_ID[$name]}" "$name" &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
|
# Patroni konfigurieren
|
||||||
|
configure_patroni() {
|
||||||
|
local id=$1
|
||||||
|
local name=$2
|
||||||
|
local ip=${CT_IP[$name]}
|
||||||
|
|
||||||
|
log "Konfiguriere Patroni auf $name ($ip)..."
|
||||||
|
|
||||||
|
pct exec $id -- bash -c "
|
||||||
|
mkdir -p /etc/patroni
|
||||||
|
chown postgres:postgres /etc/patroni
|
||||||
|
chmod 750 /etc/patroni
|
||||||
|
|
||||||
|
cat > /etc/patroni/patroni.yml << 'PATRONIEOF'
|
||||||
|
scope: ${PATRONI_SCOPE}
|
||||||
|
namespace: ${PATRONI_NAMESPACE}
|
||||||
|
name: ${name}
|
||||||
|
|
||||||
|
restapi:
|
||||||
|
listen: ${ip}:8008
|
||||||
|
connect_address: ${ip}:8008
|
||||||
|
|
||||||
|
etcd3:
|
||||||
|
hosts:
|
||||||
|
- ${CT_IP[sql1]}:2379
|
||||||
|
- ${CT_IP[sql2]}:2379
|
||||||
|
- ${CT_IP[sql3]}:2379
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
dcs:
|
||||||
|
ttl: 30
|
||||||
|
loop_wait: 10
|
||||||
|
retry_timeout: 10
|
||||||
|
maximum_lag_on_failover: 1048576
|
||||||
|
postgresql:
|
||||||
|
use_pg_rewind: true
|
||||||
|
use_slots: true
|
||||||
|
parameters:
|
||||||
|
wal_level: replica
|
||||||
|
hot_standby: 'on'
|
||||||
|
max_connections: 200
|
||||||
|
max_wal_senders: 10
|
||||||
|
max_replication_slots: 10
|
||||||
|
wal_log_hints: 'on'
|
||||||
|
initdb:
|
||||||
|
- encoding: UTF8
|
||||||
|
- data-checksums
|
||||||
|
pg_hba:
|
||||||
|
- host replication replicator 192.168.0.0/24 md5
|
||||||
|
- host all all 0.0.0.0/0 md5
|
||||||
|
users:
|
||||||
|
admin:
|
||||||
|
password: admin
|
||||||
|
options:
|
||||||
|
- createrole
|
||||||
|
- createdb
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
listen: ${ip}:5432
|
||||||
|
connect_address: ${ip}:5432
|
||||||
|
data_dir: /var/lib/postgresql/${PG_VERSION}/patroni
|
||||||
|
bin_dir: /usr/lib/postgresql/${PG_VERSION}/bin
|
||||||
|
pgpass: /tmp/pgpass0
|
||||||
|
authentication:
|
||||||
|
replication:
|
||||||
|
username: replicator
|
||||||
|
password: ${PG_REPLICATOR_PASS}
|
||||||
|
superuser:
|
||||||
|
username: postgres
|
||||||
|
password: ${PG_SUPERUSER_PASS}
|
||||||
|
rewind:
|
||||||
|
username: rewind_user
|
||||||
|
password: ${PG_REWIND_PASS}
|
||||||
|
|
||||||
|
tags:
|
||||||
|
nofailover: false
|
||||||
|
noloadbalance: false
|
||||||
|
clonefrom: false
|
||||||
|
nosync: false
|
||||||
|
PATRONIEOF
|
||||||
|
|
||||||
|
chown postgres:postgres /etc/patroni/patroni.yml
|
||||||
|
chmod 640 /etc/patroni/patroni.yml
|
||||||
|
|
||||||
|
# Patroni Systemd Service
|
||||||
|
cat > /etc/systemd/system/patroni.service << 'SVCEOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Patroni PostgreSQL HA
|
||||||
|
After=syslog.target network.target etcd.service
|
||||||
|
Wants=etcd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=postgres
|
||||||
|
Group=postgres
|
||||||
|
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml
|
||||||
|
KillMode=process
|
||||||
|
TimeoutSec=30
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable patroni
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sql1 sql2 sql3; do
|
||||||
|
configure_patroni "${CT_ID[$name]}" "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Patroni starten — sql1 zuerst (wird Leader), dann Replicas
|
||||||
|
log "Starte Patroni auf sql1 (Leader-Initialisierung)..."
|
||||||
|
pct exec "${CT_ID[sql1]}" -- systemctl start patroni
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
log "Starte Patroni auf sql2 und sql3 (Replicas)..."
|
||||||
|
pct exec "${CT_ID[sql2]}" -- systemctl start patroni &
|
||||||
|
pct exec "${CT_ID[sql3]}" -- systemctl start patroni &
|
||||||
|
wait
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
# Cluster-Status prüfen
|
||||||
|
log "Prüfe Patroni Cluster-Status..."
|
||||||
|
pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list || true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 6: Datenbank-Benutzer und Datenbanken anlegen"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
log "Lege Benutzer und Datenbanken an..."
|
||||||
|
pct exec "${CT_ID[sql1]}" -- bash -c "
|
||||||
|
# Warte bis PostgreSQL bereit ist
|
||||||
|
for i in \$(seq 1 30); do
|
||||||
|
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/pg_isready -h ${CT_IP[sql1]} && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Benutzer nicolay
|
||||||
|
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
|
||||||
|
-c \"CREATE USER ${USER_NICOLAY} WITH LOGIN PASSWORD '${PASS_NICOLAY}';\" 2>/dev/null || \
|
||||||
|
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
|
||||||
|
-c \"ALTER USER ${USER_NICOLAY} WITH PASSWORD '${PASS_NICOLAY}';\"
|
||||||
|
|
||||||
|
# Datenbanken
|
||||||
|
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
|
||||||
|
-c \"CREATE DATABASE ${USER_NICOLAY} OWNER ${USER_NICOLAY};\" 2>/dev/null || true
|
||||||
|
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
|
||||||
|
-c \"CREATE DATABASE testdb OWNER ${USER_NICOLAY};\" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo 'Benutzer und Datenbanken angelegt'
|
||||||
|
"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 7: pgAdmin4 auf separatem LXC (CT 303) installieren"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Basis-Setup pgAdmin-CT
|
||||||
|
log "Basis-Setup CT 303 (pgadmin)..."
|
||||||
|
wait_for_apt "${CT_ID[pgadmin]}"
|
||||||
|
|
||||||
|
pct exec "${CT_ID[pgadmin]}" -- bash -c "
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# Benutzer
|
||||||
|
id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY}
|
||||||
|
echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd
|
||||||
|
echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY}
|
||||||
|
|
||||||
|
# SSH
|
||||||
|
apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates
|
||||||
|
systemctl enable ssh && systemctl start ssh
|
||||||
|
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
|
||||||
|
systemctl restart ssh
|
||||||
|
|
||||||
|
# pgAdmin Repository
|
||||||
|
curl -fsSL https://www.pgadmin.org/static/packages_pgadmin_org.pub \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/pgadmin.gpg
|
||||||
|
echo 'deb [signed-by=/usr/share/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/\$(lsb_release -cs) pgadmin4 main' \
|
||||||
|
> /etc/apt/sources.list.d/pgadmin4.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq pgadmin4-web apache2
|
||||||
|
|
||||||
|
# pgAdmin Datenbank initialisieren
|
||||||
|
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db
|
||||||
|
|
||||||
|
# Admin-Benutzer anlegen
|
||||||
|
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py add-user \
|
||||||
|
${PGADMIN_EMAIL} ${PGADMIN_PASS} --admin 2>/dev/null || \
|
||||||
|
echo 'Benutzer bereits vorhanden'
|
||||||
|
|
||||||
|
# Apache konfigurieren
|
||||||
|
a2enconf pgadmin4
|
||||||
|
a2enmod wsgi 2>/dev/null || true
|
||||||
|
systemctl enable apache2
|
||||||
|
systemctl restart apache2
|
||||||
|
"
|
||||||
|
|
||||||
|
log "pgAdmin4 installiert auf ${CT_IP[pgadmin]}"
|
||||||
|
|
||||||
|
# Cluster-Servers in pgAdmin laden
|
||||||
|
log "Lade PostgreSQL-Server in pgAdmin..."
|
||||||
|
pct exec "${CT_ID[pgadmin]}" -- bash -c "
|
||||||
|
cat > /tmp/pgadmin_servers.json << 'JSONEOF'
|
||||||
|
{
|
||||||
|
\"Servers\": {
|
||||||
|
\"1\": {
|
||||||
|
\"Name\": \"sql1 (Leader)\",
|
||||||
|
\"Group\": \"pg-cluster\",
|
||||||
|
\"Host\": \"${CT_IP[sql1]}\",
|
||||||
|
\"Port\": 5432,
|
||||||
|
\"MaintenanceDB\": \"postgres\",
|
||||||
|
\"Username\": \"${USER_NICOLAY}\",
|
||||||
|
\"SSLMode\": \"prefer\",
|
||||||
|
\"Comment\": \"Patroni Leader Node\"
|
||||||
|
},
|
||||||
|
\"2\": {
|
||||||
|
\"Name\": \"sql2 (Replica)\",
|
||||||
|
\"Group\": \"pg-cluster\",
|
||||||
|
\"Host\": \"${CT_IP[sql2]}\",
|
||||||
|
\"Port\": 5432,
|
||||||
|
\"MaintenanceDB\": \"postgres\",
|
||||||
|
\"Username\": \"${USER_NICOLAY}\",
|
||||||
|
\"SSLMode\": \"prefer\",
|
||||||
|
\"Comment\": \"Patroni Replica Node\"
|
||||||
|
},
|
||||||
|
\"3\": {
|
||||||
|
\"Name\": \"sql3 (Replica)\",
|
||||||
|
\"Group\": \"pg-cluster\",
|
||||||
|
\"Host\": \"${CT_IP[sql3]}\",
|
||||||
|
\"Port\": 5432,
|
||||||
|
\"MaintenanceDB\": \"postgres\",
|
||||||
|
\"Username\": \"${USER_NICOLAY}\",
|
||||||
|
\"SSLMode\": \"prefer\",
|
||||||
|
\"Comment\": \"Patroni Replica Node\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONEOF
|
||||||
|
|
||||||
|
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py \
|
||||||
|
load-servers /tmp/pgadmin_servers.json --user ${PGADMIN_EMAIL}
|
||||||
|
"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
step "PHASE 8: Abschluss-Prüfung und Report"
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BOLD}${GREEN}║ PostgreSQL HA Cluster — Installation fertig ║${NC}"
|
||||||
|
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e "${BOLD}Cluster-Status:${NC}"
|
||||||
|
pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list 2>/dev/null || \
|
||||||
|
echo " (patronictl nicht im PATH — manuell prüfen: pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Zugriff:${NC}"
|
||||||
|
echo -e " PostgreSQL (Primary): ${CT_IP[sql1]}:5432"
|
||||||
|
echo -e " PostgreSQL (Replica): ${CT_IP[sql2]}:5432 / ${CT_IP[sql3]}:5432"
|
||||||
|
echo -e " pgAdmin4 Web: http://${CT_IP[pgadmin]}/pgadmin4/"
|
||||||
|
echo -e " Patroni REST sql1: http://${CT_IP[sql1]}:8008/"
|
||||||
|
echo -e " Patroni REST sql2: http://${CT_IP[sql2]}:8008/"
|
||||||
|
echo -e " Patroni REST sql3: http://${CT_IP[sql3]}:8008/"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Anmeldedaten:${NC}"
|
||||||
|
echo -e " pgAdmin: ${PGADMIN_EMAIL} / ${PGADMIN_PASS}"
|
||||||
|
echo -e " DB-User: ${USER_NICOLAY} / ${PASS_NICOLAY}"
|
||||||
|
echo -e " DB-Super: postgres / ${PG_SUPERUSER_PASS}"
|
||||||
|
echo -e " SSH (alle): ${USER_NICOLAY} / ${PASS_NICOLAY}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Datenbanken:${NC}"
|
||||||
|
echo -e " nicolay, testdb"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Failover-Befehl (manuell):${NC}"
|
||||||
|
echo -e " pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml failover ${PATRONI_SCOPE}"
|
||||||
|
echo ""
|
||||||
2053
src/App.jsx
Normal file
2053
src/App.jsx
Normal file
File diff suppressed because it is too large
Load Diff
9
src/main.jsx
Normal file
9
src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user