From eb62b381a1a054da07c4b8a93263c08d31aea708 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Mon, 25 May 2026 18:46:07 +0300 Subject: [PATCH] feat: multi-database backup, HTTP API endpoints, admin panel UI - backup CLI: add --all-databases, --database, --data-root flags - backup format: tar.gz with databases// + backup.json metadata - HTTP API: new endpoints /databases, /backup, /backups, /restore - HTTP API: database switching via X-Database header - Admin panel: database selector dropdown, Databases tab, Backups tab - Admin panel: backup/restore UI with fetch() to new endpoints - Update deps: hunos 1.3.0, jwt-nim-baraba 2.1.0 - Docker: backup cron uses --all-databases --data-root=/data/databases - Docs: update api-http.md, protocol.md with correct endpoints - OpenAPI spec updated to v1.1.6 Known limitation: DELETE /databases disabled due to ORC SIGSEGV in LSMTree.close(). Use SQL DROP DATABASE instead. --- baradadb.nimble | 4 +- docker-compose.prod.yml | 4 +- docs/bg/api-http.md | 147 +++++++++------ docs/bg/protocol.md | 12 +- docs/en/protocol.md | 12 +- src/barabadb/core/backup.nim | 314 +++++++++++++++++++++++++++---- src/barabadb/core/httpserver.nim | 309 +++++++++++++++++++++++++++++- src/barabadb/core/registry.nim | 7 +- 8 files changed, 697 insertions(+), 112 deletions(-) diff --git a/baradadb.nimble b/baradadb.nimble index 043173d..9b9a4ec 100644 --- a/baradadb.nimble +++ b/baradadb.nimble @@ -9,8 +9,8 @@ binDir = "build" # Dependencies requires "nim >= 2.2.0" -requires "https://github.com/katehonz/hunos >= 1.2.0" -requires "jwt >= 0.3.0" +requires "https://github.com/katehonz/hunos >= 1.3.0" +requires "https://github.com/katehonz/jwt-nim-baraba >= 2.1.0" requires "checksums >= 0.2.0" # Tasks diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 25e9c3e..99d979e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -102,8 +102,8 @@ services: sh -c ' while true; do sleep 86400; - /app/backup backup --data-dir=/data --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6; - /app/backup cleanup --keep=7; + /app/backup backup --all-databases --data-root=/data/databases --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6; + /app/backup cleanup --data-root=/data/databases --keep=7; done ' volumes: diff --git a/docs/bg/api-http.md b/docs/bg/api-http.md index 49c146f..c920981 100644 --- a/docs/bg/api-http.md +++ b/docs/bg/api-http.md @@ -5,7 +5,7 @@ JSON-базиран REST API за уеб приложения. ## Базов URL ``` -http://localhost:9470 +http://localhost:9912 ``` ## Endpoints @@ -15,53 +15,7 @@ http://localhost:9470 Health проверка: ```bash -curl http://localhost:9470/health -``` - -### GET /ready - -Readiness проверка: - -```bash -curl http://localhost:9470/ready -``` - -### POST /query - -Изпълнение на BaraQL заявка: - -```bash -curl -X POST http://localhost:9470/api/query \ - -H "Content-Type: application/json" \ - -d '{"query": "SELECT * FROM users WHERE age > 18"}' -``` - -Отговор: -```json -{ - "columns": ["name", "age"], - "rows": [["Alice", 30], ["Bob", 25]], - "row_count": 2, - "duration_ms": 12 -} -``` - -### POST /batch - -Групови заявки: - -```bash -curl -X POST http://localhost:9470/api/batch \ - -H "Content-Type: application/json" \ - -d '{"queries": ["INSERT users { name := \"Alice\" }", "INSERT users { name := \"Bob\" }"]}' -``` - -### GET /schema - -Преглед на схемата: - -```bash -curl http://localhost:9470/api/schema +curl http://localhost:9912/health ``` ### GET /metrics @@ -69,37 +23,110 @@ curl http://localhost:9470/api/schema Prometheus метрики: ```bash -curl http://localhost:9470/metrics +curl http://localhost:9912/metrics ``` -### POST /explain +### POST /auth -Обяснение на план за изпълнение: +JWT автентикация: ```bash -curl -X POST http://localhost:9470/api/explain \ +curl -X POST http://localhost:9912/auth \ -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "secret"}' +``` + +### POST /query + +Изпълнение на SQL заявка: + +```bash +curl -X POST http://localhost:9912/query \ + -H "Content-Type: application/json" \ + -H "X-Database: default" \ -d '{"query": "SELECT * FROM users WHERE age > 18"}' ``` +Отговор: +```json +{ + "columns": ["name", "age"], + "rows": [{"name": "Alice", "age": 30}], + "affectedRows": 0 +} +``` + +> **Забележка:** Header `X-Database` избира към коя база данни да се изпълни заявката. Ако липсва, се използва `default`. + +### GET /tables + +Списък с таблици в избраната база данни: + +```bash +curl http://localhost:9912/tables -H "X-Database: default" +``` + +### GET /databases + +Списък с налични бази данни: + +```bash +curl http://localhost:9912/databases +``` + +### POST /databases + +Създаване на нова база данни (изисква admin права): + +```bash +curl -X POST http://localhost:9912/databases \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"name": "mydb"}' +``` + ### POST /backup -Създаване на backup: +Създаване на backup (изисква admin права): ```bash -curl -X POST http://localhost:9470/api/backup \ +# Backup на всички бази данни +curl -X POST http://localhost:9912/backup \ -H "Content-Type: application/json" \ - -d '{"destination": "/backup/snapshot.db"}' + -H "Authorization: Bearer " \ + -d '{"all": true}' + +# Backup на единична база +curl -X POST http://localhost:9912/backup \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"database": "default"}' +``` + +### GET /backups + +Списък с налични архиви: + +```bash +curl http://localhost:9912/backups -H "Authorization: Bearer " +``` + +### POST /restore + +Възстановяване от архив (изисква admin права): + +```bash +curl -X POST http://localhost:9912/restore \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"input": "backup_1234567890.tar.gz", "all": true}' ``` ## Грешки ```json { - "error": { - "code": "INVALID_QUERY", - "message": "Грешка в синтаксиса" - } + "error": "Unauthorized" } ``` @@ -107,5 +134,5 @@ curl -X POST http://localhost:9470/api/backup \ ```bash curl -H "Authorization: Bearer " \ - http://localhost:9470/api/users + http://localhost:9912/metrics ``` diff --git a/docs/bg/protocol.md b/docs/bg/protocol.md index 96bcf96..6055c81 100644 --- a/docs/bg/protocol.md +++ b/docs/bg/protocol.md @@ -240,9 +240,19 @@ Content-Type: application/json ```http POST /backup Content-Type: application/json +Authorization: Bearer { - "destination": "/backup/snapshot.db" + "all": true +} +``` + +Отговор: +```json +{ + "success": true, + "output": "backup_1234567890.tar.gz", + "message": "Backup created" } ``` diff --git a/docs/en/protocol.md b/docs/en/protocol.md index 32b2fff..5338504 100644 --- a/docs/en/protocol.md +++ b/docs/en/protocol.md @@ -290,9 +290,19 @@ Response: ```http POST /backup Content-Type: application/json +Authorization: Bearer { - "destination": "/backup/snapshot.db" + "all": true +} +``` + +Response: +```json +{ + "success": true, + "output": "backup_1234567890.tar.gz", + "message": "Backup created" } ``` diff --git a/src/barabadb/core/backup.nim b/src/barabadb/core/backup.nim index d92724e..238101f 100644 --- a/src/barabadb/core/backup.nim +++ b/src/barabadb/core/backup.nim @@ -38,9 +38,11 @@ type const DEFAULT_DATA_DIR = "data/server" + DEFAULT_DATA_ROOT = "data/databases" DEFAULT_KEEP_COUNT = 5 DEFAULT_COMPRESSION = 6 HISTORY_FILE = "backup_history.log" + BACKUP_META_FILE = "backup.json" HELP_TEXT = """ BaraDB Backup Manager — Archive and restore your database safely ================================================================ @@ -73,20 +75,33 @@ COMMANDS: help Show this help message. OPTIONS: - -d, --data-dir Path to data directory (default: data/server) + -d, --data-dir Path to single data directory (default: data/server) + -r, --data-root Path to multi-database root (default: data/databases) -o, --output Destination path for new backup archive -i, --input Source archive for restore or verify -k, --keep Retention count for cleanup (default: 5) -e, --exclude Exclude files matching pattern (repeatable) -l, --level <0-9> Gzip compression level (default: 6, max: 9) + --all-databases Backup/restore all databases under --data-root + --database Backup/restore a specific database under --data-root + --online Create consistent backup via checkpoint (freeze + flush) --dry-run Show what restore would do without changing anything -f, --force Skip confirmation prompts (use with caution!) -v, --verbose Print detailed progress information EXAMPLES: - # Quick backup with default settings + # Quick backup of single directory (legacy) backup backup + # Backup all databases (recommended for multi-DB setups) + backup backup --all-databases --output=all_backup.tar.gz + + # Backup a specific database + backup backup --database=default --output=default_backup.tar.gz + + # Restore all databases + backup restore --input=all_backup.tar.gz --all-databases + # Backup with maximum compression and custom name backup backup --output=prod_backup_$(date +%F).tar.gz --level=9 @@ -481,6 +496,168 @@ proc incrementalBackupDataDir*(dataDir: string, output: string, verbose: bool = echo " Files: ", filesToInclude.len return true +proc discoverDatabases*(dataRoot: string): seq[string] = + ## Scan dataRoot for database directories + result = @[] + if not dirExists(dataRoot): + return + for kind, path in walkDir(dataRoot): + if kind == pcDir: + let dbName = lastPathPart(path) + if dbName.len > 0 and dbName notin [".", ".."]: + result.add(dbName) + result.sort() + +proc backupAllDatabases*(dataRoot: string, output: string, excludes: seq[string] = @[], compression: int = DEFAULT_COMPRESSION, verbose: bool = false): bool = + ## Create a tar.gz backup of all databases under dataRoot. + ## Archive layout: databases//... + backup.json + if not dirExists(dataRoot): + echo "ERROR: Data root directory not found: ", dataRoot + return false + + let databases = discoverDatabases(dataRoot) + if databases.len == 0: + echo "ERROR: No databases found in ", dataRoot + return false + + if fileExists(output): + echo "WARNING: Overwriting existing file: ", output + + let workDir = getCurrentDir() + let tempDir = getTempDir() / "baradb_backup_" & $getTime().toUnix() + createDir(tempDir / "databases") + + var meta = %*{ + "version": 1, + "timestamp": getTime().toUnix(), + "databases": databases, + "createdBy": "baradb-backup" + } + + # Copy each database into temp staging area + for dbName in databases: + let src = dataRoot / dbName + let dst = tempDir / "databases" / dbName + if verbose: + echo "Staging database: ", dbName + copyDir(src, dst) + + # Write metadata + writeFile(tempDir / BACKUP_META_FILE, $meta) + + var excludeArgs = "" + for pattern in excludes: + excludeArgs.add(" --exclude=" & quoteShell(pattern)) + + let tarCmd = "tar -cf -" & excludeArgs & " -C " & quoteShell(tempDir) & " ." + let gzipCmd = "gzip -" & $compression + let cmd = tarCmd & " | " & gzipCmd & " > " & quoteShell(output) + + if verbose: + echo "Running: ", cmd + echo "Databases: ", databases.join(", ") + echo "Output: ", output + + let (outputStr, exitCode) = execCmdEx("bash -c " & quoteShell(cmd)) + removeDir(tempDir) + + if exitCode != 0: + echo "ERROR: tar command failed with exit code ", exitCode + if outputStr.len > 0: + echo outputStr + return false + + let size = getFileSize(output) + echo "Multi-database backup created successfully:" + echo " File: ", output + echo " Size: ", formatBytes(size) + echo " Databases: ", databases.len, " (", databases.join(", "), ")" + return true + +proc restoreAllDatabases*(input: string, dataRoot: string, verbose: bool = false, dryRun: bool = false): bool = + ## Restore all databases from a multi-database archive. + if not fileExists(input): + echo "ERROR: Backup file not found: ", input + return false + + let archiveSize = getFileSize(input) + let freeSpace = getFreeSpace(parentDir(dataRoot)) + let oldBackupPath = dataRoot & ".old_" & $getTime().toUnix() + + if dryRun: + echo "DRY-RUN: The following actions would be performed:" + echo " 1. Verify archive integrity: ", input + echo " 2. Move existing data root to: ", oldBackupPath + echo " 3. Extract archive to: ", dataRoot + echo " Archive size: ", formatBytes(archiveSize) + if freeSpace >= 0: + echo " Free space: ", formatBytes(freeSpace) + else: + echo " Free space: unable to determine" + return true + + # Check free space + if freeSpace >= 0 and freeSpace < archiveSize * 2: + echo "WARNING: Free space (", formatBytes(freeSpace), ") may be insufficient." + echo " Archive is ", formatBytes(archiveSize), " — at least 2x is recommended." + + if dirExists(dataRoot): + if verbose: + echo "Moving existing data root to: ", oldBackupPath + moveDir(dataRoot, oldBackupPath) + + createDir(dataRoot) + + let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(dataRoot) + if verbose: + echo "Running: ", cmd + + let (outputStr, exitCode) = execCmdEx(cmd) + if exitCode != 0: + echo "ERROR: tar extraction failed with exit code ", exitCode + if outputStr.len > 0: + echo outputStr + # Attempt rollback + if dirExists(oldBackupPath): + echo "Attempting rollback..." + removeDir(dataRoot) + moveDir(oldBackupPath, dataRoot) + echo "Rollback complete. Data restored to previous state." + return false + + # Verify metadata + let metaPath = dataRoot / BACKUP_META_FILE + if fileExists(metaPath): + try: + let meta = parseJson(readFile(metaPath)) + let dbList = meta{"databases"} + if dbList != nil and dbList.kind == JArray: + echo "Restored databases: ", dbList.len + for db in dbList: + echo " - ", db.getStr() + except CatchableError as e: + echo "WARNING: Could not parse backup metadata: ", e.msg + + echo "Restored successfully from: ", input + echo " Target: ", dataRoot + if dirExists(oldBackupPath): + echo " Old data preserved at: ", oldBackupPath + return true + +proc readBackupMeta*(input: string): JsonNode = + ## Read backup metadata without full extraction. + ## Returns nil if not a multi-db archive or no metadata. + result = nil + if not fileExists(input): + return + let cmd = "tar -xzf " & quoteShell(input) & " -O ./" & BACKUP_META_FILE & " 2>/dev/null" + let (outStr, exitCode) = execCmdEx(cmd) + if exitCode == 0 and outStr.len > 0: + try: + result = parseJson(outStr) + except CatchableError: + discard + # ============================================================================= # CLI Entry Point # ============================================================================= @@ -488,6 +665,7 @@ when isMainModule: var command = "" dataDir = DEFAULT_DATA_DIR + dataRoot = DEFAULT_DATA_ROOT target = "" keepCount = DEFAULT_KEEP_COUNT excludes: seq[string] = @[] @@ -496,6 +674,8 @@ when isMainModule: dryRun = false force = false online = false + allDatabases = false + databaseName = "" for kind, key, val in getopt(): case kind @@ -507,6 +687,7 @@ when isMainModule: of cmdLongOption, cmdShortOption: case key of "data-dir", "d": dataDir = val + of "data-root", "r": dataRoot = val of "output", "o": target = val of "input", "i": target = val of "keep", "k": @@ -522,6 +703,8 @@ when isMainModule: of "dry-run": dryRun = true of "force", "f": force = true of "online": online = true + of "all-databases": allDatabases = true + of "database": databaseName = val of "verbose", "v": verbose = true of "help", "h": echo HELP_TEXT @@ -537,36 +720,79 @@ when isMainModule: case command of "backup": let outputFile = if target.len > 0: target else: "backup_" & $getTime().toUnix() & ".tar.gz" - if online: - echo "Creating online backup with checkpoint..." - echo " Data dir: ", dataDir - echo " Output: ", outputFile - try: - var db = newLSMTree(dataDir) - db.checkpoint() - db.close() - echo "Checkpoint complete." - except CatchableError as e: - echo "ERROR: Checkpoint failed: ", e.msg - quit(1) - let ok = incrementalBackupDataDir(dataDir, outputFile, verbose) + if allDatabases: + let ok = backupAllDatabases(dataRoot, outputFile, excludes, compression, verbose) if not ok: - quit("Online backup failed", 1) + quit("Multi-database backup failed", 1) + elif databaseName.len > 0: + let dbDir = dataRoot / databaseName + if online: + echo "Creating online backup with checkpoint..." + echo " Database: ", databaseName + echo " Data dir: ", dbDir + echo " Output: ", outputFile + try: + var db = newLSMTree(dbDir) + db.checkpoint() + db.close() + echo "Checkpoint complete." + except CatchableError as e: + echo "ERROR: Checkpoint failed: ", e.msg + quit(1) + let ok = incrementalBackupDataDir(dbDir, outputFile, verbose) + if not ok: + quit("Online backup failed", 1) + else: + let ok = backupDataDir(dbDir, outputFile, excludes, compression, verbose) + if not ok: + quit("Backup failed", 1) else: - let ok = backupDataDir(dataDir, outputFile, excludes, compression, verbose) - if not ok: - quit("Backup failed", 1) + if online: + echo "Creating online backup with checkpoint..." + echo " Data dir: ", dataDir + echo " Output: ", outputFile + try: + var db = newLSMTree(dataDir) + db.checkpoint() + db.close() + echo "Checkpoint complete." + except CatchableError as e: + echo "ERROR: Checkpoint failed: ", e.msg + quit(1) + let ok = incrementalBackupDataDir(dataDir, outputFile, verbose) + if not ok: + quit("Online backup failed", 1) + else: + let ok = backupDataDir(dataDir, outputFile, excludes, compression, verbose) + if not ok: + quit("Backup failed", 1) of "incremental": let outputFile = if target.len > 0: target else: "backup_inc_" & $getTime().toUnix() & ".tar.gz" - let ok = incrementalBackupDataDir(dataDir, outputFile, verbose) - if not ok: - quit("Incremental backup failed", 1) + if databaseName.len > 0: + let dbDir = dataRoot / databaseName + let ok = incrementalBackupDataDir(dbDir, outputFile, verbose) + if not ok: + quit("Incremental backup failed", 1) + else: + let ok = incrementalBackupDataDir(dataDir, outputFile, verbose) + if not ok: + quit("Incremental backup failed", 1) of "restore": if target.len == 0: quit("ERROR: restore requires --input=\nUse 'backup help' for usage.", 1) + # Detect archive type from metadata + let meta = readBackupMeta(target) + let isMultiDb = meta != nil and meta{"databases"} != nil + + if isMultiDb and not allDatabases and databaseName.len == 0: + echo "Detected multi-database archive containing:" + for db in meta{"databases"}: + echo " - ", db.getStr() + echo "Use --all-databases to restore all, or --database= for a single database." + # Always verify first unless dry-run if not dryRun: echo "Verifying archive before restore..." @@ -575,19 +801,32 @@ when isMainModule: logRestore(target, dataDir, false) quit("Restore aborted: archive verification failed.", 1) - if not dryRun and not force: - echo "WARNING: This will REPLACE the data in: ", dataDir - echo "Continue? [y/N] " - let answer = readLine(stdin) - if answer.toLowerAscii() notin ["y", "yes"]: - echo "Restore cancelled." - logRestore(target, dataDir, false, dryRun = false) - quit(0) - - let ok = restoreDataDir(target, dataDir, verbose, dryRun) - logRestore(target, dataDir, ok, dryRun) - if not ok and not dryRun: - quit("Restore failed", 1) + if allDatabases or isMultiDb: + if not dryRun and not force: + echo "WARNING: This will REPLACE all databases in: ", dataRoot + echo "Continue? [y/N] " + let answer = readLine(stdin) + if answer.toLowerAscii() notin ["y", "yes"]: + echo "Restore cancelled." + logRestore(target, dataRoot, false, dryRun = false) + quit(0) + let ok = restoreAllDatabases(target, dataRoot, verbose, dryRun) + logRestore(target, dataRoot, ok, dryRun) + if not ok and not dryRun: + quit("Restore failed", 1) + else: + if not dryRun and not force: + echo "WARNING: This will REPLACE the data in: ", dataDir + echo "Continue? [y/N] " + let answer = readLine(stdin) + if answer.toLowerAscii() notin ["y", "yes"]: + echo "Restore cancelled." + logRestore(target, dataDir, false, dryRun = false) + quit(0) + let ok = restoreDataDir(target, dataDir, verbose, dryRun) + logRestore(target, dataDir, ok, dryRun) + if not ok and not dryRun: + quit("Restore failed", 1) of "list": let backups = listBackups(dataDir) @@ -601,7 +840,10 @@ when isMainModule: quit("Verification failed", 1) of "cleanup": - cleanupOldBackups(dataDir, keepCount, verbose) + if allDatabases: + cleanupOldBackups(dataRoot, keepCount, verbose) + else: + cleanupOldBackups(dataDir, keepCount, verbose) of "history": printHistory() diff --git a/src/barabadb/core/httpserver.nim b/src/barabadb/core/httpserver.nim index 7f51768..f986870 100644 --- a/src/barabadb/core/httpserver.nim +++ b/src/barabadb/core/httpserver.nim @@ -7,6 +7,7 @@ import json import tables import strutils import times +import os import std/asyncdispatch import config import ../query/lexer @@ -21,6 +22,7 @@ import jwt as jwtlib import ../protocol/auth import ../protocol/ratelimit import ../core/registry +import ../core/backup type HttpServer* = ref object @@ -121,6 +123,36 @@ proc checkAuth(server: HttpServer, request: Request, ctx: Context): bool = return false return true +proc checkAdmin(server: HttpServer, request: Request, ctx: Context): bool = + if not server.config.authEnabled: + return true + let authHeader = request.headers["Authorization"] + if authHeader.len == 0 or not authHeader.startsWith("Bearer "): + ctx.json(%*{"error": "Unauthorized"}, 401) + return false + let tokenStr = authHeader[7..^1] + let (valid, _, role) = server.verifyToken(tokenStr) + if not valid: + ctx.json(%*{"error": "Unauthorized"}, 401) + return false + if role != "admin": + ctx.json(%*{"error": "Forbidden: admin role required"}, 403) + return false + return true + +proc getRequestDatabaseContext(server: HttpServer, request: Request): ExecutionContext = + ## Return execution context for the requested database (via X-Database header) + ## Falls back to server.ctx (default database) if not specified. + let dbName = request.headers["X-Database"] + if dbName.len > 0 and server.registry != nil: + try: + let dbInfo = getOrCreateDatabase(server.registry, dbName) + if dbInfo != nil and dbInfo.db != nil: + return newExecutionContext(dbInfo.db, server.registry) + except CatchableError: + discard + return newExecutionContext(server.db, server.registry) + # ---------------------------------------------------------------------- # Handlers # ---------------------------------------------------------------------- @@ -164,7 +196,7 @@ proc queryHandler(server: HttpServer): RequestHandler = ctx.json(%*{"error": "Empty query"}, 400) return - var reqCtx = cloneForConnection(server.ctx) + var reqCtx = getRequestDatabaseContext(server, request) reqCtx.currentUser = userId reqCtx.currentRole = role let tokens = tokenize(queryStr) @@ -297,11 +329,14 @@ proc openApiHandler(): RequestHandler = let ctx = newContext(request) ctx.json(%*{ "openapi": "3.0.0", - "info": {"title": "BaraDB API", "version": "0.1.0"}, + "info": {"title": "BaraDB API", "version": "1.1.6"}, "paths": { "/query": { "post": { "summary": "Execute SQL query", + "parameters": [ + {"name": "X-Database", "in": "header", "schema": {"type": "string"}, "description": "Target database (default: default)"} + ], "requestBody": { "content": { "application/json": { @@ -315,6 +350,20 @@ proc openApiHandler(): RequestHandler = } } }, + "/tables": {"get": {"summary": "List tables", "parameters": [{"name": "X-Database", "in": "header", "schema": {"type": "string"}}]}}, + "/databases": { + "get": {"summary": "List databases"}, + "post": {"summary": "Create database (admin)"} + }, + "/backup": { + "post": {"summary": "Create backup (admin)"} + }, + "/backups": { + "get": {"summary": "List backups"} + }, + "/restore": { + "post": {"summary": "Restore from backup (admin)"} + }, "/health": {"get": {"summary": "Health check"}}, "/metrics": {"get": {"summary": "Prometheus metrics"}}, "/auth": {"post": {"summary": "Authenticate and get JWT token"}} @@ -327,8 +376,9 @@ proc tablesHandler(server: HttpServer): RequestHandler = let ctx = newContext(request) if not server.checkAuth(request, ctx): return + let reqCtx = getRequestDatabaseContext(server, request) var tables = newJArray() - for name, tbl in server.ctx.tables: + for name, tbl in reqCtx.tables: var cols = newJArray() for col in tbl.columns: cols.add(%*{"name": col.name, "type": col.colType, @@ -337,6 +387,162 @@ proc tablesHandler(server: HttpServer): RequestHandler = "pkColumns": tbl.pkColumns, "fkCount": tbl.foreignKeys.len}) ctx.json(%*{"tables": tables}) +proc databasesHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAuth(request, ctx): + return + let dbs = server.registry.listDatabases() + var arr = newJArray() + for dbName in dbs: + var obj = newJObject() + obj["name"] = %dbName + try: + let dbInfo = getDatabaseInfo(server.registry, dbName) + if dbInfo != nil and dbInfo.ctx != nil: + let dbCtx = cast[ExecutionContext](cast[pointer](dbInfo.ctx)) + obj["tables"] = %dbCtx.tables.len + obj["connections"] = %getConnectionCount(server.registry, dbName) + else: + obj["tables"] = %0 + obj["connections"] = %0 + except CatchableError: + obj["tables"] = %0 + obj["connections"] = %0 + arr.add(obj) + ctx.json(%*{"databases": arr}) + +proc createDatabaseHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAdmin(request, ctx): + return + let body = parseJson(request.body) + if body == nil or "name" notin body: + ctx.json(%*{"error": "Missing 'name' in request body"}, 400) + return + let dbName = body["name"].getStr() + if dbName.len == 0: + ctx.json(%*{"error": "Empty database name"}, 400) + return + try: + discard getOrCreateDatabase(server.registry, dbName) + ctx.json(%*{"success": true, "name": dbName, "message": "Database created"}) + except CatchableError as e: + ctx.json(%*{"error": e.msg}, 400) + +proc dropDatabaseHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAdmin(request, ctx): + return + let dbName = request.pathParams.getOrDefault("name", "") + if dbName.len == 0: + ctx.json(%*{"error": "Missing database name"}, 400) + return + try: + let ok = dropDatabase(server.registry, dbName) + if ok: + ctx.json(%*{"success": true, "name": dbName, "message": "Database dropped"}) + else: + ctx.json(%*{"error": "Database not found"}, 404) + except CatchableError as e: + ctx.json(%*{"error": e.msg}, 400) + +proc backupHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAdmin(request, ctx): + return + let body = parseJson(request.body) + let dataRoot = server.registry.dataRoot + let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false + let dbName = if body != nil and "database" in body: body["database"].getStr() else: "" + let outputFile = if body != nil and "output" in body: body["output"].getStr() else: "backup_" & $getTime().toUnix() & ".tar.gz" + let compression = if body != nil and "level" in body: body["level"].getInt() else: 6 + try: + var ok = false + if allDatabases: + ok = backupAllDatabases(dataRoot, outputFile, @[], compression, false) + elif dbName.len > 0: + let dbDir = dataRoot / dbName + ok = backupDataDir(dbDir, outputFile, @[], compression, false) + else: + ok = backupAllDatabases(dataRoot, outputFile, @[], compression, false) + if ok: + ctx.json(%*{"success": true, "output": outputFile, "message": "Backup created"}) + else: + ctx.json(%*{"error": "Backup failed"}, 500) + except CatchableError as e: + ctx.json(%*{"error": e.msg}, 500) + +proc listBackupsHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAuth(request, ctx): + return + let dataRoot = server.registry.dataRoot + let backups = listBackups(dataRoot) + var arr = newJArray() + for b in backups: + var meta: JsonNode = nil + try: + meta = readBackupMeta(b.path) + except CatchableError: + discard + var obj = newJObject() + obj["path"] = %b.path + obj["size"] = %b.size + obj["sizeHuman"] = %formatBytes(b.size) + obj["timestamp"] = %b.timestamp + obj["compressed"] = %b.compressed + if meta != nil and meta{"databases"} != nil: + obj["databases"] = meta{"databases"} + obj["type"] = %"multi" + else: + obj["type"] = %"single" + arr.add(obj) + ctx.json(%*{"backups": arr}) + +proc restoreHandler(server: HttpServer): RequestHandler = + return proc(request: Request) {.gcsafe.} = + {.cast(gcsafe).}: + let ctx = newContext(request) + if not server.checkAdmin(request, ctx): + return + let body = parseJson(request.body) + if body == nil or "input" notin body: + ctx.json(%*{"error": "Missing 'input' in request body"}, 400) + return + let inputFile = body["input"].getStr() + let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false + let dbName = if body != nil and "database" in body: body["database"].getStr() else: "" + let dataRoot = server.registry.dataRoot + try: + let meta = readBackupMeta(inputFile) + let isMultiDb = meta != nil and meta{"databases"} != nil + var ok = false + if isMultiDb or allDatabases: + ok = restoreAllDatabases(inputFile, dataRoot, false, false) + elif dbName.len > 0: + let dbDir = dataRoot / dbName + ok = restoreDataDir(inputFile, dbDir, false, false) + else: + ok = restoreAllDatabases(inputFile, dataRoot, false, false) + if ok: + # Reload databases after restore + server.registry.loadExistingDatabases() + ctx.json(%*{"success": true, "message": "Restore completed"}) + else: + ctx.json(%*{"error": "Restore failed"}, 500) + except CatchableError as e: + ctx.json(%*{"error": e.msg}, 500) + proc adminHandler(server: HttpServer): RequestHandler = return proc(request: Request) {.gcsafe.} = let html = """ @@ -389,7 +595,7 @@ tr:hover td{background:#1a2744}

-

BaraDB Admin

+

BaraDB Admin

SQL Playground Tables @@ -397,6 +603,8 @@ tr:hover td{background:#1a2744} Live Metrics Cluster + Databases + Backups
@@ -434,10 +642,33 @@ tr:hover td{background:#1a2744}

Cluster Status

Cluster status not available
+
+

Databases

+
+ + +
+
Loading...
+
+
+
+

Backups

+
+ + +
+
Loading...
+
+
@@ -567,6 +854,12 @@ proc run*(server: HttpServer, port: int = 9470) = router.post("/auth/scram/finish", server.scramFinishHandler()) router.get("/tables", server.tablesHandler()) router.get("/api", openApiHandler()) + router.get("/databases", server.databasesHandler()) + router.post("/databases", server.createDatabaseHandler()) + # router.delete("/databases/@name", server.dropDatabaseHandler()) -- disabled due to ORC memory issue with LSMTree close + router.post("/backup", server.backupHandler()) + router.get("/backups", server.listBackupsHandler()) + router.post("/restore", server.restoreHandler()) var stack = newMiddlewareStack(router) stack.use(corsMiddleware()) diff --git a/src/barabadb/core/registry.nim b/src/barabadb/core/registry.nim index 72defac..5a139e5 100644 --- a/src/barabadb/core/registry.nim +++ b/src/barabadb/core/registry.nim @@ -158,13 +158,16 @@ proc dropDatabase*(reg: DatabaseRegistry, name: string): bool = "Cannot drop database '" & name & "': " & $info.activeConnections & " active connections") + # Copy needed references before deletion + let db = info.db + let dbDir = reg.dataRoot / name + # Remove from registry first so no new references can be obtained reg.databases.del(name) release(reg.lock) # Close LSMTree and remove directory outside the lock - info.db.close() - let dbDir = reg.dataRoot / name + db.close() if dirExists(dbDir): removeDir(dbDir) true