Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1504960d85 | |||
| 599f090895 | |||
| 4eb2b4c517 | |||
| be3b23ed8c | |||
| 697c5781b7 | |||
| 4c36c9160a | |||
| d559a762d2 | |||
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 | |||
| f0c3cacb06 | |||
| 5821e20086 | |||
| aff8d1517d | |||
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc |
+12
-1
@@ -15,6 +15,11 @@ DeepLAPIKey=
|
||||
# Production (kapteins-daagbok.eu):
|
||||
# RP_ID=kapteins-daagbok.eu
|
||||
# ORIGIN=https://kapteins-daagbok.eu
|
||||
# Staging (staging.kapteins-daagbok.eu):
|
||||
# RP_ID=staging.kapteins-daagbok.eu
|
||||
# ORIGIN=https://staging.kapteins-daagbok.eu
|
||||
# POSTGRES_DB=daagbox_staging
|
||||
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
|
||||
RP_ID=localhost
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
@@ -37,7 +42,7 @@ ORIGIN=http://localhost:5173
|
||||
SESSION_SECRET=
|
||||
|
||||
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||
# Example: ADMIN_USER_IDS=e6bcd493-80a0-400f-8a27-43c9cdce6e29,11111111-2222-3333-4444-555555555555
|
||||
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
|
||||
ADMIN_USER_IDS=
|
||||
|
||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||
@@ -51,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
||||
|
||||
# Plausible Analytics (frontend container — see docs/plausible-events.md)
|
||||
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
|
||||
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
||||
PLAUSIBLE_ENABLED=true
|
||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||
|
||||
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
|
||||
|
||||
|
||||
```bash
|
||||
./scripts/update-prod.sh
|
||||
./scripts/update-remotes.sh -dest prod
|
||||
```
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
|
||||
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
|
||||
|
||||
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||
|
||||
### Staging
|
||||
|
||||
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
|
||||
|
||||
```bash
|
||||
./scripts/update-remotes.sh -dest stage
|
||||
```
|
||||
|
||||
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
@@ -267,6 +279,8 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
|
||||
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
||||
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
||||
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
|
||||
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
|
||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||
|
||||
+7
-4
@@ -18,15 +18,18 @@ RUN npm run build
|
||||
FROM nginx:1.25-alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Copy custom Nginx configuration
|
||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN apk add --no-cache gettext
|
||||
|
||||
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check to verify Nginx is actively running
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
|
||||
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
|
||||
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
|
||||
|
||||
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
|
||||
true|1|yes)
|
||||
PLAUSIBLE_ENABLED_JSON=true
|
||||
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
|
||||
;;
|
||||
*)
|
||||
PLAUSIBLE_ENABLED_JSON=false
|
||||
PLAUSIBLE_CSP=""
|
||||
;;
|
||||
esac
|
||||
|
||||
export PLAUSIBLE_CSP
|
||||
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
cat > /usr/share/nginx/html/runtime-config.json <<EOF
|
||||
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
||||
EOF
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
+1
-1
@@ -22,6 +22,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<script src="/plausible-bootstrap.js"></script>
|
||||
<script src="/bootstrap-watchdog.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -38,7 +39,6 @@
|
||||
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+2
-51
@@ -1,51 +1,2 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
|
||||
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
|
||||
* data-domain is always the current hostname (prod vs staging).
|
||||
*/
|
||||
(function () {
|
||||
function load(cfg) {
|
||||
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
|
||||
var host = String(cfg.plausibleHost).replace(/\/$/, '')
|
||||
if (!host) return
|
||||
var s = document.createElement('script')
|
||||
s.defer = true
|
||||
s.dataset.domain = window.location.hostname
|
||||
s.src = host + '/js/script.tagged-events.js'
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
fetch('/runtime-config.json', { cache: 'no-store' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null
|
||||
})
|
||||
.then(load)
|
||||
.catch(function () {
|
||||
/* analytics optional */
|
||||
})
|
||||
})()
|
||||
+93
-8
@@ -5025,16 +5025,68 @@ html.theme-cupertino .events-scroll-container {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-page {
|
||||
padding: 16px;
|
||||
padding: 12px 12px 20px;
|
||||
gap: 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.admin-header-left .btn-back {
|
||||
grid-row: 1 / -1;
|
||||
align-self: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-subtitle {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-control-buttons {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-control-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-control-buttons .btn {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-charts-grid {
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5137,6 +5189,36 @@ html.theme-cupertino .events-scroll-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Admin dashboard: keep 2-column KPI grid on mobile (overrides rule above) */
|
||||
.stats-kpi-grid.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-card {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-icon {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-label {
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.admin-kpi-grid .stats-kpi-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -5952,13 +6034,15 @@ html.theme-cupertino .events-scroll-container {
|
||||
.app-tour-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
|
||||
z-index: 10010;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-tour-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: rgba(2, 6, 23, 0.62);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -5976,7 +6060,7 @@ html.theme-cupertino .events-scroll-container {
|
||||
0 0 32px rgba(56, 189, 248, 0.5),
|
||||
0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 10001;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
body.app-tour-active .app-tour-target-active {
|
||||
@@ -5987,7 +6071,8 @@ body.app-tour-active .app-tour-target-active {
|
||||
|
||||
.app-tour-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10002;
|
||||
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-width: calc(100vw - 32px);
|
||||
|
||||
+19
-1
@@ -97,6 +97,8 @@ function App() {
|
||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||
const [sessionChecked, setSessionChecked] = useState(false)
|
||||
const [serverSessionActive, setServerSessionActive] = useState(false)
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
@@ -316,6 +318,8 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
setServerSessionActive(session.authenticated)
|
||||
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
@@ -335,6 +339,10 @@ function App() {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSessionChecked(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -618,7 +626,17 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
{!sessionChecked ? (
|
||||
<div className="auth-card glass">
|
||||
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AuthOnboarding
|
||||
restoreSession={serverSessionActive}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
onOpenDemo={openDemo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
getKnownUsernames,
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
logoutUser,
|
||||
resolveRestoreUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
@@ -27,9 +28,15 @@ import {
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
onOpenDemo?: () => void
|
||||
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
|
||||
restoreSession?: boolean
|
||||
}
|
||||
|
||||
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
|
||||
export default function AuthOnboarding({
|
||||
onAuthenticated,
|
||||
onOpenDemo,
|
||||
restoreSession = false
|
||||
}: AuthOnboardingProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||
const autoUnlockAttempted = useRef(false)
|
||||
|
||||
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestoreFlow || autoUnlockAttempted.current) return
|
||||
|
||||
const user = resolveRestoreUsername()
|
||||
if (user && hasLocalPin(user)) {
|
||||
autoUnlockAttempted.current = true
|
||||
setUsername(user)
|
||||
setShowPinLogin(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (user && passkeyHostOk) {
|
||||
autoUnlockAttempted.current = true
|
||||
void handleLogin(user)
|
||||
}
|
||||
}, [isRestoreFlow, passkeyHostOk])
|
||||
|
||||
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.enter_pin_title')}</h2>
|
||||
<h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{t('auth.enter_pin_warning')}
|
||||
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
if (isRestoreFlow) {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
)
|
||||
}
|
||||
|
||||
// Render: Session restore (active server cookie, master key lost after reload)
|
||||
if (isRestoreFlow) {
|
||||
const restoreUser = resolveRestoreUsername()
|
||||
const restoreKnownUsers = getKnownUsernames()
|
||||
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.restore_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
|
||||
|
||||
{loading && (
|
||||
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
|
||||
{t('auth.restore_unlocking')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
{!loading && (
|
||||
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
|
||||
{restoreUser && passkeyHostOk && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin(restoreUser)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_passkey', { name: restoreUser })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreUser && hasLocalPin(restoreUser) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setUsername(restoreUser)
|
||||
setShowPinLogin(true)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_pin')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreKnownUsers.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{t('auth.quick_login')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
|
||||
{restoreKnownUsers.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasLocalPin(name)) {
|
||||
setUsername(name)
|
||||
setShowPinLogin(true)
|
||||
} else {
|
||||
void handleLogin(name)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="btn secondary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<UserRound size={16} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setShowStandardLogin(true)
|
||||
setError(null)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_other_account')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render 3: Standard Login / Registration options form
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
|
||||
"restore_checking": "Tjekker session…",
|
||||
"restore_title": "Gendan session",
|
||||
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser op…",
|
||||
"restore_with_passkey": "Lås op med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås op med PIN",
|
||||
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
|
||||
"restore_other_account": "Log ind med en anden konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
|
||||
"restore_checking": "Session wird geprüft…",
|
||||
"restore_title": "Session wiederherstellen",
|
||||
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
|
||||
"restore_unlocking": "Wird entsperrt…",
|
||||
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
|
||||
"restore_with_pin": "Mit PIN entsperren",
|
||||
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
|
||||
"restore_other_account": "Anderer Account anmelden"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
|
||||
"restore_checking": "Checking session…",
|
||||
"restore_title": "Restore session",
|
||||
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
|
||||
"restore_unlocking": "Unlocking…",
|
||||
"restore_with_passkey": "Unlock with passkey ({{name}})",
|
||||
"restore_with_pin": "Unlock with PIN",
|
||||
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
|
||||
"restore_other_account": "Sign in with another account"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
|
||||
"restore_checking": "Sjekker økt…",
|
||||
"restore_title": "Gjenopprett økt",
|
||||
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser opp…",
|
||||
"restore_with_passkey": "Lås opp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås opp med PIN",
|
||||
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
|
||||
"restore_other_account": "Logg inn med en annen konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
|
||||
"restore_checking": "Kontrollerar session…",
|
||||
"restore_title": "Återställ session",
|
||||
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser upp…",
|
||||
"restore_with_passkey": "Lås upp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås upp med PIN",
|
||||
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
|
||||
"restore_other_account": "Logga in med ett annat konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
|
||||
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
|
||||
export function resolveRestoreUsername(): string | null {
|
||||
const stored = localStorage.getItem('active_username')
|
||||
if (stored) return stored
|
||||
const known = getKnownUsernames()
|
||||
if (known.length === 1) return known[0]
|
||||
return null
|
||||
}
|
||||
|
||||
export async function reauthWithPasskey(): Promise<boolean> {
|
||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||
method: 'POST'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasUnlockedLocalCrypto,
|
||||
hasUnlockedLocalSession,
|
||||
resolveRestoreUsername,
|
||||
setActiveMasterKey
|
||||
} from './auth.js'
|
||||
|
||||
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRestoreUsername', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('prefers active_username from storage', () => {
|
||||
localStorage.setItem('active_username', 'captain')
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
|
||||
expect(resolveRestoreUsername()).toBe('captain')
|
||||
})
|
||||
|
||||
it('falls back to a single remembered user', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
|
||||
expect(resolveRestoreUsername()).toBe('solo')
|
||||
})
|
||||
|
||||
it('returns null when multiple users and no active username', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
|
||||
expect(resolveRestoreUsername()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSessionUserId', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
|
||||
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
|
||||
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
|
||||
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
|
||||
return { plausibleEnabled, plausibleHost: host }
|
||||
}
|
||||
|
||||
function runtimeConfigPlugin(): Plugin {
|
||||
return {
|
||||
name: 'runtime-config',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/runtime-config.json') return next()
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
|
||||
})
|
||||
},
|
||||
writeBundle(options) {
|
||||
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||
writeFileSync(
|
||||
resolve(outDir, 'runtime-config.json'),
|
||||
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
envDir: resolve(__dirname, '..'),
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts']
|
||||
@@ -59,6 +87,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
versionJsonPlugin(readAppVersion()),
|
||||
runtimeConfigPlugin(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: daagbox-staging-db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: daagbox-staging-backend
|
||||
restart: always
|
||||
environment:
|
||||
PORT: 5000
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public"
|
||||
RP_ID: ${RP_ID:-localhost}
|
||||
ORIGIN: ${ORIGIN:-http://localhost}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
command: sh -c "npx prisma db push && node dist/index.js"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
retries: 5
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: client/Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
||||
container_name: daagbox-staging-frontend
|
||||
restart: always
|
||||
environment:
|
||||
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
|
||||
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: daagbox-staging-pgdata
|
||||
@@ -35,10 +35,17 @@ services:
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
command: sh -c "npx prisma db push && node dist/index.js"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
retries: 5
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -51,6 +58,9 @@ services:
|
||||
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
||||
container_name: daagbox-prod-frontend
|
||||
restart: always
|
||||
environment:
|
||||
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
|
||||
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Server-Backup (Produktion)
|
||||
|
||||
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
|
||||
|
||||
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
|
||||
|
||||
## Was wird gesichert?
|
||||
|
||||
| Inhalt | Beschreibung |
|
||||
|--------|--------------|
|
||||
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
|
||||
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
|
||||
| `docker-compose.yml` | Aktive Compose-Datei |
|
||||
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
|
||||
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
|
||||
|
||||
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
|
||||
|
||||
## Einmalige Einrichtung (Prod-Server)
|
||||
|
||||
```bash
|
||||
ssh root@10.0.0.25
|
||||
mkdir -p /var/backups/kapteins-daagbok
|
||||
chmod 700 /var/backups/kapteins-daagbok
|
||||
cd /opt/kapteins-daagbok
|
||||
git pull
|
||||
chmod +x scripts/backup.sh scripts/restore-backup.sh
|
||||
./scripts/backup.sh --reason manual
|
||||
```
|
||||
|
||||
## Manuell sichern
|
||||
|
||||
```bash
|
||||
cd /opt/kapteins-daagbok
|
||||
./scripts/backup.sh
|
||||
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
|
||||
```
|
||||
|
||||
### Staging (manueller Test)
|
||||
|
||||
```bash
|
||||
cd /opt/kapteins-daagbok-staging
|
||||
./scripts/backup.sh -dest stage --reason manual
|
||||
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
|
||||
./scripts/backup.sh --reason manual
|
||||
```
|
||||
|
||||
## Crontab (unbeaufsichtigt)
|
||||
|
||||
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
# Zeile einfügen:
|
||||
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||
```
|
||||
|
||||
## Pre-Deploy-Backup
|
||||
|
||||
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
|
||||
|
||||
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
|
||||
|
||||
## Wiederherstellen
|
||||
|
||||
Verfügbare Backups anzeigen:
|
||||
|
||||
```bash
|
||||
./scripts/restore-backup.sh --list
|
||||
```
|
||||
|
||||
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
|
||||
|
||||
```bash
|
||||
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
|
||||
```
|
||||
|
||||
Nur Datenbank:
|
||||
|
||||
```bash
|
||||
./scripts/restore-backup.sh --restore PATH --db-only
|
||||
```
|
||||
|
||||
Nur `.env`:
|
||||
|
||||
```bash
|
||||
./scripts/restore-backup.sh --restore PATH --env-only
|
||||
```
|
||||
|
||||
Ohne Rückfragen (Notfall):
|
||||
|
||||
```bash
|
||||
./scripts/restore-backup.sh --restore PATH --full --yes
|
||||
```
|
||||
|
||||
## Vor Passwort-Rotation
|
||||
|
||||
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh --reason manual
|
||||
```
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
| Variable | Prod (default) | Staging (`-dest stage`) |
|
||||
|----------|----------------|-------------------------|
|
||||
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
|
||||
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
|
||||
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
|
||||
| `RETENTION` | `5` | `5` |
|
||||
@@ -1,14 +1,14 @@
|
||||
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
||||
|
||||
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
|
||||
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging).
|
||||
|
||||
## NPM Proxy Host
|
||||
|
||||
| Einstellung | Wert |
|
||||
|-------------|------|
|
||||
| Domain | `kapteins-daagbok.eu` |
|
||||
| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
|
||||
| Scheme | `https` |
|
||||
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
|
||||
| Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) |
|
||||
| Forward Port | `80` (Frontend-Nginx) |
|
||||
| Websockets | an, falls genutzt |
|
||||
| Block Common Exploits | an |
|
||||
@@ -40,13 +40,20 @@ TRUST_PROXY=1
|
||||
## Security-Header
|
||||
|
||||
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
||||
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
|
||||
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
|
||||
|
||||
### Plausible Analytics
|
||||
|
||||
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
|
||||
Konfiguration über `.env` (Frontend-Container):
|
||||
|
||||
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
|
||||
```env
|
||||
PLAUSIBLE_ENABLED=true
|
||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||
```
|
||||
|
||||
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
|
||||
|
||||
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
|
||||
|
||||
## Nach Deploy prüfen
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Empfohlene Schritte
|
||||
|
||||
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
||||
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
|
||||
2. Auf dem Server im Repo:
|
||||
```bash
|
||||
cd /opt/kapteins-daagbok
|
||||
|
||||
@@ -31,12 +31,12 @@ cd server && npm test
|
||||
|
||||
## Nach erfolgreichem Check
|
||||
|
||||
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
||||
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
|
||||
|
||||
```bash
|
||||
./scripts/update-prod.sh
|
||||
./scripts/update-remotes.sh -dest prod
|
||||
```
|
||||
|
||||
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
|
||||
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
|
||||
|
||||
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Staging-Umgebung
|
||||
|
||||
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
|
||||
|
||||
## Unterschiede zu Produktion
|
||||
|
||||
| | Staging | Produktion |
|
||||
|---|---------|------------|
|
||||
| Host | `10.0.0.27` | `10.0.0.25` |
|
||||
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
|
||||
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
|
||||
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
|
||||
| Release-Tag | nein | ja (`v*`) |
|
||||
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
|
||||
|
||||
Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration.
|
||||
|
||||
## Erstinstallation (VM3)
|
||||
|
||||
```bash
|
||||
ssh root@10.0.0.27
|
||||
|
||||
git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging
|
||||
cd /opt/kapteins-daagbok-staging
|
||||
git checkout master
|
||||
|
||||
# .env anlegen — Secrets neu generieren, nicht von Prod kopieren
|
||||
openssl rand -hex 24 # POSTGRES_PASSWORD
|
||||
openssl rand -base64 48 # SESSION_SECRET
|
||||
|
||||
nano .env
|
||||
docker compose -f docker-compose.staging.yml up -d --build
|
||||
```
|
||||
|
||||
### `.env` (Staging)
|
||||
|
||||
```env
|
||||
ORIGIN=https://staging.kapteins-daagbok.eu
|
||||
RP_ID=staging.kapteins-daagbok.eu
|
||||
TRUST_PROXY=1
|
||||
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=<generiert>
|
||||
POSTGRES_DB=daagbox_staging
|
||||
|
||||
SESSION_SECRET=<generiert>
|
||||
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=kapteins-daagbok-staging-feedback
|
||||
|
||||
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
|
||||
PLAUSIBLE_ENABLED=false
|
||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||
```
|
||||
|
||||
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
|
||||
|
||||
## Deploy vom Entwicklungsrechner
|
||||
|
||||
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
|
||||
|
||||
```bash
|
||||
./scripts/update-remotes.sh -dest stage
|
||||
```
|
||||
|
||||
Konfiguration via Umgebungsvariablen:
|
||||
|
||||
```bash
|
||||
REMOTE_HOST=10.0.0.27 \
|
||||
REMOTE_DIR=/opt/kapteins-daagbok-staging \
|
||||
DEPLOY_BRANCH=master \
|
||||
./scripts/update-remotes.sh -dest stage
|
||||
```
|
||||
|
||||
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
|
||||
|
||||
## NPM (VM1)
|
||||
|
||||
| Einstellung | Wert |
|
||||
|-------------|------|
|
||||
| Domain | `staging.kapteins-daagbok.eu` |
|
||||
| Forward Hostname / IP | `10.0.0.27` |
|
||||
| Forward Port | `80` |
|
||||
| SSL | Let's Encrypt |
|
||||
|
||||
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
|
||||
|
||||
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
|
||||
|
||||
## Nach Deploy prüfen
|
||||
|
||||
1. https://staging.kapteins-daagbok.eu/api/health — `status: ok`
|
||||
2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging)
|
||||
3. Passkey Login
|
||||
4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
|
||||
|
||||
## Daten zurücksetzen
|
||||
|
||||
Staging-Daten sind wegwerfbar:
|
||||
|
||||
```bash
|
||||
cd /opt/kapteins-daagbok-staging
|
||||
docker compose -f docker-compose.staging.yml down
|
||||
docker volume rm daagbox-staging-pgdata
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 359 KiB |
@@ -1,12 +1,21 @@
|
||||
# Plausible Custom Events
|
||||
|
||||
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
||||
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
||||
|
||||
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
|
||||
|
||||
```env
|
||||
PLAUSIBLE_ENABLED=true # Staging: false
|
||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||
```
|
||||
|
||||
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
|
||||
|
||||
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Script in `client/index.html` (bereits eingebunden)
|
||||
1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
|
||||
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
||||
|
||||
## Event-Übersicht
|
||||
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
|
||||
#
|
||||
# Run on the server in repo root (/opt/kapteins-daagbok on production).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/backup.sh
|
||||
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
|
||||
# ./scripts/backup.sh --reason cron
|
||||
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
|
||||
# ./scripts/backup.sh --dry-run
|
||||
#
|
||||
# Environment overrides:
|
||||
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
RETENTION="${RETENTION:-5}"
|
||||
DEST="prod"
|
||||
REASON="manual"
|
||||
EXPLICIT_TAG=""
|
||||
DRY_RUN=0
|
||||
COMPOSE_FILE=""
|
||||
DB_CONTAINER=""
|
||||
|
||||
apply_dest_config() {
|
||||
local dest="$1"
|
||||
local force="${2:-0}"
|
||||
if [[ "$dest" == "stage" ]]; then
|
||||
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||
COMPOSE_FILE="docker-compose.staging.yml"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||
DB_CONTAINER="daagbox-staging-db"
|
||||
fi
|
||||
else
|
||||
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||
DB_CONTAINER="daagbox-prod-db"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
sed -n '2,14p' "$0"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -dest prod|stage Target environment (default: prod)"
|
||||
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
|
||||
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
|
||||
echo " --dry-run Show actions without writing backup"
|
||||
echo " -h, --help Show this help"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-dest)
|
||||
DEST="${2:?-dest requires an argument}"
|
||||
shift 2
|
||||
;;
|
||||
-dest=*)
|
||||
DEST="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--reason)
|
||||
REASON="${2:?--reason requires an argument}"
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
EXPLICIT_TAG="${2:?--tag requires an argument}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$DEST" in
|
||||
prod|stage) ;;
|
||||
*)
|
||||
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
apply_dest_config "$DEST"
|
||||
|
||||
case "$REASON" in
|
||||
cron|pre-deploy|manual) ;;
|
||||
*)
|
||||
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo "Error: $COMPOSE_FILE not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
GIT_TAG="$EXPLICIT_TAG"
|
||||
if [ -z "$GIT_TAG" ]; then
|
||||
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
|
||||
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
|
||||
fi
|
||||
if [ -z "$GIT_TAG" ]; then
|
||||
GIT_TAG="unknown"
|
||||
fi
|
||||
|
||||
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
|
||||
TAG_SLUG="${GIT_TAG}"
|
||||
if [ "$REASON" = "pre-deploy" ]; then
|
||||
TAG_SLUG="${GIT_TAG}-predeploy"
|
||||
fi
|
||||
TAG_SLUG="${TAG_SLUG//\//-}"
|
||||
|
||||
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
|
||||
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
|
||||
|
||||
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8} → $ARCHIVE_PATH"
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[dry-run] Would dump database from $DB_CONTAINER"
|
||||
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
|
||||
echo "[dry-run] Would create git archive"
|
||||
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
|
||||
echo "[dry-run] Would apply retention (keep $RETENTION)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Error: $ENV_FILE not found (run from repo root)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||
|
||||
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||
apply_dest_config stage 1
|
||||
DEST="stage"
|
||||
else
|
||||
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
|
||||
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
chmod 700 "$BACKUP_DIR"
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
|
||||
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
|
||||
| gzip > "$WORK_DIR/database.sql.gz"; then
|
||||
echo "Error: pg_dump failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset PGPASSWORD
|
||||
|
||||
cp "$ENV_FILE" "$WORK_DIR/.env"
|
||||
chmod 600 "$WORK_DIR/.env"
|
||||
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
|
||||
|
||||
echo "Creating app snapshot (git archive)..."
|
||||
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
|
||||
:
|
||||
else
|
||||
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
|
||||
rm -f "$WORK_DIR/app.tar.gz"
|
||||
fi
|
||||
|
||||
python3 - "$WORK_DIR/manifest.json" <<PY
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
manifest = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"local_timestamp": "${TIMESTAMP}",
|
||||
"destination": "${DEST}",
|
||||
"reason": "${REASON}",
|
||||
"git_tag": "${GIT_TAG}",
|
||||
"git_sha": "${GIT_SHA}",
|
||||
"app_version": "${APP_VERSION}",
|
||||
"compose_file": "${COMPOSE_FILE}",
|
||||
"db_container": "${DB_CONTAINER}",
|
||||
"postgres_db": "${POSTGRES_DB}",
|
||||
"hostname": socket.gethostname(),
|
||||
"archive_name": "${ARCHIVE_NAME}",
|
||||
}
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.write("\n")
|
||||
PY
|
||||
|
||||
echo "Packing backup archive..."
|
||||
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
|
||||
manifest.json database.sql.gz .env docker-compose.yml \
|
||||
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
|
||||
chmod 600 "$ARCHIVE_PATH"
|
||||
|
||||
echo "Applying retention (keep last $RETENTION backups)..."
|
||||
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
|
||||
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
|
||||
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
|
||||
rm -f "${ALL_BACKUPS[$i]}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Backup complete: $ARCHIVE_PATH"
|
||||
echo "$ARCHIVE_PATH"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
|
||||
#
|
||||
# Install:
|
||||
# crontab -e
|
||||
# (paste the line below)
|
||||
#
|
||||
# Ensure log directory exists:
|
||||
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
|
||||
|
||||
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
|
||||
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||
Executable
+345
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore server backup created by scripts/backup.sh
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/restore-backup.sh --list
|
||||
# ./scripts/restore-backup.sh -dest stage --restore PATH
|
||||
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
|
||||
#
|
||||
# Environment overrides:
|
||||
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
MAX_WAIT=90
|
||||
DEST="prod"
|
||||
COMPOSE_FILE=""
|
||||
DB_CONTAINER=""
|
||||
BACKEND_CONTAINER=""
|
||||
|
||||
apply_dest_config() {
|
||||
local dest="$1"
|
||||
local force="${2:-0}"
|
||||
if [[ "$dest" == "stage" ]]; then
|
||||
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||
COMPOSE_FILE="docker-compose.staging.yml"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||
DB_CONTAINER="daagbox-staging-db"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||
BACKEND_CONTAINER="daagbox-staging-backend"
|
||||
fi
|
||||
else
|
||||
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||
DB_CONTAINER="daagbox-prod-db"
|
||||
fi
|
||||
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
MODE="full"
|
||||
RESTORE_PATH=""
|
||||
LIST=0
|
||||
ASSUME_YES=0
|
||||
|
||||
usage() {
|
||||
sed -n '2,10p' "$0"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -dest prod|stage Target environment (default: prod)"
|
||||
echo " --list List available backups"
|
||||
echo " --restore PATH Backup archive to restore"
|
||||
echo " --full Restore DB + .env (default)"
|
||||
echo " --db-only Restore database only"
|
||||
echo " --env-only Restore .env only"
|
||||
echo " --yes Skip confirmation prompts"
|
||||
echo " -h, --help Show this help"
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="$1"
|
||||
if [ "$ASSUME_YES" -eq 1 ]; then
|
||||
return 0
|
||||
fi
|
||||
read -r -p "$prompt [y/N] " answer
|
||||
[[ "$answer" =~ ^[yY]$ ]]
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-dest)
|
||||
DEST="${2:?-dest requires an argument}"
|
||||
shift 2
|
||||
;;
|
||||
-dest=*)
|
||||
DEST="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST=1
|
||||
shift
|
||||
;;
|
||||
--restore)
|
||||
RESTORE_PATH="${2:?--restore requires a path}"
|
||||
shift 2
|
||||
;;
|
||||
--full)
|
||||
MODE="full"
|
||||
shift
|
||||
;;
|
||||
--db-only)
|
||||
MODE="db-only"
|
||||
shift
|
||||
;;
|
||||
--env-only)
|
||||
MODE="env-only"
|
||||
shift
|
||||
;;
|
||||
--yes)
|
||||
ASSUME_YES=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$DEST" in
|
||||
prod|stage) ;;
|
||||
*)
|
||||
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
apply_dest_config "$DEST"
|
||||
|
||||
list_backups() {
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
echo "No backup directory: $BACKUP_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local found=0
|
||||
while IFS= read -r archive; do
|
||||
found=1
|
||||
echo "=== $archive ==="
|
||||
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
|
||||
echo ""
|
||||
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No backups found in $BACKUP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$LIST" -eq 1 ]; then
|
||||
list_backups
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$RESTORE_PATH" ]; then
|
||||
echo "Error: --restore PATH or --list required" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESTORE_PATH" ]; then
|
||||
echo "Error: backup archive not found: $RESTORE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Extracting $RESTORE_PATH..."
|
||||
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
|
||||
|
||||
if [ ! -f "$WORK_DIR/manifest.json" ]; then
|
||||
echo "Error: manifest.json missing in backup archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="$WORK_DIR/manifest.json"
|
||||
echo "Backup manifest:"
|
||||
python3 -m json.tool "$MANIFEST"
|
||||
echo ""
|
||||
|
||||
read_manifest_field() {
|
||||
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
|
||||
}
|
||||
|
||||
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
|
||||
MANIFEST_DB="$(read_manifest_field db_container)"
|
||||
MANIFEST_DEST="$(read_manifest_field destination)"
|
||||
|
||||
if [ -n "$MANIFEST_COMPOSE" ]; then
|
||||
COMPOSE_FILE="$MANIFEST_COMPOSE"
|
||||
fi
|
||||
if [ -n "$MANIFEST_DB" ]; then
|
||||
DB_CONTAINER="$MANIFEST_DB"
|
||||
fi
|
||||
if [ -n "$MANIFEST_DEST" ]; then
|
||||
if [[ "$MANIFEST_DEST" == "stage" ]]; then
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
|
||||
else
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||
apply_dest_config stage 1
|
||||
DEST="stage"
|
||||
else
|
||||
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
GIT_TAG="$(read_manifest_field git_tag)"
|
||||
GIT_SHA="$(read_manifest_field git_sha)"
|
||||
BACKUP_TS="$(read_manifest_field local_timestamp)"
|
||||
|
||||
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
restore_env() {
|
||||
if [ ! -f "$WORK_DIR/.env" ]; then
|
||||
echo "Error: .env missing in backup archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$ENV_FILE" "$BAK"
|
||||
echo "Current $ENV_FILE saved to $BAK"
|
||||
fi
|
||||
|
||||
cp "$WORK_DIR/.env" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "Restored $ENV_FILE"
|
||||
}
|
||||
|
||||
restore_db() {
|
||||
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
|
||||
echo "Error: database.sql.gz missing in backup archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Error: $ENV_FILE required for database restore" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||
|
||||
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping backend before database restore..."
|
||||
docker compose -f "$COMPOSE_FILE" stop backend || true
|
||||
|
||||
echo "Resetting public schema..."
|
||||
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||
DROP SCHEMA IF EXISTS public CASCADE;
|
||||
CREATE SCHEMA public;
|
||||
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
|
||||
GRANT ALL ON SCHEMA public TO public;
|
||||
SQL
|
||||
|
||||
echo "Importing database dump..."
|
||||
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
|
||||
unset PGPASSWORD
|
||||
|
||||
echo "Database restore complete."
|
||||
}
|
||||
|
||||
wait_for_healthy() {
|
||||
echo "Starting stack and waiting for health..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
local counter=0
|
||||
while [ "$counter" -lt "$MAX_WAIT" ]; do
|
||||
local status
|
||||
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "Backend is healthy."
|
||||
return 0
|
||||
fi
|
||||
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
|
||||
echo "API health check OK."
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
counter=$((counter + 1))
|
||||
printf "."
|
||||
done
|
||||
echo ""
|
||||
echo "Warning: backend did not become healthy in time." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
env-only)
|
||||
restore_env
|
||||
;;
|
||||
db-only)
|
||||
restore_db
|
||||
wait_for_healthy || exit 1
|
||||
;;
|
||||
full)
|
||||
restore_env
|
||||
restore_db
|
||||
wait_for_healthy || exit 1
|
||||
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
|
||||
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
|
||||
git fetch --tags origin
|
||||
git checkout "$GIT_TAG"
|
||||
echo "Checked out $GIT_TAG"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: unknown mode $MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
|
||||
@@ -81,7 +81,7 @@ require_node_toolchain() {
|
||||
echo ""
|
||||
echo "On the production host, prefer updating the running stack:"
|
||||
echo " docker compose -f docker-compose.yml up -d --build"
|
||||
echo " # or from your workstation: ./scripts/update-prod.sh"
|
||||
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Remote deployment configuration
|
||||
# Override any of these via environment variables if needed, e.g.:
|
||||
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh
|
||||
REMOTE_USER="${REMOTE_USER:-root}"
|
||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
|
||||
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
|
||||
|
||||
# Configuration
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||
MAX_WAIT=35
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION_FILE="$REPO_ROOT/VERSION"
|
||||
DEFAULT_VERSION="0.1.0.0"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbok Prod Environment Update "
|
||||
echo "=================================================="
|
||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
read_current_version() {
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
tr -d '[:space:]' < "$VERSION_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local latest_tag
|
||||
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
|
||||
if [ -n "$latest_tag" ]; then
|
||||
echo "${latest_tag#v}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$DEFAULT_VERSION"
|
||||
}
|
||||
|
||||
bump_patch_version() {
|
||||
local version="$1"
|
||||
local major minor patch build
|
||||
|
||||
IFS='.' read -r major minor patch build <<< "$version"
|
||||
major="${major:-0}"
|
||||
minor="${minor:-1}"
|
||||
patch="${patch:-0}"
|
||||
build="${build:-0}"
|
||||
|
||||
build=$((10#$build + 1))
|
||||
echo "${major}.${minor}.${patch}.${build}"
|
||||
}
|
||||
|
||||
ensure_clean_git_tree() {
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Uncommitted local changes detected:"
|
||||
git status --short
|
||||
echo ""
|
||||
read -r -p "Commit all changes now before release? [y/N] " answer
|
||||
|
||||
if [[ ! "$answer" =~ ^[yY]$ ]]; then
|
||||
echo "Aborting: working tree is not clean."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Commit message: " commit_message
|
||||
if [ -z "$commit_message" ]; then
|
||||
echo "Aborting: commit message is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "$commit_message"
|
||||
}
|
||||
|
||||
prepare_release() {
|
||||
local current_version release_version next_version tag_name
|
||||
|
||||
ensure_clean_git_tree
|
||||
|
||||
current_version="$(read_current_version)"
|
||||
release_version="$current_version"
|
||||
next_version="$(bump_patch_version "$current_version")"
|
||||
tag_name="v${release_version}"
|
||||
|
||||
if git rev-parse "$tag_name" >/dev/null 2>&1; then
|
||||
echo "Error: Git tag '$tag_name' already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$next_version" > "$VERSION_FILE"
|
||||
git add "$VERSION_FILE"
|
||||
git commit -m "chore: release ${tag_name}"
|
||||
git tag -a "$tag_name" -m "Release ${tag_name}"
|
||||
|
||||
echo ""
|
||||
echo "Prepared release ${tag_name}"
|
||||
echo " Released: ${tag_name}"
|
||||
echo " Next prep: v${next_version}"
|
||||
echo ""
|
||||
|
||||
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
|
||||
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
||||
current_branch="$(git branch --show-current)"
|
||||
git push origin "$current_branch"
|
||||
git push origin "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
||||
else
|
||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||
fi
|
||||
|
||||
export APP_VERSION="$release_version"
|
||||
}
|
||||
|
||||
prepare_release
|
||||
|
||||
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||
else
|
||||
echo "=================================================="
|
||||
echo " Pre-deploy checks (local)"
|
||||
echo "=================================================="
|
||||
"$SCRIPT_DIR/predeploy-check.sh"
|
||||
fi
|
||||
|
||||
echo "=================================================="
|
||||
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
# Run the whole update procedure remotely over SSH.
|
||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT'
|
||||
set -uo pipefail
|
||||
|
||||
REMOTE_DIR="$1"
|
||||
COMPOSE_FILE="$2"
|
||||
BACKEND_CONTAINER="$3"
|
||||
MAX_WAIT="$4"
|
||||
REMOTE_HOST="$5"
|
||||
APP_VERSION="$6"
|
||||
|
||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||
|
||||
echo "Syncing repository from origin..."
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Could not determine current Git branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Warning: Local changes on deployment host will be discarded."
|
||||
fi
|
||||
|
||||
git fetch --tags origin
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git fetch failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git reset --hard "origin/${CURRENT_BRANCH}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
||||
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
||||
echo " Building deployed release v${APP_VERSION}."
|
||||
fi
|
||||
|
||||
export APP_VERSION="$APP_VERSION"
|
||||
|
||||
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
|
||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Docker compose build failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting updated container stack..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to spin up docker-compose stack."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning up old/unused Docker resources..."
|
||||
docker system prune -f
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Warning: Docker system prune failed to run completely."
|
||||
fi
|
||||
|
||||
echo "Waiting for services to become healthy..."
|
||||
COUNTER=0
|
||||
IS_READY=false
|
||||
|
||||
while [ $COUNTER -lt $MAX_WAIT ]; do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
IS_READY=true
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
printf "."
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=================================================="
|
||||
echo "Container Statuses:"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
echo "=================================================="
|
||||
|
||||
if [ "$IS_READY" = true ]; then
|
||||
echo "SUCCESS: Production environment updated and healthy!"
|
||||
echo " -> Version: v${APP_VERSION}"
|
||||
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}"
|
||||
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "WARNING: Backend did not transition to healthy in time."
|
||||
echo "Check backend container logs for details:"
|
||||
echo " -> docker compose logs backend"
|
||||
echo "=================================================="
|
||||
exit 3
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
REMOTE_EXIT=$?
|
||||
echo "=================================================="
|
||||
if [ $REMOTE_EXIT -eq 0 ]; then
|
||||
echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
||||
elif [ $REMOTE_EXIT -eq 3 ]; then
|
||||
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
||||
else
|
||||
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
||||
fi
|
||||
echo "=================================================="
|
||||
exit $REMOTE_EXIT
|
||||
Executable
+432
@@ -0,0 +1,432 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-dest prod|stage]
|
||||
|
||||
Deploy Kapteins Daagbok to production or staging.
|
||||
|
||||
-dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
|
||||
-dest stage Staging: no release tag, deploy branch to 10.0.0.27
|
||||
|
||||
Environment overrides (optional):
|
||||
REMOTE_HOST, REMOTE_USER, REMOTE_DIR, COMPOSE_FILE, BACKEND_CONTAINER
|
||||
DEPLOY_BRANCH (stage only, default: master)
|
||||
SKIP_PREDEPLOY_CHECK=1
|
||||
|
||||
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -dest prod
|
||||
$(basename "$0") -dest stage
|
||||
DEPLOY_BRANCH=feature/foo $(basename "$0") -dest stage
|
||||
EOF
|
||||
}
|
||||
|
||||
DEST="prod"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-dest)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: -dest requires an argument (prod or stage)." >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
DEST="$2"
|
||||
shift 2
|
||||
;;
|
||||
-dest=*)
|
||||
DEST="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$DEST" in
|
||||
prod|stage) ;;
|
||||
*)
|
||||
echo "Error: Invalid -dest '$DEST' (use prod or stage)." >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION_FILE="$REPO_ROOT/VERSION"
|
||||
DEFAULT_VERSION="0.1.0.0"
|
||||
MAX_WAIT=90
|
||||
|
||||
REMOTE_USER="${REMOTE_USER:-root}"
|
||||
|
||||
if [[ "$DEST" == "stage" ]]; then
|
||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok-staging}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.staging.yml}"
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
|
||||
APP_URL="${APP_URL:-https://staging.kapteins-daagbok.eu}"
|
||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-master}"
|
||||
ENV_LABEL="Staging"
|
||||
else
|
||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
||||
DEPLOY_BRANCH=""
|
||||
ENV_LABEL="Production"
|
||||
fi
|
||||
|
||||
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbok ${ENV_LABEL} Update"
|
||||
echo "=================================================="
|
||||
echo "Destination: ${DEST}"
|
||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
if [[ "$DEST" == "stage" ]]; then
|
||||
echo "Branch: ${DEPLOY_BRANCH}"
|
||||
fi
|
||||
echo "URL: ${APP_URL}"
|
||||
echo "=================================================="
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
read_current_version() {
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
tr -d '[:space:]' < "$VERSION_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local latest_tag
|
||||
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
|
||||
if [ -n "$latest_tag" ]; then
|
||||
echo "${latest_tag#v}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$DEFAULT_VERSION"
|
||||
}
|
||||
|
||||
bump_patch_version() {
|
||||
local version="$1"
|
||||
local major minor patch build
|
||||
|
||||
IFS='.' read -r major minor patch build <<< "$version"
|
||||
major="${major:-0}"
|
||||
minor="${minor:-1}"
|
||||
patch="${patch:-0}"
|
||||
build="${build:-0}"
|
||||
|
||||
build=$((10#$build + 1))
|
||||
echo "${major}.${minor}.${patch}.${build}"
|
||||
}
|
||||
|
||||
ensure_clean_git_tree() {
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Uncommitted local changes detected:"
|
||||
git status --short
|
||||
echo ""
|
||||
read -r -p "Commit all changes now before release? [y/N] " answer
|
||||
|
||||
if [[ ! "$answer" =~ ^[yY]$ ]]; then
|
||||
echo "Aborting: working tree is not clean."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Commit message: " commit_message
|
||||
if [ -z "$commit_message" ]; then
|
||||
echo "Aborting: commit message is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "$commit_message"
|
||||
}
|
||||
|
||||
ensure_local_sync_with_origin() {
|
||||
local branch="$1"
|
||||
local local_sha origin_sha current_branch
|
||||
|
||||
if [ -z "$branch" ]; then
|
||||
echo "Error: deploy branch is not set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current_branch="$(git branch --show-current)"
|
||||
if [ -z "$current_branch" ]; then
|
||||
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$current_branch" != "$branch" ]; then
|
||||
echo "Error: On branch '$current_branch', expected '$branch'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Syncing with origin..."
|
||||
git fetch --tags origin
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: git fetch origin failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
|
||||
echo "Error: origin/${branch} does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_sha="$(git rev-parse HEAD)"
|
||||
origin_sha="$(git rev-parse "origin/${branch}")"
|
||||
|
||||
if [ "$local_sha" = "$origin_sha" ]; then
|
||||
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
|
||||
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
|
||||
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
|
||||
|
||||
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
|
||||
echo "Hint: run 'git pull' to fast-forward." >&2
|
||||
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
|
||||
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
|
||||
else
|
||||
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
prepare_release() {
|
||||
local current_version release_version next_version tag_name
|
||||
|
||||
ensure_clean_git_tree
|
||||
|
||||
current_version="$(read_current_version)"
|
||||
release_version="$current_version"
|
||||
next_version="$(bump_patch_version "$current_version")"
|
||||
tag_name="v${release_version}"
|
||||
|
||||
if git rev-parse "$tag_name" >/dev/null 2>&1; then
|
||||
echo "Error: Git tag '$tag_name' already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$next_version" > "$VERSION_FILE"
|
||||
git add "$VERSION_FILE"
|
||||
git commit -m "chore: release ${tag_name}"
|
||||
git tag -a "$tag_name" -m "Release ${tag_name}"
|
||||
|
||||
echo ""
|
||||
echo "Prepared release ${tag_name}"
|
||||
echo " Released: ${tag_name}"
|
||||
echo " Next prep: v${next_version}"
|
||||
echo ""
|
||||
|
||||
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
|
||||
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
||||
current_branch="$(git branch --show-current)"
|
||||
git push origin "$current_branch"
|
||||
git push origin "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
||||
else
|
||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||
fi
|
||||
|
||||
export APP_VERSION="$release_version"
|
||||
}
|
||||
|
||||
if [[ "$DEST" == "prod" ]]; then
|
||||
prepare_release
|
||||
ensure_local_sync_with_origin "$(git branch --show-current)"
|
||||
else
|
||||
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
|
||||
APP_VERSION="$(read_current_version)"
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||
else
|
||||
echo "=================================================="
|
||||
echo " Pre-deploy checks (local)"
|
||||
echo "=================================================="
|
||||
"$SCRIPT_DIR/predeploy-check.sh"
|
||||
fi
|
||||
|
||||
echo "=================================================="
|
||||
echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
|
||||
set -uo pipefail
|
||||
|
||||
REMOTE_DIR="$1"
|
||||
COMPOSE_FILE="$2"
|
||||
BACKEND_CONTAINER="$3"
|
||||
MAX_WAIT="$4"
|
||||
APP_URL="$5"
|
||||
APP_VERSION="$6"
|
||||
DEST="$7"
|
||||
DEPLOY_BRANCH="${8:-}"
|
||||
|
||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||
|
||||
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Warning: Local changes on deployment host will be discarded."
|
||||
fi
|
||||
|
||||
if [[ "$DEST" == "prod" ]]; then
|
||||
echo "Creating pre-deploy backup..."
|
||||
if [ -x "./scripts/backup.sh" ]; then
|
||||
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
|
||||
echo "Error: Pre-deploy backup failed. Aborting update."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$DEST" == "stage" ]]; then
|
||||
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
|
||||
git fetch origin
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git fetch failed."
|
||||
exit 1
|
||||
fi
|
||||
git checkout "$DEPLOY_BRANCH" 2>/dev/null || git checkout -b "$DEPLOY_BRANCH" "origin/${DEPLOY_BRANCH}"
|
||||
git reset --hard "origin/${DEPLOY_BRANCH}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git reset to origin/${DEPLOY_BRANCH} failed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Syncing repository from origin..."
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Could not determine current Git branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --tags origin
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git fetch failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git reset --hard "origin/${CURRENT_BRANCH}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
||||
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
||||
echo " Building deployed release v${APP_VERSION}."
|
||||
fi
|
||||
fi
|
||||
|
||||
export APP_VERSION="$APP_VERSION"
|
||||
|
||||
if [[ "$DEST" == "prod" ]]; then
|
||||
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
|
||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||
else
|
||||
echo "Rebuilding Docker images (APP_VERSION=${APP_VERSION})..."
|
||||
docker compose -f "$COMPOSE_FILE" build
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Docker compose build failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting updated container stack..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to spin up docker-compose stack."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning up old/unused Docker resources..."
|
||||
docker system prune -f || echo "Warning: Docker system prune failed."
|
||||
|
||||
echo "Waiting for services to become healthy..."
|
||||
COUNTER=0
|
||||
IS_READY=false
|
||||
|
||||
while [ $COUNTER -lt $MAX_WAIT ]; do
|
||||
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
IS_READY=true
|
||||
break
|
||||
fi
|
||||
|
||||
# End-to-end fallback via frontend nginx (covers missing/stale container health state)
|
||||
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
|
||||
IS_READY=true
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
printf "."
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=================================================="
|
||||
echo "Container Statuses:"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
echo "=================================================="
|
||||
|
||||
if [ "$IS_READY" = true ]; then
|
||||
echo "SUCCESS: ${DEST} environment updated and healthy!"
|
||||
echo " -> Version: v${APP_VERSION}"
|
||||
echo " -> App Frontend: ${APP_URL}"
|
||||
echo " -> Backend API Health: ${APP_URL}/api/health"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "WARNING: Backend did not transition to healthy in time."
|
||||
echo "Check backend container logs:"
|
||||
echo " -> docker compose -f ${COMPOSE_FILE} logs backend"
|
||||
echo "=================================================="
|
||||
exit 3
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
REMOTE_EXIT=$?
|
||||
echo "=================================================="
|
||||
if [ $REMOTE_EXIT -eq 0 ]; then
|
||||
echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
||||
elif [ $REMOTE_EXIT -eq 3 ]; then
|
||||
echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
||||
else
|
||||
echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
||||
fi
|
||||
echo "=================================================="
|
||||
exit $REMOTE_EXIT
|
||||
Reference in New Issue
Block a user