package etcd
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/k3s-io/k3s/pkg/daemons/config"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// S3 maintains state for S3 functionality.
type S3 struct {
config * config . Control
client * minio . Client
}
// newS3 creates a new value of type s3 pointer with a
// copy of the config.Control pointer and initializes
// a new Minio client.
func NewS3 ( ctx context . Context , config * config . Control ) ( * S3 , error ) {
if config . EtcdS3BucketName == "" {
return nil , errors . New ( "s3 bucket name was not set" )
}
tr := http . DefaultTransport
switch {
case config . EtcdS3EndpointCA != "" :
trCA , err := setTransportCA ( tr , config . EtcdS3EndpointCA , config . EtcdS3SkipSSLVerify )
if err != nil {
return nil , err
}
tr = trCA
case config . EtcdS3 && config . EtcdS3SkipSSLVerify :
tr . ( * http . Transport ) . TLSClientConfig = & tls . Config {
InsecureSkipVerify : config . EtcdS3SkipSSLVerify ,
}
}
var creds * credentials . Credentials
if len ( config . EtcdS3AccessKey ) == 0 && len ( config . EtcdS3SecretKey ) == 0 {
creds = credentials . NewIAM ( "" ) // for running on ec2 instance
} else {
creds = credentials . NewStaticV4 ( config . EtcdS3AccessKey , config . EtcdS3SecretKey , "" )
}
opt := minio . Options {
Creds : creds ,
Secure : ! config . EtcdS3Insecure ,
Region : config . EtcdS3Region ,
Transport : tr ,
BucketLookup : bucketLookupType ( config . EtcdS3Endpoint ) ,
}
c , err := minio . New ( config . EtcdS3Endpoint , & opt )
if err != nil {
return nil , err
}
logrus . Infof ( "Checking if S3 bucket %s exists" , config . EtcdS3BucketName )
ctx , cancel := context . WithTimeout ( ctx , config . EtcdS3Timeout )
defer cancel ( )
exists , err := c . BucketExists ( ctx , config . EtcdS3BucketName )
if err != nil {
return nil , err
}
if ! exists {
return nil , fmt . Errorf ( "bucket: %s does not exist" , config . EtcdS3BucketName )
}
logrus . Infof ( "S3 bucket %s exists" , config . EtcdS3BucketName )
return & S3 {
config : config ,
client : c ,
} , nil
}
// upload uploads the given snapshot to the configured S3
// compatible backend.
func ( s * S3 ) upload ( ctx context . Context , snapshot , extraMetadata string , now time . Time ) ( * snapshotFile , error ) {
logrus . Infof ( "Uploading snapshot %s to S3" , snapshot )
basename := filepath . Base ( snapshot )
var snapshotFileName string
var sf snapshotFile
if s . config . EtcdS3Folder != "" {
snapshotFileName = filepath . Join ( s . config . EtcdS3Folder , basename )
} else {
snapshotFileName = basename
}
toCtx , cancel := context . WithTimeout ( ctx , s . config . EtcdS3Timeout )
defer cancel ( )
opts := minio . PutObjectOptions { NumThreads : 2 }
if strings . HasSuffix ( snapshot , compressedExtension ) {
opts . ContentType = "application/zip"
} else {
opts . ContentType = "application/octet-stream"
}
uploadInfo , err := s . client . FPutObject ( toCtx , s . config . EtcdS3BucketName , snapshotFileName , snapshot , opts )
if err != nil {
sf = snapshotFile {
Name : filepath . Base ( uploadInfo . Key ) ,
Metadata : extraMetadata ,
NodeName : "s3" ,
CreatedAt : & metav1 . Time {
Time : now ,
} ,
Message : base64 . StdEncoding . EncodeToString ( [ ] byte ( err . Error ( ) ) ) ,
Size : 0 ,
Status : failedSnapshotStatus ,
S3 : & s3Config {
Endpoint : s . config . EtcdS3Endpoint ,
EndpointCA : s . config . EtcdS3EndpointCA ,
SkipSSLVerify : s . config . EtcdS3SkipSSLVerify ,
Bucket : s . config . EtcdS3BucketName ,
Region : s . config . EtcdS3Region ,
Folder : s . config . EtcdS3Folder ,
Insecure : s . config . EtcdS3Insecure ,
} ,
}
logrus . Errorf ( "Error received during snapshot upload to S3: %s" , err )
} else {
ca , err := time . Parse ( time . RFC3339 , uploadInfo . LastModified . Format ( time . RFC3339 ) )
if err != nil {
return nil , err
}
sf = snapshotFile {
Name : filepath . Base ( uploadInfo . Key ) ,
Metadata : extraMetadata ,
NodeName : "s3" ,
CreatedAt : & metav1 . Time {
Time : ca ,
} ,
Size : uploadInfo . Size ,
Status : successfulSnapshotStatus ,
S3 : & s3Config {
Endpoint : s . config . EtcdS3Endpoint ,
EndpointCA : s . config . EtcdS3EndpointCA ,
SkipSSLVerify : s . config . EtcdS3SkipSSLVerify ,
Bucket : s . config . EtcdS3BucketName ,
Region : s . config . EtcdS3Region ,
Folder : s . config . EtcdS3Folder ,
Insecure : s . config . EtcdS3Insecure ,
} ,
}
}
return & sf , nil
}
// download downloads the given snapshot from the configured S3
// compatible backend.
func ( s * S3 ) Download ( ctx context . Context ) error {
var remotePath string
if s . config . EtcdS3Folder != "" {
remotePath = filepath . Join ( s . config . EtcdS3Folder , s . config . ClusterResetRestorePath )
} else {
remotePath = s . config . ClusterResetRestorePath
}
logrus . Debugf ( "retrieving snapshot: %s" , remotePath )
toCtx , cancel := context . WithTimeout ( ctx , s . config . EtcdS3Timeout )
defer cancel ( )
r , err := s . client . GetObject ( toCtx , s . config . EtcdS3BucketName , remotePath , minio . GetObjectOptions { } )
if err != nil {
return nil
}
defer r . Close ( )
snapshotDir , err := snapshotDir ( s . config , true )
if err != nil {
return errors . Wrap ( err , "failed to get the snapshot dir" )
}
fullSnapshotPath := filepath . Join ( snapshotDir , s . config . ClusterResetRestorePath )
sf , err := os . Create ( fullSnapshotPath )
if err != nil {
return err
}
defer sf . Close ( )
stat , err := r . Stat ( )
if err != nil {
return err
}
if _ , err := io . CopyN ( sf , r , stat . Size ) ; err != nil {
return err
}
s . config . ClusterResetRestorePath = fullSnapshotPath
return os . Chmod ( fullSnapshotPath , 0600 )
}
// snapshotPrefix returns the prefix used in the
// naming of the snapshots.
func ( s * S3 ) snapshotPrefix ( ) string {
fullSnapshotPrefix := s . config . EtcdSnapshotName
var prefix string
if s . config . EtcdS3Folder != "" {
prefix = filepath . Join ( s . config . EtcdS3Folder , fullSnapshotPrefix )
} else {
prefix = fullSnapshotPrefix
}
return prefix
}
// snapshotRetention prunes snapshots in the configured S3 compatible backend for this specific node.
func ( s * S3 ) snapshotRetention ( ctx context . Context ) error {
if s . config . EtcdSnapshotRetention < 1 {
return nil
}
logrus . Infof ( "Applying snapshot retention policy to snapshots stored in S3: retention: %d, snapshotPrefix: %s" , s . config . EtcdSnapshotRetention , s . snapshotPrefix ( ) )
var snapshotFiles [ ] minio . ObjectInfo
toCtx , cancel := context . WithTimeout ( ctx , s . config . EtcdS3Timeout )
defer cancel ( )
loo := minio . ListObjectsOptions {
Recursive : true ,
Prefix : s . snapshotPrefix ( ) ,
}
for info := range s . client . ListObjects ( toCtx , s . config . EtcdS3BucketName , loo ) {
if info . Err != nil {
return info . Err
}
snapshotFiles = append ( snapshotFiles , info )
}
if len ( snapshotFiles ) <= s . config . EtcdSnapshotRetention {
return nil
}
sort . Slice ( snapshotFiles , func ( firstSnapshot , secondSnapshot int ) bool {
// it takes the key from the snapshot file ex: etcd-snapshot-example-{date}, makes the split using "-" to find the date, takes the date and sort by date
firstSnapshotName , secondSnapshotName := strings . Split ( snapshotFiles [ firstSnapshot ] . Key , "-" ) , strings . Split ( snapshotFiles [ secondSnapshot ] . Key , "-" )
firstSnapshotDate , secondSnapshotDate := firstSnapshotName [ len ( firstSnapshotName ) - 1 ] , secondSnapshotName [ len ( secondSnapshotName ) - 1 ]
return firstSnapshotDate < secondSnapshotDate
} )
delCount := len ( snapshotFiles ) - s . config . EtcdSnapshotRetention
for _ , df := range snapshotFiles [ : delCount ] {
logrus . Infof ( "Removing S3 snapshot: %s" , df . Key )
if err := s . client . RemoveObject ( ctx , s . config . EtcdS3BucketName , df . Key , minio . RemoveObjectOptions { } ) ; err != nil {
return err
}
}
return nil
}
func readS3EndpointCA ( endpointCA string ) ( [ ] byte , error ) {
ca , err := base64 . StdEncoding . DecodeString ( endpointCA )
if err != nil {
return os . ReadFile ( endpointCA )
}
return ca , nil
}
func setTransportCA ( tr http . RoundTripper , endpointCA string , insecureSkipVerify bool ) ( http . RoundTripper , error ) {
ca , err := readS3EndpointCA ( endpointCA )
if err != nil {
return tr , err
}
if ! isValidCertificate ( ca ) {
return tr , errors . New ( "endpoint-ca is not a valid x509 certificate" )
}
certPool := x509 . NewCertPool ( )
certPool . AppendCertsFromPEM ( ca )
tr . ( * http . Transport ) . TLSClientConfig = & tls . Config {
RootCAs : certPool ,
InsecureSkipVerify : insecureSkipVerify ,
}
return tr , nil
}
// isValidCertificate checks to see if the given
// byte slice is a valid x509 certificate.
func isValidCertificate ( c [ ] byte ) bool {
p , _ := pem . Decode ( c )
if p == nil {
return false
}
if _ , err := x509 . ParseCertificates ( p . Bytes ) ; err != nil {
return false
}
return true
}
func bucketLookupType ( endpoint string ) minio . BucketLookupType {
if strings . Contains ( endpoint , "aliyun" ) { // backwards compt with RKE1
return minio . BucketLookupDNS
}
return minio . BucketLookupAuto
}