feat: docker compose backup
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user