From 518802729423f804e673b5cf832ab3e7700a9924 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 2 Nov 2025 10:36:17 +0700 Subject: [PATCH] Add backup/restore commands --- cmd/happyDeliver/main.go | 20 ++++- internal/app/cli_backup.go | 157 ++++++++++++++++++++++++++++++++++++ internal/storage/storage.go | 30 +++++++ 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 internal/app/cli_backup.go diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index af1d30f..fa160c2 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -52,6 +52,18 @@ func main() { if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { log.Fatalf("Analyzer error: %v", err) } + case "backup": + if err := app.RunBackup(cfg); err != nil { + log.Fatalf("Backup error: %v", err) + } + case "restore": + inputFile := "" + if len(flag.Args()) >= 2 { + inputFile = flag.Args()[1] + } + if err := app.RunRestore(cfg, inputFile); err != nil { + log.Fatalf("Restore error: %v", err) + } case "version": fmt.Println(version.Version) default: @@ -63,9 +75,11 @@ func main() { func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver backup - Backup database to stdout as JSON") + fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/internal/app/cli_backup.go b/internal/app/cli_backup.go new file mode 100644 index 0000000..e906e13 --- /dev/null +++ b/internal/app/cli_backup.go @@ -0,0 +1,157 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package app + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" +) + +// BackupData represents the structure of a backup file +type BackupData struct { + Version string `json:"version"` + Reports []storage.Report `json:"reports"` +} + +// RunBackup exports the database to stdout as JSON +func RunBackup(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // Get all reports from the database + reports, err := storage.GetAllReports(store) + if err != nil { + return fmt.Errorf("failed to retrieve reports: %w", err) + } + + log.Printf("Found %d reports to backup", len(reports)) + + // Create backup data structure + backup := BackupData{ + Version: "1.0", + Reports: reports, + } + + // Encode to JSON and write to stdout + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(backup); err != nil { + return fmt.Errorf("failed to encode backup data: %w", err) + } + + return nil +} + +// RunRestore imports the database from a JSON file or stdin +func RunRestore(cfg *config.Config, inputPath string) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Determine input source + var reader io.Reader + if inputPath == "" || inputPath == "-" { + log.Println("Reading backup from stdin...") + reader = os.Stdin + } else { + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer inFile.Close() + log.Printf("Reading backup from file: %s", inputPath) + reader = inFile + } + + // Decode JSON + var backup BackupData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&backup); err != nil { + if err == io.EOF { + return fmt.Errorf("backup file is empty or corrupted") + } + return fmt.Errorf("failed to decode backup data: %w", err) + } + + log.Printf("Backup version: %s", backup.Version) + log.Printf("Found %d reports in backup", len(backup.Reports)) + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // Restore reports + restored, skipped, failed := 0, 0, 0 + for _, report := range backup.Reports { + // Check if report already exists + exists, err := store.ReportExists(report.TestID) + if err != nil { + log.Printf("Warning: Failed to check if report %s exists: %v", report.TestID, err) + failed++ + continue + } + + if exists { + log.Printf("Report %s already exists, skipping", report.TestID) + skipped++ + continue + } + + // Create the report + _, err = storage.CreateReportFromBackup(store, &report) + if err != nil { + log.Printf("Warning: Failed to restore report %s: %v", report.TestID, err) + failed++ + continue + } + + restored++ + } + + log.Printf("Restore completed: %d restored, %d skipped, %d failed", restored, skipped, failed) + if failed > 0 { + return fmt.Errorf("restore completed with %d failures", failed) + } + + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 35aa0df..39b2eb6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -147,3 +147,33 @@ func (s *DBStorage) Close() error { } return sqlDB.Close() } + +// GetAllReports retrieves all reports from the database +func GetAllReports(s Storage) ([]Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support GetAllReports") + } + + var reports []Report + if err := dbStorage.db.Find(&reports).Error; err != nil { + return nil, fmt.Errorf("failed to retrieve reports: %w", err) + } + + return reports, nil +} + +// CreateReportFromBackup creates a report from backup data, preserving timestamps +func CreateReportFromBackup(s Storage, report *Report) (*Report, error) { + dbStorage, ok := s.(*DBStorage) + if !ok { + return nil, fmt.Errorf("storage type does not support CreateReportFromBackup") + } + + // Use Create to insert the report with all fields including timestamps + if err := dbStorage.db.Create(report).Error; err != nil { + return nil, fmt.Errorf("failed to create report from backup: %w", err) + } + + return report, nil +}