feat: docker compose backup

This commit is contained in:
2026-07-04 17:20:28 +08:00
commit 5c8944fd58
27 changed files with 2070 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
package storage
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
dcfg "git.misaka.ren/M1saka/docker_backup/internal/config"
)
// S3Backend uploads to S3-compatible storage.
type S3Backend struct {
cfg *dcfg.S3Config
client *s3.Client
}
// NewS3Backend creates a configured S3 backend.
func NewS3Backend(cfg *dcfg.S3Config) (*S3Backend, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
awsCfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(cfg.Region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, ""),
),
)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
s3Opts := func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true
}
}
return &S3Backend{
cfg: cfg,
client: s3.NewFromConfig(awsCfg, s3Opts),
}, nil
}
// Upload implements Backend.
func (s *S3Backend) Upload(ctx context.Context, projectName, localPath string) error {
file, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer file.Close()
key := filepath.Base(localPath)
if s.cfg.PathPrefix != "" {
key = s.cfg.PathPrefix + projectName + "/" + key
} else {
key = projectName + "/" + key
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.cfg.Bucket),
Key: aws.String(key),
Body: file,
ContentType: aws.String("application/gzip"),
})
if err != nil {
return fmt.Errorf("s3 put object: %w", err)
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package storage
import (
"context"
"fmt"
"git.misaka.ren/M1saka/docker_backup/internal/config"
)
// Backend is the interface for uploading backups to remote storage.
type Backend interface {
Upload(ctx context.Context, projectName, localPath string) error
}
// FromConfig creates storage backends from the config.
// Returns an error only if a backend is configured but fails to initialize.
func FromConfig(cfg *config.RemoteConfig) ([]Backend, error) {
var backends []Backend
if cfg.S3 != nil {
s3, err := NewS3Backend(cfg.S3)
if err != nil {
return nil, fmt.Errorf("s3: %w", err)
}
backends = append(backends, s3)
}
if cfg.WebDAV != nil {
backends = append(backends, NewWebDAVBackend(cfg.WebDAV))
}
return backends, nil
}
+101
View File
@@ -0,0 +1,101 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
dcfg "git.misaka.ren/M1saka/docker_backup/internal/config"
)
// WebDAVBackend uploads to a WebDAV server.
type WebDAVBackend struct {
cfg *dcfg.WebDAVConfig
client *http.Client
}
// NewWebDAVBackend creates a configured WebDAV backend.
func NewWebDAVBackend(cfg *dcfg.WebDAVConfig) *WebDAVBackend {
return &WebDAVBackend{
cfg: cfg,
client: &http.Client{
Timeout: 30 * time.Minute,
},
}
}
// Upload implements Backend.
func (w *WebDAVBackend) Upload(ctx context.Context, projectName, localPath string) error {
baseURL := strings.TrimRight(strings.TrimSpace(w.cfg.URL), "/")
if baseURL == "" {
return fmt.Errorf("webdav url is required")
}
file, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("stat: %w", err)
}
parentURL := baseURL + "/" + projectName
if err := w.mkcol(ctx, parentURL); err != nil {
return fmt.Errorf("webdav mkcol %s: %w", parentURL, err)
}
remoteURL := parentURL + "/" + filepath.Base(localPath)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, remoteURL, file)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/gzip")
if w.cfg.Username != "" || w.cfg.Password != "" {
req.SetBasicAuth(w.cfg.Username, w.cfg.Password)
}
resp, err := w.client.Do(req)
if err != nil {
return fmt.Errorf("webdav put: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav put failed: %s (%d): %s", remoteURL, resp.StatusCode, string(body))
}
return nil
}
// mkcol creates a WebDAV collection (directory). Existing collections are OK.
func (w *WebDAVBackend) mkcol(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, "MKCOL", url, nil)
if err != nil {
return err
}
if w.cfg.Username != "" || w.cfg.Password != "" {
req.SetBasicAuth(w.cfg.Username, w.cfg.Password)
}
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 201 Created is success. Many servers return 405 if collection already exists.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusMethodNotAllowed {
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}