feat: now fully functionnal api

This commit is contained in:
2025-11-06 11:37:03 +01:00
parent 5bb6d6e41f
commit 05b975979b
11 changed files with 484 additions and 51 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

@@ -9,8 +9,7 @@ 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
@@ -20,12 +19,11 @@ FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/ddbb .
COPY --from=builder /app/templates/ templates/
RUN apk update
RUN apk add bash mariadb-client postgresql17-client mongodb-tools
EXPOSE 12345
EXPOSE 3000
# Run the server
CMD ["./ddbb"]

View File

@@ -2,18 +2,21 @@ package main
import (
"ddbb/internal/api"
// "ddbb/pkg/cron"
"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(":3000")
if err != nil {
log.Fatalf("API fatal error: %v", err)
apiErr := r.Run(":3000")
if apiErr != nil {
log.Fatalf("API fatal error: %v", apiErr)
}
}

View File

@@ -1,58 +1,112 @@
package api
import (
"time"
"ddbb/internal/docker"
"ddbb/internal/dump"
"ddbb/internal/integrity"
"ddbb/internal/restore"
"ddbb/internal/types"
"log"
"net/http"
"slices"
"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"`
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
}
type DumpJobResult struct {
Id string
TimeStart time.Time
TimeEnd time.Time
Result string
Error error
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)
}
type RestoreJobResult struct {
Id string
TimeStart time.Time
TimeEnd time.Time
Result string
Error error
}
func listJobs() error {
return nil
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) DumpJobResult {
return DumpJobResult{}
func triggerDump(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
return
}
if isValidJobId(jobId) {
result, err := dump.NewDumpJob(selectJob(jobId))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
// Restore any database based on Id
func triggerRestore(c *gin.Context) RestoreJobResult {
return RestoreJobResult{}
func triggerRestore(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
return
}
if isValidJobId(jobId) {
result, err := restore.NewRestoreJob(selectJob(jobId))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
// Check any database based on Id
func triggerIntegrityCheck(c *gin.Context) Job {
return Job{}
func triggerIntegrityCheck(c *gin.Context) {
jobId := c.PostForm("id")
if jobId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing backup job id"})
return
}
if isValidJobId(jobId) {
result, err := integrity.NewIntegrityCheckJob(selectJob(jobId))
if err != nil {
log.Printf("%v", err)
}
c.JSON(200, result)
return
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid job id"})
return
}
}
// API entrypoint
@@ -60,7 +114,10 @@ func SetupRouter() *gin.Engine {
// GIN General setup
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.SetTrustedProxies([]string{"127.0.0.1"})
err := r.SetTrustedProxies([]string{"127.0.0.1"})
if err != nil {
log.Printf("%v", err)
}
// GIN endpoints
r.GET("/list", listJobs)

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
package docker
import (
"context"
"ddbb/internal/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"log"
"strings"
)
func GetConfigsFromContainers() ([]types.Job, 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 jobs []types.Job
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)
}
jobs = append(jobs, types.Job{
Id: id,
Kind: kind,
Host: host,
Port: port,
Database: database,
User: user,
Password: password,
})
}
return jobs, nil
}

113
internal/dump/dump.go Normal file
View File

@@ -0,0 +1,113 @@
package dump
import (
"ddbb/internal/types"
"fmt"
"log"
"os"
"os/exec"
"strings"
)
var outFileRoot = "/backups/"
func NewDumpJob(job types.Job) (types.DumpJobResult, error) {
var err error
switch strings.ToLower(job.Kind) {
case "mariadb":
err = dumpMariaDB(job)
case "postgres":
err = dumpPostgres(job)
case "mongodb":
err = dumpMongoDB(job)
default:
log.Printf("Invalid database kind for job %s", job.Id)
}
if err != nil {
log.Printf("%v", err)
}
return types.DumpJobResult{}, nil
}
func dumpMariaDB(job types.Job) error {
log.Printf("Starting dump of mariadb database %s on %s", job.Database, job.Host)
outFileName := outFileRoot + job.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", job.Host,
"-u", job.User,
"-p"+job.Password,
"-P", job.Port,
job.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 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)
return err
}
defer outFile.Close()
// Build pg_dump command
cmd := exec.Command(
"pg_dump",
"--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", job.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 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", 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", job.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

@@ -0,0 +1,41 @@
package integrity
import (
"ddbb/internal/types"
"log"
"strings"
)
func NewIntegrityCheckJob(job types.Job) (types.DumpJobResult, error) {
var err error
switch strings.ToLower(job.Kind) {
case "mariadb":
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{}, nil
}
func checkMariaDB(job types.Job) error {
log.Println("Not yet implemented")
return nil
}
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
}

View File

@@ -0,0 +1,95 @@
package restore
import (
"ddbb/internal/types"
"fmt"
"log"
"os"
"os/exec"
"strings"
)
var dumpFileRoot = "/backups/"
func NewRestoreJob(job types.Job) (types.RestoreJobResult, error) {
switch strings.ToLower(job.Kind) {
case "mariadb":
restoreMariaDB(job)
case "postgres":
restorePostgres(job)
case "mongodb":
restoreMongo(job)
default:
log.Printf("unsupported database kind: %s", job.Kind)
}
return types.RestoreJobResult{}, 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
}