feat: first commit of new api

This commit is contained in:
2025-11-06 10:19:54 +01:00
parent ea72298a0f
commit 5bb6d6e41f
14 changed files with 76 additions and 683 deletions

View File

@@ -1,18 +1,18 @@
package main
import (
"ddbb/pkg/api"
"ddbb/pkg/cron"
"ddbb/internal/api"
// "ddbb/pkg/cron"
"log"
)
func main() {
// Set up the cron job
cron.ScheduleBackupFromEnv()
// cron.ScheduleBackupFromEnv()
// Run the api endpoint
r := api.SetupRouter()
err := r.Run(":12345")
err := r.Run(":3000")
if err != nil {
log.Fatalf("API fatal error: %v", err)
}

71
internal/api/api.go Normal file
View File

@@ -0,0 +1,71 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
)
type Job struct {
Id string `json:"id"`
Kind string `json:"kind"`
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
User string `json:"user"`
Password string `json:"-"` // omit from JSON responses
DumpFile string `json:"dump_file"`
DumpFileModTime time.Time `json:"dump_file_mod_time"`
IntegrityStatus string `json:"integrity_status"`
IntegrityLastCheckTime time.Time `json:"integrity_last_check_time"`
}
type DumpJobResult struct {
Id string
TimeStart time.Time
TimeEnd time.Time
Result string
Error error
}
type RestoreJobResult struct {
Id string
TimeStart time.Time
TimeEnd time.Time
Result string
Error error
}
func listJobs() error {
return nil
}
// Dump any database based on Id
func triggerDump(c *gin.Context) DumpJobResult {
return DumpJobResult{}
}
// Restore any database based on Id
func triggerRestore(c *gin.Context) RestoreJobResult {
return RestoreJobResult{}
}
// Check any database based on Id
func triggerIntegrityCheck(c *gin.Context) Job {
return Job{}
}
// API entrypoint
func SetupRouter() *gin.Engine {
// GIN General setup
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.SetTrustedProxies([]string{"127.0.0.1"})
// GIN endpoints
r.GET("/list", listJobs)
r.POST("/dump", triggerDump)
r.POST("/integrity", triggerIntegrityCheck)
r.POST("/restore", triggerRestore)
return r
}

View File

@@ -0,0 +1 @@
package backup

0
internal/cron/cron.go Normal file
View File

View File

View File

View File

View File

@@ -1,123 +0,0 @@
package api
import (
"ddbb/pkg/backup"
"ddbb/pkg/docker"
"ddbb/pkg/restore"
"log"
"net/http"
"sync"
"github.com/gin-gonic/gin"
)
// Init backup status
var (
backupStatus = "Idle"
statusMutex sync.Mutex
)
func BackupAllDatabases() {
allBackups, _ := docker.GetBackupConfigsFromContainers()
for _, job := range allBackups {
backup.BackupJob(job)
}
}
// Dump any database giving an Id
func triggerDump(c *gin.Context) {
// Extract backup job name from the form POST data
backupId := c.PostForm("id")
if backupId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
return
}
// Find the backup job config with the matching name
var chosenJob *backup.Config
jobs, err := docker.GetBackupConfigsFromContainers()
if err != nil {
log.Fatalf("Error while parsing docker labels: %v", err)
}
for i, job := range jobs {
if job.Id == backupId {
chosenJob = &jobs[i]
break
}
}
if chosenJob == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup job not found"})
return
}
// Run the backup asynchronously
go func(cfg backup.Config) {
statusMutex.Lock()
backupStatus = "Running"
statusMutex.Unlock()
backup.BackupJob(cfg)
statusMutex.Lock()
backupStatus = "Completed"
statusMutex.Unlock()
}(*chosenJob)
c.Redirect(http.StatusSeeOther, "/")
}
// Restore any database giving an Id
func triggerRestore(c *gin.Context) {
// Extract backup job name from the form POST data
restoreId := c.PostForm("id")
if restoreId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing restore job id"})
return
}
// Find the backup job config with the matching name
var chosenJob *backup.Config
jobs, err := docker.GetBackupConfigsFromContainers()
if err != nil {
log.Fatalf("Error while parsing docker labels: %v", err)
}
for i, job := range jobs {
if job.Id == restoreId {
chosenJob = &jobs[i]
break
}
}
if chosenJob == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Restore job not found"})
return
}
// Run the backup asynchronously
go func(cfg backup.Config) {
statusMutex.Lock()
backupStatus = "Running"
statusMutex.Unlock()
restore.RestoreJob(cfg)
statusMutex.Lock()
backupStatus = "Completed"
statusMutex.Unlock()
}(*chosenJob)
c.Redirect(http.StatusSeeOther, "/")
}
func SetupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.SetTrustedProxies([]string{"127.0.0.1"})
r.LoadHTMLGlob("templates/*")
r.POST("/backup", triggerDump)
r.POST("/restore", triggerRestore)
r.GET("/", ServeBackupPage)
return r
}

View File

@@ -1,47 +0,0 @@
package api
import (
"ddbb/pkg/backup"
"ddbb/pkg/docker"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
func ServeBackupPage(c *gin.Context) {
allBackups, _ := docker.GetBackupConfigsFromContainers()
setBackupDumpFiles(allBackups, "/backups/")
c.HTML(http.StatusOK, "backups.tmpl", gin.H{
"Backups": allBackups,
})
}
func setBackupDumpFiles(allBackups []backup.Config, dumpDir string) error {
for i := range allBackups {
host := allBackups[i].Host
// Try to find a dump file matching host.* in dumpDir
pattern := filepath.Join(dumpDir, host+".*")
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
if len(matches) == 0 {
continue // no dump found for this host
}
dumpFile := matches[0] // take first match
fi, err := os.Stat(dumpFile)
if err != nil {
return err
}
modTime := fi.ModTime().Format(time.RFC822)
allBackups[i].DumpFile = dumpFile
allBackups[i].DumpFileModTime = modTime
}
return nil
}

View File

@@ -1,127 +0,0 @@
package backup
import (
"fmt"
"log"
"os"
"os/exec"
)
type Config struct {
Id string
Kind string
Host string
Port string
Database string
User string
Password string
DumpFile string
DumpFileModTime string
}
type ConfigList struct {
Jobs []Config
}
var outFileRoot = "/backups/"
func BackupJob(job Config) {
var err error
switch kind := job.Kind; kind {
case "mariadb":
err = backupMariaDB(job)
case "postgres":
err = backupPostgres(job)
case "mongodb":
err = backupMongoDB(job)
default:
log.Printf("Invalid database kind for job %s", job.Id)
}
if err != nil {
log.Printf("%v", err)
}
}
func backupMariaDB(cfg Config) error {
log.Printf("Starting dump of mariadb database %s on %s", cfg.Database, cfg.Host)
outFileName := outFileRoot + cfg.Host + ".sql"
outFile, err := os.Create(outFileName)
if err != nil {
log.Fatalf("failed to create output file: %v", err)
return err
}
defer outFile.Close()
cmd := exec.Command(
"mariadb-dump",
"--ssl-verify-server-cert=FALSE",
"-h", cfg.Host,
"-u", cfg.User,
"-p"+cfg.Password,
"-P", cfg.Port,
cfg.Database,
)
cmd.Stdout = outFile
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Mariadb dump failed: %v", err)
return err
}
log.Printf("Database backup saved to %s", outFileName)
return nil
}
func backupPostgres(cfg Config) error {
outFileName := outFileRoot + cfg.Host + ".sql"
outFile, err := os.Create(outFileName)
if err != nil {
log.Fatalf("failed to create output file: %v", err)
return err
}
defer outFile.Close()
// Build pg_dump command
cmd := exec.Command(
"pg_dump",
"--dbname="+fmt.Sprintf("postgresql://%s:%s@%s:%s/%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
)
cmd.Stdout = outFile
cmd.Stderr = os.Stderr
log.Printf("Running pg_dump for database %s", cfg.Database)
if err := cmd.Run(); err != nil {
log.Fatalf("pg_dump failed: %v", err)
return err
}
log.Printf("Database backup saved to %s", outFileName)
return nil
}
func backupMongoDB(cfg Config) error {
outFileName := outFileRoot + cfg.Host + ".gz"
cmd := exec.Command(
"mongodump",
"--uri",
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
"--archive="+outFileName,
"--gzip",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("Running mongodump for database %s with gzip compression to file %s", cfg.Database, outFileName)
if err := cmd.Run(); err != nil {
log.Fatalf("mongodump failed: %v", err)
return err
}
log.Printf("MongoDB backup saved to compressed archive %s", outFileName)
return nil
}

View File

@@ -1,28 +0,0 @@
package cron
import (
"ddbb/pkg/api"
"github.com/robfig/cron/v3"
"log"
"os"
)
func ScheduleBackupFromEnv() {
expr := os.Getenv("BACKUP_CRON")
if expr == "" {
log.Fatal("Environment variable BACKUP_CRON not set")
}
c := cron.New()
_, err := c.AddFunc(expr, func() {
log.Println("Starting scheduled backup")
api.BackupAllDatabases()
})
if err != nil {
log.Fatalf("Invalid cron expression in BACKUP_CRON: %v", err)
}
c.Start()
// defer c.Stop() on application shutdown if needed
}

View File

@@ -1,89 +0,0 @@
package docker
import (
"context"
"ddbb/pkg/backup"
"log"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
func GetBackupConfigsFromContainers() ([]backup.Config, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, err
}
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return nil, err
}
var backups []backup.Config
for _, container := range containers {
if container.Labels["ddbb.enable"] != "true" {
continue
}
kind := container.Labels["ddbb.kind"]
id := container.ID[:12]
inspect, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil {
continue
}
envMap := map[string]string{}
for _, env := range inspect.Config.Env {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
var host, port, database, user, password string
// Set hostname
if len(container.Names) > 0 {
host = strings.TrimPrefix(container.Names[0], "/")
} else {
// fallback
host = id
}
switch kind {
case "mariadb":
port = "3306"
database = envMap["MARIADB_DATABASE"]
user = envMap["MARIADB_USER"]
password = envMap["MARIADB_PASSWORD"]
case "postgres":
port = "5432"
database = envMap["POSTGRES_DB"]
user = envMap["POSTGRES_USER"]
password = envMap["POSTGRES_PASSWORD"]
case "mongodb":
port = "27017"
database = envMap["MONGO_INITDB_DATABASE"]
user = envMap["MONGO_INITDB_ROOT_USERNAME"]
password = envMap["MONGO_INITDB_ROOT_PASSWORD"]
default:
log.Printf("Kind %s is not supported", kind)
}
backups = append(backups, backup.Config{
Id: id,
Kind: kind,
Host: host,
Port: port,
Database: database,
User: user,
Password: password,
})
}
return backups, nil
}

View File

@@ -1,94 +0,0 @@
package restore
import (
"ddbb/pkg/backup"
"fmt"
"log"
"os"
"os/exec"
"strings"
)
var dumpFileRoot = "/backups/"
func RestoreJob(cfg backup.Config) {
switch strings.ToLower(cfg.Kind) {
case "mariadb":
restoreMySQL(cfg)
case "postgres":
restorePostgres(cfg)
case "mongodb":
restoreMongo(cfg)
default:
log.Printf("unsupported database kind: %s", cfg.Kind)
}
}
func restoreMySQL(cfg backup.Config) error {
cmd := exec.Command(
"mariadb",
"--ssl-verify-server-cert=FALSE",
"-h", cfg.Host,
"-u", cfg.User,
fmt.Sprintf("-p%s", cfg.Password),
cfg.Database,
)
dumpFileName := dumpFileRoot + cfg.Host + ".sql"
dumpFile, err := os.Open(dumpFileName)
if err != nil {
return err
}
defer dumpFile.Close()
cmd.Stdin = dumpFile
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("mysql restore failed: %s, output: %s", err, output)
return err
}
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
return nil
}
func restorePostgres(cfg backup.Config) error {
cmd := exec.Command(
"psql", "-h", cfg.Host, "-U", cfg.User, "-d", cfg.Database,
"-f", "-", // read input from stdin
)
dumpFileName := dumpFileRoot + cfg.Host + ".sql"
dumpFile, err := os.Open(dumpFileName)
if err != nil {
return err
}
defer dumpFile.Close()
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", cfg.Password))
cmd.Stdin = dumpFile
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("postgres restore failed: %s, output: %s", err, output)
return err
}
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
return nil
}
func restoreMongo(cfg backup.Config) error {
dumpFileName := dumpFileRoot + cfg.Host + ".gz"
cmd := exec.Command(
"mongorestore", "--archive", "--gzip", "--drop", "--archive="+dumpFileName,
"--uri",
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database),
)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("mongodb restore failed: %s, output: %s", err, output)
return err
}
log.Printf("Restore job with ID %s completed successfully", cfg.Id)
return nil
}

View File

@@ -1,171 +0,0 @@
{{define "backups.tmpl"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Databases dump jobs</title>
<link href="https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap" rel="stylesheet">
<style>
.confirm-prompt {
margin-top: 8px;
font-size: 0.9em;
color: #f0f0f0;
background: #444;
padding: 6px 10px;
border-radius: 4px;
}
.confirm-prompt button {
margin-left: 6px;
background-color: #e14c4c;
border: none;
color: white;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
}
.confirm-prompt button.confirm-no {
background-color: #444;
}
.confirm-prompt button:hover {
opacity: 0.8;
}
body {
font-family: 'Fira Mono', Consolas, 'Courier New', monospace;
background-color: #1e1e1e;
color: #d4d4d4;
margin: 20px;
}
h1 {
text-align: center;
color: #003e6b;
margin-bottom: 30px;
font-family: 'Fira Mono', monospace;
}
table {
border-collapse: separate;
border-spacing: 0 8px;
width: 90%;
margin: auto;
background: #252526;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
border-radius: 0px;
overflow: hidden;
}
th, td {
padding: 12px 20px;
text-align: left;
font-variant-numeric: tabular-nums; /* consistent number width */
}
th {
background-color: #003e6b;
color: #d4d4d4;
font-weight: 600;
}
tr:hover {
background-color: #333842;
}
button {
background-color: #007acc;
border: none;
color: white;
padding: 8px 16px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
font-family: 'Fira Mono', monospace;
}
button:hover {
background-color: #005a9e;
}
form {
margin: 0;
}
</style>
<script>
function confirmRestore(form) {
// Check if confirmation prompt already exists
if (form.querySelector('.confirm-prompt')) {
return true; // allow submit on second confirmation
}
// Create confirmation prompt div
const prompt = document.createElement('div');
prompt.className = 'confirm-prompt';
prompt.innerHTML = `
<span>Are you sure? </span>
<button type='button' class='confirm-yes'>Yes</button>
<button type='button' class='confirm-no'>No</button>
`;
form.appendChild(prompt);
// Focus to yes button for accessibility
prompt.querySelector('.confirm-yes').focus();
// Disable original submit button
form.querySelector('button[type="submit"]').disabled = true;
// Handle yes/no clicks
prompt.querySelector('.confirm-yes').onclick = () => {
// Clear prompt and submit form
form.removeChild(prompt);
form.querySelector('button[type="submit"]').disabled = false;
form.submit();
};
prompt.querySelector('.confirm-no').onclick = () => {
// Remove prompt and re-enable button, do not submit
form.removeChild(prompt);
form.querySelector('button[type="submit"]').disabled = false;
};
return false; // prevent normal submit until confirmed
}
</script>
</head>
<body>
<h1>Databases dump jobs</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Kind</th>
<th>Host</th>
<th>Port</th>
<th>Database</th>
<th>User</th>
<th>Dump file</th>
<th>Dump date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{{range .Backups}}
<tr>
<td>{{.Id}}</td>
<td>{{.Kind}}</td>
<td>{{.Host}}</td>
<td>{{.Port}}</td>
<td>{{.Database}}</td>
<td>{{.User}}</td>
<td>{{.DumpFile}}</td>
<td>{{.DumpFileModTime}}</td>
<td>
<form method="POST" action="/backup" style="display:inline;">
<input type="hidden" name="id" value="{{.Id}}">
<button type="submit">Backup</button>
</form>
<form method="POST" action="/restore" class="restore-form" onsubmit="return confirmRestore(this);" style="display:inline; margin-left: 8px;">
<input type="hidden" name="id" value="{{.Id}}">
<button type="submit">Restore</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="7" style="text-align:center;">No backups found</td></tr>
{{end}}
</tbody>
</table>
</body>
</html>
{{end}}