Merge pull request 'new-api' (#7) from new-api into master

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2025-11-12 13:44:02 +00:00
20 changed files with 898 additions and 529 deletions

11
.gitignore vendored
View File

@@ -1,8 +1,9 @@
backups
./config.yaml
compose.yaml
./ddbb
./*.sql
./*.bson
./*.archive
./*.gz
ddbb
main
*.sql
*.bson
*.archive
*.gz

View File

@@ -1,5 +1,6 @@
# Step 1: Build the Go binary
FROM golang:alpine AS builder
###############
# Build backend
FROM golang:alpine AS go-builder
WORKDIR /app
@@ -9,23 +10,22 @@ RUN go mod download
# Copy all source code
COPY cmd/ /app/cmd/
COPY pkg/ /app/pkg/
COPY templates/ /app/templates/
COPY internal/ /app/internal/
# Build the binary
RUN go build -o ddbb ./cmd/ddbb/main.go
######################
# Production container
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/ddbb .
COPY --from=builder /app/templates/ templates/
COPY --from=go-builder /app/ddbb .
RUN apk update
RUN apk add bash mariadb-client postgresql17-client mongodb-tools
EXPOSE 12345
EXPOSE 3000
# Run the server
CMD ["./ddbb"]

9
Justfile Normal file
View File

@@ -0,0 +1,9 @@
build:
docker compose build
run: build
docker compose up -d
docker compose logs -f
clean:
docker compose down -v

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 RamiusLr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
ddbb
===
`ddbb`, for *Docker DataBase Backup*, is a tool to manage dumps of docker stacks
databases.
The purpose isn't to manage full versionning, RPO, RTO, etc. I just want
something simple to dump databases to a local file, that my backup tool would
next handle.
This means this tool targets *Docker* databases only, and relies heavily on
*Docker* API and labels.
# Installation and prerequisites
Use this `compose.yaml` as a base example:
```yaml
services:
ddbb:
image: ramiuslr/ddbb
container_name: ddbb
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- <your local dump directory>:/backups
networks:
- ddbb
ports:
- 3000:3000
environment:
BACKUP_CRON: "0 11,23 * * *"
networks:
ddbb:
name: ddbb
```
The `BACKUP_CRON` variable is the cron variable format to schedule automatic databases
dumps. You can check syntax on https://crontab.guru/.
Then you can run it with:
```bash
docker compose up -d
```
Please note that the container should have access to your databases networks,
and the *Docker* socket.
You also need to declare your database credentials using variables, as show in
the example below.
This tool has only been tested locally for now, and support only:
- `mariadb`
- `postgres`
- `mongodb`
# Usage
## Setting up the targets
When you want to manage a database with `ddbb`, you just need to add two labels
to its *Compose* manifest, for example:
```yaml
services:
mariadb:
image: mariadb:11.8.2
container_name: mariadb
restart: always
expose:
- 3306
environment:
MARIADB_RANDOM_ROOT_PASSWORD: true
MARIADB_USER: <username>
MARIADB_PASSWORD: <password>
MARIADB_DATABASE: <database>
volumes:
- ./mariadb:/var/lib/mysql
networks:
- ddbb
labels:
ddbb.enable: true
ddbb.kind: mariadb
```
The kind can be one of those:
- `mariadb`
- `postgres`
- `mongodb`
The tool will then "see" your database and be able to connect to it, to perform
the following actions:
- Dump
- Restore
- Integrity check
# Building yourself
There is a `Justfile` (*Makefile* alternative), which produces a *Docker* image, so this is pretty simple:
```bash
just run
```
# Contributing
Every suggestion, issue, or PR is very welcome !
However, please note that I am developing this on my spare time, so I can't
guarantee response time, release dates, etc.

View File

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

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

@@ -0,0 +1,173 @@
package api
import (
"ddbb/internal/docker"
"ddbb/internal/dump"
"ddbb/internal/integrity"
"ddbb/internal/restore"
"ddbb/internal/types"
"io/fs"
"log"
"net/http"
"slices"
"embed"
"github.com/gin-gonic/gin"
)
//go:embed public
var embeddedFiles embed.FS
type RequestBody struct {
Id string `json:"id"`
}
// API entrypoint
func SetupRouter() *gin.Engine {
// GIN General setup
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
err := r.SetTrustedProxies([]string{"127.0.0.1"})
if err != nil {
log.Printf("%v", err)
}
// API group: isolate endpoints under /api
apiGroup := r.Group("/api")
{
apiGroup.GET("/list", listJobs)
apiGroup.POST("/dump", triggerDump)
apiGroup.POST("/restore", triggerRestore)
apiGroup.POST("/integrity", triggerIntegrityCheck)
}
// Serve static assets under /static to avoid conflict
staticFS, _ := fs.Sub(embeddedFiles, "public/static")
r.StaticFS("/static", http.FS(staticFS))
// Serve SPA index.html at root or fallback route for client-side routing
r.GET("/", func(c *gin.Context) {
indexFile, err := embeddedFiles.Open("public/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found")
return
}
defer indexFile.Close()
stat, err := indexFile.Stat()
if err != nil {
c.String(http.StatusInternalServerError, "could not stat index.html")
return
}
c.DataFromReader(http.StatusOK, stat.Size(), "text/html; charset=utf-8", indexFile, nil)
})
return r
}
func selectJob(id string) types.Job {
var chosenJob *types.Job
jobs, err := docker.GetConfigsFromContainers()
if err != nil {
log.Fatalf("Error while parsing docker labels: %v", err)
}
for i, job := range jobs {
if job.Id == id {
chosenJob = &jobs[i]
break
}
}
return *chosenJob
}
func isValidJobId(id string) bool {
var idLists []string
jobs, err := docker.GetConfigsFromContainers()
if err != nil {
log.Fatalf("Error while parsing docker labels: %v", err)
}
for _, job := range jobs {
idLists = append(idLists, job.Id)
}
return slices.Contains(idLists, id)
}
func listJobs(c *gin.Context) {
jobs, err := docker.GetConfigsFromContainers()
if err != nil {
log.Fatalf("Error while parsing docker labels: %v", err)
}
c.JSON(200, jobs)
}
// Dump any database based on Id
func triggerDump(c *gin.Context) {
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := dump.NewDumpJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
}
}
// Restore any database based on Id
func triggerRestore(c *gin.Context) {
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := restore.NewRestoreJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
}
}
// Check any database based on Id
func triggerIntegrityCheck(c *gin.Context) {
// Read Id from JSON submitted data
var req RequestBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
}
if req.Id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
}
if isValidJobId(req.Id) {
result, err := integrity.NewIntegrityCheckJob(selectJob(req.Id))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
}
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Docker Database Backup</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div id="app"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,278 @@
document.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('app');
if (!app) return;
let jobs = [];
let filteredJobs = [];
let filters = {};
let sortField = null;
let sortAsc = true;
const columns = ['id', 'kind', 'host', 'database', 'user'];
const displayNames = ['ID', 'Kind', 'Host', 'Database', 'User'];
// Build layout with centered title and responsive table
function buildLayout() {
app.innerHTML = `
<div class="container my-4">
<h2 class="text-center mb-4">Job Control Panel</h2>
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover mx-auto" style="max-width: 900px;">
<thead class="thead-light">
<tr>
${columns.map(col => `<th><input type="text" class="form-control form-control-sm filter-input" data-col="${col}" placeholder="Filter ${col}"></th>`).join('')}
<th class="text-center">Actions</th>
</tr>
<tr>
${columns.map(col => `<th class="sortable text-center" data-col="${col}" style="cursor:pointer;">${col.toUpperCase()}<span class="sort-arrow"></span></th>`).join('')}
<th></th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
</div>
</div>
`;
// Filter listeners
app.querySelectorAll('.filter-input').forEach(input => {
input.addEventListener('input', e => {
const col = e.target.getAttribute('data-col');
const val = e.target.value.trim().toLowerCase();
if (val) filters[col] = val;
else delete filters[col];
applyFiltersAndSort();
});
});
// Sorting listeners
app.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.getAttribute('data-col');
if (sortField === col) {
sortAsc = !sortAsc;
} else {
sortField = col;
sortAsc = true;
}
applyFiltersAndSort();
updateSortArrows();
});
});
}
// Update sorting arrows
function updateSortArrows() {
app.querySelectorAll('th.sortable span.sort-arrow').forEach(span => {
span.textContent = '';
});
if (sortField) {
const th = app.querySelector(`th.sortable[data-col="${sortField}"] span.sort-arrow`);
if (th) th.textContent = sortAsc ? ' ▲' : ' ▼';
}
}
// Filter and sort jobs based on user input
function applyFiltersAndSort() {
filteredJobs = jobs.filter(job =>
Object.entries(filters).every(([col, val]) =>
(job[col] || '').toLowerCase().includes(val)
)
);
if (sortField) {
filteredJobs.sort((a, b) => {
if (a[sortField] < b[sortField]) return sortAsc ? -1 : 1;
if (a[sortField] > b[sortField]) return sortAsc ? 1 : -1;
return 0;
});
}
renderRows();
}
// Render table rows with dropdown action menu
function renderRows() {
const tbody = app.querySelector('#jobs-tbody');
tbody.innerHTML = '';
filteredJobs.forEach(job => {
const tr = document.createElement('tr');
columns.forEach(col => {
const td = document.createElement('td');
td.textContent = job[col] || '';
td.className = 'text-center align-middle';
tr.appendChild(td);
});
// Actions dropdown
const actionsTd = document.createElement('td');
actionsTd.className = 'text-center align-middle';
actionsTd.innerHTML = `
<div class="dropdown">
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-action="dump">Dump</a></li>
<li><a class="dropdown-item" href="#" data-action="integrity">Integrity check</a></li>
<li><a class="dropdown-item" href="#" data-action="restore">Restore</a></li>
</ul>
</div>
`;
// Attach event listeners for dropdown actions
actionsTd.querySelectorAll('a.dropdown-item').forEach(a => {
a.addEventListener('click', async (e) => {
e.preventDefault();
const action = a.getAttribute('data-action');
if (action === 'restore') {
const confirmed = await showModalConfirm(`Confirm restore job ${job.id}?`);
if (!confirmed) return;
}
await triggerAction(job.id, action);
});
});
tr.appendChild(actionsTd);
tbody.appendChild(tr);
});
}
// Modal confirm dialog with Bootstrap
function showModalConfirm(message) {
return new Promise(resolve => {
const modalId = 'confirmModal';
let modalEl = document.getElementById(modalId);
if (!modalEl) {
modalEl = document.createElement('div');
modalEl.id = modalId;
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Please Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"><p>${message}</p></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmBtn">OK</button>
</div>
</div>
</div>`;
document.body.appendChild(modalEl);
} else {
modalEl.querySelector('.modal-body p').textContent = message;
}
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
modalEl.querySelector('#confirmBtn').onclick = () => {
bsModal.hide();
resolve(true);
};
modalEl.querySelector('.btn-secondary').onclick = () => {
bsModal.hide();
resolve(false);
};
modalEl.querySelector('.btn-close').onclick = () => {
bsModal.hide();
resolve(false);
};
modalEl.addEventListener('hidden.bs.modal', () => {
resolve(false);
}, { once: true });
bsModal.show();
});
}
// Perform backend action POST call
async function triggerAction(id, action) {
try {
const res = await fetch(`/api/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const json = await res.json();
await showModalAlert(`${json.Result || 'Unknown'}`);
await loadJobs();
} catch {
await showModalAlert(`Error performing ${action}`);
}
}
// Modal alert dialog
function showModalAlert(message) {
return new Promise(resolve => {
const modalId = 'alertModal';
let modalEl = document.getElementById(modalId);
if (!modalEl) {
modalEl = document.createElement('div');
modalEl.id = modalId;
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-hidden', 'true');
modalEl.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Result</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"><p>${message}</p></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>`;
document.body.appendChild(modalEl);
} else {
modalEl.querySelector('.modal-body p').textContent = message;
}
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
let resolveFunc;
const promise = new Promise(res => { resolveFunc = res; });
modalEl.querySelector('.btn-primary').onclick = () => {
bsModal.hide();
resolveFunc();
};
modalEl.querySelector('.btn-close').onclick = () => {
bsModal.hide();
resolveFunc();
};
modalEl.addEventListener('hidden.bs.modal', () => {
resolveFunc();
}, { once: true });
bsModal.show();
return promise;
});
}
// Load jobs from API and refresh UI
async function loadJobs() {
try {
const res = await fetch('/api/list');
jobs = await res.json();
applyFiltersAndSort();
} catch {
await showModalAlert('Failed to load jobs');
}
}
buildLayout();
loadJobs();
});

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

@@ -0,0 +1,5 @@
package cron
func SetupNewCronJob() error {
return nil
}

View File

@@ -2,15 +2,14 @@ package docker
import (
"context"
"ddbb/pkg/backup"
"log"
"strings"
"ddbb/internal/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"log"
"strings"
)
func GetBackupConfigsFromContainers() ([]backup.Config, error) {
func GetConfigsFromContainers() ([]types.Job, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, err
@@ -21,7 +20,7 @@ func GetBackupConfigsFromContainers() ([]backup.Config, error) {
return nil, err
}
var backups []backup.Config
var jobs []types.Job
for _, container := range containers {
if container.Labels["ddbb.enable"] != "true" {
@@ -74,7 +73,7 @@ func GetBackupConfigsFromContainers() ([]backup.Config, error) {
log.Printf("Kind %s is not supported", kind)
}
backups = append(backups, backup.Config{
jobs = append(jobs, types.Job{
Id: id,
Kind: kind,
Host: host,
@@ -85,5 +84,5 @@ func GetBackupConfigsFromContainers() ([]backup.Config, error) {
})
}
return backups, nil
return jobs, nil
}

View File

@@ -1,53 +1,53 @@
package backup
package dump
import (
"ddbb/internal/types"
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
)
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) {
func NewDumpJob(job types.Job) (types.DumpJobResult, error) {
var err error
switch kind := job.Kind; kind {
switch strings.ToLower(job.Kind) {
case "mariadb":
err = backupMariaDB(job)
err = dumpMariaDB(job)
case "postgres":
err = backupPostgres(job)
err = dumpPostgres(job)
case "mongodb":
err = backupMongoDB(job)
err = dumpMongoDB(job)
default:
log.Printf("Invalid database kind for job %s", job.Id)
}
if err != nil {
log.Printf("%v", err)
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Failure",
Error: err,
}, err
}
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Success",
Error: nil,
}, nil
}
func backupMariaDB(cfg Config) error {
log.Printf("Starting dump of mariadb database %s on %s", cfg.Database, cfg.Host)
func dumpMariaDB(job types.Job) error {
log.Printf("Starting dump of mariadb database %s on %s", job.Database, job.Host)
outFileName := outFileRoot + cfg.Host + ".sql"
outFileName := outFileRoot + job.Host + ".sql"
outFile, err := os.Create(outFileName)
if err != nil {
log.Fatalf("failed to create output file: %v", err)
@@ -58,11 +58,11 @@ func backupMariaDB(cfg Config) error {
cmd := exec.Command(
"mariadb-dump",
"--ssl-verify-server-cert=FALSE",
"-h", cfg.Host,
"-u", cfg.User,
"-p"+cfg.Password,
"-P", cfg.Port,
cfg.Database,
"-h", job.Host,
"-u", job.User,
"-p"+job.Password,
"-P", job.Port,
job.Database,
)
cmd.Stdout = outFile
cmd.Stderr = os.Stderr
@@ -76,8 +76,8 @@ func backupMariaDB(cfg Config) error {
return nil
}
func backupPostgres(cfg Config) error {
outFileName := outFileRoot + cfg.Host + ".sql"
func dumpPostgres(job types.Job) error {
outFileName := outFileRoot + job.Host + ".sql"
outFile, err := os.Create(outFileName)
if err != nil {
log.Fatalf("failed to create output file: %v", err)
@@ -88,12 +88,12 @@ func backupPostgres(cfg Config) error {
// 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),
"--dbname="+fmt.Sprintf("postgresql://%s:%s@%s:%s/%s", job.User, job.Password, job.Host, job.Port, job.Database),
)
cmd.Stdout = outFile
cmd.Stderr = os.Stderr
log.Printf("Running pg_dump for database %s", cfg.Database)
log.Printf("Running pg_dump for database %s", job.Database)
if err := cmd.Run(); err != nil {
log.Fatalf("pg_dump failed: %v", err)
return err
@@ -103,20 +103,20 @@ func backupPostgres(cfg Config) error {
return nil
}
func backupMongoDB(cfg Config) error {
outFileName := outFileRoot + cfg.Host + ".gz"
func dumpMongoDB(job types.Job) error {
outFileName := outFileRoot + job.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),
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", job.User, job.Password, job.Host, job.Port, job.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)
log.Printf("Running mongodump for database %s with gzip compression to file %s", job.Database, outFileName)
if err := cmd.Run(); err != nil {
log.Fatalf("mongodump failed: %v", err)
return err

View File

@@ -0,0 +1,81 @@
package integrity
import (
"bytes"
"ddbb/internal/types"
"log"
"os"
"os/exec"
"strings"
"time"
)
func NewIntegrityCheckJob(job types.Job) (types.DumpJobResult, error) {
var err error
var result string
switch strings.ToLower(job.Kind) {
case "mariadb":
result, err = checkMariaDB(job)
case "postgres":
err = checkPostgres(job)
case "mongodb":
err = checkMongoDB(job)
default:
log.Printf("Invalid database kind for job %s", job.Id)
}
if err != nil {
log.Printf("%v", err)
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: result,
Error: err,
}, nil
}
return types.DumpJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: result,
Error: nil,
}, nil
}
func checkMariaDB(job types.Job) (string, error) {
var out bytes.Buffer
var err error
log.Printf("Starting integrity check of mariadb database %s on %s", job.Database, job.Host)
cmd := exec.Command(
"mariadb-check",
"--ssl-verify-server-cert=FALSE",
"-h", job.Host,
"-u", job.User,
"-p"+job.Password,
"-P", job.Port,
job.Database,
)
cmd.Stdout = &out
cmd.Stdout = &out
cmd.Stderr = os.Stderr
if err = cmd.Run(); err != nil {
log.Fatalf("Mariadb integrity check failed: %v", err)
} else {
log.Printf("Integrity check with id %s successfully complete", job.Id)
}
return out.String(), err
}
func checkPostgres(job types.Job) error {
log.Println("Not yet implemented")
return nil
}
func checkMongoDB(job types.Job) error {
log.Println("Not yet implemented")
return nil
}

114
internal/restore/restore.go Normal file
View File

@@ -0,0 +1,114 @@
package restore
import (
"ddbb/internal/types"
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
)
var dumpFileRoot = "/backups/"
func NewRestoreJob(job types.Job) (types.RestoreJobResult, error) {
var err error
switch strings.ToLower(job.Kind) {
case "mariadb":
err = restoreMariaDB(job)
case "postgres":
err = restorePostgres(job)
case "mongodb":
err = restoreMongo(job)
default:
log.Printf("unsupported database kind: %s", job.Kind)
}
if err != nil {
log.Printf("%v", err)
return types.RestoreJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Failure",
Error: err,
}, err
}
return types.RestoreJobResult{
Id: job.Id,
TimeStart: time.Now(),
TimeEnd: time.Now(),
Result: "Success",
Error: nil,
}, nil
}
func restoreMariaDB(job types.Job) error {
cmd := exec.Command(
"mariadb",
"--ssl-verify-server-cert=FALSE",
"-h", job.Host,
"-u", job.User,
fmt.Sprintf("-p%s", job.Password),
job.Database,
)
dumpFileName := dumpFileRoot + job.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", job.Id)
return nil
}
func restorePostgres(job types.Job) error {
cmd := exec.Command(
"psql", "-h", job.Host, "-U", job.User, "-d", job.Database,
"-f", "-", // read input from stdin
)
dumpFileName := dumpFileRoot + job.Host + ".sql"
dumpFile, err := os.Open(dumpFileName)
if err != nil {
return err
}
defer dumpFile.Close()
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", job.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", job.Id)
return nil
}
func restoreMongo(job types.Job) error {
dumpFileName := dumpFileRoot + job.Host + ".gz"
cmd := exec.Command(
"mongorestore", "--archive", "--gzip", "--drop", "--archive="+dumpFileName,
"--uri",
fmt.Sprintf("mongodb://%s:%s@%s:%s/%s?authSource=admin", job.User, job.Password, job.Host, job.Port, job.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", job.Id)
return nil
}

33
internal/types/types.go Normal file
View File

@@ -0,0 +1,33 @@
package types
import "time"
type Job struct {
Id string `json:"id"`
Kind string `json:"kind"`
Host string `json:"host"`
Port string `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
}

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,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,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}}