256 lines
6.5 KiB
Go
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
|
|
}
|