diff --git a/.gitignore b/.gitignore index 884686e..7114170 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ backups ./config.yaml compose.yaml -./ddbb -./*.sql -./*.bson -./*.archive -./*.gz +ddbb +main +*.sql +*.bson +*.archive +*.gz diff --git a/Dockerfile b/Dockerfile index 9a5f9a7..1bcb1bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/cmd/ddbb/main.go b/cmd/ddbb/main.go index da29dbe..b688343 100644 --- a/cmd/ddbb/main.go +++ b/cmd/ddbb/main.go @@ -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) } } diff --git a/internal/api/api.go b/internal/api/api.go index 1215505..dc197c0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/backup/backup.go b/internal/backup/backup.go deleted file mode 100644 index 0d44d47..0000000 --- a/internal/backup/backup.go +++ /dev/null @@ -1 +0,0 @@ -package backup diff --git a/internal/cron/cron.go b/internal/cron/cron.go index e69de29..0826995 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -0,0 +1,5 @@ +package cron + +func SetupNewCronJob() error { + return nil +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index e69de29..946274b 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -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 +} diff --git a/internal/dump/dump.go b/internal/dump/dump.go new file mode 100644 index 0000000..79169ff --- /dev/null +++ b/internal/dump/dump.go @@ -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 +} diff --git a/internal/integrity/integrity.go b/internal/integrity/integrity.go index e69de29..71afe3e 100644 --- a/internal/integrity/integrity.go +++ b/internal/integrity/integrity.go @@ -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 +} diff --git a/internal/restore/restore.go b/internal/restore/restore.go index e69de29..a4c12d5 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -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 +} diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..48f7d47 --- /dev/null +++ b/internal/types/types.go @@ -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 +}