github.com/percona/percona-xtradb-cluster-operator@v1.14.0/cmd/pitr/recoverer/recoverer.go (about)

     1  package recoverer
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"io"
     7  	"log"
     8  	"net/url"
     9  	"os"
    10  	"os/exec"
    11  	"sort"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/percona/percona-xtradb-cluster-operator/cmd/pitr/pxc"
    17  	"github.com/percona/percona-xtradb-cluster-operator/pkg/pxc/backup/storage"
    18  
    19  	"github.com/pkg/errors"
    20  )
    21  
    22  type Recoverer struct {
    23  	db             *pxc.PXC
    24  	recoverTime    string
    25  	storage        storage.Storage
    26  	pxcUser        string
    27  	pxcPass        string
    28  	recoverType    RecoverType
    29  	pxcServiceName string
    30  	binlogs        []string
    31  	gtidSet        string
    32  	startGTID      string
    33  	recoverFlag    string
    34  	recoverEndTime time.Time
    35  	gtid           string
    36  	verifyTLS      bool
    37  }
    38  
    39  type Config struct {
    40  	PXCServiceName     string `env:"PXC_SERVICE,required"`
    41  	PXCUser            string `env:"PXC_USER,required"`
    42  	PXCPass            string `env:"PXC_PASS,required"`
    43  	BackupStorageS3    BackupS3
    44  	BackupStorageAzure BackupAzure
    45  	RecoverTime        string `env:"PITR_DATE"`
    46  	RecoverType        string `env:"PITR_RECOVERY_TYPE,required"`
    47  	GTID               string `env:"PITR_GTID"`
    48  	VerifyTLS          bool   `env:"VERIFY_TLS" envDefault:"true"`
    49  	StorageType        string `env:"STORAGE_TYPE,required"`
    50  	BinlogStorageS3    BinlogS3
    51  	BinlogStorageAzure BinlogAzure
    52  }
    53  
    54  func (c Config) storages(ctx context.Context) (storage.Storage, storage.Storage, error) {
    55  	var binlogStorage, defaultStorage storage.Storage
    56  	switch c.StorageType {
    57  	case "s3":
    58  		bucket, prefix, err := getBucketAndPrefix(c.BinlogStorageS3.BucketURL)
    59  		if err != nil {
    60  			return nil, nil, errors.Wrap(err, "get bucket and prefix")
    61  		}
    62  		binlogStorage, err = storage.NewS3(ctx, c.BinlogStorageS3.Endpoint, c.BinlogStorageS3.AccessKeyID, c.BinlogStorageS3.AccessKey, bucket, prefix, c.BinlogStorageS3.Region, c.VerifyTLS)
    63  		if err != nil {
    64  			return nil, nil, errors.Wrap(err, "new s3 storage")
    65  		}
    66  
    67  		bucket, prefix, err = getBucketAndPrefix(c.BackupStorageS3.BackupDest)
    68  		if err != nil {
    69  			return nil, nil, errors.Wrap(err, "get bucket and prefix")
    70  		}
    71  		prefix = prefix[:len(prefix)-1]
    72  		defaultStorage, err = storage.NewS3(ctx, c.BackupStorageS3.Endpoint, c.BackupStorageS3.AccessKeyID, c.BackupStorageS3.AccessKey, bucket, prefix+".sst_info/", c.BackupStorageS3.Region, c.VerifyTLS)
    73  		if err != nil {
    74  			return nil, nil, errors.Wrap(err, "new storage manager")
    75  		}
    76  	case "azure":
    77  		var err error
    78  		container, prefix := getContainerAndPrefix(c.BinlogStorageAzure.ContainerPath)
    79  		binlogStorage, err = storage.NewAzure(c.BinlogStorageAzure.AccountName, c.BinlogStorageAzure.AccountKey, c.BinlogStorageAzure.Endpoint, container, prefix)
    80  		if err != nil {
    81  			return nil, nil, errors.Wrap(err, "new azure storage")
    82  		}
    83  		defaultStorage, err = storage.NewAzure(c.BackupStorageAzure.AccountName, c.BackupStorageAzure.AccountKey, c.BackupStorageAzure.Endpoint, c.BackupStorageAzure.ContainerName, c.BackupStorageAzure.BackupDest+".sst_info/")
    84  		if err != nil {
    85  			return nil, nil, errors.Wrap(err, "new azure storage")
    86  		}
    87  	default:
    88  		return nil, nil, errors.New("unknown STORAGE_TYPE")
    89  	}
    90  	return binlogStorage, defaultStorage, nil
    91  }
    92  
    93  type BackupS3 struct {
    94  	Endpoint    string `env:"ENDPOINT" envDefault:"s3.amazonaws.com"`
    95  	AccessKeyID string `env:"ACCESS_KEY_ID,required"`
    96  	AccessKey   string `env:"SECRET_ACCESS_KEY,required"`
    97  	Region      string `env:"DEFAULT_REGION,required"`
    98  	BackupDest  string `env:"S3_BUCKET_URL,required"`
    99  }
   100  
   101  type BackupAzure struct {
   102  	Endpoint      string `env:"AZURE_ENDPOINT,required"`
   103  	ContainerName string `env:"AZURE_CONTAINER_NAME,required"`
   104  	StorageClass  string `env:"AZURE_STORAGE_CLASS"`
   105  	AccountName   string `env:"AZURE_STORAGE_ACCOUNT,required"`
   106  	AccountKey    string `env:"AZURE_ACCESS_KEY,required"`
   107  	BackupDest    string `env:"BACKUP_PATH,required"`
   108  }
   109  
   110  type BinlogS3 struct {
   111  	Endpoint    string `env:"BINLOG_S3_ENDPOINT" envDefault:"s3.amazonaws.com"`
   112  	AccessKeyID string `env:"BINLOG_ACCESS_KEY_ID,required"`
   113  	AccessKey   string `env:"BINLOG_SECRET_ACCESS_KEY,required"`
   114  	Region      string `env:"BINLOG_S3_REGION,required"`
   115  	BucketURL   string `env:"BINLOG_S3_BUCKET_URL,required"`
   116  }
   117  
   118  type BinlogAzure struct {
   119  	Endpoint      string `env:"BINLOG_AZURE_ENDPOINT,required"`
   120  	ContainerPath string `env:"BINLOG_AZURE_CONTAINER_PATH,required"`
   121  	StorageClass  string `env:"BINLOG_AZURE_STORAGE_CLASS"`
   122  	AccountName   string `env:"BINLOG_AZURE_STORAGE_ACCOUNT,required"`
   123  	AccountKey    string `env:"BINLOG_AZURE_ACCESS_KEY,required"`
   124  }
   125  
   126  func (c *Config) Verify() {
   127  	if len(c.BackupStorageS3.Endpoint) == 0 {
   128  		c.BackupStorageS3.Endpoint = "s3.amazonaws.com"
   129  	}
   130  	if len(c.BinlogStorageS3.Endpoint) == 0 {
   131  		c.BinlogStorageS3.Endpoint = "s3.amazonaws.com"
   132  	}
   133  }
   134  
   135  type RecoverType string
   136  
   137  func New(ctx context.Context, c Config) (*Recoverer, error) {
   138  	c.Verify()
   139  
   140  	binlogStorage, storage, err := c.storages(ctx)
   141  	if err != nil {
   142  		return nil, errors.Wrap(err, "new binlog storage manager")
   143  	}
   144  
   145  	startGTID, err := getStartGTIDSet(ctx, storage)
   146  	if err != nil {
   147  		return nil, errors.Wrap(err, "get start GTID")
   148  	}
   149  
   150  	if c.RecoverType == string(Transaction) {
   151  		gtidSplitted := strings.Split(startGTID, ":")
   152  		if len(gtidSplitted) != 2 {
   153  			return nil, errors.New("Invalid start gtidset provided")
   154  		}
   155  		lastSetIdx := 1
   156  		setSplitted := strings.Split(gtidSplitted[1], "-")
   157  		if len(setSplitted) == 1 {
   158  			lastSetIdx = 0
   159  		}
   160  		lastSet := setSplitted[lastSetIdx]
   161  		lastSetInt, err := strconv.ParseInt(lastSet, 10, 64)
   162  		if err != nil {
   163  			return nil, errors.Wrap(err, "failed to cast last set value to in")
   164  		}
   165  		transactionNum, err := strconv.ParseInt(strings.Split(c.GTID, ":")[1], 10, 64)
   166  		if err != nil {
   167  			return nil, errors.Wrap(err, "failed to parse transaction num to restore")
   168  		}
   169  		if transactionNum < lastSetInt {
   170  			return nil, errors.New("Can't restore to transaction before backup")
   171  		}
   172  	}
   173  
   174  	return &Recoverer{
   175  		storage:        binlogStorage,
   176  		recoverTime:    c.RecoverTime,
   177  		pxcUser:        c.PXCUser,
   178  		pxcPass:        c.PXCPass,
   179  		pxcServiceName: c.PXCServiceName,
   180  		recoverType:    RecoverType(c.RecoverType),
   181  		startGTID:      startGTID,
   182  		gtid:           c.GTID,
   183  		verifyTLS:      c.VerifyTLS,
   184  	}, nil
   185  }
   186  
   187  func getContainerAndPrefix(s string) (string, string) {
   188  	container, prefix, _ := strings.Cut(s, "/")
   189  	if prefix != "" {
   190  		prefix += "/"
   191  	}
   192  	return container, prefix
   193  }
   194  
   195  func getBucketAndPrefix(bucketURL string) (bucket string, prefix string, err error) {
   196  	u, err := url.Parse(bucketURL)
   197  	if err != nil {
   198  		err = errors.Wrap(err, "parse url")
   199  		return bucket, prefix, err
   200  	}
   201  	path := strings.TrimPrefix(strings.TrimSuffix(u.Path, "/"), "/")
   202  
   203  	if u.IsAbs() && u.Scheme == "s3" {
   204  		bucket = u.Host
   205  		prefix = path + "/"
   206  		return bucket, prefix, err
   207  	}
   208  	bucketArr := strings.Split(path, "/")
   209  	if len(bucketArr) > 1 {
   210  		prefix = strings.TrimPrefix(path, bucketArr[0]+"/") + "/"
   211  	}
   212  	bucket = bucketArr[0]
   213  	if len(bucket) == 0 {
   214  		err = errors.Errorf("can't get bucket name from %s", bucketURL)
   215  		return bucket, prefix, err
   216  	}
   217  
   218  	return bucket, prefix, err
   219  }
   220  
   221  func getStartGTIDSet(ctx context.Context, s storage.Storage) (string, error) {
   222  	sstInfo, err := s.ListObjects(ctx, "sst_info")
   223  	if err != nil {
   224  		return "", errors.Wrapf(err, "list objects")
   225  	}
   226  	if len(sstInfo) == 0 {
   227  		return "", errors.New("no info files in sst dir")
   228  	}
   229  	sort.Strings(sstInfo)
   230  
   231  	sstInfoObj, err := s.GetObject(ctx, sstInfo[0])
   232  	if err != nil {
   233  		return "", errors.Wrapf(err, "get object")
   234  	}
   235  	defer sstInfoObj.Close()
   236  
   237  	s.SetPrefix(strings.TrimSuffix(s.GetPrefix(), ".sst_info/") + "/")
   238  	xtrabackupInfo, err := s.ListObjects(ctx, "xtrabackup_info")
   239  	if err != nil {
   240  		return "", errors.Wrapf(err, "list objects")
   241  	}
   242  	if len(xtrabackupInfo) == 0 {
   243  		return "", errors.New("no info files in backup")
   244  	}
   245  	sort.Strings(xtrabackupInfo)
   246  
   247  	xtrabackupInfoObj, err := s.GetObject(ctx, xtrabackupInfo[0])
   248  	if err != nil {
   249  		return "", errors.Wrapf(err, "get object")
   250  	}
   251  
   252  	lastGTID, err := getLastBackupGTID(ctx, sstInfoObj, xtrabackupInfoObj)
   253  	if err != nil {
   254  		return "", errors.Wrap(err, "get last backup gtid")
   255  	}
   256  
   257  	return lastGTID, nil
   258  }
   259  
   260  const (
   261  	Latest      RecoverType = "latest"      // recover to the latest existing binlog
   262  	Date        RecoverType = "date"        // recover to exact date
   263  	Transaction RecoverType = "transaction" // recover to needed trunsaction
   264  	Skip        RecoverType = "skip"        // skip transactions
   265  )
   266  
   267  func (r *Recoverer) Run(ctx context.Context) error {
   268  	host, err := pxc.GetPXCFirstHost(ctx, r.pxcServiceName)
   269  	if err != nil {
   270  		return errors.Wrap(err, "get host")
   271  	}
   272  	r.db, err = pxc.NewPXC(host, r.pxcUser, r.pxcPass)
   273  	if err != nil {
   274  		return errors.Wrapf(err, "new manager with host %s", host)
   275  	}
   276  
   277  	err = r.setBinlogs(ctx)
   278  	if err != nil {
   279  		return errors.Wrap(err, "get binlog list")
   280  	}
   281  
   282  	switch r.recoverType {
   283  	case Skip:
   284  		r.recoverFlag = "--exclude-gtids=" + r.gtid
   285  	case Transaction:
   286  		r.recoverFlag = "--exclude-gtids=" + r.gtidSet
   287  	case Date:
   288  		r.recoverFlag = `--stop-datetime="` + r.recoverTime + `"`
   289  
   290  		const format = "2006-01-02 15:04:05"
   291  		endTime, err := time.Parse(format, r.recoverTime)
   292  		if err != nil {
   293  			return errors.Wrap(err, "parse date")
   294  		}
   295  		r.recoverEndTime = endTime
   296  	case Latest:
   297  	default:
   298  		return errors.New("wrong recover type")
   299  	}
   300  
   301  	err = r.recover(ctx)
   302  	if err != nil {
   303  		return errors.Wrap(err, "recover")
   304  	}
   305  
   306  	return nil
   307  }
   308  
   309  func (r *Recoverer) recover(ctx context.Context) (err error) {
   310  	err = r.db.DropCollectorFunctions(ctx)
   311  	if err != nil {
   312  		return errors.Wrap(err, "drop collector funcs")
   313  	}
   314  
   315  	err = os.Setenv("MYSQL_PWD", os.Getenv("PXC_PASS"))
   316  	if err != nil {
   317  		return errors.Wrap(err, "set mysql pwd env var")
   318  	}
   319  
   320  	mysqlStdin, binlogStdout := io.Pipe()
   321  	defer mysqlStdin.Close()
   322  
   323  	mysqlCmd := exec.CommandContext(ctx, "mysql", "-h", r.db.GetHost(), "-P", "33062", "-u", r.pxcUser)
   324  	log.Printf("Running %s", mysqlCmd.String())
   325  	mysqlCmd.Stdin = mysqlStdin
   326  	mysqlCmd.Stderr = os.Stderr
   327  	mysqlCmd.Stdout = os.Stdout
   328  	if err := mysqlCmd.Start(); err != nil {
   329  		return errors.Wrap(err, "start mysql")
   330  	}
   331  
   332  	for i, binlog := range r.binlogs {
   333  		remaining := len(r.binlogs) - i
   334  		log.Printf("working with %s, %d out of %d remaining\n", binlog, remaining, len(r.binlogs))
   335  		if r.recoverType == Date {
   336  			binlogArr := strings.Split(binlog, "_")
   337  			if len(binlogArr) < 2 {
   338  				return errors.New("get timestamp from binlog name")
   339  			}
   340  			binlogTime, err := strconv.ParseInt(binlogArr[1], 10, 64)
   341  			if err != nil {
   342  				return errors.Wrap(err, "get binlog time")
   343  			}
   344  			if binlogTime > r.recoverEndTime.Unix() {
   345  				log.Printf("Stopping at %s because it's after the recovery time (%d > %d)", binlog, binlogTime, r.recoverEndTime.Unix())
   346  				break
   347  			}
   348  		}
   349  
   350  		binlogObj, err := r.storage.GetObject(ctx, binlog)
   351  		if err != nil {
   352  			return errors.Wrap(err, "get obj")
   353  		}
   354  
   355  		cmd := exec.CommandContext(ctx, "sh", "-c", "mysqlbinlog --disable-log-bin "+r.recoverFlag+" -")
   356  		log.Printf("Running %s", cmd.String())
   357  		cmd.Stdin = binlogObj
   358  		cmd.Stdout = binlogStdout
   359  		cmd.Stderr = os.Stderr
   360  		err = cmd.Run()
   361  		if err != nil {
   362  			return errors.Wrapf(err, "run mysqlbinlog")
   363  		}
   364  	}
   365  
   366  	if err := binlogStdout.Close(); err != nil {
   367  		return errors.Wrap(err, "close binlog stdout")
   368  	}
   369  
   370  	log.Printf("Waiting for mysql to finish")
   371  
   372  	if err := mysqlCmd.Wait(); err != nil {
   373  		return errors.Wrap(err, "wait mysql")
   374  	}
   375  
   376  	log.Printf("Finished")
   377  
   378  	return nil
   379  }
   380  
   381  func getLastBackupGTID(ctx context.Context, sstInfo, xtrabackupInfo io.Reader) (string, error) {
   382  	sstContent, err := getDecompressedContent(ctx, sstInfo, "sst_info")
   383  	if err != nil {
   384  		return "", errors.Wrap(err, "get sst_info content")
   385  	}
   386  
   387  	xtrabackupContent, err := getDecompressedContent(ctx, xtrabackupInfo, "xtrabackup_info")
   388  	if err != nil {
   389  		return "", errors.Wrap(err, "get xtrabackup info content")
   390  	}
   391  
   392  	sstGTIDset, err := getGTIDFromSSTInfo(sstContent)
   393  	if err != nil {
   394  		return "", err
   395  	}
   396  	currGTID := strings.Split(sstGTIDset, ":")[0]
   397  
   398  	set, err := getSetFromXtrabackupInfo(currGTID, xtrabackupContent)
   399  	if err != nil {
   400  		return "", err
   401  	}
   402  
   403  	return currGTID + ":" + set, nil
   404  }
   405  
   406  func getSetFromXtrabackupInfo(gtid string, xtrabackupInfo []byte) (string, error) {
   407  	gtids, err := getGTIDFromXtrabackup(xtrabackupInfo)
   408  	if err != nil {
   409  		return "", errors.Wrap(err, "get gtid from xtrabackup info")
   410  	}
   411  	for _, v := range strings.Split(gtids, ",") {
   412  		valueSplitted := strings.Split(v, ":")
   413  		if valueSplitted[0] == gtid {
   414  			return valueSplitted[1], nil
   415  		}
   416  	}
   417  	return "", errors.New("can't find current gtid in xtrabackup file")
   418  }
   419  
   420  func getGTIDFromXtrabackup(content []byte) (string, error) {
   421  	sep := []byte("GTID of the last")
   422  	startIndex := bytes.Index(content, sep)
   423  	if startIndex == -1 {
   424  		return "", errors.New("no gtid data in backup")
   425  	}
   426  	newOut := content[startIndex+len(sep):]
   427  	e := bytes.Index(newOut, []byte("'\n"))
   428  	if e == -1 {
   429  		return "", errors.New("can't find gtid data in backup")
   430  	}
   431  
   432  	se := bytes.Index(newOut, []byte("'"))
   433  	set := newOut[se+1 : e]
   434  
   435  	return string(set), nil
   436  }
   437  
   438  func getGTIDFromSSTInfo(content []byte) (string, error) {
   439  	sep := []byte("galera-gtid=")
   440  	startIndex := bytes.Index(content, sep)
   441  	if startIndex == -1 {
   442  		return "", errors.New("no gtid data in backup")
   443  	}
   444  	newOut := content[startIndex+len(sep):]
   445  	e := bytes.Index(newOut, []byte("\n"))
   446  	if e == -1 {
   447  		return "", errors.New("can't find gtid data in backup")
   448  	}
   449  	return string(newOut[:e]), nil
   450  }
   451  
   452  func getDecompressedContent(ctx context.Context, infoObj io.Reader, filename string) ([]byte, error) {
   453  	tmpDir := os.TempDir()
   454  
   455  	cmd := exec.CommandContext(ctx, "xbstream", "-x", "--decompress")
   456  	cmd.Dir = tmpDir
   457  	cmd.Stdin = infoObj
   458  	var outb, errb bytes.Buffer
   459  	cmd.Stdout = &outb
   460  	cmd.Stderr = &errb
   461  	err := cmd.Run()
   462  	if err != nil {
   463  		return nil, errors.Wrapf(err, "xbstream cmd run. stderr: %s, stdout: %s", &errb, &outb)
   464  	}
   465  	if errb.Len() > 0 {
   466  		return nil, errors.Errorf("run xbstream error: %s", &errb)
   467  	}
   468  
   469  	decContent, err := os.ReadFile(tmpDir + "/" + filename)
   470  	if err != nil {
   471  		return nil, errors.Wrap(err, "read xtrabackup_info file")
   472  	}
   473  
   474  	return decContent, nil
   475  }
   476  
   477  func (r *Recoverer) setBinlogs(ctx context.Context) error {
   478  	list, err := r.storage.ListObjects(ctx, "binlog_")
   479  	if err != nil {
   480  		return errors.Wrap(err, "list objects with prefix 'binlog_'")
   481  	}
   482  	reverse(list)
   483  	binlogs := []string{}
   484  	sourceID := strings.Split(r.startGTID, ":")[0]
   485  	log.Println("current gtid set is", r.startGTID)
   486  	for _, binlog := range list {
   487  		if strings.Contains(binlog, "-gtid-set") {
   488  			continue
   489  		}
   490  		infoObj, err := r.storage.GetObject(ctx, binlog+"-gtid-set")
   491  		if err != nil {
   492  			log.Println("Can't get binlog object with gtid set. Name:", binlog, "error", err)
   493  			continue
   494  		}
   495  		content, err := io.ReadAll(infoObj)
   496  		if err != nil {
   497  			return errors.Wrapf(err, "read %s gtid-set object", binlog)
   498  		}
   499  		binlogGTIDSet := string(content)
   500  		log.Println("checking current file", " name ", binlog, " gtid ", binlogGTIDSet)
   501  
   502  		if len(r.gtid) > 0 && r.recoverType == Transaction {
   503  			subResult, err := r.db.SubtractGTIDSet(ctx, binlogGTIDSet, r.gtid)
   504  			if err != nil {
   505  				return errors.Wrapf(err, "check if '%s' is a subset of '%s", binlogGTIDSet, r.gtid)
   506  			}
   507  			if subResult != binlogGTIDSet {
   508  				set, err := getExtendGTIDSet(binlogGTIDSet, r.gtid)
   509  				if err != nil {
   510  					return errors.Wrap(err, "get gtid set for extend")
   511  				}
   512  				r.gtidSet = set
   513  			}
   514  			if len(r.gtidSet) == 0 {
   515  				continue
   516  			}
   517  		}
   518  
   519  		binlogs = append(binlogs, binlog)
   520  		subResult, err := r.db.SubtractGTIDSet(ctx, r.startGTID, binlogGTIDSet)
   521  		log.Println("Checking sub result", " binlog gtid ", binlogGTIDSet, " sub result ", subResult)
   522  		if err != nil {
   523  			return errors.Wrapf(err, "check if '%s' is a subset of '%s", r.startGTID, binlogGTIDSet)
   524  		}
   525  		if subResult != r.startGTID {
   526  			break
   527  		}
   528  	}
   529  	if len(binlogs) == 0 {
   530  		return errors.Errorf("no objects for prefix binlog_ or with source_id=%s", sourceID)
   531  	}
   532  	reverse(binlogs)
   533  	r.binlogs = binlogs
   534  
   535  	return nil
   536  }
   537  
   538  func getExtendGTIDSet(gtidSet, gtid string) (string, error) {
   539  	if gtidSet == gtid {
   540  		return gtid, nil
   541  	}
   542  
   543  	s := strings.Split(gtidSet, ":")
   544  	if len(s) < 2 {
   545  		return "", errors.Errorf("incorrect source in gtid set %s", gtidSet)
   546  	}
   547  
   548  	eidx := 1
   549  	e := strings.Split(s[1], "-")
   550  	if len(e) == 1 {
   551  		eidx = 0
   552  	}
   553  
   554  	gs := strings.Split(gtid, ":")
   555  	if len(gs) < 2 {
   556  		return "", errors.Errorf("incorrect source in gtid set %s", gtid)
   557  	}
   558  
   559  	es := strings.Split(gs[1], "-")
   560  
   561  	return gs[0] + ":" + es[0] + "-" + e[eidx], nil
   562  }
   563  
   564  func reverse(list []string) {
   565  	for i := len(list)/2 - 1; i >= 0; i-- {
   566  		opp := len(list) - 1 - i
   567  		list[i], list[opp] = list[opp], list[i]
   568  	}
   569  }