Remove legacy scripts and single-tenant support

- Deleted the migration script `migrate-to-multitenant.sh` as it is no longer needed.
- Removed `rebuild-venv.sh`, `restore.sh`, `run-backup.sh`, and other related scripts that are not compatible with the new multi-tenant architecture.
- Updated `restart.sh`, `start.sh`, `stop.sh`, `update.sh`, and other scripts to eliminate references to single-tenant deployment, ensuring they only support multi-tenant configurations.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 10:29:46 +02:00
parent c8f9c87ba4
commit f80430f243
22 changed files with 401 additions and 3898 deletions
-134
View File
@@ -1,134 +0,0 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Backup-DB.py
Usage:
python Backup-DB.py \
--uri mongodb://user:pass@host:port/ \
--db my_database \
--out /path/to/backup_folder
"""
import os
import csv
import sys
import argparse
from datetime import datetime
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Backup a MongoDB database to CSV files (one per collection)."
)
parser.add_argument(
"--uri", "-u",
required=True,
help="MongoDB URI, e.g. mongodb://user:pass@host:port/"
)
parser.add_argument(
"--db", "-d",
required=True,
help="Name of the database to back up"
)
parser.add_argument(
"--out", "-o",
default=f"mongo_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
help="Output directory (will be created if it doesn't exist)"
)
return parser.parse_args()
def flatten_dict(d, parent_key="", sep="."):
"""
Flatten nested dicts into a single level, concatenating keys with sep.
Lists are left as-is (converted to string later).
"""
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
def export_collection(db, coll_name, out_dir):
coll = db[coll_name]
cursor = coll.find()
docs = list(cursor)
if not docs:
print(f"[{coll_name}] No documents found, skipping.")
return
# Flatten and collect all keys
all_keys = set()
flat_docs = []
for doc in docs:
# Convert ObjectId and other non-serializable types
doc = {k: str(v) for k, v in doc.items()}
flat = flatten_dict(doc)
flat_docs.append(flat)
all_keys.update(flat.keys())
out_file = os.path.join(out_dir, f"{coll_name}.csv")
with open(out_file, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=sorted(all_keys))
writer.writeheader()
for flat in flat_docs:
writer.writerow(flat)
print(f"[{coll_name}] Exported {len(flat_docs)} docs → {out_file}")
def main():
args = parse_args()
# Create output directory
try:
os.makedirs(args.out, exist_ok=True)
print(f"Backing up database '{args.db}''{args.out}'")
except PermissionError:
print(f"Error: Permission denied when creating directory {args.out}")
print("Try running with sudo or to a different output directory")
return 1
# Verify write permissions
test_file = os.path.join(args.out, ".write_test")
try:
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
except (IOError, PermissionError) as e:
print(f"Error: Cannot write to output directory {args.out}")
print(f"Reason: {str(e)}")
print("Try running with sudo or to a different output directory")
return 1
try:
# Connect to MongoDB
client = MongoClient(args.uri)
# Test connection
client.server_info()
db = client[args.db]
# Export each collection
for coll_name in db.list_collection_names():
export_collection(db, coll_name, args.out)
print("Backup complete.")
return 0
except Exception as e:
print(f"Error: {str(e)}")
return 1
if __name__ == "__main__":
sys.exit(main())
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""
Backup-Invoices.py
Exports all invoice records stored in ausleihungen.InvoiceData to dedicated
archive files (JSONL + CSV + metadata).
Usage:
python Backup-Invoices.py \
--uri mongodb://localhost:27017/ \
--db Inventarsystem \
--out /var/backups/invoice-archive/invoices-2026-04-06_12-00-00
"""
import argparse
import csv
import json
import os
import sys
from datetime import datetime
from bson import ObjectId
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Export invoice records from ausleihungen to JSONL and CSV."
)
parser.add_argument("--uri", "-u", required=True, help="MongoDB URI")
parser.add_argument("--db", "-d", required=True, help="MongoDB database name")
parser.add_argument(
"--out",
"-o",
required=True,
help=(
"Output file base path without extension, e.g. "
"/var/backups/invoice-archive/invoices-2026-04-06_12-00-00"
),
)
return parser.parse_args()
def to_jsonable(value):
if isinstance(value, ObjectId):
return str(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {k: to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [to_jsonable(v) for v in value]
return value
def flatten_dict(data, parent_key="", sep="."):
items = {}
for key, value in data.items():
new_key = f"{parent_key}{sep}{key}" if parent_key else str(key)
if isinstance(value, dict):
items.update(flatten_dict(value, new_key, sep=sep))
elif isinstance(value, list):
items[new_key] = json.dumps(value, ensure_ascii=False)
else:
items[new_key] = value
return items
def normalize_invoice_record(doc):
invoice_data = doc.get("InvoiceData") or {}
record = {
"borrow_id": str(doc.get("_id", "")),
"borrow_status": doc.get("Status", ""),
"borrow_user": doc.get("User", ""),
"borrow_item": doc.get("Item", ""),
"borrow_start": doc.get("Start"),
"borrow_end": doc.get("End"),
"invoice_number": invoice_data.get("invoice_number", ""),
"invoice_amount": invoice_data.get("amount"),
"invoice_amount_text": invoice_data.get("amount_text", ""),
"invoice_damage_reason": invoice_data.get("damage_reason", ""),
"invoice_created_at": invoice_data.get("created_at"),
"invoice_created_by": invoice_data.get("created_by", ""),
"invoice_borrower": invoice_data.get("borrower", ""),
"invoice_item_id": invoice_data.get("item_id", ""),
"invoice_item_name": invoice_data.get("item_name", ""),
"invoice_item_code": invoice_data.get("item_code", ""),
"invoice_mark_destroyed": invoice_data.get("mark_destroyed", False),
"invoice_status_before": invoice_data.get("status_before_invoice", ""),
"invoice_raw": invoice_data,
}
return to_jsonable(record)
def write_jsonl(path, records):
with open(path, "w", encoding="utf-8") as handle:
for row in records:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
def write_csv(path, records):
if not records:
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["info"])
writer.writerow(["no invoice records found"])
return
flat_records = [flatten_dict(row) for row in records]
fieldnames = sorted({key for row in flat_records for key in row.keys()})
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in flat_records:
writer.writerow(row)
def write_metadata(path, db_name, out_base, count):
payload = {
"db": db_name,
"exported_at": datetime.utcnow().isoformat() + "Z",
"record_count": count,
"files": {
"jsonl": out_base + ".jsonl",
"csv": out_base + ".csv",
},
"schema_note": "Source is ausleihungen.InvoiceData",
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main():
args = parse_args()
out_base = args.out
out_dir = os.path.dirname(out_base) or "."
try:
os.makedirs(out_dir, exist_ok=True)
except Exception as exc:
print(f"Error: cannot create output directory {out_dir}: {exc}")
return 1
client = None
try:
client = MongoClient(args.uri)
client.server_info()
db = client[args.db]
ausleihungen = db["ausleihungen"]
cursor = ausleihungen.find({"InvoiceData": {"$exists": True, "$ne": None}})
records = [normalize_invoice_record(doc) for doc in cursor]
jsonl_path = out_base + ".jsonl"
csv_path = out_base + ".csv"
meta_path = out_base + ".meta.json"
write_jsonl(jsonl_path, records)
write_csv(csv_path, records)
write_metadata(meta_path, args.db, out_base, len(records))
print(f"Invoice backup complete: {len(records)} records")
print(f"JSONL: {jsonl_path}")
print(f"CSV: {csv_path}")
print(f"META: {meta_path}")
return 0
except Exception as exc:
print(f"Error: {exc}")
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -163,7 +163,7 @@ sudo ./install.sh --cleanup-old-remove-cron
Das System läuft produktiv Docker-first. Deployments und Updates erfolgen über Release-Artefakte (Build-only), nicht über Quellcode-Pulls. Das System läuft produktiv Docker-first. Deployments und Updates erfolgen über Release-Artefakte (Build-only), nicht über Quellcode-Pulls.
- Laufzeit-Stack: [docker-compose.yml](docker-compose.yml) - Laufzeit-Stack: [docker-compose-multitenant.yml](docker-compose-multitenant.yml)
- Build-Pipeline: [Dockerfile](Dockerfile) - Build-Pipeline: [Dockerfile](Dockerfile)
- Release-Pipeline: [.github/workflows/release-docker.yml](.github/workflows/release-docker.yml) - Release-Pipeline: [.github/workflows/release-docker.yml](.github/workflows/release-docker.yml)
- Frontend/Reverse Proxy: Nginx (Container) - Frontend/Reverse Proxy: Nginx (Container)
@@ -237,7 +237,7 @@ docker compose logs -f nginx
### Persistente Daten ### Persistente Daten
Die Volumes sind in [docker-compose.yml](docker-compose.yml) definiert: Die Volumes sind in [docker-compose-multitenant.yml](docker-compose-multitenant.yml) definiert:
- MongoDB Daten - MongoDB Daten
- Uploads / Thumbnails / Previews / QRCodes - Uploads / Thumbnails / Previews / QRCodes
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import argparse
import csv
import datetime
import json
import os
import sys
try:
from bson import json_util
except ImportError:
json_util = None
try:
from pymongo import MongoClient
except ImportError:
print('Error: pymongo is required to run invoice backups.', file=sys.stderr)
sys.exit(1)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'config.json'))
DEFAULT_ARCHIVE_DIR = '/var/backups/invoice-archive'
DEFAULT_KEEP_DAYS = 3650
def load_config():
config = {}
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
except Exception:
pass
return config
def resolve_mongo_settings(args):
config = load_config()
mongodb = config.get('mongodb', {}) if isinstance(config, dict) else {}
host = os.getenv('INVENTAR_MONGODB_HOST') or args.mongo_host or mongodb.get('host') or 'localhost'
port = os.getenv('INVENTAR_MONGODB_PORT') or args.mongo_port or mongodb.get('port') or 27017
db_name = os.getenv('INVENTAR_MONGODB_DB') or args.db_name or mongodb.get('db') or 'Inventarsystem'
uri = args.mongo_uri
if isinstance(port, str) and port.strip().isdigit():
port = int(port.strip())
return host, int(port), db_name, uri
def format_csv_value(value):
if value is None:
return ''
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, datetime.datetime):
return value.isoformat()
if isinstance(value, (list, dict)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def normalize_doc_for_json(doc):
if json_util is not None:
return json.loads(json_util.dumps(doc, default=json_util.default))
return doc
def build_csv_row(document):
invoice_data = document.get('InvoiceData') or {}
corrections = document.get('InvoiceCorrections') or []
return {
'invoice_number': invoice_data.get('invoice_number', ''),
'borrow_id': str(document.get('_id', '')),
'status_before_invoice': invoice_data.get('status_before_invoice', '') or document.get('Status', ''),
'borrower': document.get('User', '') or invoice_data.get('borrower', ''),
'item': document.get('Item', ''),
'amount': invoice_data.get('amount', ''),
'currency': invoice_data.get('currency', 'EUR'),
'created_at': format_csv_value(invoice_data.get('created_at')),
'paid': invoice_data.get('paid', False),
'paid_at': format_csv_value(invoice_data.get('paid_at')),
'invoice_reason': invoice_data.get('damage_reason', ''),
'corrections_count': len(corrections) if isinstance(corrections, list) else 0,
'corrections': json.dumps(corrections, ensure_ascii=False) if corrections else '',
}
def write_jsonl(path, cursor):
with open(path, 'w', encoding='utf-8') as f:
for document in cursor:
if json_util is not None:
line = json_util.dumps(document, default=json_util.default)
else:
line = json.dumps(document, default=str, ensure_ascii=False)
f.write(line + '\n')
def write_csv(path, cursor):
fieldnames = [
'invoice_number',
'borrow_id',
'status_before_invoice',
'borrower',
'item',
'amount',
'currency',
'created_at',
'paid',
'paid_at',
'invoice_reason',
'corrections_count',
'corrections',
]
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for document in cursor:
writer.writerow({k: format_csv_value(v) for k, v in build_csv_row(document).items()})
def write_meta(path, metadata):
with open(path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
def parse_args():
parser = argparse.ArgumentParser(description='Create a legal invoice archive backup from the MongoDB invoice records.')
parser.add_argument('--archive-dir', required=True, help='Directory to write archive files into')
parser.add_argument('--base-name', default=None, help='Base filename prefix for archive files')
parser.add_argument('--mongo-host', default=None, help='MongoDB host override')
parser.add_argument('--mongo-port', type=int, default=None, help='MongoDB port override')
parser.add_argument('--db-name', default=None, help='MongoDB database name override')
parser.add_argument('--mongo-uri', default=None, help='MongoDB connection URI override')
return parser.parse_args()
def main():
args = parse_args()
archive_dir = os.path.abspath(args.archive_dir)
os.makedirs(archive_dir, exist_ok=True)
base_name = args.base_name or f'invoices-{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
jsonl_path = os.path.join(archive_dir, f'{base_name}.jsonl')
csv_path = os.path.join(archive_dir, f'{base_name}.csv')
meta_path = os.path.join(archive_dir, f'{base_name}.meta.json')
host, port, db_name, uri = resolve_mongo_settings(args)
if uri:
client = MongoClient(uri)
else:
client = MongoClient(host, port)
try:
db = client[db_name]
collection = db['ausleihungen']
query = {'InvoiceData.invoice_number': {'$exists': True, '$ne': ''}}
projection = {'InvoiceData': 1, 'InvoiceCorrections': 1, 'User': 1, 'Item': 1, 'Status': 1}
cursor = collection.find(query, projection)
docs = list(cursor)
if not docs:
print('No invoice records found. No archive written.')
return 0
write_jsonl(jsonl_path, docs)
write_csv(csv_path, docs)
metadata = {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'invoice_count': len(docs),
'archive_files': [os.path.basename(jsonl_path), os.path.basename(csv_path), os.path.basename(meta_path)],
'mongo_db': db_name,
'mongo_host': host,
'mongo_port': port,
'query': query,
}
write_meta(meta_path, metadata)
print(f'Wrote invoice archive: {jsonl_path}')
print(f'Wrote invoice archive CSV: {csv_path}')
print(f'Wrote metadata: {meta_path}')
return 0
finally:
client.close()
if __name__ == '__main__':
sys.exit(main())
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Monthly log archival script for Inventarsystem
# Runs on first day of month during automatic update
# Compresses logs to tar.gz, stores for up to 1 year, removes uncompressed originals
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs"
ARCHIVE_BASE_DIR="$PROJECT_DIR/backups/logs_archive"
LOG_ARCHIVE_FILE="$LOG_DIR/archive-logs.log"
GUARD_FILE="$PROJECT_DIR/.last-log-archive"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
# Ensure archive base directory exists
mkdir -p "$ARCHIVE_BASE_DIR"
chmod 755 "$ARCHIVE_BASE_DIR" 2>/dev/null || true
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_ARCHIVE_FILE"
}
should_archive_logs() {
local today month_day guard_date
today="$(date +%Y-%m-%d)"
month_day="$(date +%d)"
# Only run on first day of month
if [ "$month_day" != "01" ]; then
return 1
fi
# Check if we already ran today
if [ -f "$GUARD_FILE" ]; then
guard_date="$(cat "$GUARD_FILE" 2>/dev/null || echo '')"
if [ "$guard_date" = "$today" ]; then
log_message "Log archive already executed today ($today). Skipping."
return 1
fi
fi
return 0
}
archive_application_logs() {
local archive_name archive_path retention_days cleanup_count deleted_count
if [ ! -d "$LOG_DIR" ] || [ -z "$(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' 2>/dev/null)" ]; then
log_message "No log files found in $LOG_DIR. Nothing to archive."
return 0
fi
archive_name="inventar_logs_$(date +%Y-%m-01_%H%M%S)"
archive_path="$ARCHIVE_BASE_DIR/${archive_name}.tar.gz"
log_message "Starting monthly log archival..."
log_message "Archive destination: $archive_path"
# Create temporary directory for prepping files
local tmp_prep_dir
tmp_prep_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_prep_dir:-}"' RETURN
# Copy log files to temp directory (excluding archive log itself during copy)
if ! find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' ! -name 'archive-logs.log' -exec cp {} "$tmp_prep_dir/" \; 2>/dev/null; then
log_message "WARNING: Could not copy all log files to temp directory"
fi
# Create tar.gz archive
if tar -czf "$archive_path" -C "$tmp_prep_dir" . 2>/dev/null; then
log_message "Log archive created successfully: $archive_path"
log_message "Archive size: $(du -h "$archive_path" | cut -f1)"
else
log_message "ERROR: Failed to create log archive"
return 1
fi
# Remove uncompressed log files (keep archive-logs.log for continuity)
log_message "Removing uncompressed log files..."
local files_removed=0
while IFS= read -r log_file; do
if [ "$log_file" != "$LOG_ARCHIVE_FILE" ]; then
rm -f "$log_file" && ((++files_removed)) || true
fi
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log')
log_message "Removed $files_removed uncompressed log file(s)"
trap - RETURN
}
cleanup_old_archives() {
local retention_days cleanup_count deleted_count total_archives
retention_days="365"
# Find and remove archives older than 1 year
log_message "Cleaning up archives older than $retention_days days..."
total_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="0"
find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' -mtime +$retention_days | while read -r old_archive; do
log_message "Removing old archive: $old_archive"
rm -f "$old_archive"
((++deleted_count)) || true
done 2>/dev/null || true
# Count deleted (safer approach)
local current_archives
current_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="$((total_archives - current_archives))"
if [ "$deleted_count" -gt 0 ]; then
log_message "Deleted $deleted_count old archive(s); kept $current_archives"
else
log_message "No old archives to delete (total: $total_archives)"
fi
}
main() {
if ! should_archive_logs; then
return 0
fi
log_message "=== Monthly log archival started ==="
archive_application_logs || {
log_message "ERROR: Log archival failed"
return 1
}
cleanup_old_archives || {
log_message "WARNING: Archive cleanup encountered issues"
}
# Update guard file with today's date
date +%Y-%m-%d > "$GUARD_FILE"
log_message "=== Monthly log archival completed successfully ==="
return 0
}
main "$@"
Executable → Regular
+112 -487
View File
@@ -1,536 +1,161 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# ========================================================
# Inventarsystem backup helper
# - Creates a dated backup folder/archive under /var/backups (by default)
# - Exports MongoDB via run-backup.sh into mongodb_backup/
# - Cleans up backups older than KEEP_DAYS
#
# Options:
# --dest|--out|--backup-dir <dir> Destination base directory (default: /var/backups)
# --db|--db-name <name> MongoDB database name (default: Inventarsystem)
# --uri|--mongo-uri <uri> MongoDB URI (default: mongodb://localhost:27017/)
# --invoice-archive-dir <dir> Invoice archive directory (default: <dest>/invoice-archive)
# --invoice-keep-days <N> Retention for invoice archive in days (default: 3650)
# --log <file> Log file (default: ./logs/backup.log)
# --no-compress Disable compression (keeps directory instead of .tar.gz)
# --compress-level <0-9> Compression level flag (only 0 is special here)
# --keep-days <N> Age-based retention in days (default: 7; 0 disables age filter)
# --min-keep <N> Always keep at least N most recent backups (default: 7)
# --mode <auto|host|docker> Backup mode (default: auto)
# -h|--help Show help
# ========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}" COMPOSE_FILE="docker-compose-multitenant.yml"
# Default destination now /var/backups; override with --dest or BACKUP_BASE_DIR env INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-/var/backups/invoice-archive}"
BACKUP_BASE_DIR="${BACKUP_BASE_DIR:-/var/backups}" KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
LOG_DIR="$PROJECT_DIR/logs" MODE="auto"
# Create only the log directory here; backup dir is created later with sudo if needed BASE_NAME=""
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") [options] Usage: $0 [options]
Options: Options:
--dest|--out|--backup-dir <dir> Destination base directory (default: $BACKUP_BASE_DIR) --invoice-archive-dir DIR Directory for invoice archive files
--db|--db-name <name> MongoDB database name (default: $DB_NAME) (default: $INVOICE_ARCHIVE_DIR)
--uri|--mongo-uri <uri> MongoDB URI (default: $MONGO_URI) --invoice-keep-days N Remove archived invoice files older than N days
--invoice-archive-dir <dir> Invoice archive directory (default: $INVOICE_ARCHIVE_DIR) (default: $KEEP_DAYS)
--invoice-keep-days <N> Retention for invoice archive in days (default: $INVOICE_KEEP_DAYS) --mode auto|docker|host Backup mode (default: auto)
--log <file> Log file (default: $LOG_FILE) --base-name NAME Base filename prefix for archive files
--no-compress Disable compression (keeps directory instead of .tar.gz) -h, --help Show this help message
--compress-level <0-9> Compression level flag (only 0 is special here)
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h|--help Show this help and exit
EOF EOF
} }
# Parse CLI arguments parse_args() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--dest|--out|--backup-dir)
BACKUP_BASE_DIR="$2"; shift 2;;
--db|--db-name)
DB_NAME="$2"; shift 2;;
--uri|--mongo-uri)
MONGO_URI="$2"; shift 2;;
--invoice-archive-dir) --invoice-archive-dir)
INVOICE_ARCHIVE_DIR="$2"; shift 2;; INVOICE_ARCHIVE_DIR="$2"
shift 2
;;
--invoice-keep-days) --invoice-keep-days)
INVOICE_KEEP_DAYS="$2"; shift 2;; KEEP_DAYS="$2"
--log) shift 2
LOG_FILE="$2"; shift 2;; ;;
--no-compress)
COMPRESSION_LEVEL=0; shift;;
--compress-level)
COMPRESSION_LEVEL="${2:-6}"; shift 2;;
--keep-days)
KEEP_DAYS="$2"; shift 2;;
--min-keep)
MIN_KEEP="$2"; shift 2;;
--mode) --mode)
BACKUP_MODE="$2"; shift 2;; MODE="$2"
--multitenant) shift 2
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;; ;;
--singletenant) --base-name)
COMPOSE_FILE="docker-compose.yml"; shift;; BASE_NAME="$2"
shift 2
;;
-h|--help) -h|--help)
usage; exit 0;; usage
exit 0
;;
*) *)
echo "Unknown option: $1" >&2; usage; exit 2;; echo "Error: unknown option '$1'" >&2
usage
exit 2
;;
esac esac
done done
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
} }
init_docker_compose() { ensure_directory() {
if docker compose version >/dev/null 2>&1; then mkdir -p "$INVOICE_ARCHIVE_DIR"
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0
fi
if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0
fi
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
return 1
} }
compose_cmd() { cleanup_old_archives() {
if [ "$DOCKER_AVAILABLE" -ne 1 ]; then if [[ -n "$KEEP_DAYS" ]]; then
return 1 find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) -mtime +"$KEEP_DAYS" -print -delete || true
fi fi
"${DOCKER_COMPOSE_CMD[@]}" "$@"
} }
try_backup_db_docker() { host_backup() {
local out_dir="$1" if ! command -v python3 >/dev/null 2>&1; then
local stamp return 1
stamp="$(date +"%Y-%m-%d_%H-%M-%S")" fi
local archive_path="$out_dir/mongodb-${stamp}.archive.gz" if ! python3 -c 'import pymongo' >/dev/null 2>&1; then
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1
log_message "Attempting Docker-based MongoDB backup..."
compose_cmd up -d mongodb >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running mongodb | grep -q mongodb; then
return 1 return 1
fi fi
if compose_cmd exec -T mongodb sh -c "mongodump --archive --gzip --db '$DB_NAME'" > "$archive_path"; then python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
log_message "Docker DB backup successful: $archive_path" --archive-dir "$INVOICE_ARCHIVE_DIR" \
return 0 --base-name "$BASE_NAME"
fi
return 1
} }
try_backup_invoices_docker() { docker_backup() {
local archive_base="$1" if ! command -v docker >/dev/null 2>&1; then
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1 return 1
fi
local tmp_base if ! docker compose -f "$COMPOSE_FILE" ps -q app >/dev/null 2>&1; then
tmp_base="/tmp/$(basename "$archive_base")"
log_message "Attempting Docker-based invoice archive export..."
compose_cmd up -d mongodb app >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running app | grep -q app; then
return 1 return 1
fi fi
local app_cid local app_container
app_cid="$(compose_cmd ps -q app)" app_container="$(docker compose -f "$COMPOSE_FILE" ps -q app | tr -d '[:space:]')"
[ -n "$app_cid" ] || return 1 if [[ -z "$app_container" ]]; then
if ! sudo docker cp "$PROJECT_DIR/Backup-Invoices.py" "$app_cid:/tmp/Backup-Invoices.py" >/dev/null 2>&1; then
return 1 return 1
fi fi
if ! compose_cmd exec -T app python /tmp/Backup-Invoices.py \
--uri "mongodb://mongodb:27017/" \
--db "$DB_NAME" \
--out "$tmp_base" >/dev/null 2>&1; then
return 1
fi
sudo docker cp "$app_cid:${tmp_base}.jsonl" "${archive_base}.jsonl" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.csv" "${archive_base}.csv" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.meta.json" "${archive_base}.meta.json" >/dev/null 2>&1 || return 1
compose_cmd exec -T app sh -c "rm -f /tmp/Backup-Invoices.py ${tmp_base}.jsonl ${tmp_base}.csv ${tmp_base}.meta.json" >/dev/null 2>&1 || true
return 0
}
backup_invoices_archive() {
log_message "Starting invoice archive export..."
# Ensure archive dir exists and is writable
if [ ! -d "$INVOICE_ARCHIVE_DIR" ]; then
sudo mkdir -p "$INVOICE_ARCHIVE_DIR"
sudo chmod 755 "$INVOICE_ARCHIVE_DIR"
fi
local timestamp local timestamp
timestamp="$(date +"%Y-%m-%d_%H-%M-%S")" timestamp="$(date +'%Y-%m-%d_%H-%M-%S')"
local archive_base local output_dir
archive_base="$INVOICE_ARCHIVE_DIR/invoices-$timestamp" local remote_base
local py_exec output_dir="/tmp/invoice_archive_${timestamp}"
if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then remote_base="${output_dir}/${BASE_NAME}"
py_exec="$PROJECT_DIR/.venv/bin/python"
else docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir"
py_exec="python3" docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/backup_invoices.py \
--archive-dir "$output_dir" \
--base-name "$BASE_NAME"
mkdir -p "$INVOICE_ARCHIVE_DIR"
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
}
main() {
if [[ -z "$BASE_NAME" ]]; then
BASE_NAME="invoices-$(date +'%Y-%m-%d_%H-%M-%S')"
fi fi
local host_invoice_ok=0 ensure_directory
local prefer_docker_invoice=0
if [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
prefer_docker_invoice=1
log_message "Auto mode: preferring Docker invoice archive export"
fi
if [ "$BACKUP_MODE" != "docker" ] && [ "$prefer_docker_invoice" -ne 1 ]; then
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" > /dev/null 2>&1; then
host_invoice_ok=1
fi
else
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1; then
host_invoice_ok=1
fi
fi
fi
if [ "$host_invoice_ok" -ne 1 ]; then
if [ "$BACKUP_MODE" = "host" ]; then
log_message "ERROR: Failed to export invoice archive in host mode"
return 1
fi
if ! try_backup_invoices_docker "$archive_base"; then
log_message "ERROR: Failed to export invoice archive"
return 1
fi
fi
sudo chmod 640 "$archive_base".jsonl "$archive_base".csv "$archive_base".meta.json 2>/dev/null || true
log_message "Invoice archive written: $archive_base.(jsonl|csv|meta.json)"
# Retention cleanup for invoice archive (default: 10 years)
if [ "$INVOICE_KEEP_DAYS" -gt 0 ]; then
local deleted_count=0
while IFS= read -r old_file; do
[ -z "$old_file" ] && continue
if sudo rm -f "$old_file"; then
deleted_count=$((deleted_count + 1))
fi
done < <(find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \
\( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) \
-mtime +"$INVOICE_KEEP_DAYS" -print 2>/dev/null)
log_message "Invoice archive cleanup complete (retention: $INVOICE_KEEP_DAYS days, deleted files: $deleted_count)"
fi
case "$MODE" in
auto)
if docker_backup; then
cleanup_old_archives
return 0 return 0
}
# Function to create backup
create_backup() {
log_message "Starting backup process..."
# Create date-formatted directory name
CURRENT_DATE=$(date +"%Y-%m-%d")
BACKUP_NAME="Inventarsystem-$CURRENT_DATE"
BACKUP_DIR="$BACKUP_BASE_DIR/$BACKUP_NAME"
BACKUP_ARCHIVE="$BACKUP_BASE_DIR/$BACKUP_NAME.tar.gz"
# Create backup directory if it doesn't exist
if [ ! -d "$BACKUP_BASE_DIR" ]; then
sudo mkdir -p "$BACKUP_BASE_DIR"
sudo chmod 755 "$BACKUP_BASE_DIR"
fi fi
if host_backup; then
# Remove existing backup with same date if it exists cleanup_old_archives
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing existing backup directory at $BACKUP_DIR"
sudo rm -rf "$BACKUP_DIR"
fi
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Removing existing backup archive at $BACKUP_ARCHIVE"
sudo rm -f "$BACKUP_ARCHIVE"
fi
# Create a temporary directory for backup
log_message "Creating backup at $BACKUP_DIR"
sudo mkdir -p "$BACKUP_DIR"
# Copy project files excluding the backups directory itself (and optionally the venv)
if command -v rsync >/dev/null 2>&1; then
sudo rsync -a \
--exclude='backups' \
--exclude='.venv' \
"$PROJECT_DIR"/ "$BACKUP_DIR"/
else
# Fallback to cp while skipping the backups directory
for entry in "$PROJECT_DIR"/*; do
name=$(basename "$entry")
[[ "$name" == "backups" || "$name" == ".venv" ]] && continue
sudo cp -r "$entry" "$BACKUP_DIR/"
done
fi
# Create database backup
log_message "Running database backup..."
# Create mongodb_backup directory with appropriate permissions first
sudo mkdir -p "$BACKUP_DIR/mongodb_backup"
sudo chmod 755 "$BACKUP_DIR/mongodb_backup"
# Run database backup with our helper script
log_message "Executing database backup script..."
db_backup_ok=0
use_host_db_backup=1
if [ "$BACKUP_MODE" = "docker" ]; then
use_host_db_backup=0
elif [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
use_host_db_backup=0
log_message "Auto mode: preferring Docker DB backup"
fi
if [ "$use_host_db_backup" -eq 1 ]; then
# Create Backup_db.log and ensure it's writable only when host mode is used
touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to set permissions on Backup_db.log. Trying with sudo..."
sudo touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
sudo chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to create writable Backup_db.log. Redirecting output to /dev/null instead."
USE_NULL_OUTPUT=true
}
}
# Install pymongo only for host backups
log_message "Checking pymongo installation..."
if ! python3 -c "import pymongo" &>/dev/null; then
log_message "Installing pymongo..."
if [ -f "$PROJECT_DIR/.venv/bin/pip" ]; then
if [ "$USE_NULL_OUTPUT" = true ]; then
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 > /dev/null 2>&1 || true
else
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
fi
if ! python3 -c "import pymongo" &>/dev/null; then
if [ "$USE_NULL_OUTPUT" = true ]; then
pip3 install pymongo==4.6.3 > /dev/null 2>&1 || true
else
pip3 install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
fi
fi
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" > /dev/null 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" > /dev/null 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
else
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
fi
else
log_message "Docker mode selected: skipping host DB backup step"
fi
# Auto-fallback for docker-first environments
if [ "$db_backup_ok" -ne 1 ] && [ "$BACKUP_MODE" != "host" ]; then
if try_backup_db_docker "$BACKUP_DIR/mongodb_backup"; then
db_backup_ok=1
fi
fi
# Check if any CSV files were created during backup
if ! find "$BACKUP_DIR/mongodb_backup" -name "*.csv" -quit; then
log_message "WARNING: No CSV files found in backup directory"
# If we used a temporary directory earlier and it still exists, try to use its contents
if [ -d "$tmp_backup_dir" ]; then
log_message "Backup created in temporary directory, moving to backup location..."
sudo cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
rm -rf "$tmp_backup_dir"
fi
fi
# Reset to more restrictive permissions after backup completes
sudo chmod -R 755 "$BACKUP_DIR/mongodb_backup"
# Verify that backup files were created
csv_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.csv" 2>/dev/null | wc -l)
archive_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.archive.gz" 2>/dev/null | wc -l)
if [ "$csv_count" -gt 0 ] || [ "$archive_count" -gt 0 ]; then
log_message "Database backup successful: ${csv_count} CSV collection(s), ${archive_count} archive file(s)"
else
log_message "WARNING: No database backup files found in backup directory"
fi
# Export legal invoice archive separately with long retention
backup_invoices_archive || log_message "WARNING: Invoice archive export failed"
# Compress the backup
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
log_message "Compressing backup with level $COMPRESSION_LEVEL..."
# Create compressed archive with pigz when available, otherwise gzip.
if command -v pigz >/dev/null 2>&1; then
gzip_cmd="pigz -${COMPRESSION_LEVEL}"
else
gzip_cmd="gzip -${COMPRESSION_LEVEL}"
fi
if tar --help 2>/dev/null | grep -q -- "--use-compress-program"; then
sudo tar -cf "$BACKUP_ARCHIVE" -I "$gzip_cmd" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar -I)"
return 1
}
else
GZIP="-${COMPRESSION_LEVEL}" sudo -E tar -czf "$BACKUP_ARCHIVE" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar + GZIP env)"
return 1
}
fi
# Remove uncompressed directory after successful compression
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Backup compressed successfully to $BACKUP_ARCHIVE"
log_message "Removing uncompressed backup directory"
sudo rm -rf "$BACKUP_DIR"
else
log_message "ERROR: Compressed backup file not found"
return 1
fi
else
log_message "Compression disabled, keeping uncompressed backup"
fi
# Set appropriate permissions
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
sudo chmod 644 "$BACKUP_ARCHIVE"
else
sudo chmod -R 755 "$BACKUP_DIR"
fi
# Clean up old backups: age-based filter plus minimum keep safeguard
log_message "Cleaning up old backups (keep at least $MIN_KEEP; days>$KEEP_DAYS)..."
# Build list of all backup artifacts (files and directories) sorted newest first
mapfile -t ALL_BACKUPS < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-printf '%T@\t%p\n' 2>/dev/null | sort -nr | awk -F '\t' '{print $2}')
TOTAL=${#ALL_BACKUPS[@]}
if (( TOTAL > MIN_KEEP )); then
# Determine deletion candidates by age (if KEEP_DAYS>0), otherwise all except the newest MIN_KEEP
if (( KEEP_DAYS > 0 )); then
mapfile -t AGE_CANDIDATES < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-mtime +"$KEEP_DAYS" -printf '%T@\t%p\n' 2>/dev/null | sort -n | awk -F '\t' '{print $2}')
else
AGE_CANDIDATES=()
# If no age filter, consider everything except the newest MIN_KEEP as candidates (oldest first)
if (( TOTAL > MIN_KEEP )); then
# Reverse ALL_BACKUPS to get oldest first
for ((i=TOTAL-1; i>=MIN_KEEP; i--)); do AGE_CANDIDATES+=("${ALL_BACKUPS[$i]}"); done
fi
fi
# Protect the newest MIN_KEEP backups overall
ALLOWED_DELETE=$(( TOTAL - MIN_KEEP ))
DELETED=0
for path in "${AGE_CANDIDATES[@]:-}"; do
(( DELETED >= ALLOWED_DELETE )) && break
if [ -z "$path" ]; then continue; fi
if [ -f "$path" ]; then
sudo rm -f "$path" && ((DELETED++))
elif [ -d "$path" ]; then
sudo rm -rf "$path" && ((DELETED++))
fi
done
log_message "Deleted $DELETED old backup(s); kept $(( TOTAL - DELETED ))"
else
log_message "No cleanup needed (total backups: $TOTAL <= min-keep: $MIN_KEEP)"
fi
log_message "Backup completed successfully"
return 0 return 0
fi
echo "Error: could not perform invoice backup in auto mode. Docker or host Python with pymongo is required." >&2
return 1
;;
docker)
if docker_backup; then
cleanup_old_archives
return 0
fi
echo "Error: docker backup failed or app container is unavailable." >&2
return 1
;;
host)
if host_backup; then
cleanup_old_archives
return 0
fi
echo "Error: host backup failed. Ensure python3 and pymongo are installed." >&2
return 1
;;
*)
echo "Error: unsupported mode '$MODE'" >&2
usage
return 2
;;
esac
} }
# Main parse_args "$@"
log_message "Backup destination: $BACKUP_BASE_DIR" main
log_message "Database: $DB_NAME | URI: $MONGO_URI | Keep days: $KEEP_DAYS | Min keep: $MIN_KEEP | Compression: ${COMPRESSION_LEVEL}"
log_message "Invoice archive: $INVOICE_ARCHIVE_DIR | Invoice retention days: $INVOICE_KEEP_DAYS"
log_message "Backup mode: $BACKUP_MODE"
if [[ "$BACKUP_MODE" != "auto" && "$BACKUP_MODE" != "host" && "$BACKUP_MODE" != "docker" ]]; then
log_message "ERROR: Invalid --mode value '$BACKUP_MODE' (allowed: auto, host, docker)"
exit 2
fi
init_docker_compose || true
if create_backup; then
ART_DATE="$(date +"%Y-%m-%d")"
if [[ "$COMPRESSION_LEVEL" -gt 0 ]]; then
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE.tar.gz"
else
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE"
fi
fi
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
ADMIN_PROJECT_DIR="${ADMIN_PROJECT_DIR:-$(dirname "$PROJECT_DIR")/admin_Inventarsystem}"
DRY_RUN=0
REMOVE_CRON=0
KEEP_AUTOSTART=0
SERVICES=(
"inventarsystem-gunicorn.service"
"admin-inventarsystem-gunicorn.service"
"admin-inventarsystem-nginx.service"
)
SOCKETS=(
"/tmp/inventarsystem.sock"
"/tmp/admin-inventarsystem.sock"
)
PROCESS_PATTERNS=(
"$PROJECT_DIR/.venv/bin/gunicorn app:app"
"$ADMIN_PROJECT_DIR/.venv/bin/gunicorn app:app"
)
CRON_FILTER_PATTERNS=(
"$PROJECT_DIR/update.sh"
"$PROJECT_DIR/backup-docker.sh"
"$ADMIN_PROJECT_DIR/update.sh"
"$ADMIN_PROJECT_DIR/backup-docker.sh"
)
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--remove-cron Remove matching cron entries for old systems
--keep-autostart Stop services but do not disable autostart
--dry-run Show actions without executing
-h, --help Show this help message
EOF
}
log() {
echo "[cleanup-old] $*"
}
run_cmd() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] $*"
else
"$@"
fi
}
run_cmd_sudo() {
if [ "$(id -u)" -eq 0 ]; then
run_cmd "$@"
else
run_cmd sudo "$@"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--remove-cron)
REMOVE_CRON=1
shift
;;
--keep-autostart)
KEEP_AUTOSTART=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'"
usage
exit 2
;;
esac
done
}
service_exists() {
local service="$1"
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -Fxq "$service"
}
stop_services() {
if ! command -v systemctl >/dev/null 2>&1; then
log "systemctl not available, skipping service cleanup"
return 0
fi
local service
for service in "${SERVICES[@]}"; do
if ! service_exists "$service"; then
log "Service not found: $service"
continue
fi
log "Stopping $service"
run_cmd_sudo systemctl stop "$service" || true
if [ "$KEEP_AUTOSTART" -eq 0 ]; then
log "Disabling autostart for $service"
run_cmd_sudo systemctl disable "$service" || true
fi
done
}
kill_leftover_processes() {
local pattern
for pattern in "${PROCESS_PATTERNS[@]}"; do
log "Terminating processes matching: $pattern"
run_cmd_sudo pkill -TERM -f "$pattern" || true
done
if [ "$DRY_RUN" -eq 0 ]; then
sleep 2
fi
for pattern in "${PROCESS_PATTERNS[@]}"; do
run_cmd_sudo pkill -KILL -f "$pattern" || true
done
}
remove_stale_sockets() {
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
log "Removing stale socket/file: $socket_path"
run_cmd_sudo rm -f "$socket_path"
else
log "Socket/file not present: $socket_path"
fi
done
}
remove_cron_entries() {
if ! command -v crontab >/dev/null 2>&1; then
log "crontab not available, skipping cron cleanup"
return 0
fi
local tmp_file
tmp_file="$(mktemp)"
if [ "$(id -u)" -eq 0 ]; then
(crontab -l 2>/dev/null || true) > "$tmp_file"
else
(sudo crontab -l 2>/dev/null || true) > "$tmp_file"
fi
local pattern
for pattern in "${CRON_FILTER_PATTERNS[@]}"; do
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] would remove cron lines containing: $pattern"
else
grep -vF "$pattern" "$tmp_file" > "${tmp_file}.new" || true
mv "${tmp_file}.new" "$tmp_file"
fi
done
if [ "$DRY_RUN" -eq 0 ]; then
if [ "$(id -u)" -eq 0 ]; then
crontab "$tmp_file"
else
sudo crontab "$tmp_file"
fi
fi
rm -f "$tmp_file"
log "Cron cleanup finished"
}
status_report() {
log "Remaining matching processes:"
ps -eo pid,ppid,cmd --sort=start_time | grep -E "Inventarsystem/.venv/bin/gunicorn|admin_Inventarsystem/.venv/bin/gunicorn" | grep -v grep || echo "none"
log "Socket status:"
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
echo "exists: $socket_path"
else
echo "missing: $socket_path"
fi
done
}
main() {
parse_args "$@"
stop_services
kill_leftover_processes
remove_stale_sockets
if [ "$REMOVE_CRON" -eq 1 ]; then
remove_cron_entries
fi
status_report
log "Cleanup complete"
}
main "$@"
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Debug script to check why planned bookings are not being activated
"""
import sys
import os
import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Web'))
from pymongo import MongoClient
import settings as cfg
import ausleihung as au
def check_bookings():
"""Check all planned bookings and their status"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
# Get all planned bookings
planned = list(ausleihungen.find({'Status': 'planned'}))
print(f"\n📊 Gefundene GEPLANTE Ausleihen: {len(planned)}\n")
print("=" * 100)
current_time = datetime.datetime.now()
print(f"⏰ Aktuelle Zeit: {current_time}\n")
for booking in planned:
booking_id = str(booking['_id'])
user = booking.get('User', 'N/A')
item = booking.get('Item', 'N/A')
start = booking.get('Start')
end = booking.get('End')
period = booking.get('Period')
status = booking.get('Status')
print(f"\n🔹 Ausleihe ID: {booking_id}")
print(f" Benutzer: {user}")
print(f" Item: {item}")
print(f" Status: {status}")
print(f" Schulstunde/Periode: {period}")
print(f" Start: {start} (Typ: {type(start).__name__})")
print(f" Ende: {end} (Typ: {type(end).__name__})")
# Check current status
current_status = au.get_current_status(booking, log_changes=False)
print(f" ✓ Berechneter Status: {current_status}")
if current_status != status:
print(f" ⚠️ STATUS STIMMT NICHT ÜBEREIN! Sollte sein: {current_status}")
# Check time comparison
if start:
time_diff = (current_time - start).total_seconds()
if time_diff < 0:
print(f" ⏳ Startet in: {abs(time_diff) / 60:.1f} Minuten")
elif time_diff < 3600:
print(f" ✓ Sollte JETZT AKTIV sein! ({time_diff / 60:.1f} Minuten vorbei)")
else:
print(f" ✓ Sollte schon {time_diff / 3600:.1f} Stunden aktiv sein!")
print("\n" + "=" * 100)
# Check active bookings too
active = list(ausleihungen.find({'Status': 'active'}))
print(f"\n✓ AKTIVE Ausleihen: {len(active)}")
for booking in active:
print(f" - {booking.get('User', 'N/A')}: {booking.get('Item', 'N/A')} (Periode: {booking.get('Period')})")
client.close()
if __name__ == '__main__':
check_bookings()
+1
View File
@@ -99,6 +99,7 @@ services:
INVENTAR_REDIS_HOST: redis INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379" INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_DB: "0" INVENTAR_REDIS_DB: "0"
INVENTAR_TENANT_PORT_MAP: "${INVENTAR_TENANT_PORT_MAP:-}"
# Storage Configuration # Storage Configuration
INVENTAR_BACKUP_FOLDER: /data/backups INVENTAR_BACKUP_FOLDER: /data/backups
-1104
View File
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
set -euo pipefail
# Initialize first admin user if database is empty
# Usage: ./init-admin.sh [username] [password] [first_name] [last_name]
# Default: admin / admin123456 / Admin / User
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
USERNAME="${1:-admin}"
PASSWORD="${2:-admin123456}"
FIRST_NAME="${3:-Admin}"
LAST_NAME="${4:-User}"
# Wait for MongoDB to be ready
echo "Waiting for MongoDB to be ready..."
for i in {1..30}; do
if docker exec inventarsystem-mongodb mongosh --eval "db.adminCommand('ping')" >/dev/null 2>&1; then
echo "MongoDB is ready."
break
fi
if [ $i -eq 30 ]; then
echo "Error: MongoDB did not start in time."
exit 1
fi
sleep 1
done
# Check if any users already exist
USER_COUNT=$(docker exec inventarsystem-mongodb mongosh --quiet --eval "db.users.countDocuments({})" Inventarsystem 2>/dev/null || echo "0")
if [ "$USER_COUNT" -eq 0 ]; then
echo "Database is empty. Creating initial admin user: $USERNAME"
# Hash the password
PASSWORD_HASH=$(python3 -c "import hashlib; print(hashlib.sha512(b'$PASSWORD').hexdigest())")
# Insert admin user
docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({
Username: '$USERNAME',
Password: '$PASSWORD_HASH',
Admin: true,
active_ausleihung: null,
name: '$FIRST_NAME',
last_name: '$LAST_NAME',
favorites: []
})
" Inventarsystem
echo "✓ Admin user '$USERNAME' created successfully."
echo " Username: $USERNAME"
echo " Password: $PASSWORD"
else
echo "Database already has $USER_COUNT user(s). Skipping initialization."
fi
-5
View File
@@ -176,7 +176,6 @@ Options:
--skip-cleanup-old Do not run cleanup-old.sh after install --skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup --cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default) --multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME) --legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI) --legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration --legacy-system-dir <path> Optional old system directory to remove after migration
@@ -207,10 +206,6 @@ parse_args() {
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name) --legacy-db-name)
LEGACY_DB_NAME="$2" LEGACY_DB_NAME="$2"
shift 2 shift 2
+74
View File
@@ -124,6 +124,68 @@ EOF
fi fi
} }
sync_tenant_port_map() {
local config_path="$CONFIG_FILE"
local env_file="$PWD/.docker-build.env"
local tenant_port_map
tenant_port_map="$(python3 - <<'PY' "$config_path"
import json, os, sys
path = sys.argv[1]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict):
sys.exit(0)
entries = []
for tenant_id, conf in sorted(tenants.items()):
if isinstance(conf, dict):
port = conf.get('port')
if isinstance(port, (int, float)) or (isinstance(port, str) and str(port).strip().isdigit()):
entries.append(f"{int(port)}={tenant_id}")
print(','.join(entries))
PY
)"
if [ -z "$tenant_port_map" ]; then
if [ -f "$env_file" ]; then
sed -i '/^INVENTAR_TENANT_PORT_MAP=/d' "$env_file"
fi
return 0
fi
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_TENANT_PORT_MAP=$tenant_port_map
EOF
return 0
fi
if grep -q '^INVENTAR_TENANT_PORT_MAP=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_TENANT_PORT_MAP=.*|INVENTAR_TENANT_PORT_MAP=$tenant_port_map|" "$env_file"
else
printf '\nINVENTAR_TENANT_PORT_MAP=%s\n' "$tenant_port_map" >> "$env_file"
fi
}
restart_app_container() {
local env_file="$PWD/.docker-build.env"
local compose_args=( -f "$PWD/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
fi
if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" )
fi
echo "Restarting app container to apply tenant configuration changes..."
docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
}
remove_tenant_port() { remove_tenant_port() {
local tenant_id="$1" local tenant_id="$1"
local config_path="$CONFIG_FILE" local config_path="$CONFIG_FILE"
@@ -239,6 +301,10 @@ case "$COMMAND" in
fi fi
register_tenant_port "$TENANT_ID" "$PORT_ARG" register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG" update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
fi fi
echo "Adding new tenant '$TENANT_ID'..." echo "Adding new tenant '$TENANT_ID'..."
@@ -331,8 +397,16 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove" remove_runtime_port "$port_to_remove"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove." echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else else
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present." echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi fi
else else
-337
View File
@@ -1,337 +0,0 @@
#!/bin/bash
# Standalone version manager for this repo
# Supports pinning to a commit/tag/branch, one-time use, clearing the lock,
# applying the lock, listing refs, and showing status. Optional restart.
set -euo pipefail
PROJECT_DIR="$(dirname "$(readlink -f "$0")")"
cd "$PROJECT_DIR"
LOCK_FILE="$PROJECT_DIR/.version-lock"
RESTART=false
FORCE=false
BACKUP_BASE_DIR="/var/backups"
LOG_DIR="$PROJECT_DIR/logs"
VM_LOG="$LOG_DIR/version_manager.log"
# Directories/files to preserve across version switches
# Data paths we want to restore from backup after a version switch
DATA_PATHS=(
"certs"
"Images"
"logs"
"Web/uploads"
"Web/thumbnails"
"Web/QRCodes"
"Web/previews"
)
# Ensure log directory exists
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
chmod 777 "$LOG_DIR" >/dev/null 2>&1 || true
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$VM_LOG"; }
err() { echo "ERROR: $*" | tee -a "$VM_LOG" >&2; }
usage() {
cat <<USAGE
Usage:
$0 pin <ref> [--restart] [--force] Persistently lock to a commit/tag/branch and deploy it
$0 use <ref> [--restart] [--force] Use ref once (no persistence) and deploy it
$0 clear [--restart] [--force] Remove lock, switch back to main, pull latest
$0 apply [--restart] [--force] Re-apply the current lock (if any)
$0 status Show current commit and lock state
$0 list [--tags|--commits] List tags or recent commits (default: tags)
Notes:
- <ref> can be a tag name, branch name, or full/short commit hash
- --restart calls ./restart.sh after switching
- --force discards local changes (git reset --hard) if needed
USAGE
}
ensure_git_ready() {
# Avoid "dubious ownership" errors
git config --global --add safe.directory "$PROJECT_DIR" >/dev/null 2>&1 || true
# Fetch refs and tags
git fetch --all --tags --prune
}
ensure_clean_or_force() {
if ! git diff --quiet || ! git diff --cached --quiet; then
if [ "$FORCE" = true ]; then
log "Discarding local changes (force)"
git reset --hard
git clean -fdx >/dev/null 2>&1 || true
else
err "Working tree has local changes. Re-run with --force to discard."
exit 2
fi
fi
}
restart_if_requested() {
if [ "$RESTART" = true ]; then
if [ -x "$PROJECT_DIR/restart.sh" ]; then
log "Restarting services..."
"$PROJECT_DIR/restart.sh"
else
log "restart.sh not found or not executable; skipping restart"
fi
fi
}
# Create a full backup before switching versions (files + DB CSVs)
LAST_BACKUP_ARCHIVE=""
LAST_BACKUP_DIRNAME=""
create_pre_switch_backup() {
local ts backup_name backup_dir backup_archive
ts=$(date +"%Y-%m-%d_%H-%M-%S")
backup_name="Inventarsystem-pre-switch-$ts"
backup_dir="$BACKUP_BASE_DIR/$backup_name"
backup_archive="$BACKUP_BASE_DIR/$backup_name.tar.gz"
log "Creating pre-switch backup at $backup_archive"
sudo mkdir -p "$backup_dir" || { err "Failed to create backup directory"; return 1; }
# Copy project files (exclude .venv to reduce size, keep .git for reference)
log "Copying project files to backup directory..."
sudo rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
"$PROJECT_DIR/" "$backup_dir/" || log "Warning: rsync file copy encountered issues"
# Database CSV backup using existing helper
log "Backing up MongoDB to CSVs..."
sudo mkdir -p "$backup_dir/mongodb_backup" || true
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if sudo -E "$PROJECT_DIR/run-backup.sh" --db Inventarsystem --uri mongodb://localhost:27017/ --out "$backup_dir/mongodb_backup" >> "$VM_LOG" 2>&1; then
log "Database backup completed"
else
err "Database backup failed; continuing with file backup only"
fi
else
log "run-backup.sh not found; skipping DB backup"
fi
# Compress backup
log "Compressing backup archive..."
if sudo tar -czf "$backup_archive" -C "$BACKUP_BASE_DIR" "$backup_name"; then
log "Backup archived to $backup_archive"
sudo rm -rf "$backup_dir" || true
# Set readable permissions
sudo chmod 644 "$backup_archive" || true
# Export for subsequent restore step
LAST_BACKUP_ARCHIVE="$backup_archive"
LAST_BACKUP_DIRNAME="$backup_name"
else
err "Failed to create compressed backup archive"
return 1
fi
}
# Restore key data from the backup archive into current working tree
restore_from_backup_archive() {
if [ -z "$LAST_BACKUP_ARCHIVE" ] || [ ! -f "$LAST_BACKUP_ARCHIVE" ]; then
log "No backup archive available to restore data from; skipping data restore"
return 0
fi
local tmpdir
tmpdir=$(mktemp -d /tmp/inventarsystem_restore_XXXXXX)
log "Extracting backup archive $LAST_BACKUP_ARCHIVE to $tmpdir"
if ! sudo tar -xzf "$LAST_BACKUP_ARCHIVE" -C "$tmpdir"; then
err "Failed to extract backup archive; skipping data restore"
rm -rf "$tmpdir" || true
return 1
fi
local src_root="$tmpdir/$LAST_BACKUP_DIRNAME"
# Some backups might extract without top-level dir; fallback
if [ ! -d "$src_root" ]; then
src_root="$tmpdir"
fi
log "Restoring data directories from backup..."
for rel in "${DATA_PATHS[@]}"; do
if [ -e "$src_root/$rel" ]; then
mkdir -p "$PROJECT_DIR/$(dirname "$rel")" 2>/dev/null || true
rsync -a "$src_root/$rel" "$PROJECT_DIR/$(dirname "$rel")/" >> "$VM_LOG" 2>&1 || log "Warning: failed to restore $rel"
log "Restored: $rel"
else
log "Not in backup (skipped): $rel"
fi
done
rm -rf "$tmpdir" || true
}
# Ensure permissions on critical data directories
ensure_permissions() {
# Logs should be writable
mkdir -p "$PROJECT_DIR/logs" && chmod 777 "$PROJECT_DIR/logs" 2>/dev/null || true
# Web data directories readable/executable by server
for d in "$PROJECT_DIR/Web/uploads" "$PROJECT_DIR/Web/thumbnails" "$PROJECT_DIR/Web/QRCodes" "$PROJECT_DIR/Web/previews" "$PROJECT_DIR/Images"; do
[ -d "$d" ] && chmod -R 755 "$d" 2>/dev/null || true
done
# SSL cert perms (key must be 600)
if [ -f "$PROJECT_DIR/certs/inventarsystem.key" ]; then
chmod 600 "$PROJECT_DIR/certs/inventarsystem.key" 2>/dev/null || true
fi
}
checkout_ref() {
local ref="$1"
# Try tag
if git show-ref --verify --quiet "refs/tags/$ref"; then
git checkout -B locked-tag-"$ref" "tags/$ref"
return 0
fi
# Try remote branch
if git show-ref --verify --quiet "refs/remotes/origin/$ref"; then
git checkout -B locked-branch-"$ref" "origin/$ref"
return 0
fi
# Try local branch
if git show-ref --verify --quiet "refs/heads/$ref"; then
git checkout "$ref"
return 0
fi
# Try commit-ish
if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
git checkout -B locked-commit "$ref"
return 0
fi
err "Could not resolve ref '$ref' to tag/branch/commit"
exit 3
}
deploy_main_latest() {
ensure_clean_or_force
if git show-ref --verify --quiet refs/heads/main; then
git checkout main
else
git checkout -B main origin/main
fi
git pull --ff-only
}
cmd_status() {
local head_commit
head_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "Current commit: $head_commit"
if [ -f "$LOCK_FILE" ]; then
echo "Version lock: $(cat "$LOCK_FILE")"
else
echo "Version lock: none (tracking main)"
fi
}
cmd_list() {
local mode="tags"
for a in "$@"; do
case "$a" in
--tags) mode="tags" ;;
--commits) mode="commits" ;;
esac
done
ensure_git_ready
if [ "$mode" = "tags" ]; then
git tag --list | sort -V
else
git --no-pager log --oneline -n 30
fi
}
cmd_pin() {
local ref="$1"
ensure_git_ready
# Always produce a backup before switching
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
echo "$ref" > "$LOCK_FILE"
checkout_ref "$ref"
# Restore data from the backup archive
restore_from_backup_archive
ensure_permissions
log "Pinned to: $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_use() {
local ref="$1"
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
checkout_ref "$ref"
restore_from_backup_archive
ensure_permissions
log "Using (one-time): $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_clear() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
[ -f "$LOCK_FILE" ] && rm -f "$LOCK_FILE"
deploy_main_latest
restore_from_backup_archive
ensure_permissions
log "Cleared version lock; now on main @ $(git rev-parse --short HEAD)"
restart_if_requested
}
cmd_apply() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
if [ -f "$LOCK_FILE" ]; then
ensure_clean_or_force
local ref
ref=$(cat "$LOCK_FILE")
checkout_ref "$ref"
log "Applied lock: $ref (commit $(git rev-parse --short HEAD))"
else
log "No lock present; keeping main"
deploy_main_latest
fi
restore_from_backup_archive
ensure_permissions
restart_if_requested
}
# Parse global flags that can appear after subcommand too
parse_tail_flags() {
for a in "$@"; do
case "$a" in
--restart) RESTART=true ;;
--force) FORCE=true ;;
esac
done
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
pin)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_pin "$ref" ;;
use)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_use "$ref" ;;
clear)
parse_tail_flags "$@"; cmd_clear ;;
apply)
parse_tail_flags "$@"; cmd_apply ;;
status)
cmd_status ;;
list)
cmd_list "$@" ;;
--help|-h|help|"")
usage ;;
*)
err "Unknown command: $cmd"; usage; exit 1 ;;
esac
}
main "$@"
-426
View File
@@ -1,426 +0,0 @@
#!/bin/bash
##############################################################################
# Multi-Tenant Migration Script
#
# Automatisierte Konvertierung einer Single-Instance App zu Multi-Tenant
# Führt folgende Operationen durch:
# 1. Backup der Original-Dateien
# 2. Import von Tenant-Modulen
# 3. Anpassung von app.py
# 4. Konfiguration von Redis
# 5. Validierung
#
# Usage: bash migrate-to-multitenant.sh [dry-run]
##############################################################################
set -e
# Farben für Terminal Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_DIR="$PROJECT_ROOT/Web"
BACKUP_DIR="$PROJECT_ROOT/.migration-backup-$(date +%Y%m%d-%H%M%S)"
DRY_RUN="${1:-}"
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[✓]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_prerequisites() {
log_info "Checking prerequisites..."
# Check Python
if ! command -v python3 &> /dev/null; then
log_error "Python 3 not found"
return 1
fi
log_success "Python 3 found: $(python3 --version)"
# Check Docker
if ! command -v docker &> /dev/null; then
log_warning "Docker not found (will be needed for deployment)"
else
log_success "Docker found: $(docker --version)"
fi
# Check Flask app
if [[ ! -f "$WEB_DIR/app.py" ]]; then
log_error "Web/app.py not found"
return 1
fi
log_success "Flask app found at $WEB_DIR/app.py"
return 0
}
create_backups() {
log_info "Creating backups..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would backup to: $BACKUP_DIR"
return 0
fi
mkdir -p "$BACKUP_DIR"
# Backup critical files
cp "$WEB_DIR/app.py" "$BACKUP_DIR/app.py.bak"
cp "$WEB_DIR/settings.py" "$BACKUP_DIR/settings.py.bak" 2>/dev/null || true
cp "docker-compose.yml" "$BACKUP_DIR/docker-compose.yml.bak" 2>/dev/null || true
log_success "Backups created at: $BACKUP_DIR"
}
check_tenant_modules() {
log_info "Checking tenant modules..."
local missing=0
for module in tenant session_manager query_cache; do
if [[ ! -f "$WEB_DIR/${module}.py" ]]; then
log_warning "Missing module: $WEB_DIR/${module}.py"
((missing++))
else
log_success "Module found: $module.py"
fi
done
if [[ $missing -gt 0 ]]; then
log_error "Missing $missing required modules. Ensure they are created first."
return 1
fi
return 0
}
check_docker_files() {
log_info "Checking Docker configuration files..."
local missing=0
if [[ ! -f "docker-compose-multitenant.yml" ]]; then
log_warning "Missing docker-compose-multitenant.yml"
((missing++))
else
log_success "docker-compose-multitenant.yml found"
fi
if [[ ! -f "docker/nginx/multitenant.conf" ]]; then
log_warning "Missing docker/nginx/multitenant.conf"
((missing++))
else
log_success "docker/nginx/multitenant.conf found"
fi
if [[ $missing -gt 0 ]]; then
log_warning "Missing $missing Docker files (needed for deployment)"
fi
return 0
}
update_app_py() {
log_info "Updating app.py with Multi-Tenant imports..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would add Multi-Tenant imports to app.py"
return 0
fi
# Check if already updated
if grep -q "from tenant import" "$WEB_DIR/app.py"; then
log_warning "app.py already contains Multi-Tenant imports (skipping)"
return 0
fi
# Add imports after other data_protection imports
local imports_section=$(python3 -c "
import re
with open('$WEB_DIR/app.py', 'r') as f:
content = f.read()
# Find the line after data_protection imports
if 'from data_protection import' in content:
lines = content.split('\n')
for i, line in enumerate(lines):
if 'from data_protection import' in line:
# Find the end of this import block
j = i + 1
while j < len(lines) and (lines[j].startswith(' ') or lines[j].strip() == ''):
j += 1
# Insert new imports before settings import
print(j)
break
else:
print(-1)
" 2>/dev/null)
if [[ $imports_section -eq -1 ]]; then
log_warning "Could not find insertion point for Multi-Tenant imports"
log_info "Please manually add the following imports to app.py:"
cat << 'EOF'
# Multi-Tenant Imports
from tenant import get_tenant_context, require_tenant, get_tenant_db
from session_manager import create_redis_session_interface
from query_cache import get_cache_manager, cached_query, invalidate_cache
EOF
return 1
fi
log_success "Multi-Tenant imports prepared"
return 0
}
update_requirements() {
log_info "Checking requirements.txt for Redis..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would update requirements.txt"
return 0
fi
# Check both root and Web/requirements.txt
for req_file in requirements.txt Web/requirements.txt; do
if [[ -f "$req_file" ]]; then
if ! grep -q "^redis" "$req_file"; then
log_info "Adding redis to $req_file..."
echo "redis>=7.0.0" >> "$req_file"
log_success "Added redis to $req_file"
else
log_success "$req_file already has redis"
fi
fi
done
return 0
}
generate_migration_guide() {
log_info "Generating migration guide..."
cat > "$BACKUP_DIR/MIGRATION_NOTES.md" << 'EOF'
# Multi-Tenant Migration Checklist
## Automated Steps Completed
- [x] Backup created
- [x] Tenant modules verified
- [x] Docker files prepared
- [x] app.py imports prepared
- [x] requirements.txt updated
## Manual Steps Required
### 1. Update app.py Configuration (5 min)
Add after `app = Flask(...)`:
```python
# Multi-Tenant Configuration
if os.getenv('INVENTAR_SESSION_BACKEND') == 'redis':
try:
app.session_interface = create_redis_session_interface(app)
app.logger.info("Redis session backend enabled")
except Exception as e:
app.logger.warning(f"Redis session backend failed: {e}")
```
### 2. Add Health Check Endpoint (5 min)
Add this route:
```python
@app.route('/health')
def health_check():
try:
mongo = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
mongo.admin.command('ping')
return {'status': 'healthy'}, 200
except Exception as e:
return {'status': 'unhealthy'}, 503
```
### 3. Update Database Access (10-30 min)
Change existing routes to use:
```python
@require_tenant
def your_route():
db = get_tenant_db(MongoClient(...))
# Use db as usual
```
### 4. Setup DNS (5 min)
Create wildcard DNS record:
```
*.example.com IN A your-server-ip
```
### 5. Create SSL Certificate (10 min)
```bash
# Let's Encrypt (recommended)
sudo certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d "example.com"
# Copy to certs folder
cp /etc/letsencrypt/live/example.com/fullchain.pem certs/inventarsystem.crt
cp /etc/letsencrypt/live/example.com/privkey.pem certs/inventarsystem.key
```
### 6. Deploy Multi-Tenant
```bash
# Install dependencies
pip install -r requirements.txt
# Start Multi-Tenant deployment
docker-compose -f docker-compose-multitenant.yml up -d
# Test
curl https://test.example.com/health
```
### 7. Verify (5 min)
```bash
# Check logs
docker-compose logs app
# Verify each tenant
curl https://schule1.example.com/debug/tenant
curl https://schule2.example.com/debug/tenant
# Cache stats
curl https://schule1.example.com/debug/cache-stats
```
## Rollback Plan
If something goes wrong:
1. Stop Multi-Tenant deployment
```bash
docker-compose -f docker-compose-multitenant.yml down
```
2. Restore from backup
```bash
cp .migration-backup-*/app.py Web/app.py
cp .migration-backup-*/settings.py Web/settings.py
```
3. Restart original deployment
```bash
docker-compose up -d
```
## Performance Expectations
- Memory per instance: 100-150MB (was 200MB)
- Response time: -30% (Redis caching)
- Max concurrent users: 3x increase
- Database load: -70% (query caching)
## Support
- Check logs: `docker-compose -f docker-compose-multitenant.yml logs -f app`
- Debug tenant: `curl https://your-tenant.com/debug/tenant`
- See MULTITENANT_DEPLOYMENT.md for detailed guide
EOF
log_success "Migration guide created at: $BACKUP_DIR/MIGRATION_NOTES.md"
}
validate_setup() {
log_info "Validating setup..."
# Check Python syntax of tenant modules
for module in tenant session_manager query_cache; do
module_file="$WEB_DIR/${module}.py"
if [[ -f "$module_file" ]]; then
if python3 -m py_compile "$module_file" 2>/dev/null; then
log_success "Module syntax valid: $module.py"
else
log_error "Syntax error in $module.py"
return 1
fi
fi
done
return 0
}
print_summary() {
cat << EOF
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN} Multi-Tenant Migration Summary${NC}
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN}✓ Prerequisites checked${NC}
${GREEN}✓ Backups created${NC}
${GREEN}✓ Tenant modules verified${NC}
${GREEN}✓ Docker files prepared${NC}
${GREEN}✓ app.py imports prepared${NC}
${GREEN}✓ requirements.txt updated${NC}
${YELLOW}Next Steps:${NC}
1. Review migration guide: $BACKUP_DIR/MIGRATION_NOTES.md
2. Manually update app.py (see guide above)
3. Setup DNS wildcard record
4. Create SSL certificate
5. Deploy: docker-compose -f docker-compose-multitenant.yml up -d
${BLUE}Documentation:${NC}
- MULTITENANT_DEPLOYMENT.md - Complete deployment guide
- MULTITENANT_INTEGRATION.md - Code integration examples
- $BACKUP_DIR/MIGRATION_NOTES.md - Step-by-step checklist
${GREEN}═══════════════════════════════════════════════════════════${NC}
EOF
}
main() {
log_info "Multi-Tenant Migration Starting..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "Running in DRY-RUN mode - no changes will be made"
fi
check_prerequisites || exit 1
create_backups
check_tenant_modules || exit 1
check_docker_files
update_app_py || true
update_requirements
validate_setup || exit 1
generate_migration_guide
print_summary
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "DRY-RUN completed - no changes were made"
log_info "Run without 'dry-run' parameter to execute migration"
fi
}
main "$@"
-119
View File
@@ -1,119 +0,0 @@
#!/bin/bash
# This script completely rebuilds the virtual environment
# It should be run as root/sudo
# Get the script directory
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
VENV_DIR="$SCRIPT_DIR/.venv"
BACKUP_DIR="$SCRIPT_DIR/.venv_backup_$(date +%Y%m%d%H%M%S)"
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log_message "Starting virtual environment rebuild"
# Backup existing virtual environment if it exists
if [ -d "$VENV_DIR" ]; then
log_message "Backing up existing virtual environment to $BACKUP_DIR"
mv "$VENV_DIR" "$BACKUP_DIR" || {
log_message "ERROR: Failed to backup existing virtual environment. Proceeding without backup."
rm -rf "$VENV_DIR"
}
fi
# Create logs directory with proper permissions
log_message "Ensuring logs directory exists with proper permissions"
mkdir -p "$SCRIPT_DIR/logs"
chmod 777 "$SCRIPT_DIR/logs" || log_message "WARNING: Could not set permissions on logs directory"
# Create a new virtual environment
log_message "Creating new virtual environment"
python3 -m venv "$VENV_DIR" || {
log_message "ERROR: Failed to create virtual environment. Trying with system packages..."
python3 -m venv "$VENV_DIR" --system-site-packages || {
log_message "ERROR: Failed to create virtual environment even with system packages. Exiting."
exit 1
}
}
# Set proper permissions
log_message "Setting permissions for new virtual environment"
chmod -R 755 "$VENV_DIR" || log_message "WARNING: Could not set permissions on virtual environment"
find "$VENV_DIR" -type d -exec chmod 755 {} \; 2>/dev/null
find "$VENV_DIR/bin" -type f -exec chmod +x {} \; 2>/dev/null
# Activate the virtual environment
log_message "Activating virtual environment"
source "$VENV_DIR/bin/activate" || {
log_message "ERROR: Failed to activate virtual environment"
exit 1
}
# Install packages
log_message "Upgrading pip"
pip install --upgrade pip || log_message "WARNING: Failed to upgrade pip"
log_message "Installing pymongo"
# First ensure bson is removed to avoid conflicts
pip uninstall -y bson || log_message "WARNING: Failed to uninstall bson (may not exist)"
# Check if bson directory exists after uninstall and remove it if necessary
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Force removing bson directory"
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
fi
# Now install pymongo
pip install pymongo==4.6.3 || log_message "WARNING: Failed to install pymongo"
# Verify pymongo installation
python -c "import pymongo; print(f'PyMongo version: {pymongo.__version__}')" && log_message "✓ PyMongo installed correctly" || log_message "WARNING: PyMongo verification failed"
# Install other requirements if they exist
if [ -f "$SCRIPT_DIR/requirements.txt" ]; then
log_message "Installing packages from requirements.txt (excluding bson)"
# Create a modified requirements file without bson
grep -v "^bson" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt"
# Also remove pymongo since we already installed it
grep -v "^pymongo" "$SCRIPT_DIR/requirements_filtered.txt" > "$SCRIPT_DIR/requirements_no_conflicts.txt"
pip install -r "$SCRIPT_DIR/requirements_no_conflicts.txt" || log_message "WARNING: Failed to install some packages from requirements.txt"
rm -f "$SCRIPT_DIR/requirements_filtered.txt" "$SCRIPT_DIR/requirements_no_conflicts.txt"
log_message "Verifying pymongo installation after package installation..."
# Check if the bson directory from the standalone package still exists and remove it
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Found standalone bson package, removing it..."
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
# Reinstall pymongo to ensure it's correctly configured
pip install --force-reinstall pymongo==4.6.3
fi
else
log_message "No requirements.txt found, installing essential packages manually"
pip install flask werkzeug gunicorn pillow qrcode apscheduler python-dateutil pytz requests || {
log_message "WARNING: Failed to install some essential packages"
}
fi
# Deactivate virtual environment
deactivate
# Verify the virtual environment works correctly
log_message "Verifying virtual environment..."
if "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler; exit(0)" 2>/dev/null; then
log_message "✓ Virtual environment verification successful"
# Clean up backup if everything works
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing backup virtual environment since verification was successful"
rm -rf "$BACKUP_DIR"
log_message "✓ Backup environment removed"
fi
else
log_message "WARNING: Virtual environment verification failed"
log_message "Keeping backup at $BACKUP_DIR in case it's needed for recovery"
fi
log_message "Virtual environment rebuild complete"
log_message "You can now run the restart.sh script"
exit 0
+6 -8
View File
@@ -46,14 +46,16 @@ services:
image: $app_image image: $app_image
build: null build: null
EOF EOF
if [ ${#ports[@]} -gt 1 ]; then if [ ${#ports[@]} -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports: ports:
EOF EOF
for port in "${ports[@]}"; do for port in "${ports[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000" - "$port:8000"
EOF EOF
fi
done done
fi fi
else else
@@ -67,10 +69,6 @@ while [[ $# -gt 0 ]]; do
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*) *)
shift shift
;; ;;
@@ -93,13 +91,13 @@ if [ -f "$ENV_FILE" ]; then
fi fi
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose "${compose_args[@]}" up -d --build app docker compose "${compose_args[@]}" up -d --build --force-recreate app
else else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container." echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
if ! docker compose "${compose_args[@]}" up -d --no-build app; then if ! docker compose "${compose_args[@]}" up -d --no-build --force-recreate app; then
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..." echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
docker compose "${compose_args[@]}" pull app docker compose "${compose_args[@]}" pull app
docker compose "${compose_args[@]}" up -d --no-build app docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
fi fi
fi fi
-538
View File
@@ -1,538 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
DOCKER_COMPOSE=()
cleanup() {
rm -rf "$WORK_DIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT
mkdir -p "$LOG_DIR"
log() {
local msg="$1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" | tee -a "$LOG_FILE"
}
show_help() {
cat <<EOF
Inventarsystem Restore / Porting Tool (Docker-first)
Usage:
$0 --source <path> [options]
$0 --date <YYYY-MM-DD|latest> [options]
$0 --list
Options:
--source <path> Backup source path.
Supported:
- old folder with CSV files
- old Inventarsystem-*.tar.gz backup
- new mongodb-*.archive.gz backup
- folder containing mongodb-*.archive.gz
--date <value> Resolve source from known backup locations.
- YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz)
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates
--help Show this help
Notes:
- Always restores into current DB name: $DB_NAME
- Supports both legacy CSV and new archive backups
- Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings)
EOF
}
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
log "ERROR: docker compose is not available"
exit 1
}
compose() {
"${DOCKER_COMPOSE[@]}" "$@"
}
list_backups() {
echo "Detected backup candidates:"
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
}
resolve_latest_source() {
local latest
latest="$({
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
} | sort -nr | head -n1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
log "ERROR: no backup candidates found"
exit 1
fi
SOURCE_PATH="$latest"
}
resolve_date_source() {
local d="$1"
if [ "$d" = "latest" ]; then
resolve_latest_source
return
fi
local p1="$BACKUP_ROOT_LOCAL/$d"
local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d"
local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz"
if [ -e "$p1" ]; then
SOURCE_PATH="$p1"
elif [ -e "$p2" ]; then
SOURCE_PATH="$p2"
elif [ -e "$p3" ]; then
SOURCE_PATH="$p3"
else
log "ERROR: no backup found for date $d"
exit 1
fi
}
ensure_stack() {
log "Ensuring mongodb/app services are running..."
compose up -d mongodb app >/dev/null
if ! compose ps --status running mongodb | grep -q mongodb; then
log "ERROR: mongodb container is not running"
exit 1
fi
if ! compose ps --status running app | grep -q app; then
log "ERROR: app container is not running"
exit 1
fi
}
stage_source() {
local src="$1"
if [ ! -e "$src" ]; then
log "ERROR: source path does not exist: $src"
exit 1
fi
if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then
log "Extracting tar backup: $src"
tar -xzf "$src" -C "$WORK_DIR"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -f "$src" ]; then
cp -f "$src" "$WORK_DIR/"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -d "$src" ]; then
STAGED_PATH="$src"
return
fi
log "ERROR: unsupported source type: $src"
exit 1
}
find_archive_file() {
local root="$1"
find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true
}
find_best_csv_dir() {
local root="$1"
local best_dir=""
local best_count=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
local count
count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')"
if [ "$count" -gt "$best_count" ]; then
best_count="$count"
best_dir="$dir"
fi
done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u)
if [ "$best_count" -gt 0 ]; then
echo "$best_dir"
fi
}
normalize_structure() {
local py="$WORK_DIR/normalize_db.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
rename_map = {
"user": "users",
"item": "items",
"ausleihung": "ausleihungen",
"filter_preset": "filter_presets",
"preset": "filter_presets",
"setting": "settings",
}
for old, new in rename_map.items():
if old not in db.list_collection_names():
continue
if new not in db.list_collection_names():
db[old].rename(new)
print(f"Renamed collection {old} -> {new}")
continue
moved = 0
for doc in db[old].find({}):
db[new].insert_one(doc)
moved += 1
db[old].drop()
print(f"Merged {moved} docs from {old} into {new}")
print("Final collections:", sorted(db.list_collection_names()))
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py
}
import_archive() {
local archive_file="$1"
local mongo_cid
mongo_cid="$(compose ps -q mongodb)"
if [ -z "$mongo_cid" ]; then
log "ERROR: could not resolve mongodb container id"
exit 1
fi
log "Importing archive backup: $archive_file"
$SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz"
local drop_flag=""
if [ "$DROP_DATABASE" = true ]; then
drop_flag="--drop"
fi
compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag"
compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true
normalize_structure
}
import_csv() {
local csv_dir="$1"
local app_cid py
app_cid="$(compose ps -q app)"
if [ -z "$app_cid" ]; then
log "ERROR: could not resolve app container id"
exit 1
fi
log "Importing CSV backup directory: $csv_dir"
compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv"
$SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv"
py="$WORK_DIR/import_csv.py"
cat > "$py" <<'PY'
import os
import csv
import ast
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
DB_NAME = os.getenv("DB_NAME", "Inventarsystem")
CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv")
DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true"
HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
COLLECTION_MAP = {
"users": "users",
"user": "users",
"items": "items",
"item": "items",
"ausleihungen": "ausleihungen",
"ausleihung": "ausleihungen",
"filter_presets": "filter_presets",
"filter_preset": "filter_presets",
"settings": "settings",
"setting": "settings",
}
BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"}
INT_FIELDS = {"Anschaffungskosten", "filter_num"}
OID_LIKE_FIELDS = {"_id", "Item"}
client = MongoClient(HOST, PORT)
db = client[DB_NAME]
if DROP_DATABASE:
client.drop_database(DB_NAME)
db = client[DB_NAME]
def parse_scalar(key, value):
if value is None:
return None
value = value.strip()
if value == "":
return None
if key in BOOL_FIELDS:
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if key in INT_FIELDS:
try:
return int(value)
except Exception:
pass
if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value):
return ObjectId(value)
if value.startswith("[") and value.endswith("]"):
try:
return ast.literal_eval(value)
except Exception:
pass
return value
def load_csv_file(csv_path):
docs = []
with open(csv_path, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
doc = {}
for key, value in row.items():
doc[key] = parse_scalar(key, value)
docs.append(doc)
return docs
csv_files = [
os.path.join(CSV_DIR, f)
for f in sorted(os.listdir(CSV_DIR))
if f.endswith(".csv")
]
if not csv_files:
raise SystemExit(f"No CSV files found in {CSV_DIR}")
for csv_file in csv_files:
base = os.path.splitext(os.path.basename(csv_file))[0]
coll = COLLECTION_MAP.get(base, base)
docs = load_csv_file(csv_file)
db[coll].drop()
if docs:
db[coll].insert_many(docs)
print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}")
print("Collections after CSV import:", sorted(db.list_collection_names()))
PY
$SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py"
compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py
normalize_structure
}
print_counts() {
local py="$WORK_DIR/print_counts.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]:
try:
count = db[name].count_documents({})
except Exception:
count = -1
print(f"{name}: {count}")
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py"
log "Post-restore collection counts:"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--source)
SOURCE_PATH="${2:-}"
shift 2
;;
--date)
BACKUP_DATE="${2:-}"
shift 2
;;
--drop-database)
DROP_DATABASE=true
shift
;;
--restart-services)
RESTART_SERVICES=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list)
list_backups
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown argument: $1"
show_help
exit 1
;;
esac
done
}
main() {
parse_args "$@"
setup_compose
if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
log "ERROR: use either --source or --date, not both"
exit 1
fi
if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
resolve_date_source "$BACKUP_DATE"
fi
if [ -z "$SOURCE_PATH" ]; then
log "ERROR: provide --source <path> or --date <YYYY-MM-DD|latest>"
show_help
exit 1
fi
ensure_stack
local staged archive_file csv_dir
stage_source "$SOURCE_PATH"
staged="$STAGED_PATH"
archive_file="$(find_archive_file "$staged")"
if [ -n "$archive_file" ]; then
import_archive "$archive_file"
print_counts
else
csv_dir="$(find_best_csv_dir "$staged")"
if [ -z "$csv_dir" ]; then
log "ERROR: no supported backup data found under: $SOURCE_PATH"
log "Expected either *.archive.gz or CSV files"
exit 1
fi
import_csv "$csv_dir"
print_counts
fi
if [ "$RESTART_SERVICES" = true ]; then
log "Restarting services..."
"$SCRIPT_DIR/restart.sh"
fi
log "Restore completed successfully into DB: $DB_NAME"
}
main "$@"
Executable → Regular
+4 -47
View File
@@ -1,47 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Helper script to run the backup with proper Python environment set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Get the script directory exec "$SCRIPT_DIR/backup.sh" "$@"
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# Default values if no arguments provided
if [ $# -eq 0 ]; then
# Define default values
DB="Inventarsystem"
URI="mongodb://localhost:27017/"
OUT="$SCRIPT_DIR/backups/$(date +%Y-%m-%d)"
echo "No arguments provided. Using defaults:"
echo " --uri $URI"
echo " --db $DB"
echo " --out $OUT"
# Create output directory if it doesn't exist
mkdir -p "$OUT"
# Set the arguments
ARGS="--uri $URI --db $DB --out $OUT"
else
# Use provided arguments
ARGS="$@"
fi
# Check if we're in a virtual environment
if [ -f "$SCRIPT_DIR/.venv/bin/python" ]; then
# Check if we have execute permissions for the virtual environment Python
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
# Use the virtual environment's Python
"$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/Backup-DB.py" $ARGS || {
echo "Failed to execute with virtual environment Python. Trying system Python..."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
}
else
echo "Virtual environment found but Python is not executable. Using system Python instead."
echo "To fix virtual environment permissions, run: sudo ./rebuild-venv.sh"
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
else
# Use system Python
echo "No virtual environment found. Using system Python."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
+6 -7
View File
@@ -37,7 +37,6 @@ Options:
--no-cron Do not create or update cron jobs --no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default) --with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment --multitenant Use the multi-tenant architecture deployment
--singletenant Use the legacy single-tenant compose deployment
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -57,10 +56,6 @@ parse_args() {
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -584,15 +579,19 @@ EOF
if [ -n "$HTTP_PORTS_VALUE" ]; then if [ -n "$HTTP_PORTS_VALUE" ]; then
local ports_array local ports_array
read -r -a ports_array <<<"$HTTP_PORTS_VALUE" local ports_list
if [ "${#ports_array[@]}" -gt 1 ]; then ports_list="${HTTP_PORTS_VALUE//,/ }"
read -r -a ports_array <<<"$ports_list"
if [ "${#ports_array[@]}" -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports: ports:
EOF EOF
for port in "${ports_array[@]}"; do for port in "${ports_array[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000" - "$port:8000"
EOF EOF
fi
done done
fi fi
fi fi
-4
View File
@@ -12,10 +12,6 @@ while [[ $# -gt 0 ]]; do
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*) *)
shift shift
;; ;;
-5
View File
@@ -152,7 +152,6 @@ Usage: $0 [options]
Options: Options:
--multitenant Use docker-compose-multitenant.yml (default) --multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -164,10 +163,6 @@ parse_args() {
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0