Files
backup/storage/s3.go
2026-02-03 14:47:04 +08:00

256 lines
6.5 KiB
Go

package storage
import (
"fmt"
"os"
"path"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"git.nyne.dev/x/backup/config"
)
// S3Storage handles S3 storage operations
type S3Storage struct {
client *s3.S3
uploader *s3manager.Uploader
config config.S3Config
}
// NewS3Storage creates a new S3 storage handler
func NewS3Storage(cfg config.S3Config) (*S3Storage, error) {
awsConfig := &aws.Config{
Credentials: credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, ""),
Region: aws.String(cfg.Region),
}
if cfg.Endpoint != "" {
awsConfig.Endpoint = aws.String(cfg.Endpoint)
awsConfig.S3ForcePathStyle = aws.Bool(true)
}
sess, err := session.NewSession(awsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create AWS session: %w", err)
}
return &S3Storage{
client: s3.New(sess),
uploader: s3manager.NewUploader(sess),
config: cfg,
}, nil
}
// Upload uploads a file to S3
func (s *S3Storage) Upload(filePath, key string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Construct the full key with path prefix
fullKey := key
if s.config.Path != "" {
fullKey = path.Join(s.config.Path, key)
}
// Upload the file
_, err = s.uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(s.config.Bucket),
Key: aws.String(fullKey),
Body: file,
})
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
return nil
}
// ListBackups lists all backup files in S3
func (s *S3Storage) ListBackups(prefix string) ([]string, error) {
// Construct the full prefix
fullPrefix := prefix
if s.config.Path != "" {
fullPrefix = path.Join(s.config.Path, prefix)
}
input := &s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Bucket),
Prefix: aws.String(fullPrefix),
}
var backups []string
err := s.client.ListObjectsV2Pages(input, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
for _, obj := range page.Contents {
if obj.Key != nil {
// Remove the path prefix if present
key := *obj.Key
if s.config.Path != "" {
key = strings.TrimPrefix(key, s.config.Path+"/")
}
backups = append(backups, key)
}
}
return true
})
if err != nil {
return nil, fmt.Errorf("failed to list objects: %w", err)
}
return backups, nil
}
// DeleteBackup deletes a backup file from S3, including all versions if versioning is enabled
func (s *S3Storage) DeleteBackup(key string) error {
// Construct the full key with path prefix
fullKey := key
if s.config.Path != "" {
fullKey = path.Join(s.config.Path, key)
}
// First, try to list all versions of this object
versionsInput := &s3.ListObjectVersionsInput{
Bucket: aws.String(s.config.Bucket),
Prefix: aws.String(fullKey),
}
versions, err := s.client.ListObjectVersions(versionsInput)
if err != nil {
// If listing versions fails (e.g., versioning not enabled or permission denied),
// fall back to simple delete
fmt.Printf("Versioning not available or error listing versions, using simple delete: %v\n", err)
_, err := s.client.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.config.Bucket),
Key: aws.String(fullKey),
})
if err != nil {
return fmt.Errorf("failed to delete object: %w", err)
}
return nil
}
// Count versions for this exact key
versionCount := 0
markerCount := 0
for _, version := range versions.Versions {
if version.Key != nil && *version.Key == fullKey {
versionCount++
}
}
for _, marker := range versions.DeleteMarkers {
if marker.Key != nil && *marker.Key == fullKey {
markerCount++
}
}
if versionCount > 0 || markerCount > 0 {
fmt.Printf("Found %d version(s) and %d delete marker(s) for %s\n", versionCount, markerCount, key)
}
// Delete all versions and delete markers for this exact key
var deleteErrors []error
// Delete all object versions
for _, version := range versions.Versions {
if version.Key != nil && *version.Key == fullKey {
_, err := s.client.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.config.Bucket),
Key: version.Key,
VersionId: version.VersionId,
})
if err != nil {
deleteErrors = append(deleteErrors, fmt.Errorf("failed to delete version %s: %w",
aws.StringValue(version.VersionId), err))
}
}
}
// Delete all delete markers
for _, marker := range versions.DeleteMarkers {
if marker.Key != nil && *marker.Key == fullKey {
_, err := s.client.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.config.Bucket),
Key: marker.Key,
VersionId: marker.VersionId,
})
if err != nil {
deleteErrors = append(deleteErrors, fmt.Errorf("failed to delete marker %s: %w",
aws.StringValue(marker.VersionId), err))
}
}
}
// If there were any errors, return them
if len(deleteErrors) > 0 {
return fmt.Errorf("errors during deletion: %v", deleteErrors)
}
return nil
}
// CleanupOldBackups deletes old backups exceeding the max backup count
func (s *S3Storage) CleanupOldBackups(prefix string, maxBackups int) error {
backups, err := s.ListBackups(prefix)
if err != nil {
return fmt.Errorf("failed to list backups: %w", err)
}
if len(backups) <= maxBackups {
return nil
}
// Sort backups by name (which includes timestamp)
// Newer backups have later timestamps and will be at the end
sort.Strings(backups)
// Calculate how many to delete
toDelete := len(backups) - maxBackups
// Delete the oldest backups
for i := 0; i < toDelete; i++ {
if err := s.DeleteBackup(backups[i]); err != nil {
return fmt.Errorf("failed to delete backup %s: %w", backups[i], err)
}
fmt.Printf("Deleted old backup: %s\n", backups[i])
}
return nil
}
// GenerateBackupFileName generates a backup filename with timestamp
func GenerateBackupFileName(name string) string {
timestamp := time.Now().Format("20060102-150405")
return fmt.Sprintf("%s-%s.tar.gz", name, timestamp)
}
// Test tests if S3 storage is accessible
func (s *S3Storage) Test() error {
// Test if bucket exists and is accessible by listing objects
input := &s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Bucket),
MaxKeys: aws.Int64(1),
}
if s.config.Path != "" {
input.Prefix = aws.String(s.config.Path)
}
_, err := s.client.ListObjectsV2(input)
if err != nil {
return fmt.Errorf("cannot access S3 bucket '%s': %w", s.config.Bucket, err)
}
return nil
}