working
This commit is contained in:
commit
d840597cb8
12
.env.example
Normal file
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
SMTP_HOST=mail.toto.fr
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=backea@maric.ro
|
||||
SMTP_PASSWORD=CHANGEME
|
||||
SMTP_FROM=backea@maric.ro
|
||||
|
||||
B2_APPLICATION_KEY=B2
|
||||
B2_KEY_ID=b2
|
||||
|
||||
|
||||
|
||||
|
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.env
|
||||
kopia.env
|
||||
|
||||
.vscode/
|
||||
|
||||
bin/
|
||||
|
||||
TODO
|
||||
|
||||
config/
|
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@ -0,0 +1,47 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN /go/bin/templ generate
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backea-server ./cmd/server/main.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backup-performer ./cmd/backup_performer/main.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/list-backups ./cmd/list-backups/main.go
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN echo "@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
|
||||
|
||||
RUN apk add --no-cache \
|
||||
rsync \
|
||||
zip \
|
||||
unzip \
|
||||
borgbackup \
|
||||
python3 \
|
||||
py3-pip \
|
||||
curl \
|
||||
fuse \
|
||||
openssh-client \
|
||||
ca-certificates \
|
||||
kopia@edge
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/backea-server .
|
||||
COPY --from=builder /app/backup-performer .
|
||||
COPY --from=builder /app/list-backups .
|
||||
|
||||
COPY --from=builder /app/static ./static
|
||||
COPY --from=builder /app/templates ./templates
|
||||
|
||||
RUN chmod +x /app/backea-server /app/backup-performer /app/list-backups
|
||||
|
||||
CMD ["/app/backea-server"]
|
37
cmd/backup_performer/main.go
Normal file
37
cmd/backup_performer/main.go
Normal file
@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yml", "Path to config file")
|
||||
serviceFlag := flag.String("service", "", "Service to backup (format: group or group.index)")
|
||||
flag.Parse()
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse the service flag to extract group and index if provided
|
||||
var serviceName, serviceIndex string
|
||||
if *serviceFlag != "" {
|
||||
parts := strings.SplitN(*serviceFlag, "-", 2)
|
||||
serviceName = parts[0]
|
||||
if len(parts) > 1 {
|
||||
serviceIndex = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if err := backup.PerformBackups(ctx, *configPath, serviceName, serviceIndex); err != nil {
|
||||
log.Fatalf("Backup failed: %v", err)
|
||||
}
|
||||
}
|
98
cmd/list-backups/main.go
Normal file
98
cmd/list-backups/main.go
Normal file
@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
configPath := "config.yml"
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
// Create backup factory
|
||||
factory, err := backup.NewBackupFactory(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create backup factory: %v", err)
|
||||
}
|
||||
|
||||
// Process each service group
|
||||
for groupName, serviceGroup := range factory.Config.Services {
|
||||
fmt.Printf("Service Group: %s (Directory: %s)\n", groupName, serviceGroup.Source.Path)
|
||||
|
||||
// Process each backup config in the group
|
||||
for configIndex := range serviceGroup.BackupConfigs {
|
||||
// Format the full service name
|
||||
serviceName := fmt.Sprintf("%s.%s", groupName, configIndex)
|
||||
|
||||
// Create the appropriate backup strategy using the factory
|
||||
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create backup strategy for service %s: %v", serviceName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// List backups for this service
|
||||
backups, err := strategy.ListBackups(ctx, serviceName)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list backups for service %s: %v", serviceName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Display the backups
|
||||
fmt.Printf("Found %d backups for service %s:\n", len(backups), serviceName)
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "----------------------------------------------------")
|
||||
fmt.Fprintln(w, "ID | Date | Size | Retention")
|
||||
fmt.Fprintln(w, "----------------------------------------------------")
|
||||
for _, backup := range backups {
|
||||
fmt.Fprintf(w, "%s | %s | %s | %s\n",
|
||||
backup.ID,
|
||||
backup.CreationTime.Format(time.RFC3339),
|
||||
formatSize(backup.Size),
|
||||
backup.RetentionTag,
|
||||
)
|
||||
}
|
||||
fmt.Fprintln(w, "----------------------------------------------------")
|
||||
w.Flush()
|
||||
|
||||
// Display storage usage
|
||||
usage, err := strategy.GetStorageUsage(ctx, serviceName)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get storage usage for service %s: %v", serviceName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Storage Usage for %s Repository\n", serviceName)
|
||||
fmt.Println("---------------------------------------")
|
||||
fmt.Printf("Provider: %s\n", usage.Provider)
|
||||
fmt.Printf("Repository ID: %s\n", usage.ProviderID)
|
||||
fmt.Printf("Physical Size: %s\n", formatSize(usage.TotalBytes))
|
||||
|
||||
fmt.Println() // Add a blank line between services for readability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(size int64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := size / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||
}
|
43
cmd/list-buckets/main.go
Normal file
43
cmd/list-buckets/main.go
Normal file
@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
b2_client "backea/internal/client"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create B2 client
|
||||
client, err := b2_client.NewClientFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating B2 client: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// List buckets
|
||||
buckets, err := client.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error listing buckets: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Print buckets
|
||||
fmt.Println("B2 Buckets:")
|
||||
fmt.Println("===========")
|
||||
if len(buckets) == 0 {
|
||||
fmt.Println("No buckets found")
|
||||
} else {
|
||||
for i, bucket := range buckets {
|
||||
fmt.Printf("%d. %s (ID: %s)\n",
|
||||
i+1,
|
||||
bucket.Name,
|
||||
bucket.ID)
|
||||
}
|
||||
}
|
||||
}
|
34
cmd/notify/main.go
Normal file
34
cmd/notify/main.go
Normal file
@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"backea/internal/mail"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Warning: No .env file found")
|
||||
}
|
||||
|
||||
client := mail.NewClient()
|
||||
|
||||
// Check if required environment variables are set
|
||||
if client.Config.Host == "" || client.Config.Username == "" || client.Config.Password == "" {
|
||||
log.Fatal("Missing required SMTP configuration")
|
||||
}
|
||||
|
||||
// Send notification
|
||||
recipients := []string{"romaric.sirii@gmail.com"}
|
||||
subject := "Backup Notification"
|
||||
body := "Your backup has completed successfully!"
|
||||
|
||||
if err := client.SendMail(recipients, subject, body); err != nil {
|
||||
log.Fatalf("Failed to send email: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Notification sent successfully")
|
||||
}
|
51
cmd/restore/main.go
Normal file
51
cmd/restore/main.go
Normal file
@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
b2_client "backea/internal/client"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("Starting Kopia Restore Tool")
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Create B2 client
|
||||
b2Client, err := b2_client.NewClientFromEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create B2 client: %v", err)
|
||||
}
|
||||
|
||||
// Create restore manager
|
||||
restoreManager := backup.NewRestoreManager(b2Client)
|
||||
|
||||
// List snapshots for the 'montre' service
|
||||
serviceName := "montre"
|
||||
log.Printf("Listing snapshots for service '%s' (bucket: 'backea-%s')", serviceName, serviceName)
|
||||
|
||||
err = restoreManager.ListSnapshots(ctx, serviceName)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list snapshots: %v", err)
|
||||
}
|
||||
|
||||
// Example usage of RestoreFile function
|
||||
// Use the complete snapshot ID from the output
|
||||
// Looking at your output, let's use the most recent snapshot for /home/gevo/Documents/backea2
|
||||
snapshotID := "kdf031570cab920b40d296bda16d27ae7" // The latest snapshot ID
|
||||
sourcePath := "" // Leave empty to restore the entire snapshot
|
||||
targetPath := "./restored_files" // Local directory to restore files to
|
||||
|
||||
log.Printf("Restoring files from snapshot %s to %s", snapshotID, targetPath)
|
||||
err = restoreManager.RestoreFile(ctx, serviceName, snapshotID, sourcePath, targetPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to restore files: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Restore tool completed successfully")
|
||||
}
|
26
cmd/server/main.go
Normal file
26
cmd/server/main.go
Normal file
@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"backea/internal/server"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get port from environment or use default
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// Create backup factory
|
||||
backupFactory, _ := backup.NewBackupFactory("config.yml")
|
||||
|
||||
// Initialize and start the server
|
||||
srv := server.New(backupFactory)
|
||||
log.Printf("Starting server on port %s", port)
|
||||
if err := srv.Start(":" + port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
34
compose.yaml
Normal file
34
compose.yaml
Normal file
@ -0,0 +1,34 @@
|
||||
services:
|
||||
backea:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Configuration files
|
||||
- ./.env:/app/.env
|
||||
- ./config.yml:/app/config.yml
|
||||
- ./kopia.env:/app/kopia.env
|
||||
|
||||
# Source directories to backup - map to identical paths inside container
|
||||
- /home/gevo/Documents/backea2:/home/gevo/Documents/backea2
|
||||
- /home/gevo/Images:/home/gevo/Images
|
||||
|
||||
# Dedicated repository storage
|
||||
- /home/gevo/.kopia:/root/.kopia
|
||||
|
||||
# SSH keys if needed
|
||||
- /home/gevo/.ssh:/root/.ssh:ro
|
||||
- ./config/kopia:/tmp/kopia_configs
|
||||
environment:
|
||||
- HOME=/root
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
58
config.yml
Normal file
58
config.yml
Normal file
@ -0,0 +1,58 @@
|
||||
defaults:
|
||||
retention:
|
||||
keep_latest: 10
|
||||
keep_hourly: 24
|
||||
keep_daily: 30
|
||||
keep_weekly: 8
|
||||
keep_monthly: 12
|
||||
keep_yearly: 3
|
||||
|
||||
services:
|
||||
backealocal:
|
||||
source:
|
||||
host: "local"
|
||||
path: "/home/gevo/Documents/backea2"
|
||||
hooks:
|
||||
before_hook: "ls"
|
||||
after_hook: ""
|
||||
backup_configs:
|
||||
1:
|
||||
backup_strategy:
|
||||
type: "kopia"
|
||||
provider: "local"
|
||||
destination:
|
||||
path: "/home/gevo/Documents/backea2"
|
||||
2:
|
||||
backup_strategy:
|
||||
type: "kopia"
|
||||
provider: "local"
|
||||
destination:
|
||||
path: "/home/gevo/Documents/backea2"
|
||||
|
||||
imageslocal:
|
||||
source:
|
||||
host: "local"
|
||||
path: "/home/gevo/Images/"
|
||||
hooks:
|
||||
before_hook: "ls"
|
||||
after_hook: ""
|
||||
backup_configs:
|
||||
1:
|
||||
backup_strategy:
|
||||
type: "kopia"
|
||||
provider: "local"
|
||||
destination:
|
||||
host: "local"
|
||||
|
||||
b2backupstotozzeeeaaaeeee:
|
||||
source:
|
||||
host: "local"
|
||||
path: "/home/gevo/Images/"
|
||||
hooks:
|
||||
before_hook: "ls"
|
||||
after_hook: ""
|
||||
backup_configs:
|
||||
1:
|
||||
backup_strategy:
|
||||
type: "kopia"
|
||||
provider: "b2_backblaze"
|
46
go.mod
Normal file
46
go.mod
Normal file
@ -0,0 +1,46 @@
|
||||
module backea
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
github.com/spf13/viper v1.19.0
|
||||
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/golang/glog v1.2.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
103
go.sum
Normal file
103
go.sum
Normal file
@ -0,0 +1,103 @@
|
||||
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
|
||||
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 h1:6Be3nkuJFyRfCgr6qTIzmRp8y9QwDIbqy/nYr9WDPos=
|
||||
github.com/google/readahead v0.0.0-20161222183148-eaceba169032/go.mod h1:qYysrqQXuV4tzsizt4oOQ6mrBZQ0xnQXP3ylXX8Jk5Y=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 h1:xoIK0ctDddBMnc74udxJYBqlo9Ylnsp1waqjLsnef20=
|
||||
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216 h1:2TSTkQ8PMvGOD5eeqqRVv6Z9+BYI+bowK97RCr3W+9M=
|
||||
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216/go.mod h1:zJ2QpyDCYo1KvLXlmdnFlQAyF/Qfth0fB8239Qg7BIE=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
131
internal/backup/backup.go
Normal file
131
internal/backup/backup.go
Normal file
@ -0,0 +1,131 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"backea/internal/mail"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PerformBackups executes backups for multiple services based on configuration
|
||||
func PerformBackups(ctx context.Context, configPath string, serviceName string, serviceIndex string) error {
|
||||
// Create backup factory
|
||||
factory, err := NewBackupFactory(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize mailer
|
||||
mailer := mail.NewMailer()
|
||||
|
||||
// Process services
|
||||
if serviceName != "" {
|
||||
// Process single service group or specific service
|
||||
if serviceIndex != "" {
|
||||
// Process specific service within a group
|
||||
return processSpecificService(ctx, factory, serviceName, serviceIndex, mailer)
|
||||
} else {
|
||||
// Process all services in the specified group
|
||||
return processServiceGroup(ctx, factory, serviceName, mailer)
|
||||
}
|
||||
} else {
|
||||
// Process all service groups in parallel
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, len(factory.Config.Services))
|
||||
for groupName := range factory.Config.Services {
|
||||
wg.Add(1)
|
||||
go func(group string) {
|
||||
defer wg.Done()
|
||||
if err := processServiceGroup(ctx, factory, group, mailer); err != nil {
|
||||
log.Printf("Failed to backup service group %s: %v", group, err)
|
||||
errs <- fmt.Errorf("backup failed for group %s: %w", group, err)
|
||||
}
|
||||
}(groupName)
|
||||
}
|
||||
// Wait for all backups to complete
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
// Check if any errors occurred
|
||||
var lastErr error
|
||||
for err := range errs {
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
|
||||
// processServiceGroup handles the backup for all services in a group
|
||||
func processServiceGroup(ctx context.Context, factory *BackupFactory, groupName string, mailer *mail.Mailer) error {
|
||||
// Get service group configuration
|
||||
serviceGroup, exists := factory.Config.Services[groupName]
|
||||
if !exists {
|
||||
log.Printf("Service group not found: %s", groupName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute the before hook once for the entire group
|
||||
if serviceGroup.Hooks.BeforeHook != "" {
|
||||
log.Printf("Executing before hook for group %s: %s", groupName, serviceGroup.Hooks.BeforeHook)
|
||||
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook); err != nil {
|
||||
log.Printf("Failed to execute before hook for group %s: %v", groupName, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Process all services in the group in parallel
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, len(serviceGroup.BackupConfigs))
|
||||
for configIndex := range serviceGroup.BackupConfigs {
|
||||
wg.Add(1)
|
||||
go func(group, index string) {
|
||||
defer wg.Done()
|
||||
if err := processSpecificService(ctx, factory, group, index, mailer); err != nil {
|
||||
log.Printf("Failed to backup service %s.%s: %v", group, index, err)
|
||||
errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err)
|
||||
}
|
||||
}(groupName, configIndex)
|
||||
}
|
||||
// Wait for all backups to complete
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
// Execute the after hook once for the entire group
|
||||
if serviceGroup.Hooks.AfterHook != "" {
|
||||
log.Printf("Executing after hook for group %s: %s", groupName, serviceGroup.Hooks.AfterHook)
|
||||
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.AfterHook); err != nil {
|
||||
log.Printf("Failed to execute after hook for group %s: %v", groupName, err)
|
||||
// We don't return here because we want to process the errors from the backups
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any errors occurred
|
||||
var lastErr error
|
||||
for err := range errs {
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// processSpecificService handles the backup for a specific service in a group
|
||||
func processSpecificService(ctx context.Context, factory *BackupFactory, groupName string, configIndex string, mailer *mail.Mailer) error {
|
||||
// Get service configuration
|
||||
serviceGroup := factory.Config.Services[groupName]
|
||||
|
||||
// Create the appropriate backup strategy using the factory
|
||||
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create and run service
|
||||
service := NewService(
|
||||
fmt.Sprintf("%s.%s", groupName, configIndex),
|
||||
serviceGroup.Source.Path,
|
||||
strategy,
|
||||
mailer,
|
||||
)
|
||||
return service.Backup(ctx)
|
||||
}
|
187
internal/backup/backup_factory.go
Normal file
187
internal/backup/backup_factory.go
Normal file
@ -0,0 +1,187 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupFactory creates backup strategies and managers
|
||||
type BackupFactory struct {
|
||||
Config *Configuration
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// NewBackupFactory creates a new backup factory
|
||||
func NewBackupFactory(configPath string) (*BackupFactory, error) {
|
||||
// Load configuration
|
||||
config, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load config: %w", err)
|
||||
}
|
||||
return &BackupFactory{
|
||||
Config: config,
|
||||
ConfigPath: configPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateKopiaStrategy creates a new Kopia backup strategy with specific retention settings and provider
|
||||
func (f *BackupFactory) CreateKopiaStrategy(retention Retention, provider KopiaProvider) *KopiaStrategy {
|
||||
// Create temp directory for Kopia configs if it doesn't exist
|
||||
tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs")
|
||||
if err := os.MkdirAll(tmpConfigDir, 0755); err != nil {
|
||||
log.Printf("Warning: failed to create temp config directory: %v", err)
|
||||
// Fall back to default config path
|
||||
tmpConfigDir = os.TempDir()
|
||||
}
|
||||
|
||||
// Generate a unique config file path for this instance
|
||||
configPath := filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config",
|
||||
provider.GetProviderType(), time.Now().UnixNano()))
|
||||
|
||||
// Need to update this line to pass configPath
|
||||
return NewKopiaStrategy(retention, provider, configPath)
|
||||
}
|
||||
|
||||
// CreateKopiaProvider creates the appropriate Kopia provider based on config
|
||||
func (f *BackupFactory) CreateKopiaProvider(strategyConfig StrategyConfig) (KopiaProvider, error) {
|
||||
switch strategyConfig.Provider {
|
||||
case "b2_backblaze":
|
||||
return NewKopiaB2Provider(), nil
|
||||
case "local":
|
||||
// Extract options for local path if specified
|
||||
basePath := ""
|
||||
if strategyConfig.Options != "" {
|
||||
basePath = strategyConfig.Options
|
||||
} else {
|
||||
// Default to ~/.backea/repos if not specified
|
||||
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
|
||||
}
|
||||
return NewKopiaLocalProvider(basePath), nil
|
||||
case "sftp":
|
||||
// Parse options for SFTP - expected format: "user@host:/path/to/backups"
|
||||
host := strategyConfig.Destination.Host
|
||||
path := strategyConfig.Destination.Path
|
||||
sshKey := strategyConfig.Destination.SSHKey
|
||||
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("SFTP provider requires host in destination config")
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SFTP provider requires path in destination config")
|
||||
}
|
||||
|
||||
// Default SSH key if not specified
|
||||
if sshKey == "" {
|
||||
sshKey = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa")
|
||||
}
|
||||
|
||||
// Extract username from host if in format user@host
|
||||
username := "root" // default
|
||||
if hostParts := strings.Split(host, "@"); len(hostParts) > 1 {
|
||||
username = hostParts[0]
|
||||
host = hostParts[1]
|
||||
}
|
||||
|
||||
return NewKopiaSFTPProvider(host, path, username, sshKey), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Kopia provider: %s", strategyConfig.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBackupStrategyForService creates a backup strategy for the specified service within a group
|
||||
func (f *BackupFactory) CreateBackupStrategyForService(groupName string, serviceIndex string) (Strategy, error) {
|
||||
// Find service group
|
||||
serviceGroup, exists := f.Config.Services[groupName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("service group not found: %s", groupName)
|
||||
}
|
||||
|
||||
// Find specific backup config within the group
|
||||
backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex)
|
||||
}
|
||||
|
||||
// Create appropriate strategy based on type
|
||||
strategyConfig := backupConfig.BackupStrategy
|
||||
|
||||
// Extract retention settings once
|
||||
retention := Retention{
|
||||
KeepLatest: strategyConfig.Retention.KeepLatest,
|
||||
KeepHourly: strategyConfig.Retention.KeepHourly,
|
||||
KeepDaily: strategyConfig.Retention.KeepDaily,
|
||||
KeepWeekly: strategyConfig.Retention.KeepWeekly,
|
||||
KeepMonthly: strategyConfig.Retention.KeepMonthly,
|
||||
KeepYearly: strategyConfig.Retention.KeepYearly,
|
||||
}
|
||||
|
||||
switch strategyConfig.Type {
|
||||
case "kopia":
|
||||
// Create appropriate provider based on configuration
|
||||
provider, err := f.CreateKopiaProvider(strategyConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create kopia provider: %w", err)
|
||||
}
|
||||
return f.CreateKopiaStrategy(retention, provider), nil
|
||||
case "rsync":
|
||||
// Uncomment when rsync implementation is ready
|
||||
// return NewRsyncStrategy(
|
||||
// strategyConfig.Options,
|
||||
// Destination{
|
||||
// Host: strategyConfig.Destination.Host,
|
||||
// Path: strategyConfig.Destination.Path,
|
||||
// SSHKey: strategyConfig.Destination.SSHKey,
|
||||
// },
|
||||
// ), nil
|
||||
fallthrough
|
||||
default:
|
||||
// Default to local kopia if type is unknown or rsync (temporarily)
|
||||
provider, _ := f.CreateKopiaProvider(StrategyConfig{Provider: "local"})
|
||||
return f.CreateKopiaStrategy(retention, provider), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteGroupHooks runs the hooks for a service group
|
||||
func (f *BackupFactory) ExecuteGroupHooks(groupName string, isBeforeHook bool) error {
|
||||
serviceGroup, exists := f.Config.Services[groupName]
|
||||
if !exists {
|
||||
return fmt.Errorf("service group not found: %s", groupName)
|
||||
}
|
||||
|
||||
var hook string
|
||||
if isBeforeHook {
|
||||
hook = serviceGroup.Hooks.BeforeHook
|
||||
} else {
|
||||
hook = serviceGroup.Hooks.AfterHook
|
||||
}
|
||||
|
||||
if hook == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute the hook
|
||||
cmd := exec.Command("sh", "-c", hook)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("hook execution failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
log.Printf("Hook executed successfully: %s, output: %s", hook, string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadConfig refreshes the configuration from the config file
|
||||
func (f *BackupFactory) ReloadConfig() error {
|
||||
config, err := LoadConfig(f.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not reload config: %w", err)
|
||||
}
|
||||
f.Config = config
|
||||
return nil
|
||||
}
|
767
internal/backup/backup_kopia.go
Normal file
767
internal/backup/backup_kopia.go
Normal file
@ -0,0 +1,767 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KopiaStrategy implements the backup strategy using Kopia
|
||||
type KopiaStrategy struct {
|
||||
Retention Retention
|
||||
Provider KopiaProvider
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// NewKopiaStrategy creates a new kopia strategy with specified provider
|
||||
func NewKopiaStrategy(retention Retention, provider KopiaProvider, configPath string) *KopiaStrategy {
|
||||
return &KopiaStrategy{
|
||||
Retention: retention,
|
||||
Provider: provider,
|
||||
ConfigPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute performs the kopia backup
|
||||
func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory string) error {
|
||||
log.Printf("Performing kopia backup for service %s: %s", serviceName, directory)
|
||||
|
||||
// Get or create password for this service
|
||||
password, err := getOrCreatePassword(serviceName, 48)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get or create password: %w", err)
|
||||
}
|
||||
|
||||
// Ensure Kopia config directory exists
|
||||
kopiaConfigDir := filepath.Join(os.Getenv("HOME"), ".kopia")
|
||||
if err := os.MkdirAll(kopiaConfigDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create kopia config directory: %w", err)
|
||||
}
|
||||
|
||||
// Repository not connected, connect or create via provider
|
||||
log.Printf("Connecting to repository for %s", serviceName)
|
||||
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
|
||||
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
log.Printf("Creating snapshot for directory: %s", directory)
|
||||
snapshotCmd := exec.Command("kopia", "--config-file", s.ConfigPath, "snapshot", "create", directory)
|
||||
snapshotOutput, err := snapshotCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create snapshot: %w\nOutput: %s", err, snapshotOutput)
|
||||
}
|
||||
|
||||
// Set retention policy
|
||||
log.Printf("Setting retention policy for %s", serviceName)
|
||||
args := []string{
|
||||
"--config-file", s.ConfigPath,
|
||||
"policy", "set",
|
||||
"--keep-latest", fmt.Sprintf("%d", s.Retention.KeepLatest),
|
||||
"--keep-hourly", fmt.Sprintf("%d", s.Retention.KeepHourly),
|
||||
"--keep-daily", fmt.Sprintf("%d", s.Retention.KeepDaily),
|
||||
"--keep-weekly", fmt.Sprintf("%d", s.Retention.KeepWeekly),
|
||||
"--keep-monthly", fmt.Sprintf("%d", s.Retention.KeepMonthly),
|
||||
"--keep-annual", fmt.Sprintf("%d", s.Retention.KeepYearly),
|
||||
directory,
|
||||
}
|
||||
policyCmd := exec.Command("kopia", args...)
|
||||
policyOutput, err := policyCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set policy: %w\nOutput: %s", err, policyOutput)
|
||||
}
|
||||
|
||||
log.Printf("Snapshot and policy set successfully for %s", serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns information about existing backups
|
||||
func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) {
|
||||
// Parse service group and index from service name (e.g., "backealocal.1")
|
||||
groupName, _, err := parseServiceName(serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid service name format: %w", err)
|
||||
}
|
||||
|
||||
// Get service directory from config
|
||||
factory, err := NewBackupFactory("config.yml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create factory: %w", err)
|
||||
}
|
||||
|
||||
// Find service group
|
||||
serviceGroup, exists := factory.Config.Services[groupName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("service group not found: %s", groupName)
|
||||
}
|
||||
|
||||
// Get directory from the service group level
|
||||
directoryPath := strings.TrimRight(serviceGroup.Source.Path, "/")
|
||||
|
||||
// Ensure we're connected to the repository
|
||||
err = s.EnsureRepositoryConnected(ctx, serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Run kopia snapshot list command with JSON output
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", s.ConfigPath,
|
||||
"snapshot",
|
||||
"list",
|
||||
"--json",
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list snapshots: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON output
|
||||
var snapshots []map[string]interface{}
|
||||
if err := json.Unmarshal(output, &snapshots); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse snapshot list: %w", err)
|
||||
}
|
||||
|
||||
// Convert to BackupInfo
|
||||
var result []BackupInfo
|
||||
for _, snap := range snapshots {
|
||||
// Get basic info
|
||||
id, _ := snap["id"].(string)
|
||||
endTime, _ := snap["endTime"].(string)
|
||||
|
||||
// Only include snapshots for the requested service by directory path
|
||||
source, ok := snap["source"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath, _ := source["path"].(string)
|
||||
// Match by exact directory path, not service name
|
||||
if sourcePath != directoryPath {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse time
|
||||
var creationTime time.Time
|
||||
if endTime != "" {
|
||||
t, err := time.Parse(time.RFC3339, endTime)
|
||||
if err == nil {
|
||||
creationTime = t
|
||||
}
|
||||
}
|
||||
|
||||
// Get size information from stats
|
||||
var size int64
|
||||
if stats, ok := snap["stats"].(map[string]interface{}); ok {
|
||||
if totalSize, ok := stats["totalSize"].(float64); ok {
|
||||
size = int64(totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine retention tag
|
||||
retentionTag := "none"
|
||||
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
|
||||
// Get the first reason which indicates the highest priority retention
|
||||
if reason, ok := reasons[0].(string); ok {
|
||||
parts := strings.SplitN(reason, "-", 2)
|
||||
if len(parts) > 0 {
|
||||
retentionTag = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, BackupInfo{
|
||||
ID: id,
|
||||
CreationTime: creationTime,
|
||||
Size: size,
|
||||
Source: sourcePath,
|
||||
Type: "kopia",
|
||||
RetentionTag: retentionTag,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStorageUsage returns information about the total storage used by the repository
|
||||
func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) {
|
||||
// Ensure we're connected to the repository
|
||||
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Get provider type (b2, local, sftp)
|
||||
providerType := "unknown"
|
||||
switch s.Provider.(type) {
|
||||
case *KopiaB2Provider:
|
||||
providerType = "b2"
|
||||
case *KopiaLocalProvider:
|
||||
providerType = "local"
|
||||
case *KopiaSFTPProvider:
|
||||
providerType = "sftp"
|
||||
}
|
||||
|
||||
// Initialize storage info
|
||||
info := &StorageUsageInfo{
|
||||
Provider: providerType,
|
||||
ProviderID: s.Provider.GetBucketName(serviceName),
|
||||
}
|
||||
|
||||
// Calculate logical size by summing up the sizes of all snapshots
|
||||
backups, err := s.ListBackups(ctx, serviceName)
|
||||
if err == nil {
|
||||
var totalLogicalBytes int64
|
||||
for _, backup := range backups {
|
||||
totalLogicalBytes += backup.Size
|
||||
}
|
||||
info.TotalBytes = totalLogicalBytes
|
||||
}
|
||||
|
||||
// Try to get physical storage stats using blob stats command
|
||||
blobStatsCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", s.ConfigPath,
|
||||
"blob",
|
||||
"stats",
|
||||
)
|
||||
|
||||
blobStatsOutput, err := blobStatsCmd.CombinedOutput()
|
||||
if err == nil {
|
||||
// Parse the text output
|
||||
outputStr := string(blobStatsOutput)
|
||||
log.Printf("Blob stats output: %s", outputStr)
|
||||
|
||||
// Look for the line with "Total:"
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Total:") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
sizeStr := strings.TrimSpace(parts[1])
|
||||
size, err := parseHumanSize(sizeStr)
|
||||
if err == nil {
|
||||
info.TotalBytes = size
|
||||
log.Printf("Got physical size from blob stats: %d bytes", size)
|
||||
break
|
||||
} else {
|
||||
log.Printf("Failed to parse size '%s': %v", sizeStr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Blob stats command failed: %v - %s", err, string(blobStatsOutput))
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Helper method to ensure the repository is connected before operations
|
||||
func (s *KopiaStrategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error {
|
||||
// Check if kopia repository is already connected with config file
|
||||
cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status")
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// Repository not connected, try to connect
|
||||
password, err := getOrCreatePassword(serviceName, 48)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get password: %w", err)
|
||||
}
|
||||
|
||||
// Connect using provider
|
||||
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
|
||||
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseServiceName splits a service name in the format "group.index" into its components
|
||||
func parseServiceName(serviceName string) (string, string, error) {
|
||||
parts := strings.SplitN(serviceName, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// getOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist
|
||||
// Extracted to a package-level function since it's utility functionality
|
||||
func getOrCreatePassword(serviceName string, length int) (string, error) {
|
||||
// Define the expected key in the env file
|
||||
// Replace dots with underscores for environment variable name
|
||||
safeServiceName := strings.ReplaceAll(serviceName, ".", "_")
|
||||
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName))
|
||||
|
||||
// Try to read from kopia.env first
|
||||
kopiaEnvPath := "kopia.env"
|
||||
if _, err := os.Stat(kopiaEnvPath); err == nil {
|
||||
// File exists, check if the password is already there
|
||||
file, err := os.Open(kopiaEnvPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open kopia.env: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, passwordKey+"=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
// Found the password, remove quotes if present
|
||||
password := parts[1]
|
||||
password = strings.Trim(password, "\"")
|
||||
return password, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("error reading kopia.env: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Password not found or file doesn't exist, generate a new password
|
||||
password, err := generateSecurePassword(length)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
|
||||
// Create or append to kopia.env
|
||||
file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open kopia.env for writing: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write the new password entry with quotes
|
||||
passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password)
|
||||
if _, err := file.WriteString(passwordEntry); err != nil {
|
||||
return "", fmt.Errorf("failed to write to kopia.env: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Created new password for service %s and stored in kopia.env", serviceName)
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// generateSecurePassword creates a cryptographically secure random password
|
||||
// using only safe characters that work well with command line tools
|
||||
func generateSecurePassword(length int) (string, error) {
|
||||
// Use a more robust but safe character set
|
||||
// Avoiding characters that might cause shell interpretation issues
|
||||
// No quotes, backslashes, spaces, or common special chars that need escaping
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
result := make([]byte, length)
|
||||
|
||||
// Create a secure source of randomness
|
||||
randomBytes := make([]byte, length)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Ensure minimum complexity requirements
|
||||
// At least 2 uppercase, 2 lowercase, 2 digits
|
||||
if length >= 6 {
|
||||
// Force first characters to meet minimum requirements
|
||||
result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase
|
||||
result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase
|
||||
result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase
|
||||
result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase
|
||||
result[4] = '0' + byte(randomBytes[4]%10) // Digit
|
||||
result[5] = '0' + byte(randomBytes[5]%10) // Digit
|
||||
|
||||
// Fill the rest with random chars
|
||||
for i := 6; i < length; i++ {
|
||||
result[i] = chars[int(randomBytes[i])%len(chars)]
|
||||
}
|
||||
|
||||
// Shuffle the result to avoid predictable pattern
|
||||
for i := length - 1; i > 0; i-- {
|
||||
j := int(randomBytes[i]) % (i + 1)
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
} else {
|
||||
// For very short passwords just use random chars
|
||||
for i := 0; i < length; i++ {
|
||||
result[i] = chars[int(randomBytes[i])%len(chars)]
|
||||
}
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes
|
||||
func parseHumanSize(sizeStr string) (int64, error) {
|
||||
parts := strings.Fields(sizeStr)
|
||||
if len(parts) != 2 {
|
||||
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size value: %w", err)
|
||||
}
|
||||
|
||||
unit := strings.ToUpper(parts[1])
|
||||
switch unit {
|
||||
case "B":
|
||||
return int64(value), nil
|
||||
case "KB", "KIB":
|
||||
return int64(value * 1024), nil
|
||||
case "MB", "MIB":
|
||||
return int64(value * 1024 * 1024), nil
|
||||
case "GB", "GIB":
|
||||
return int64(value * 1024 * 1024 * 1024), nil
|
||||
case "TB", "TIB":
|
||||
return int64(value * 1024 * 1024 * 1024 * 1024), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown size unit: %s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreBackup restores a backup with the given ID
|
||||
func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error {
|
||||
// Ensure repository is connected
|
||||
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Parse service group and index from service name
|
||||
groupName, _, err := parseServiceName(serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service name format: %w", err)
|
||||
}
|
||||
|
||||
// Get service directory from config
|
||||
factory, err := NewBackupFactory("config.yml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create factory: %w", err)
|
||||
}
|
||||
|
||||
// Find service group
|
||||
serviceGroup, exists := factory.Config.Services[groupName]
|
||||
if !exists {
|
||||
return fmt.Errorf("service group not found: %s", groupName)
|
||||
}
|
||||
|
||||
// Create a temporary directory for restore
|
||||
restoreDir := filepath.Join(os.TempDir(), fmt.Sprintf("backea-restore-%s-%d", serviceName, time.Now().Unix()))
|
||||
if err := os.MkdirAll(restoreDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create restore directory: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Restoring backup %s to temporary directory %s", backupID, restoreDir)
|
||||
|
||||
// Run kopia restore command to restore the snapshot to the temporary directory
|
||||
restoreCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", s.ConfigPath,
|
||||
"snapshot",
|
||||
"restore",
|
||||
backupID,
|
||||
restoreDir,
|
||||
)
|
||||
|
||||
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
|
||||
}
|
||||
|
||||
// Now we need to sync the restored data to the original directory
|
||||
targetDir := serviceGroup.Source.Path
|
||||
log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir)
|
||||
|
||||
// Use rsync for the final transfer to avoid permissions issues
|
||||
syncCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"rsync",
|
||||
"-av",
|
||||
"--delete", // Delete extraneous files from target
|
||||
restoreDir+"/", // Source directory with trailing slash to copy contents
|
||||
targetDir, // Target directory
|
||||
)
|
||||
|
||||
syncOutput, err := syncCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync restored data: %w\nOutput: %s", err, syncOutput)
|
||||
}
|
||||
|
||||
// Clean up the temporary directory
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute) // Wait 5 minutes before cleaning up
|
||||
log.Printf("Cleaning up temporary restore directory %s", restoreDir)
|
||||
os.RemoveAll(restoreDir)
|
||||
}()
|
||||
|
||||
log.Printf("Successfully restored backup %s to %s", backupID, targetDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadBackup provides a reader with backup summary information in text format
|
||||
func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) {
|
||||
|
||||
// Ensure repository is connected
|
||||
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary directories for restore and ZIP creation
|
||||
tempDir, err := os.MkdirTemp("", "backea-download-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "restore")
|
||||
zipFile := filepath.Join(tempDir, "backup.zip")
|
||||
|
||||
if err := os.MkdirAll(restoreDir, 0755); err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to create restore directory: %w", err)
|
||||
}
|
||||
|
||||
// Restore the snapshot to the temporary directory using the proper snapshot ID
|
||||
log.Printf("Restoring snapshot %s to temporary directory %s", backupID, restoreDir)
|
||||
|
||||
restoreCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", s.ConfigPath,
|
||||
"snapshot",
|
||||
"restore",
|
||||
backupID,
|
||||
restoreDir,
|
||||
)
|
||||
|
||||
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
|
||||
}
|
||||
|
||||
// Create ZIP archive of the restored files
|
||||
log.Printf("Creating ZIP archive at %s", zipFile)
|
||||
|
||||
// Use Go's zip package instead of command line tools
|
||||
zipWriter, err := os.Create(zipFile)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||
}
|
||||
|
||||
defer zipWriter.Close()
|
||||
|
||||
// Create ZIP writer
|
||||
archive := zip.NewWriter(zipWriter)
|
||||
defer archive.Close()
|
||||
|
||||
// Walk the restore directory and add files to ZIP
|
||||
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories, we only want to add files
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create ZIP header
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make the path relative to the restore directory
|
||||
relPath, err := filepath.Rel(restoreDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the name in the archive to the relative path
|
||||
header.Name = relPath
|
||||
|
||||
// Create the file in the ZIP
|
||||
writer, err := archive.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the file
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Copy the file content to the ZIP
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
|
||||
// Close the ZIP writer
|
||||
archive.Close()
|
||||
zipWriter.Close()
|
||||
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to create zip archive: %w", err)
|
||||
}
|
||||
|
||||
// Open the ZIP file for reading
|
||||
zipReader, err := os.Open(zipFile)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to open zip archive: %w", err)
|
||||
}
|
||||
|
||||
// Return a reader that will clean up when closed
|
||||
return &cleanupReadCloser{
|
||||
ReadCloser: zipReader,
|
||||
cleanup: func() {
|
||||
zipReader.Close()
|
||||
os.RemoveAll(tempDir)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBackupInfo returns detailed information about a specific backup
|
||||
func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*BackupInfo, error) {
|
||||
|
||||
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||
}
|
||||
|
||||
// Run kopia snapshot describe command with JSON output
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", s.ConfigPath,
|
||||
"snapshot",
|
||||
"describe",
|
||||
"--json",
|
||||
backupID,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to describe snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON output
|
||||
var snap map[string]interface{}
|
||||
if err := json.Unmarshal(output, &snap); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse snapshot info: %w", err)
|
||||
}
|
||||
|
||||
// Extract the relevant information
|
||||
id, _ := snap["id"].(string)
|
||||
endTime, _ := snap["endTime"].(string)
|
||||
|
||||
// Parse time
|
||||
var creationTime time.Time
|
||||
if endTime != "" {
|
||||
t, err := time.Parse(time.RFC3339, endTime)
|
||||
if err == nil {
|
||||
creationTime = t
|
||||
}
|
||||
}
|
||||
|
||||
// Get size information from stats
|
||||
var size int64
|
||||
if stats, ok := snap["stats"].(map[string]interface{}); ok {
|
||||
if totalSize, ok := stats["totalSize"].(float64); ok {
|
||||
size = int64(totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Get source path
|
||||
sourcePath := ""
|
||||
if source, ok := snap["source"].(map[string]interface{}); ok {
|
||||
if path, ok := source["path"].(string); ok {
|
||||
sourcePath = path
|
||||
}
|
||||
}
|
||||
|
||||
// Determine retention tag
|
||||
retentionTag := "none"
|
||||
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
|
||||
// Get the first reason which indicates the highest priority retention
|
||||
if reason, ok := reasons[0].(string); ok {
|
||||
parts := strings.SplitN(reason, "-", 2)
|
||||
if len(parts) > 0 {
|
||||
retentionTag = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &BackupInfo{
|
||||
ID: id,
|
||||
CreationTime: creationTime,
|
||||
Size: size,
|
||||
Source: sourcePath,
|
||||
Type: "kopia",
|
||||
RetentionTag: retentionTag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cleanupReadCloser is a wrapper around io.ReadCloser that performs cleanup when closed
|
||||
type cleanupReadCloser struct {
|
||||
io.ReadCloser
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// Close closes the underlying ReadCloser and performs cleanup
|
||||
func (c *cleanupReadCloser) Close() error {
|
||||
err := c.ReadCloser.Close()
|
||||
c.cleanup()
|
||||
return err
|
||||
}
|
||||
|
||||
// extractServiceNameFromBackupID extracts the service name from a backup ID
|
||||
// This is an approximation as the exact format depends on your ID structure
|
||||
func extractServiceNameFromBackupID(backupID string) string {
|
||||
// Kopia snapshot IDs don't directly include the service name
|
||||
// You may need to adjust this based on your actual ID format
|
||||
|
||||
// Try to extract the pattern from your backups list
|
||||
// If you're using ListBackups to find all backups, you might have
|
||||
// a mapping of IDs to service names that you can use
|
||||
|
||||
// For this example, let's assume we're using environment variables to track this
|
||||
envVar := fmt.Sprintf("BACKEA_SNAPSHOT_%s", backupID)
|
||||
if serviceName := os.Getenv(envVar); serviceName != "" {
|
||||
return serviceName
|
||||
}
|
||||
|
||||
// Fallback: For testing, we'll return a default or parse from the first part
|
||||
// This should be replaced with your actual logic
|
||||
parts := strings.Split(backupID, "-")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
51
internal/backup/backup_rsync.go
Normal file
51
internal/backup/backup_rsync.go
Normal file
@ -0,0 +1,51 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// RsyncStrategy implements the backup strategy using rsync
|
||||
type RsyncStrategy struct {
|
||||
Options string
|
||||
Destination Destination
|
||||
}
|
||||
|
||||
// NewRsyncStrategy creates a new rsync strategy
|
||||
func NewRsyncStrategy(options string, destination Destination) *RsyncStrategy {
|
||||
return &RsyncStrategy{
|
||||
Options: options,
|
||||
Destination: destination,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute performs the rsync backup
|
||||
func (s *RsyncStrategy) Execute(ctx context.Context, serviceName, directory string) error {
|
||||
log.Printf("Performing rsync backup for %s", serviceName)
|
||||
|
||||
// Build rsync command
|
||||
dst := s.Destination
|
||||
sshKeyOption := ""
|
||||
|
||||
// Without
|
||||
if dst.SSHKey != "" {
|
||||
sshKeyOption = fmt.Sprintf("-e 'ssh -i %s'", dst.SSHKey)
|
||||
}
|
||||
|
||||
destination := fmt.Sprintf("%s:%s", dst.Host, dst.Path)
|
||||
rsyncCmd := fmt.Sprintf("rsync %s %s %s %s",
|
||||
s.Options,
|
||||
sshKeyOption,
|
||||
directory,
|
||||
destination)
|
||||
|
||||
// Run rsync command
|
||||
log.Printf("Running: %s", rsyncCmd)
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", rsyncCmd)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
112
internal/backup/config.go
Normal file
112
internal/backup/config.go
Normal file
@ -0,0 +1,112 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Configuration holds the complete application configuration
|
||||
type Configuration struct {
|
||||
Defaults DefaultsConfig `mapstructure:"defaults"`
|
||||
Services map[string]ServiceGroup `mapstructure:"services"`
|
||||
}
|
||||
|
||||
// DefaultsConfig holds the default settings for all services
|
||||
type DefaultsConfig struct {
|
||||
Retention RetentionConfig `mapstructure:"retention"`
|
||||
}
|
||||
|
||||
// ServiceGroup represents a service group with a common directory and multiple backup configurations
|
||||
type ServiceGroup struct {
|
||||
Directory string `mapstructure:"directory,omitempty"`
|
||||
Source SourceConfig `mapstructure:"source"`
|
||||
Hooks HooksConfig `mapstructure:"hooks"`
|
||||
BackupConfigs map[string]BackupConfig `mapstructure:"backup_configs"`
|
||||
}
|
||||
|
||||
// SourceConfig represents source configuration for backups
|
||||
type SourceConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Path string `mapstructure:"path"`
|
||||
SSHKey string `mapstructure:"ssh_key"`
|
||||
}
|
||||
|
||||
// HooksConfig contains the hooks to run before and after the backup
|
||||
type HooksConfig struct {
|
||||
BeforeHook string `mapstructure:"before_hook"`
|
||||
AfterHook string `mapstructure:"after_hook"`
|
||||
}
|
||||
|
||||
// BackupConfig represents a specific backup configuration
|
||||
type BackupConfig struct {
|
||||
BackupStrategy StrategyConfig `mapstructure:"backup_strategy"`
|
||||
}
|
||||
|
||||
// StrategyConfig represents a backup strategy configuration
|
||||
type StrategyConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
Options string `mapstructure:"options,omitempty"`
|
||||
Retention RetentionConfig `mapstructure:"retention,omitempty"`
|
||||
Destination DestConfig `mapstructure:"destination"`
|
||||
}
|
||||
|
||||
// RetentionConfig represents retention policy configuration
|
||||
type RetentionConfig struct {
|
||||
KeepLatest int `mapstructure:"keep_latest"`
|
||||
KeepHourly int `mapstructure:"keep_hourly"`
|
||||
KeepDaily int `mapstructure:"keep_daily"`
|
||||
KeepWeekly int `mapstructure:"keep_weekly"`
|
||||
KeepMonthly int `mapstructure:"keep_monthly"`
|
||||
KeepYearly int `mapstructure:"keep_yearly"`
|
||||
}
|
||||
|
||||
// DestConfig represents destination configuration for remote backups
|
||||
type DestConfig struct {
|
||||
Host string `mapstructure:"host,omitempty"`
|
||||
Path string `mapstructure:"path"`
|
||||
SSHKey string `mapstructure:"ssh_key,omitempty"`
|
||||
}
|
||||
|
||||
// LoadConfig loads the application configuration from a file
|
||||
func LoadConfig(configPath string) (*Configuration, error) {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("Warning: Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
viper.SetConfigFile(configPath)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config Configuration
|
||||
if err := viper.Unmarshal(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply default retention settings where needed
|
||||
for serviceName, service := range config.Services {
|
||||
for configID, backupConfig := range service.BackupConfigs {
|
||||
// If retention is not set, use defaults
|
||||
if isRetentionEmpty(backupConfig.BackupStrategy.Retention) {
|
||||
backupConfig.BackupStrategy.Retention = config.Defaults.Retention
|
||||
service.BackupConfigs[configID] = backupConfig
|
||||
}
|
||||
}
|
||||
config.Services[serviceName] = service
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// isRetentionEmpty checks if a retention config is empty (all zeros)
|
||||
func isRetentionEmpty(retention RetentionConfig) bool {
|
||||
return retention.KeepLatest == 0 &&
|
||||
retention.KeepHourly == 0 &&
|
||||
retention.KeepDaily == 0 &&
|
||||
retention.KeepWeekly == 0 &&
|
||||
retention.KeepMonthly == 0 &&
|
||||
retention.KeepYearly == 0
|
||||
}
|
277
internal/backup/kopia_providers.go
Normal file
277
internal/backup/kopia_providers.go
Normal file
@ -0,0 +1,277 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
b2_client "backea/internal/client"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KopiaProvider defines the interface for different Kopia storage backends
|
||||
type KopiaProvider interface {
|
||||
// Connect connects to an existing repository or creates a new one
|
||||
Connect(ctx context.Context, serviceName string, password string, configPath string) error
|
||||
// GetRepositoryParams returns the parameters needed for repository operations
|
||||
GetRepositoryParams(serviceName string) ([]string, error)
|
||||
// GetBucketName returns the storage identifier (bucket name or path)
|
||||
GetBucketName(serviceName string) string
|
||||
// GetProviderType returns a string identifying the provider type
|
||||
GetProviderType() string
|
||||
}
|
||||
|
||||
// KopiaB2Provider implements the KopiaProvider interface for Backblaze B2
|
||||
type KopiaB2Provider struct{}
|
||||
|
||||
// NewKopiaB2Provider creates a new B2 provider
|
||||
func NewKopiaB2Provider() *KopiaB2Provider {
|
||||
return &KopiaB2Provider{}
|
||||
}
|
||||
|
||||
// Connect connects to a B2 repository
|
||||
func (p *KopiaB2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||
// Load environment variables for B2 credentials
|
||||
keyID := os.Getenv("B2_KEY_ID")
|
||||
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||
if keyID == "" || applicationKey == "" {
|
||||
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||
}
|
||||
|
||||
bucketName := p.GetBucketName(serviceName)
|
||||
|
||||
// Create B2 client and check if bucket exists, create if not
|
||||
B2Client, _ := b2_client.NewClientFromEnv()
|
||||
if B2Client == nil {
|
||||
return fmt.Errorf("B2 client not initialized")
|
||||
}
|
||||
|
||||
_, err := B2Client.GetBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
log.Printf("Bucket %s not found, creating...", bucketName)
|
||||
_, err = B2Client.CreateBucket(ctx, bucketName, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket: %w", err)
|
||||
}
|
||||
log.Printf("Created bucket: %s", bucketName)
|
||||
}
|
||||
|
||||
// Try to connect to existing repository with config file
|
||||
connectCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "connect", "b2",
|
||||
"--bucket", bucketName,
|
||||
"--key-id", keyID,
|
||||
"--key", applicationKey,
|
||||
"--password", password,
|
||||
)
|
||||
err = connectCmd.Run()
|
||||
if err != nil {
|
||||
// Connection failed, create new repository
|
||||
log.Printf("Creating new B2 repository for %s", serviceName)
|
||||
createCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "create", "b2",
|
||||
"--bucket", bucketName,
|
||||
"--key-id", keyID,
|
||||
"--key", applicationKey,
|
||||
"--password", password,
|
||||
)
|
||||
createOutput, err := createCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepositoryParams returns parameters for B2 operations
|
||||
func (p *KopiaB2Provider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||
keyID := os.Getenv("B2_KEY_ID")
|
||||
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||
if keyID == "" || applicationKey == "" {
|
||||
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||
}
|
||||
|
||||
bucketName := p.GetBucketName(serviceName)
|
||||
|
||||
return []string{
|
||||
"b2",
|
||||
"--bucket", bucketName,
|
||||
"--key-id", keyID,
|
||||
"--key", applicationKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Modify the GetBucketName method in your KopiaB2Provider
|
||||
func (p *KopiaB2Provider) GetBucketName(serviceName string) string {
|
||||
// Backblaze dont allow .
|
||||
sanitized := strings.ReplaceAll(serviceName, ".", "-")
|
||||
|
||||
// Add a prefix to make the bucket name unique
|
||||
return fmt.Sprintf("backea-%s", sanitized)
|
||||
}
|
||||
|
||||
// GetProviderType returns the provider type identifier
|
||||
func (p *KopiaB2Provider) GetProviderType() string {
|
||||
return "b2"
|
||||
}
|
||||
|
||||
// KopiaLocalProvider implements the KopiaProvider interface for local storage
|
||||
type KopiaLocalProvider struct {
|
||||
BasePath string
|
||||
}
|
||||
|
||||
// NewKopiaLocalProvider creates a new local provider
|
||||
func NewKopiaLocalProvider(basePath string) *KopiaLocalProvider {
|
||||
// If basePath is empty, use a default location
|
||||
if basePath == "" {
|
||||
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
|
||||
}
|
||||
return &KopiaLocalProvider{
|
||||
BasePath: basePath,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect connects to a local repository
|
||||
func (p *KopiaLocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||
repoPath := filepath.Join(p.BasePath, serviceName)
|
||||
log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath)
|
||||
|
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(repoPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create repository directory: %w", err)
|
||||
}
|
||||
|
||||
// Try to connect to existing repository with config file
|
||||
connectCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "connect", "filesystem",
|
||||
"--path", repoPath,
|
||||
"--password", password,
|
||||
)
|
||||
err := connectCmd.Run()
|
||||
if err != nil {
|
||||
// Connection failed, create new repository
|
||||
log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath)
|
||||
createCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "create", "filesystem",
|
||||
"--path", repoPath,
|
||||
"--password", password,
|
||||
)
|
||||
createOutput, err := createCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepositoryParams returns parameters for local filesystem operations
|
||||
func (p *KopiaLocalProvider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||
repoPath := filepath.Join(p.BasePath, serviceName)
|
||||
return []string{
|
||||
"filesystem",
|
||||
"--path", repoPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBucketName returns the path for a service
|
||||
func (p *KopiaLocalProvider) GetBucketName(serviceName string) string {
|
||||
return filepath.Join(p.BasePath, serviceName)
|
||||
}
|
||||
|
||||
// GetProviderType returns the provider type identifier
|
||||
func (p *KopiaLocalProvider) GetProviderType() string {
|
||||
return "local"
|
||||
}
|
||||
|
||||
// KopiaSFTPProvider implements the KopiaProvider interface for SFTP remote storage
|
||||
type KopiaSFTPProvider struct {
|
||||
Host string
|
||||
BasePath string
|
||||
Username string
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
// NewKopiaSFTPProvider creates a new SFTP provider
|
||||
func NewKopiaSFTPProvider(host, basePath, username, keyFile string) *KopiaSFTPProvider {
|
||||
return &KopiaSFTPProvider{
|
||||
Host: host,
|
||||
BasePath: basePath,
|
||||
Username: username,
|
||||
KeyFile: keyFile,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect connects to an SFTP repository
|
||||
func (p *KopiaSFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
|
||||
|
||||
// Try to connect to existing repository with config file
|
||||
connectCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "connect", "sftp",
|
||||
"--path", repoPath,
|
||||
"--keyfile", p.KeyFile,
|
||||
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||
"--password", password,
|
||||
)
|
||||
err := connectCmd.Run()
|
||||
if err != nil {
|
||||
// Connection failed, create new repository
|
||||
log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath)
|
||||
createCmd := exec.CommandContext(
|
||||
ctx,
|
||||
"kopia",
|
||||
"--config-file", configPath,
|
||||
"repository", "create", "sftp",
|
||||
"--path", repoPath,
|
||||
"--keyfile", p.KeyFile,
|
||||
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||
"--password", password,
|
||||
)
|
||||
createOutput, err := createCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepositoryParams returns parameters for SFTP operations
|
||||
func (p *KopiaSFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
|
||||
return []string{
|
||||
"sftp",
|
||||
"--path", repoPath,
|
||||
"--keyfile", p.KeyFile,
|
||||
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBucketName returns the path for a service
|
||||
func (p *KopiaSFTPProvider) GetBucketName(serviceName string) string {
|
||||
return fmt.Sprintf("%s/%s", p.BasePath, serviceName)
|
||||
}
|
||||
|
||||
// GetProviderType returns the provider type identifier
|
||||
func (p *KopiaSFTPProvider) GetProviderType() string {
|
||||
return "sftp"
|
||||
}
|
179
internal/backup/restore.go
Normal file
179
internal/backup/restore.go
Normal file
@ -0,0 +1,179 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
client "backea/internal/client"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RestoreManager handles restoring files from Kopia backups
|
||||
type RestoreManager struct {
|
||||
B2Client *client.Client
|
||||
}
|
||||
|
||||
// NewRestoreManager creates a new restore manager
|
||||
func NewRestoreManager(b2Client *client.Client) *RestoreManager {
|
||||
return &RestoreManager{
|
||||
B2Client: b2Client,
|
||||
}
|
||||
}
|
||||
|
||||
// getStoredPassword retrieves a password from kopia.env file
|
||||
func (r *RestoreManager) getStoredPassword(serviceName string) (string, error) {
|
||||
// Define the expected key in the env file
|
||||
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(serviceName))
|
||||
|
||||
// Try to read from kopia.env
|
||||
kopiaEnvPath := "kopia.env"
|
||||
if _, err := os.Stat(kopiaEnvPath); err != nil {
|
||||
return "", fmt.Errorf("kopia.env file not found: %w", err)
|
||||
}
|
||||
|
||||
// File exists, check if the password is there
|
||||
file, err := os.Open(kopiaEnvPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open kopia.env: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, passwordKey+"=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
// Found the password, remove quotes if present
|
||||
password := parts[1]
|
||||
password = strings.Trim(password, "\"")
|
||||
return password, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("error reading kopia.env: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("password for service %s not found in kopia.env", serviceName)
|
||||
}
|
||||
|
||||
// connectToRepository connects to an existing Kopia repository
|
||||
func (r *RestoreManager) connectToRepository(ctx context.Context, serviceName string) error {
|
||||
if r.B2Client == nil {
|
||||
return fmt.Errorf("B2 client not initialized")
|
||||
}
|
||||
|
||||
// Load environment variables for B2 credentials
|
||||
keyID := os.Getenv("B2_KEY_ID")
|
||||
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||
if keyID == "" || applicationKey == "" {
|
||||
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||
}
|
||||
|
||||
// Get stored password for this service
|
||||
password, err := r.getStoredPassword(serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stored password: %w", err)
|
||||
}
|
||||
|
||||
// Generate bucket name from service name
|
||||
bucketName := fmt.Sprintf("backea-%s", strings.ToLower(serviceName))
|
||||
|
||||
// Check if bucket exists
|
||||
_, err = r.B2Client.GetBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket %s not found: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Check if kopia repository is already connected
|
||||
cmd := exec.Command("kopia", "repository", "status")
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
// Already connected
|
||||
log.Printf("Already connected to a repository, disconnecting first")
|
||||
disconnectCmd := exec.Command("kopia", "repository", "disconnect")
|
||||
if err := disconnectCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to disconnect from current repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the repository
|
||||
log.Printf("Connecting to B2 repository for %s", serviceName)
|
||||
connectCmd := exec.Command(
|
||||
"kopia", "repository", "connect", "b2",
|
||||
"--bucket", bucketName,
|
||||
"--key-id", keyID,
|
||||
"--key", applicationKey,
|
||||
"--password", password,
|
||||
)
|
||||
connectOutput, err := connectCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to repository: %w\nOutput: %s", err, connectOutput)
|
||||
}
|
||||
|
||||
log.Printf("Successfully connected to repository for %s", serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSnapshots lists all snapshots in the repository for a given service
|
||||
func (r *RestoreManager) ListSnapshots(ctx context.Context, serviceName string) error {
|
||||
// Connect to the repository
|
||||
if err := r.connectToRepository(ctx, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// List all snapshots
|
||||
log.Printf("Listing snapshots for %s", serviceName)
|
||||
listCmd := exec.Command("kopia", "snapshot", "list")
|
||||
listOutput, err := listCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list snapshots: %w\nOutput: %s", err, listOutput)
|
||||
}
|
||||
|
||||
// Print the output
|
||||
fmt.Println("Available snapshots:")
|
||||
fmt.Println(string(listOutput))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreFile restores a file or directory from a specific snapshot to a target location
|
||||
func (r *RestoreManager) RestoreFile(ctx context.Context, serviceName, snapshotID, sourcePath, targetPath string) error {
|
||||
// Connect to the repository
|
||||
if err := r.connectToRepository(ctx, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a subdirectory with the snapshot ID name
|
||||
snapshotDirPath := filepath.Join(targetPath, snapshotID)
|
||||
if err := os.MkdirAll(snapshotDirPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
// Construct the kopia restore command
|
||||
log.Printf("Restoring from snapshot %s to %s", snapshotID, snapshotDirPath)
|
||||
|
||||
// Build the command with all required parameters
|
||||
restoreCmd := exec.Command(
|
||||
"kopia", "snapshot", "restore",
|
||||
snapshotID, // Just the snapshot ID
|
||||
snapshotDirPath, // Target location where files will be restored
|
||||
)
|
||||
|
||||
// Execute the command and capture output
|
||||
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore files: %w\nOutput: %s", err, restoreOutput)
|
||||
}
|
||||
|
||||
log.Printf("Successfully restored files to %s", snapshotDirPath)
|
||||
log.Printf("Restore output: %s", string(restoreOutput))
|
||||
|
||||
return nil
|
||||
}
|
71
internal/backup/service.go
Normal file
71
internal/backup/service.go
Normal file
@ -0,0 +1,71 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"backea/internal/mail"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service represents a service to be backed up
|
||||
type Service struct {
|
||||
Name string
|
||||
Directory string
|
||||
Strategy Strategy
|
||||
Mailer *mail.Mailer
|
||||
}
|
||||
|
||||
// NewService creates a new backup service
|
||||
func NewService(name string, directory string, strategy Strategy, mailer *mail.Mailer) *Service {
|
||||
return &Service{
|
||||
Name: name,
|
||||
Directory: directory,
|
||||
Strategy: strategy,
|
||||
Mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
// Backup performs the backup for this service
|
||||
func (s *Service) Backup(ctx context.Context) error {
|
||||
log.Printf("Backing up service: %s", s.Name)
|
||||
startTime := time.Now()
|
||||
|
||||
// Ensure directory exists
|
||||
if _, err := os.Stat(s.Directory); os.IsNotExist(err) {
|
||||
return fmt.Errorf("directory does not exist: %s", s.Directory)
|
||||
}
|
||||
|
||||
// Execute the backup strategy
|
||||
if err := s.Strategy.Execute(ctx, s.Name, s.Directory); err != nil {
|
||||
if s.Mailer != nil {
|
||||
s.Mailer.SendErrorNotification(s.Name, err)
|
||||
}
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
// Record backup completion
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("Backup completed for %s in %v", s.Name, duration)
|
||||
|
||||
// Send success notification
|
||||
if s.Mailer != nil {
|
||||
s.Mailer.SendSuccessNotification(s.Name, duration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunCommand executes a shell command in the specified directory
|
||||
// This is now a package function rather than a method on Service
|
||||
func RunCommand(dir, command string) error {
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
57
internal/backup/strategy.go
Normal file
57
internal/backup/strategy.go
Normal file
@ -0,0 +1,57 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Strategy defines the backup/restore strategy interface
|
||||
type Strategy interface {
|
||||
// Execute performs a backup
|
||||
Execute(ctx context.Context, serviceName, directory string) error
|
||||
|
||||
ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error)
|
||||
|
||||
GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error)
|
||||
|
||||
RestoreBackup(ctx context.Context, backupID string, serviceName string) error
|
||||
|
||||
DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error)
|
||||
|
||||
GetBackupInfo(context.Context, string, string) (*BackupInfo, error)
|
||||
}
|
||||
|
||||
// BackupInfo contains information about a single backup
|
||||
type BackupInfo struct {
|
||||
ID string // Unique identifier for the backup
|
||||
CreationTime time.Time // When the backup was created
|
||||
Size int64 // Size in bytes
|
||||
Source string // Original source path
|
||||
Type string // Type of backup (e.g., "kopia", "restic")
|
||||
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
|
||||
}
|
||||
|
||||
// StorageUsageInfo contains information about backup storage usage
|
||||
type StorageUsageInfo struct {
|
||||
TotalBytes int64 // Total bytes stored
|
||||
Provider string // Storage provider (e.g., "local", "b2", "s3")
|
||||
ProviderID string // Provider-specific ID (bucket name, path, etc.)
|
||||
}
|
||||
|
||||
// Retention represents retention policy for backups
|
||||
type Retention struct {
|
||||
KeepLatest int
|
||||
KeepHourly int
|
||||
KeepDaily int
|
||||
KeepWeekly int
|
||||
KeepMonthly int
|
||||
KeepYearly int
|
||||
}
|
||||
|
||||
// Destination represents rsync destination
|
||||
type Destination struct {
|
||||
Host string
|
||||
Path string
|
||||
SSHKey string
|
||||
}
|
91
internal/client/b2_client.go
Normal file
91
internal/client/b2_client.go
Normal file
@ -0,0 +1,91 @@
|
||||
package b2_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/kothar/go-backblaze.v0"
|
||||
)
|
||||
|
||||
// Client represents a B2 client
|
||||
type Client struct {
|
||||
b2 *backblaze.B2
|
||||
}
|
||||
|
||||
// NewClientFromEnv creates a new B2 client using credentials from .env
|
||||
func NewClientFromEnv() (*Client, error) {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
return nil, fmt.Errorf("error loading .env file: %w", err)
|
||||
}
|
||||
|
||||
// Get credentials from environment
|
||||
keyID := os.Getenv("B2_KEY_ID")
|
||||
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||
|
||||
if keyID == "" || applicationKey == "" {
|
||||
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||
}
|
||||
|
||||
// Create B2 client
|
||||
b2, err := backblaze.NewB2(backblaze.Credentials{
|
||||
AccountID: keyID,
|
||||
ApplicationKey: applicationKey,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating B2 client: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
b2: b2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListBuckets lists all buckets
|
||||
func (c *Client) ListBuckets(ctx context.Context) ([]*backblaze.Bucket, error) {
|
||||
// List all buckets
|
||||
buckets, err := c.b2.ListBuckets()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing buckets: %w", err)
|
||||
}
|
||||
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// GetBucket gets a bucket by name
|
||||
func (c *Client) GetBucket(ctx context.Context, name string) (*backblaze.Bucket, error) {
|
||||
// List all buckets
|
||||
buckets, err := c.b2.ListBuckets()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing buckets: %w", err)
|
||||
}
|
||||
|
||||
// Find bucket by name
|
||||
for _, bucket := range buckets {
|
||||
if bucket.Name == name {
|
||||
return bucket, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("bucket not found: %s", name)
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
func (c *Client) CreateBucket(ctx context.Context, name string, public bool) (*backblaze.Bucket, error) {
|
||||
// Set bucket type
|
||||
bucketType := backblaze.AllPrivate
|
||||
if public {
|
||||
bucketType = backblaze.AllPublic
|
||||
}
|
||||
|
||||
// Create bucket
|
||||
bucket, err := c.b2.CreateBucket(name, bucketType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating bucket: %w", err)
|
||||
}
|
||||
|
||||
return bucket, nil
|
||||
}
|
104
internal/mail/mail.go
Normal file
104
internal/mail/mail.go
Normal file
@ -0,0 +1,104 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds SMTP server configuration
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// Client handles email operations
|
||||
type Client struct {
|
||||
Config Config
|
||||
}
|
||||
|
||||
// Mailer is an alias for Client to maintain compatibility with backup package
|
||||
type Mailer struct {
|
||||
Config Config
|
||||
}
|
||||
|
||||
// NewClient creates a new mail client from environment variables
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
Config: Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: os.Getenv("SMTP_PORT"),
|
||||
Username: os.Getenv("SMTP_USERNAME"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewMailer creates a new mailer from environment variables
|
||||
func NewMailer() *Mailer {
|
||||
client := NewClient()
|
||||
return &Mailer{
|
||||
Config: client.Config,
|
||||
}
|
||||
}
|
||||
|
||||
// SendMail sends an email notification
|
||||
func (c *Client) SendMail(to []string, subject, body string) error {
|
||||
addr := fmt.Sprintf("%s:%s", c.Config.Host, c.Config.Port)
|
||||
// Compose message
|
||||
message := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n", c.Config.From, to[0], subject, body))
|
||||
// Authenticate
|
||||
auth := smtp.PlainAuth("", c.Config.Username, c.Config.Password, c.Config.Host)
|
||||
// Send mail
|
||||
return smtp.SendMail(addr, auth, c.Config.From, to, message)
|
||||
}
|
||||
|
||||
// SendMail sends an email notification (Mailer version)
|
||||
func (m *Mailer) SendMail(to []string, subject, body string) error {
|
||||
addr := fmt.Sprintf("%s:%s", m.Config.Host, m.Config.Port)
|
||||
// Compose message
|
||||
message := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n", m.Config.From, to[0], subject, body))
|
||||
// Authenticate
|
||||
auth := smtp.PlainAuth("", m.Config.Username, m.Config.Password, m.Config.Host)
|
||||
// Send mail
|
||||
return smtp.SendMail(addr, auth, m.Config.From, to, message)
|
||||
}
|
||||
|
||||
// SendSuccessNotification sends a backup success notification
|
||||
func (m *Mailer) SendSuccessNotification(serviceName string, duration time.Duration) error {
|
||||
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
|
||||
if recipients[0] == "" {
|
||||
recipients[0] = m.Config.From // Fallback to sender if no notification email set
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Backup Successful: %s", serviceName)
|
||||
body := fmt.Sprintf("The backup for service %s completed successfully in %v.", serviceName, duration)
|
||||
|
||||
return m.SendMail(recipients, subject, body)
|
||||
}
|
||||
|
||||
// SendErrorNotification sends a backup error notification
|
||||
func (m *Mailer) SendErrorNotification(serviceName string, err error) error {
|
||||
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
|
||||
if recipients[0] == "" {
|
||||
recipients[0] = m.Config.From // Fallback to sender if no notification email set
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Backup Failed: %s", serviceName)
|
||||
body := fmt.Sprintf("The backup for service %s failed with error: %v", serviceName, err)
|
||||
|
||||
return m.SendMail(recipients, subject, body)
|
||||
}
|
39
internal/mail/mail_test.go
Normal file
39
internal/mail/mail_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func TestSendMailWithRealEnv(t *testing.T) {
|
||||
// Load the real .env file
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
t.Skip("Skipping test: .env file not found")
|
||||
}
|
||||
|
||||
// Check if SMTP configuration exists
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
username := os.Getenv("SMTP_USERNAME")
|
||||
password := os.Getenv("SMTP_PASSWORD")
|
||||
|
||||
if host == "" || username == "" || password == "" {
|
||||
t.Skip("Skipping test: SMTP configuration not found in .env")
|
||||
}
|
||||
|
||||
// Create a real client using environment variables
|
||||
client := NewClient()
|
||||
|
||||
// Test with a real email address (consider using a test address)
|
||||
recipients := []string{"romaric.sirii@gmail.com"}
|
||||
subject := "Test Email from Go Test"
|
||||
body := "This is a test email sent from the mail package test"
|
||||
|
||||
// Send the email
|
||||
err = client.SendMail(recipients, subject, body)
|
||||
if err != nil {
|
||||
t.Errorf("SendMail failed with error: %v", err)
|
||||
}
|
||||
}
|
26
internal/server/server.go
Normal file
26
internal/server/server.go
Normal file
@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"backea/internal/web/routes"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// New creates a new configured Echo server
|
||||
func New(factory *backup.BackupFactory) *echo.Echo {
|
||||
e := echo.New()
|
||||
|
||||
// Add middleware
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// Setup static file serving
|
||||
e.Static("/static", "static")
|
||||
|
||||
// Register routes
|
||||
routes.RegisterRoutes(e, factory)
|
||||
|
||||
return e
|
||||
}
|
377
internal/web/handlers/backup_actions_handler.go
Normal file
377
internal/web/handlers/backup_actions_handler.go
Normal file
@ -0,0 +1,377 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// BackupActionsHandler handles backup-related actions like restore and download
|
||||
type BackupActionsHandler struct {
|
||||
backupFactory *backup.BackupFactory
|
||||
}
|
||||
|
||||
// NewBackupActionsHandler creates a new backup actions handler
|
||||
func NewBackupActionsHandler(factory *backup.BackupFactory) *BackupActionsHandler {
|
||||
return &BackupActionsHandler{
|
||||
backupFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreRequest represents the request data for a backup restore
|
||||
type RestoreRequest struct {
|
||||
BackupID string `json:"backupID" form:"backupID"`
|
||||
GroupName string `json:"groupName" form:"groupName"`
|
||||
ServiceIndex string `json:"serviceIndex" form:"serviceIndex"`
|
||||
DestinationPath string `json:"destinationPath" form:"destinationPath"`
|
||||
}
|
||||
|
||||
// RestoreBackup handles the request to restore a backup with destination path support
|
||||
func (h *BackupActionsHandler) RestoreBackup(c echo.Context) error {
|
||||
backupID := c.Param("backupID")
|
||||
if backupID == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "backupID is required")
|
||||
}
|
||||
|
||||
// Try to parse the group and service from query parameters
|
||||
groupName := c.QueryParam("groupName")
|
||||
serviceIndex := c.QueryParam("serviceIndex")
|
||||
destinationPath := c.QueryParam("destinationPath")
|
||||
|
||||
// If not in query parameters, check form data
|
||||
if groupName == "" || serviceIndex == "" {
|
||||
var req RestoreRequest
|
||||
if err := c.Bind(&req); err == nil {
|
||||
groupName = req.GroupName
|
||||
serviceIndex = req.ServiceIndex
|
||||
destinationPath = req.DestinationPath
|
||||
}
|
||||
}
|
||||
|
||||
if groupName == "" || serviceIndex == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
"groupName and serviceIndex are required either as query parameters or in the request body")
|
||||
}
|
||||
|
||||
// Construct full service name
|
||||
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||
|
||||
log.Printf("Restoring backup ID=%s to service %s", backupID, serviceName)
|
||||
if destinationPath != "" {
|
||||
log.Printf("Restoring to custom destination: %s", destinationPath)
|
||||
}
|
||||
|
||||
// Get the appropriate strategy for this service
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||
}
|
||||
|
||||
// If destination path is provided, use our custom restore function
|
||||
if destinationPath != "" {
|
||||
err = h.restoreBackupToCustomDestination(c.Request().Context(), strategy, backupID, serviceName, destinationPath)
|
||||
} else {
|
||||
// Otherwise use the regular restore
|
||||
err = strategy.RestoreBackup(c.Request().Context(), backupID, serviceName)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to restore backup: %v", err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"status": "success",
|
||||
"message": fmt.Sprintf("Backup %s restored successfully", backupID),
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreBackupForm shows the restore form with directory input
|
||||
func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error {
|
||||
backupID := c.QueryParam("backupID")
|
||||
groupName := c.QueryParam("groupName")
|
||||
serviceIndex := c.QueryParam("serviceIndex")
|
||||
|
||||
if backupID == "" || groupName == "" || serviceIndex == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
|
||||
}
|
||||
|
||||
// Build service name for lookup
|
||||
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||
|
||||
// Get backup info if available
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||
}
|
||||
|
||||
// Try to get backup info
|
||||
backupInfo, err := strategy.GetBackupInfo(c.Request().Context(), backupID, serviceName)
|
||||
var backupTimeStr string
|
||||
var backupSizeStr string
|
||||
var defaultPath string
|
||||
|
||||
if err == nil && backupInfo != nil {
|
||||
backupTimeStr = backupInfo.CreationTime.Format("Jan 02, 2006 15:04:05")
|
||||
backupSizeStr = formatSize(backupInfo.Size)
|
||||
defaultPath = backupInfo.Source // Use original source as default path
|
||||
} else {
|
||||
backupTimeStr = "Unknown"
|
||||
backupSizeStr = "Unknown"
|
||||
defaultPath = ""
|
||||
}
|
||||
|
||||
// TODO : create a real template
|
||||
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||
<div class="restore-modal gruvbox-bg1 round padding">
|
||||
<h3 class="gruvbox-yellow">Restore Backup</h3>
|
||||
<div class="backup-details">
|
||||
<p><span class="gruvbox-fg-dim">Backup Date:</span> <span class="gruvbox-yellow">%s</span></p>
|
||||
<p><span class="gruvbox-fg-dim">Size:</span> <span class="gruvbox-yellow">%s</span></p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="restore-form"
|
||||
hx-post="/api/backups/%s/restore"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#restore-indicator"
|
||||
>
|
||||
<input type="hidden" name="backupID" value="%s">
|
||||
<input type="hidden" name="groupName" value="%s">
|
||||
<input type="hidden" name="serviceIndex" value="%s">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="destinationPath" class="gruvbox-fg-dim">Destination Path:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="destinationPath"
|
||||
name="destinationPath"
|
||||
class="gruvbox-input"
|
||||
value="%s"
|
||||
placeholder="Leave empty to restore to original location"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-buttons">
|
||||
<button type="submit" class="gruvbox-button gruvbox-bg2">
|
||||
Restore
|
||||
<span id="restore-indicator" class="htmx-indicator">
|
||||
<span class="loading-spinner inline-spinner"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="gruvbox-button gruvbox-bg3"
|
||||
onclick="document.querySelector('.restore-modal').remove()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`, backupTimeStr, backupSizeStr, backupID, backupID, groupName, serviceIndex, defaultPath))
|
||||
}
|
||||
|
||||
// restoreBackupToCustomDestination handles restoring a backup to a custom directory
|
||||
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy backup.Strategy, backupID, serviceName, destinationPath string) error {
|
||||
// Validate destination path
|
||||
destinationPath = filepath.Clean(destinationPath)
|
||||
|
||||
// Check if destination exists
|
||||
stat, err := os.Stat(destinationPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(destinationPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("failed to check destination directory: %w", err)
|
||||
}
|
||||
} else if !stat.IsDir() {
|
||||
return fmt.Errorf("destination path is not a directory")
|
||||
}
|
||||
|
||||
// Create a temporary directory to store the downloaded backup
|
||||
tempDir, err := os.MkdirTemp("", "backea-restore-download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary download directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a temporary directory for extraction
|
||||
extractDir, err := os.MkdirTemp("", "backea-restore-extract-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary extraction directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
// Download the backup to a temporary file
|
||||
log.Printf("Downloading backup %s to temporary location for service %s", backupID, serviceName)
|
||||
reader, err := strategy.DownloadBackup(ctx, backupID, serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download backup: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Create a temporary file to store the zip
|
||||
zipFile, err := os.CreateTemp(tempDir, "backup-*.zip")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
zipPath := zipFile.Name()
|
||||
defer os.Remove(zipPath)
|
||||
|
||||
// Copy the zip content to the temp file
|
||||
log.Printf("Writing backup data to temporary file: %s", zipPath)
|
||||
_, err = io.Copy(zipFile, reader)
|
||||
if err != nil {
|
||||
zipFile.Close()
|
||||
return fmt.Errorf("failed to write backup to disk: %w", err)
|
||||
}
|
||||
|
||||
// Flush to disk and close the file
|
||||
zipFile.Sync()
|
||||
zipFile.Close()
|
||||
|
||||
// Extract the zip to the extraction directory
|
||||
log.Printf("Extracting backup to %s", extractDir)
|
||||
if err := extractZip(zipPath, extractDir); err != nil {
|
||||
return fmt.Errorf("failed to extract backup: %w", err)
|
||||
}
|
||||
|
||||
// Sync extracted files to the destination using rsync
|
||||
log.Printf("Syncing files from %s to %s", extractDir, destinationPath)
|
||||
if err := syncDirectories(extractDir, destinationPath); err != nil {
|
||||
return fmt.Errorf("failed to sync files to destination: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Backup successfully restored to %s", destinationPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZip extracts a zip file to a destination directory
|
||||
func extractZip(zipFile, destDir string) error {
|
||||
// Use unzip command for extraction
|
||||
cmd := exec.Command("unzip", "-o", "-q", zipFile, "-d", destDir)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract zip: %w, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDirectories syncs files from source to destination using rsync
|
||||
func syncDirectories(src, dest string) error {
|
||||
// Ensure source path ends with a slash to copy contents, not the directory itself
|
||||
if !strings.HasSuffix(src, "/") {
|
||||
src = src + "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command("rsync", "-av", "--delete", src, dest)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync directories: %w, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
func formatSize(size int64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := size / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// DownloadBackup handles the backup download request
|
||||
func (h *BackupActionsHandler) DownloadBackup(c echo.Context) error {
|
||||
// Get query parameters
|
||||
backupID := c.QueryParam("backupID")
|
||||
groupName := c.QueryParam("groupName")
|
||||
serviceIndex := c.QueryParam("serviceIndex")
|
||||
|
||||
// Validate required parameters
|
||||
if backupID == "" || groupName == "" || serviceIndex == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
|
||||
}
|
||||
|
||||
// Create service name for logging
|
||||
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||
log.Printf("Downloading backup: ID=%s, Service=%s", backupID, serviceName)
|
||||
|
||||
// Get the appropriate strategy for this service
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||
}
|
||||
|
||||
// Get all backups for this service
|
||||
backups, err := strategy.ListBackups(c.Request().Context(), serviceName)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to list backups: %v. Will try with provided ID directly.", err)
|
||||
} else {
|
||||
// Look for a matching backup with the proper snapshot ID
|
||||
found := false
|
||||
for _, backup := range backups {
|
||||
// If we find an exact match, or if the backup ID contains our ID (partial match)
|
||||
if backup.ID == backupID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
log.Printf("Warning: Backup ID %s not found in service backups. Will try direct download anyway.", backupID)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to download the backup using the provided ID
|
||||
backupReader, err := strategy.DownloadBackup(c.Request().Context(), backupID, serviceName)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to download backup: %v", err))
|
||||
}
|
||||
defer backupReader.Close()
|
||||
|
||||
// Set filename for download
|
||||
filename := fmt.Sprintf("%s_%s_%s.zip",
|
||||
groupName,
|
||||
serviceIndex,
|
||||
time.Now().Format("20060102_150405"),
|
||||
)
|
||||
|
||||
// Set response headers for zip download
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Response().Header().Set("Content-Type", "application/zip")
|
||||
|
||||
// Stream the file to the client
|
||||
log.Printf("Streaming backup as %s", filename)
|
||||
_, err = io.Copy(c.Response().Writer, backupReader)
|
||||
if err != nil {
|
||||
log.Printf("Error streaming backup: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Backup download for %s completed successfully", backupID)
|
||||
return nil
|
||||
}
|
317
internal/web/handlers/homepage_handler.go
Normal file
317
internal/web/handlers/homepage_handler.go
Normal file
@ -0,0 +1,317 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"backea/templates"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type HomepageHandler struct {
|
||||
backupFactory *backup.BackupFactory
|
||||
}
|
||||
|
||||
func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler {
|
||||
return &HomepageHandler{
|
||||
backupFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
// Home handles the homepage request and displays latest backups by service
|
||||
func (h *HomepageHandler) Home(c echo.Context) error {
|
||||
// Create the data structures
|
||||
serviceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||
serviceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||
groupDirectories := make(map[string]string) // Store directories by group name
|
||||
|
||||
// Process each service group
|
||||
for groupName, serviceGroup := range h.backupFactory.Config.Services {
|
||||
// Initialize maps for this group
|
||||
serviceBackups[groupName] = make(map[string][]backup.BackupInfo)
|
||||
serviceConfigs[groupName] = make(map[string]templates.ServiceProviderInfo)
|
||||
|
||||
// Store the directory at the group level in a separate map
|
||||
groupDirectories[groupName] = serviceGroup.Source.Path
|
||||
|
||||
// Process each backup config in the group
|
||||
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||
// Store service configuration
|
||||
serviceConfigs[groupName][configIndex] = templates.ServiceProviderInfo{
|
||||
Type: backupConfig.BackupStrategy.Type,
|
||||
Provider: backupConfig.BackupStrategy.Provider,
|
||||
Directory: serviceGroup.Source.Path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get sorted group names for alphabetical ordering
|
||||
var sortedGroupNames []string
|
||||
for groupName := range serviceBackups {
|
||||
sortedGroupNames = append(sortedGroupNames, groupName)
|
||||
}
|
||||
sort.Strings(sortedGroupNames)
|
||||
|
||||
// Render the template with sorted group names and directories
|
||||
// We don't load actual backup data on initial page load, it will be loaded via HTMX
|
||||
component := templates.Home(serviceBackups, serviceConfigs, sortedGroupNames, groupDirectories)
|
||||
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||
}
|
||||
|
||||
// ServiceGroupHeader returns the header section for a specific service group (HTMX endpoint)
|
||||
func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error {
|
||||
groupName := c.Param("groupName")
|
||||
|
||||
// Check if the service group exists
|
||||
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||
if !exists {
|
||||
return echo.NewHTTPError(404, "Service group not found")
|
||||
}
|
||||
|
||||
// Create data structures
|
||||
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||
|
||||
// Setup synchronization
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Process each backup config in the group
|
||||
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||
// Store service configuration
|
||||
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||
Type: backupConfig.BackupStrategy.Type,
|
||||
Provider: backupConfig.BackupStrategy.Provider,
|
||||
Directory: serviceGroup.Source.Path,
|
||||
}
|
||||
|
||||
// Fetch backups in parallel
|
||||
wg.Add(1)
|
||||
go func(index string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Get backup strategy
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||
if err != nil {
|
||||
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get backups
|
||||
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||
if err != nil {
|
||||
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort backups by time (newest first)
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||
})
|
||||
|
||||
// Store result
|
||||
mu.Lock()
|
||||
serviceBackups[index] = backups
|
||||
mu.Unlock()
|
||||
}(configIndex)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
wg.Wait()
|
||||
|
||||
// Render just the header component
|
||||
component := templates.GroupHeaderComponent(
|
||||
groupName,
|
||||
serviceBackups,
|
||||
serviceConfigs,
|
||||
serviceGroup.Source.Path,
|
||||
)
|
||||
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||
}
|
||||
|
||||
// ServiceGroupBackups returns just the backups table for a specific service group (HTMX endpoint)
|
||||
func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
|
||||
groupName := c.Param("groupName")
|
||||
|
||||
// Check if the service group exists
|
||||
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||
if !exists {
|
||||
return echo.NewHTTPError(404, "Service group not found")
|
||||
}
|
||||
|
||||
// Create data structures
|
||||
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||
|
||||
// Setup synchronization
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Process each backup config in the group
|
||||
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||
// Store service configuration
|
||||
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||
Type: backupConfig.BackupStrategy.Type,
|
||||
Provider: backupConfig.BackupStrategy.Provider,
|
||||
Directory: serviceGroup.Source.Path,
|
||||
}
|
||||
|
||||
// Fetch backups in parallel
|
||||
wg.Add(1)
|
||||
go func(index string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Get backup strategy
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||
if err != nil {
|
||||
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get backups
|
||||
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||
if err != nil {
|
||||
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort backups by time (newest first)
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||
})
|
||||
|
||||
// Store result
|
||||
mu.Lock()
|
||||
serviceBackups[index] = backups
|
||||
mu.Unlock()
|
||||
}(configIndex)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
wg.Wait()
|
||||
|
||||
// Create a map with just the group for the template
|
||||
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||
groupServiceBackups[groupName] = serviceBackups
|
||||
|
||||
// Create a map with just the group configs for the template
|
||||
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||
groupServiceConfigs[groupName] = serviceConfigs
|
||||
|
||||
// Render only the backups table component
|
||||
component := templates.ServiceGroupBackupsTable(groupName, serviceBackups, groupServiceConfigs)
|
||||
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||
}
|
||||
|
||||
// ServiceGroupAllBackups returns all backups for a specific service group (HTMX endpoint)
|
||||
func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
|
||||
groupName := c.Param("groupName")
|
||||
|
||||
// Check if the service group exists
|
||||
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||
if !exists {
|
||||
return echo.NewHTTPError(404, "Service group not found")
|
||||
}
|
||||
|
||||
// Create data structures
|
||||
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||
|
||||
// Setup synchronization
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Process each backup config in the group
|
||||
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||
// Store service configuration
|
||||
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||
Type: backupConfig.BackupStrategy.Type,
|
||||
Provider: backupConfig.BackupStrategy.Provider,
|
||||
Directory: serviceGroup.Source.Path,
|
||||
}
|
||||
|
||||
// Fetch backups in parallel
|
||||
wg.Add(1)
|
||||
go func(index string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Get backup strategy
|
||||
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||
if err != nil {
|
||||
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get backups
|
||||
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||
if err != nil {
|
||||
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort backups by time (newest first)
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||
})
|
||||
|
||||
// Store result
|
||||
mu.Lock()
|
||||
serviceBackups[index] = backups
|
||||
mu.Unlock()
|
||||
}(configIndex)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
wg.Wait()
|
||||
|
||||
// Create a map with just the group for the template
|
||||
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||
groupServiceBackups[groupName] = serviceBackups
|
||||
|
||||
// Create a map with just the group configs for the template
|
||||
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||
groupServiceConfigs[groupName] = serviceConfigs
|
||||
|
||||
// Also trigger a header update to refresh stats
|
||||
go func() {
|
||||
// Create a new context since the original one might be cancelled
|
||||
ctx := context.Background()
|
||||
|
||||
// Sleep briefly to ensure the table loads first
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Make an HTTP request to refresh the header
|
||||
url := fmt.Sprintf("http://localhost:%s/api/service-group/%s/header",
|
||||
os.Getenv("PORT"),
|
||||
groupName,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("Error creating request to refresh header: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set HTMX headers to target the correct element
|
||||
req.Header.Set("HX-Request", "true")
|
||||
req.Header.Set("HX-Target", fmt.Sprintf("group-header-%s", groupName))
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{}
|
||||
_, err = client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Error refreshing header: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Render the all backups table component
|
||||
component := templates.ServiceGroupAllBackupsTable(groupName, serviceBackups, groupServiceConfigs)
|
||||
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||
}
|
34
internal/web/routes/routes.go
Normal file
34
internal/web/routes/routes.go
Normal file
@ -0,0 +1,34 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"backea/internal/web/handlers"
|
||||
"fmt"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// RegisterRoutes sets up all the routes for the web application
|
||||
func RegisterRoutes(e *echo.Echo, factory *backup.BackupFactory) {
|
||||
// Create handlers with backup factory
|
||||
homeHandler := handlers.NewHomepageHandler(factory)
|
||||
actionsHandler := handlers.NewBackupActionsHandler(factory)
|
||||
|
||||
// Register main routes
|
||||
e.GET("/", homeHandler.Home)
|
||||
|
||||
// Register HTMX API endpoints for lazy loading
|
||||
e.GET("/api/service-group/:groupName/header", homeHandler.ServiceGroupHeader)
|
||||
e.GET("/api/service-group/:groupName/backups", homeHandler.ServiceGroupBackups)
|
||||
e.GET("/api/service-group/:groupName/all-backups", homeHandler.ServiceGroupAllBackups)
|
||||
|
||||
// Register backup action routes - using named parameter for backupID
|
||||
e.POST("/api/backups/:backupID/restore", actionsHandler.RestoreBackup)
|
||||
e.GET("/api/backups/restore-form", actionsHandler.RestoreBackupForm)
|
||||
e.GET("/api/backups/download", actionsHandler.DownloadBackup)
|
||||
|
||||
// Add this after routes registration but before server start to see all routes
|
||||
for _, route := range e.Routes() {
|
||||
fmt.Printf("Method: %s, Path: %s, Handler: %s\n", route.Method, route.Path, route.Name)
|
||||
}
|
||||
}
|
505
templates/home.templ
Normal file
505
templates/home.templ
Normal file
@ -0,0 +1,505 @@
|
||||
package templates
|
||||
import (
|
||||
"backea/internal/backup"
|
||||
"backea/templates/layouts"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatSize formats byte size to human-readable format
|
||||
func FormatSize(size int64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := size / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// FormatTime formats time to a readable format
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.Format("Jan 02, 2006 15:04:05")
|
||||
}
|
||||
|
||||
// FormatTimeSince formats the duration since a time in a human-readable way
|
||||
func FormatTimeSince(t time.Time) string {
|
||||
timeSince := time.Since(t)
|
||||
hours := int(timeSince.Hours())
|
||||
|
||||
if hours < 1 {
|
||||
return fmt.Sprintf("%d minutes ago", int(timeSince.Minutes()))
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
|
||||
// GetStatusClass returns the appropriate status class based on backup age
|
||||
func GetStatusClass(t time.Time) string {
|
||||
timeSince := time.Since(t)
|
||||
hours := int(timeSince.Hours())
|
||||
|
||||
if hours > 72 {
|
||||
return "Failed"
|
||||
} else if hours > 24 {
|
||||
return "Warning"
|
||||
}
|
||||
return "Healthy"
|
||||
}
|
||||
|
||||
// FormatServiceName returns a display-friendly service name
|
||||
func FormatServiceName(groupName, serviceIndex string) string {
|
||||
if serviceIndex == "" {
|
||||
return groupName
|
||||
}
|
||||
return fmt.Sprintf("%s - %s", groupName, serviceIndex)
|
||||
}
|
||||
|
||||
// CalculateTotalSize calculates total size of all backups
|
||||
func CalculateTotalSize(backups []backup.BackupInfo) int64 {
|
||||
var total int64
|
||||
for _, b := range backups {
|
||||
total += b.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// CalculateGroupTotalSize calculates total size of all backups for a service group
|
||||
func CalculateGroupTotalSize(serviceGroup map[string][]backup.BackupInfo) int64 {
|
||||
var total int64
|
||||
for _, backups := range serviceGroup {
|
||||
for _, b := range backups {
|
||||
total += b.Size
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// GetGroupTotalBackupCount returns the total number of backups across all services in a group
|
||||
func GetGroupTotalBackupCount(serviceGroup map[string][]backup.BackupInfo) int {
|
||||
count := 0
|
||||
for _, backups := range serviceGroup {
|
||||
count += len(backups)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetLatestBackupTime returns the most recent backup time for a service group
|
||||
func GetLatestBackupTime(serviceGroup map[string][]backup.BackupInfo) (time.Time, bool) {
|
||||
var latestTime time.Time
|
||||
found := false
|
||||
|
||||
for _, backups := range serviceGroup {
|
||||
if len(backups) > 0 && (latestTime.IsZero() || backups[0].CreationTime.After(latestTime)) {
|
||||
latestTime = backups[0].CreationTime
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
return latestTime, found
|
||||
}
|
||||
|
||||
// GetGroupStatus returns the status of a service group based on the most recent backup
|
||||
func GetGroupStatus(serviceGroup map[string][]backup.BackupInfo) string {
|
||||
latestTime, found := GetLatestBackupTime(serviceGroup)
|
||||
if !found {
|
||||
return "No Backups"
|
||||
}
|
||||
return GetStatusClass(latestTime)
|
||||
}
|
||||
|
||||
// ServiceProviderInfo holds the backup strategy info for a service
|
||||
type ServiceProviderInfo struct {
|
||||
Type string
|
||||
Provider string
|
||||
Directory string
|
||||
}
|
||||
|
||||
// BackupWithService represents a backup with its service identifier
|
||||
type BackupWithService struct {
|
||||
ServiceIndex string
|
||||
Backup backup.BackupInfo
|
||||
}
|
||||
|
||||
// GetSortedBackups collects all backups from a service group and sorts them by time
|
||||
func GetSortedBackups(serviceGroup map[string][]backup.BackupInfo) []BackupWithService {
|
||||
var allBackups []BackupWithService
|
||||
|
||||
// Collect all backups with their service indices
|
||||
for serviceIndex, backups := range serviceGroup {
|
||||
for _, b := range backups {
|
||||
allBackups = append(allBackups, BackupWithService{
|
||||
ServiceIndex: serviceIndex,
|
||||
Backup: b,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
sort.Slice(allBackups, func(i, j int) bool {
|
||||
return allBackups[i].Backup.CreationTime.After(allBackups[j].Backup.CreationTime)
|
||||
})
|
||||
|
||||
return allBackups
|
||||
}
|
||||
|
||||
// Home renders the homepage with lazy-loaded backup information
|
||||
templ Home(serviceBackups map[string]map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) {
|
||||
@layouts.Base("Backea - Backup Dashboard") {
|
||||
<div class="responsive gruvbox-dark">
|
||||
<div class="gruvbox-bg-hard round padding margin-bottom">
|
||||
<h1 class="extra-large medium gruvbox-aqua">Welcome to Backea</h1>
|
||||
<p class="large gruvbox-blue">Unified guardians.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="large medium margin-bottom gruvbox-orange">Latest Backups by Service</h2>
|
||||
|
||||
<!-- Check if we have any backups -->
|
||||
if len(serviceBackups) == 0 {
|
||||
<div id="no-backups" class="gruvbox-bg1 gruvbox-yellow-border round border padding">
|
||||
<p class="gruvbox-yellow">No backup services configured or no backups found.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div id="has-backups">
|
||||
<!-- Loop through groups in alphabetical order -->
|
||||
for _, groupName := range sortedGroupNames {
|
||||
<div class="margin-bottom-large service-group-container" id={ fmt.Sprintf("group-%s", groupName) }>
|
||||
<!-- Header section with loading skeleton - will be replaced with actual header -->
|
||||
<div id={ fmt.Sprintf("group-header-%s", groupName) }
|
||||
hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
||||
hx-trigger="load once"
|
||||
hx-swap="outerHTML">
|
||||
<div class="gruvbox-bg1 round padding margin-bottom">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 class="medium">
|
||||
<!-- Display directory from groupDirectories map -->
|
||||
if directory, exists := groupDirectories[groupName]; exists && directory != "" {
|
||||
<span class="gruvbox-orange">{ directory }</span>
|
||||
} else {
|
||||
<span class="gruvbox-green">Unknown Directory</span>
|
||||
}
|
||||
<span class="gruvbox-green">{ groupName }</span>
|
||||
</h3>
|
||||
<div>
|
||||
<!-- Display backup type and provider based on first service in group -->
|
||||
if len(serviceBackups[groupName]) > 0 {
|
||||
for serviceIndex := range serviceBackups[groupName] {
|
||||
if providerInfo, exists := serviceConfigs[groupName][serviceIndex]; exists {
|
||||
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
||||
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
||||
} else if providerInfo.Provider == "ftp" {
|
||||
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
||||
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
||||
} else if providerInfo.Provider == "s3" {
|
||||
<span class="backup-tool-tag gruvbox-red">S3</span>
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Summary stats with skeleton loading -->
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Total Size</p>
|
||||
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Backups</p>
|
||||
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Last Backup</p>
|
||||
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Status</p>
|
||||
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always render the lazy-loading skeleton for backup table -->
|
||||
<div class="table-skeleton"
|
||||
id={ fmt.Sprintf("skeleton-%s", groupName) }
|
||||
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
||||
hx-trigger="load once"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator={ fmt.Sprintf("#loading-indicator-%s", groupName) }>
|
||||
<div class="skeleton-table gruvbox-bg1 round padding">
|
||||
<div class="skeleton-header">
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
</div>
|
||||
<div class="skeleton-row">
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
</div>
|
||||
<div class="skeleton-row">
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
<div class="skeleton-cell"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ fmt.Sprintf("loading-indicator-%s", groupName) } class="htmx-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// GroupHeaderComponent renders just the group header with up-to-date stats
|
||||
templ GroupHeaderComponent(groupName string, serviceBackups map[string][]backup.BackupInfo, serviceConfigs map[string]ServiceProviderInfo, directory string) {
|
||||
<div id={ fmt.Sprintf("group-header-%s", groupName) } class="gruvbox-bg1 round padding margin-bottom">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 class="medium">
|
||||
if directory != "" {
|
||||
<span class="gruvbox-orange">{ directory }</span>
|
||||
} else {
|
||||
<span class="gruvbox-green">Unknown Directory</span>
|
||||
}
|
||||
<span class="gruvbox-green">{ groupName }</span>
|
||||
</h3>
|
||||
<div>
|
||||
<!-- Display backup type and provider based on first service in group -->
|
||||
if len(serviceBackups) > 0 {
|
||||
for serviceIndex := range serviceBackups {
|
||||
if providerInfo, exists := serviceConfigs[serviceIndex]; exists {
|
||||
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
||||
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
||||
} else if providerInfo.Provider == "ftp" {
|
||||
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
||||
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
||||
} else if providerInfo.Provider == "s3" {
|
||||
<span class="backup-tool-tag gruvbox-red">S3</span>
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Summary stats with actual data -->
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Total Size</p>
|
||||
<p class="gruvbox-yellow">{ FormatSize(CalculateGroupTotalSize(serviceBackups)) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Backups</p>
|
||||
<p class="gruvbox-yellow">{ fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceBackups)) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Last Backup</p>
|
||||
if latestTime, found := GetLatestBackupTime(serviceBackups); found {
|
||||
<p class="gruvbox-yellow">{ FormatTimeSince(latestTime) }</p>
|
||||
} else {
|
||||
<p class="gruvbox-yellow">Never</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p class="gruvbox-fg-dim">Status</p>
|
||||
if status := GetGroupStatus(serviceBackups); status == "No Backups" {
|
||||
<p class="gruvbox-red">No Backups</p>
|
||||
} else if status == "Failed" {
|
||||
<p class="gruvbox-red">Failed</p>
|
||||
} else if status == "Warning" {
|
||||
<p class="gruvbox-yellow">Warning</p>
|
||||
} else {
|
||||
<p class="gruvbox-green">Healthy</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Updated table templates with action column
|
||||
templ ServiceGroupBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||
<div class="group-backups-table" id={ fmt.Sprintf("backups-%s", groupName) }>
|
||||
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
||||
<div class="overflow">
|
||||
<table class="gruvbox-table">
|
||||
<thead>
|
||||
<tr class="gruvbox-bg1">
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Get all backups from all services in this group, sorted by creation time -->
|
||||
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 5, serviceConfigs)
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- "View all" link - now using HTMX to replace the table content -->
|
||||
if GetGroupTotalBackupCount(serviceGroup) > 5 {
|
||||
<div class="margin-top right-align">
|
||||
<button class="gruvbox-button gruvbox-bg2"
|
||||
hx-get={ fmt.Sprintf("/api/service-group/%s/all-backups", groupName) }
|
||||
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator={ fmt.Sprintf("#view-all-indicator-%s", groupName) }>
|
||||
View all { fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceGroup)) } backups
|
||||
<span id={ fmt.Sprintf("view-all-indicator-%s", groupName) } class="htmx-indicator">
|
||||
<span class="loading-spinner inline-spinner"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="gruvbox-bg1 round padding">
|
||||
<p class="gruvbox-yellow">No backups found for this service.</p>
|
||||
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ServiceGroupAllBackupsTable template for showing all backups
|
||||
templ ServiceGroupAllBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||
<!-- We're returning just the INNER content now, not the whole container -->
|
||||
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
||||
<div class="overflow">
|
||||
<table class="gruvbox-table">
|
||||
<thead>
|
||||
<tr class="gruvbox-bg1">
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
||||
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Show ALL backups, without limit -->
|
||||
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 9999, serviceConfigs)
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- "Show fewer" button that reloads original table -->
|
||||
<div class="margin-top right-align">
|
||||
<button class="gruvbox-button gruvbox-bg2"
|
||||
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
||||
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator={ fmt.Sprintf("#show-fewer-indicator-%s", groupName) }>
|
||||
Show fewer backups
|
||||
<span id={ fmt.Sprintf("show-fewer-indicator-%s", groupName) } class="htmx-indicator">
|
||||
<span class="loading-spinner inline-spinner"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="gruvbox-bg1 round padding">
|
||||
<p class="gruvbox-yellow">No backups found for this service.</p>
|
||||
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Trigger header refresh with out-of-band swap -->
|
||||
<div hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
||||
hx-trigger="load once"
|
||||
hx-target={ fmt.Sprintf("#group-header-%s", groupName) }
|
||||
hx-swap="outerHTML"
|
||||
style="display:none;"></div>
|
||||
}
|
||||
|
||||
// renderSortedBackups with action buttons
|
||||
templ renderSortedBackups(groupName string, sortedBackups []BackupWithService, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||
for i := 0; i < len(sortedBackups) && i < limit; i++ {
|
||||
<tr class={ templ.Classes(
|
||||
templ.KV("gruvbox-bg-hard", i % 2 == 0),
|
||||
templ.KV("gruvbox-bg0", i % 2 != 0),
|
||||
) }>
|
||||
<td class="padding-small border-bottom gruvbox-fg">{ FormatServiceName(groupName, sortedBackups[i].ServiceIndex) }</td>
|
||||
<td class="padding-small border-bottom gruvbox-fg">{ FormatTime(sortedBackups[i].Backup.CreationTime) }</td>
|
||||
<td class="padding-small border-bottom gruvbox-fg">{ FormatSize(sortedBackups[i].Backup.Size) }</td>
|
||||
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.Type }</td>
|
||||
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.RetentionTag }</td>
|
||||
<td class="padding-small border-bottom">
|
||||
if providerInfo, exists := serviceConfigs[groupName][sortedBackups[i].ServiceIndex]; exists {
|
||||
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||
<span class="backup-tool-tag gruvbox-purple">{ providerInfo.Type } B2 Backblaze</span>
|
||||
} else if providerInfo.Provider == "ftp" {
|
||||
<span class="backup-tool-tag gruvbox-yellow">{ providerInfo.Type } FTP</span>
|
||||
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||
<span class="backup-tool-tag gruvbox-orange">{ providerInfo.Type } SSH</span>
|
||||
} else if providerInfo.Provider == "s3" {
|
||||
<span class="backup-tool-tag gruvbox-red">{ providerInfo.Type } S3</span>
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Type } { providerInfo.Provider }</span>
|
||||
}
|
||||
} else {
|
||||
<span class="backup-tool-tag gruvbox-gray">Unknown</span>
|
||||
}
|
||||
</td>
|
||||
<td class="padding-small border-bottom action-buttons">
|
||||
<button class="action-button restore-button"
|
||||
hx-get={ fmt.Sprintf("/api/backups/restore-form?backupID=%s&groupName=%s&serviceIndex=%s",
|
||||
sortedBackups[i].Backup.ID,
|
||||
groupName,
|
||||
sortedBackups[i].ServiceIndex) }
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Restore
|
||||
</button>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/api/backups/download?backupID=%s&groupName=%s&serviceIndex=%s",
|
||||
sortedBackups[i].Backup.ID,
|
||||
groupName,
|
||||
sortedBackups[i].ServiceIndex)) }
|
||||
class="action-button download-button">
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// backupsTableRowsSorted renders backup rows sorted by creation time across all services
|
||||
templ backupsTableRowsSorted(groupName string, serviceGroup map[string][]backup.BackupInfo, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), limit, serviceConfigs)
|
||||
}
|
||||
|
1231
templates/home_templ.go
Normal file
1231
templates/home_templ.go
Normal file
File diff suppressed because it is too large
Load Diff
632
templates/layouts/base.templ
Normal file
632
templates/layouts/base.templ
Normal file
@ -0,0 +1,632 @@
|
||||
package layouts
|
||||
|
||||
// Base provides the basic HTML structure for all pages
|
||||
templ Base(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Storage meter styles */
|
||||
.storage-meter {
|
||||
height: 12px;
|
||||
background-color: var(--gruvbox-bg2);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.storage-meter.small {
|
||||
height: 6px;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.storage-progress {
|
||||
height: 100%;
|
||||
background-color: var(--gruvbox-blue);
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.storage-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
color: var(--gruvbox-red);
|
||||
}
|
||||
|
||||
.status-icon.warning {
|
||||
color: var(--gruvbox-yellow);
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
color: var(--gruvbox-green);
|
||||
}
|
||||
|
||||
/* Backup tool tags */
|
||||
.backup-tool-tag {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 0.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Chart styles */
|
||||
.donut-chart {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* Gruvbox Material Dark Theme for Backea */
|
||||
:root {
|
||||
/* Gruvbox Hard Dark Palette */
|
||||
--gruvbox-bg-hard: #1d2021;
|
||||
--gruvbox-bg0: #282828;
|
||||
--gruvbox-bg1: #3c3836;
|
||||
--gruvbox-bg2: #504945;
|
||||
--gruvbox-bg3: #665c54;
|
||||
--gruvbox-bg4: #7c6f64;
|
||||
|
||||
--gruvbox-fg: #fbf1c7;
|
||||
--gruvbox-fg-dim: #d5c4a1;
|
||||
|
||||
--gruvbox-red: #fb4934;
|
||||
--gruvbox-green: #b8bb26;
|
||||
--gruvbox-yellow: #fabd2f;
|
||||
--gruvbox-blue: #83a598;
|
||||
--gruvbox-purple: #d3869b;
|
||||
--gruvbox-aqua: #8ec07c;
|
||||
--gruvbox-orange: #fe8019;
|
||||
--gruvbox-gray: #928374;
|
||||
}
|
||||
/* Base dark mode styles */
|
||||
.gruvbox-dark {
|
||||
background-color: var(--gruvbox-bg-hard);
|
||||
color: var(--gruvbox-fg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
/* Background colors */
|
||||
.gruvbox-bg-hard { background-color: var(--gruvbox-bg-hard); }
|
||||
.gruvbox-bg0 { background-color: var(--gruvbox-bg0); }
|
||||
.gruvbox-bg1 { background-color: var(--gruvbox-bg1); }
|
||||
.gruvbox-bg2 { background-color: var(--gruvbox-bg2); }
|
||||
/* Text colors */
|
||||
.gruvbox-fg { color: var(--gruvbox-fg); }
|
||||
.gruvbox-red { color: var(--gruvbox-red); }
|
||||
.gruvbox-green { color: var(--gruvbox-green); }
|
||||
.gruvbox-yellow { color: var(--gruvbox-yellow); }
|
||||
.gruvbox-blue { color: var(--gruvbox-blue); }
|
||||
.gruvbox-purple { color: var(--gruvbox-purple); }
|
||||
.gruvbox-aqua { color: var(--gruvbox-aqua); }
|
||||
.gruvbox-orange { color: var(--gruvbox-orange); }
|
||||
.gruvbox-gray { color: var(--gruvbox-gray); }
|
||||
/* Border colors */
|
||||
.gruvbox-red-border { border-color: var(--gruvbox-red); }
|
||||
.gruvbox-green-border { border-color: var(--gruvbox-green); }
|
||||
.gruvbox-yellow-border { border-color: var(--gruvbox-yellow); }
|
||||
.gruvbox-blue-border { border-color: var(--gruvbox-blue); }
|
||||
.gruvbox-purple-border { border-color: var(--gruvbox-purple); }
|
||||
.gruvbox-aqua-border { border-color: var(--gruvbox-aqua); }
|
||||
/* Base dark mode styles */
|
||||
html, body {
|
||||
background-color: var(--gruvbox-bg-hard);
|
||||
color: var(--gruvbox-fg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
/* Table Styles */
|
||||
.gruvbox-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-color: var(--gruvbox-bg3);
|
||||
}
|
||||
.gruvbox-table th {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid var(--gruvbox-bg3);
|
||||
}
|
||||
.gruvbox-table tr {
|
||||
border-bottom: 1px solid var(--gruvbox-bg2);
|
||||
}
|
||||
.gruvbox-table td, .gruvbox-table th {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
/* Link styles */
|
||||
.gruvbox-link {
|
||||
color: var(--gruvbox-blue);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.gruvbox-link:hover {
|
||||
color: var(--gruvbox-aqua);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Warning container */
|
||||
.gruvbox-warning {
|
||||
background-color: var(--gruvbox-bg1);
|
||||
border: 1px solid var(--gruvbox-yellow);
|
||||
color: var(--gruvbox-yellow);
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
/* Add Beer CSS compatibility for spacing and layout classes */
|
||||
.responsive {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.round {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
.padding-small {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.margin-bottom {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.margin-bottom-large {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.margin-top {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.margin-top-small {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.border {
|
||||
border: 1px solid;
|
||||
}
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--gruvbox-bg3);
|
||||
}
|
||||
.left-align {
|
||||
text-align: left;
|
||||
}
|
||||
.right-align {
|
||||
text-align: right;
|
||||
}
|
||||
.center-align {
|
||||
text-align: center;
|
||||
}
|
||||
.overflow {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.extra-large {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.large {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Skeleton loading styles */
|
||||
.skeleton-table {
|
||||
max-width: 95%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
height: 1.2rem;
|
||||
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 20px auto;
|
||||
border: 3px solid rgba(168, 153, 132, 0.3);
|
||||
border-top: 3px solid #b8bb26; /* gruvbox green */
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Improve spacing in tables */
|
||||
.overflow {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-skeleton {
|
||||
background-color: var(--gruvbox-bg1);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Fade in animation for loaded content */
|
||||
.group-backups-table {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.skeleton-text {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
color: transparent !important;
|
||||
width: 40px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Skeleton table styles remain the same */
|
||||
.skeleton-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
height: 1.2rem;
|
||||
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 20px auto;
|
||||
border: 3px solid rgba(168, 153, 132, 0.3);
|
||||
border-top: 3px solid #b8bb26; /* gruvbox green */
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Entry animation for both header and content */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.group-backups-table,
|
||||
#group-header-* {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.gruvbox-button {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--gruvbox-fg);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.gruvbox-button:hover {
|
||||
background-color: var(--gruvbox-bg3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.gruvbox-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Inline spinner for buttons */
|
||||
.inline-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Animation for table transitions */
|
||||
.group-backups-table {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.htmx-swapping {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Make the table header sticky */
|
||||
.gruvbox-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--gruvbox-bg1);
|
||||
}
|
||||
|
||||
/* Action buttons styling */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border: none;
|
||||
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.restore-button {
|
||||
background-color: var(--gruvbox-blue);
|
||||
color: var(--gruvbox-bg-hard);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
background-color: var(--gruvbox-green);
|
||||
color: var(--gruvbox-bg-hard);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Make sure the table has enough width */
|
||||
.gruvbox-table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
/* Ensure columns have appropriate widths */
|
||||
.gruvbox-table th:last-child,
|
||||
.gruvbox-table td:last-child {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for small screens */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
}
|
||||
/* Add these styles to your CSS file */
|
||||
|
||||
.restore-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.restore-modal:before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.backup-details {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(40, 40, 40, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gruvbox-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #1d2021;
|
||||
border: 1px solid #3c3836;
|
||||
color: #ebdbb2;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.gruvbox-input:focus {
|
||||
outline: none;
|
||||
border-color: #fabd2f;
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
<body class="gruvbox-bg0 gruvbox-fg">
|
||||
<header class="gruvbox-bg-hard padding">
|
||||
<div class="responsive">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<a href="/" class="gruvbox-yellow no-underline">
|
||||
<h1 class="large">Backea</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<ul style="display: flex; gap: 1rem;">
|
||||
<li><a href="/" class="gruvbox-link">Home</a></li>
|
||||
<li><a href="/about" class="gruvbox-link">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Notification area for displaying feedback -->
|
||||
<div id="notification-area"></div>
|
||||
|
||||
<main class="padding">
|
||||
{ children... }
|
||||
</main>
|
||||
|
||||
<footer class="gruvbox-bg-hard padding margin-top">
|
||||
<div class="responsive">
|
||||
<p class="text-center">
|
||||
<small class="gruvbox-fg-dim">Backea - Unified Backup Dashboard</small>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
}
|
62
templates/layouts/base_templ.go
Normal file
62
templates/layouts/base_templ.go
Normal file
File diff suppressed because one or more lines are too long
39
templates/notifications.templ
Normal file
39
templates/notifications.templ
Normal file
@ -0,0 +1,39 @@
|
||||
package templates
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Notification displays a temporary notification message
|
||||
templ Notification(message string, notificationType string) {
|
||||
<div id="notification"
|
||||
class={ templ.Classes(
|
||||
"notification",
|
||||
templ.KV(notificationType, true),
|
||||
)}
|
||||
_="on load wait 4s then add .fade-out wait 1s then remove me">
|
||||
<p>{ message }</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Success notification
|
||||
templ SuccessNotification(message string) {
|
||||
@Notification(message, "success")
|
||||
}
|
||||
|
||||
// Error notification
|
||||
templ ErrorNotification(message string) {
|
||||
@Notification(message, "error")
|
||||
}
|
||||
|
||||
// RestoreSuccessResponse returns a response for successful restore operation
|
||||
templ RestoreSuccessResponse(backupID string) {
|
||||
<div hx-swap-oob="true:#notification-area">
|
||||
@SuccessNotification("Backup restoration started successfully")
|
||||
</div>
|
||||
}
|
||||
|
||||
// RestoreErrorResponse returns a response for failed restore operation
|
||||
templ RestoreErrorResponse(errorMessage string) {
|
||||
<div hx-swap-oob="true:#notification-area">
|
||||
@ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage))
|
||||
</div>
|
||||
}
|
213
templates/notifications_templ.go
Normal file
213
templates/notifications_templ.go
Normal file
@ -0,0 +1,213 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.833
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Notification displays a temporary notification message
|
||||
func Notification(message string, notificationType string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{templ.Classes(
|
||||
"notification",
|
||||
templ.KV(notificationType, true),
|
||||
)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"notification\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" _=\"on load wait 4s then add .fade-out wait 1s then remove me\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 13, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Success notification
|
||||
func SuccessNotification(message string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Notification(message, "success").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Error notification
|
||||
func ErrorNotification(message string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Notification(message, "error").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreSuccessResponse returns a response for successful restore operation
|
||||
func RestoreSuccessResponse(backupID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div hx-swap-oob=\"true:#notification-area\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = SuccessNotification("Backup restoration started successfully").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreErrorResponse returns a response for failed restore operation
|
||||
func RestoreErrorResponse(errorMessage string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div hx-swap-oob=\"true:#notification-area\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
Loading…
x
Reference in New Issue
Block a user