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:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,8 +1,9 @@
|
||||
backups
|
||||
./config.yaml
|
||||
compose.yaml
|
||||
./ddbb
|
||||
./*.sql
|
||||
./*.bson
|
||||
./*.archive
|
||||
./*.gz
|
||||
ddbb
|
||||
main
|
||||
*.sql
|
||||
*.bson
|
||||
*.archive
|
||||
*.gz
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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
9
Justfile
Normal 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
21
LICENSE
Normal 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
102
README.md
Normal 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.
|
||||
@@ -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
173
internal/api/api.go
Normal 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"})
|
||||
}
|
||||
}
|
||||
13
internal/api/public/index.html
Normal file
13
internal/api/public/index.html
Normal 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>
|
||||
278
internal/api/public/static/app.js
Normal file
278
internal/api/public/static/app.js
Normal 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
5
internal/cron/cron.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package cron
|
||||
|
||||
func SetupNewCronJob() error {
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
81
internal/integrity/integrity.go
Normal file
81
internal/integrity/integrity.go
Normal 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
114
internal/restore/restore.go
Normal 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
33
internal/types/types.go
Normal 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
|
||||
}
|
||||
123
pkg/api/api.go
123
pkg/api/api.go
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user