github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/event/target/mysql.go (about)

     1  // Copyright (c) 2015-2023 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package target
    19  
    20  import (
    21  	"context"
    22  	"database/sql"
    23  	"encoding/json"
    24  	"errors"
    25  	"fmt"
    26  	"net/url"
    27  	"os"
    28  	"path/filepath"
    29  	"strconv"
    30  	"strings"
    31  	"time"
    32  
    33  	"github.com/go-sql-driver/mysql"
    34  	"github.com/minio/minio/internal/event"
    35  	"github.com/minio/minio/internal/logger"
    36  	"github.com/minio/minio/internal/once"
    37  	"github.com/minio/minio/internal/store"
    38  	xnet "github.com/minio/pkg/v2/net"
    39  )
    40  
    41  const (
    42  	mysqlTableExists = `SELECT 1 FROM %s;`
    43  	// Some MySQL has a 3072 byte limit on key sizes.
    44  	mysqlCreateNamespaceTable = `CREATE TABLE %s (
    45               key_name VARCHAR(3072) NOT NULL,
    46               key_hash CHAR(64) GENERATED ALWAYS AS (SHA2(key_name, 256)) STORED NOT NULL PRIMARY KEY,
    47               value JSON)
    48             CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;`
    49  	mysqlCreateAccessTable = `CREATE TABLE %s (event_time DATETIME NOT NULL, event_data JSON)
    50                                      ROW_FORMAT = Dynamic;`
    51  
    52  	mysqlUpdateRow = `INSERT INTO %s (key_name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value);`
    53  	mysqlDeleteRow = `DELETE FROM %s WHERE key_hash = SHA2(?, 256);`
    54  	mysqlInsertRow = `INSERT INTO %s (event_time, event_data) VALUES (?, ?);`
    55  )
    56  
    57  // MySQL related constants
    58  const (
    59  	MySQLFormat             = "format"
    60  	MySQLDSNString          = "dsn_string"
    61  	MySQLTable              = "table"
    62  	MySQLHost               = "host"
    63  	MySQLPort               = "port"
    64  	MySQLUsername           = "username"
    65  	MySQLPassword           = "password"
    66  	MySQLDatabase           = "database"
    67  	MySQLQueueLimit         = "queue_limit"
    68  	MySQLQueueDir           = "queue_dir"
    69  	MySQLMaxOpenConnections = "max_open_connections"
    70  
    71  	EnvMySQLEnable             = "MINIO_NOTIFY_MYSQL_ENABLE"
    72  	EnvMySQLFormat             = "MINIO_NOTIFY_MYSQL_FORMAT"
    73  	EnvMySQLDSNString          = "MINIO_NOTIFY_MYSQL_DSN_STRING"
    74  	EnvMySQLTable              = "MINIO_NOTIFY_MYSQL_TABLE"
    75  	EnvMySQLHost               = "MINIO_NOTIFY_MYSQL_HOST"
    76  	EnvMySQLPort               = "MINIO_NOTIFY_MYSQL_PORT"
    77  	EnvMySQLUsername           = "MINIO_NOTIFY_MYSQL_USERNAME"
    78  	EnvMySQLPassword           = "MINIO_NOTIFY_MYSQL_PASSWORD"
    79  	EnvMySQLDatabase           = "MINIO_NOTIFY_MYSQL_DATABASE"
    80  	EnvMySQLQueueLimit         = "MINIO_NOTIFY_MYSQL_QUEUE_LIMIT"
    81  	EnvMySQLQueueDir           = "MINIO_NOTIFY_MYSQL_QUEUE_DIR"
    82  	EnvMySQLMaxOpenConnections = "MINIO_NOTIFY_MYSQL_MAX_OPEN_CONNECTIONS"
    83  )
    84  
    85  // MySQLArgs - MySQL target arguments.
    86  type MySQLArgs struct {
    87  	Enable             bool     `json:"enable"`
    88  	Format             string   `json:"format"`
    89  	DSN                string   `json:"dsnString"`
    90  	Table              string   `json:"table"`
    91  	Host               xnet.URL `json:"host"`
    92  	Port               string   `json:"port"`
    93  	User               string   `json:"user"`
    94  	Password           string   `json:"password"`
    95  	Database           string   `json:"database"`
    96  	QueueDir           string   `json:"queueDir"`
    97  	QueueLimit         uint64   `json:"queueLimit"`
    98  	MaxOpenConnections int      `json:"maxOpenConnections"`
    99  }
   100  
   101  // Validate MySQLArgs fields
   102  func (m MySQLArgs) Validate() error {
   103  	if !m.Enable {
   104  		return nil
   105  	}
   106  
   107  	if m.Format != "" {
   108  		f := strings.ToLower(m.Format)
   109  		if f != event.NamespaceFormat && f != event.AccessFormat {
   110  			return fmt.Errorf("unrecognized format")
   111  		}
   112  	}
   113  
   114  	if m.Table == "" {
   115  		return fmt.Errorf("table unspecified")
   116  	}
   117  
   118  	if m.DSN != "" {
   119  		if _, err := mysql.ParseDSN(m.DSN); err != nil {
   120  			return err
   121  		}
   122  	} else {
   123  		// Some fields need to be specified when DSN is unspecified
   124  		if m.Port == "" {
   125  			return fmt.Errorf("unspecified port")
   126  		}
   127  		if _, err := strconv.Atoi(m.Port); err != nil {
   128  			return fmt.Errorf("invalid port")
   129  		}
   130  		if m.Database == "" {
   131  			return fmt.Errorf("database unspecified")
   132  		}
   133  	}
   134  
   135  	if m.QueueDir != "" {
   136  		if !filepath.IsAbs(m.QueueDir) {
   137  			return errors.New("queueDir path should be absolute")
   138  		}
   139  	}
   140  
   141  	if m.MaxOpenConnections < 0 {
   142  		return errors.New("maxOpenConnections cannot be less than zero")
   143  	}
   144  
   145  	return nil
   146  }
   147  
   148  // MySQLTarget - MySQL target.
   149  type MySQLTarget struct {
   150  	initOnce once.Init
   151  
   152  	id         event.TargetID
   153  	args       MySQLArgs
   154  	updateStmt *sql.Stmt
   155  	deleteStmt *sql.Stmt
   156  	insertStmt *sql.Stmt
   157  	db         *sql.DB
   158  	store      store.Store[event.Event]
   159  	firstPing  bool
   160  	loggerOnce logger.LogOnce
   161  
   162  	quitCh chan struct{}
   163  }
   164  
   165  // ID - returns target ID.
   166  func (target *MySQLTarget) ID() event.TargetID {
   167  	return target.id
   168  }
   169  
   170  // Name - returns the Name of the target.
   171  func (target *MySQLTarget) Name() string {
   172  	return target.ID().String()
   173  }
   174  
   175  // Store returns any underlying store if set.
   176  func (target *MySQLTarget) Store() event.TargetStore {
   177  	return target.store
   178  }
   179  
   180  // IsActive - Return true if target is up and active
   181  func (target *MySQLTarget) IsActive() (bool, error) {
   182  	if err := target.init(); err != nil {
   183  		return false, err
   184  	}
   185  	return target.isActive()
   186  }
   187  
   188  func (target *MySQLTarget) isActive() (bool, error) {
   189  	if err := target.db.Ping(); err != nil {
   190  		if IsConnErr(err) {
   191  			return false, store.ErrNotConnected
   192  		}
   193  		return false, err
   194  	}
   195  	return true, nil
   196  }
   197  
   198  // Save - saves the events to the store which will be replayed when the SQL connection is active.
   199  func (target *MySQLTarget) Save(eventData event.Event) error {
   200  	if target.store != nil {
   201  		return target.store.Put(eventData)
   202  	}
   203  	if err := target.init(); err != nil {
   204  		return err
   205  	}
   206  
   207  	_, err := target.isActive()
   208  	if err != nil {
   209  		return err
   210  	}
   211  	return target.send(eventData)
   212  }
   213  
   214  // send - sends an event to the mysql.
   215  func (target *MySQLTarget) send(eventData event.Event) error {
   216  	if target.args.Format == event.NamespaceFormat {
   217  		objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
   218  		if err != nil {
   219  			return err
   220  		}
   221  		key := eventData.S3.Bucket.Name + "/" + objectName
   222  
   223  		if eventData.EventName == event.ObjectRemovedDelete {
   224  			_, err = target.deleteStmt.Exec(key)
   225  		} else {
   226  			var data []byte
   227  			if data, err = json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}}); err != nil {
   228  				return err
   229  			}
   230  
   231  			_, err = target.updateStmt.Exec(key, data)
   232  		}
   233  
   234  		return err
   235  	}
   236  
   237  	if target.args.Format == event.AccessFormat {
   238  		eventTime, err := time.Parse(event.AMZTimeFormat, eventData.EventTime)
   239  		if err != nil {
   240  			return err
   241  		}
   242  
   243  		data, err := json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}})
   244  		if err != nil {
   245  			return err
   246  		}
   247  
   248  		_, err = target.insertStmt.Exec(eventTime, data)
   249  
   250  		return err
   251  	}
   252  
   253  	return nil
   254  }
   255  
   256  // SendFromStore - reads an event from store and sends it to MySQL.
   257  func (target *MySQLTarget) SendFromStore(key store.Key) error {
   258  	if err := target.init(); err != nil {
   259  		return err
   260  	}
   261  
   262  	_, err := target.isActive()
   263  	if err != nil {
   264  		return err
   265  	}
   266  
   267  	if !target.firstPing {
   268  		if err := target.executeStmts(); err != nil {
   269  			if IsConnErr(err) {
   270  				return store.ErrNotConnected
   271  			}
   272  			return err
   273  		}
   274  	}
   275  
   276  	eventData, eErr := target.store.Get(key.Name)
   277  	if eErr != nil {
   278  		// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
   279  		// Such events will not exist and wouldve been already been sent successfully.
   280  		if os.IsNotExist(eErr) {
   281  			return nil
   282  		}
   283  		return eErr
   284  	}
   285  
   286  	if err := target.send(eventData); err != nil {
   287  		if IsConnErr(err) {
   288  			return store.ErrNotConnected
   289  		}
   290  		return err
   291  	}
   292  
   293  	// Delete the event from store.
   294  	return target.store.Del(key.Name)
   295  }
   296  
   297  // Close - closes underneath connections to MySQL database.
   298  func (target *MySQLTarget) Close() error {
   299  	close(target.quitCh)
   300  	if target.updateStmt != nil {
   301  		// FIXME: log returned error. ignore time being.
   302  		_ = target.updateStmt.Close()
   303  	}
   304  
   305  	if target.deleteStmt != nil {
   306  		// FIXME: log returned error. ignore time being.
   307  		_ = target.deleteStmt.Close()
   308  	}
   309  
   310  	if target.insertStmt != nil {
   311  		// FIXME: log returned error. ignore time being.
   312  		_ = target.insertStmt.Close()
   313  	}
   314  
   315  	if target.db != nil {
   316  		return target.db.Close()
   317  	}
   318  
   319  	return nil
   320  }
   321  
   322  // Executes the table creation statements.
   323  func (target *MySQLTarget) executeStmts() error {
   324  	_, err := target.db.Exec(fmt.Sprintf(mysqlTableExists, target.args.Table))
   325  	if err != nil {
   326  		createStmt := mysqlCreateNamespaceTable
   327  		if target.args.Format == event.AccessFormat {
   328  			createStmt = mysqlCreateAccessTable
   329  		}
   330  
   331  		if _, dbErr := target.db.Exec(fmt.Sprintf(createStmt, target.args.Table)); dbErr != nil {
   332  			return dbErr
   333  		}
   334  	}
   335  
   336  	switch target.args.Format {
   337  	case event.NamespaceFormat:
   338  		// insert or update statement
   339  		if target.updateStmt, err = target.db.Prepare(fmt.Sprintf(mysqlUpdateRow, target.args.Table)); err != nil {
   340  			return err
   341  		}
   342  		// delete statement
   343  		if target.deleteStmt, err = target.db.Prepare(fmt.Sprintf(mysqlDeleteRow, target.args.Table)); err != nil {
   344  			return err
   345  		}
   346  	case event.AccessFormat:
   347  		// insert statement
   348  		if target.insertStmt, err = target.db.Prepare(fmt.Sprintf(mysqlInsertRow, target.args.Table)); err != nil {
   349  			return err
   350  		}
   351  	}
   352  
   353  	return nil
   354  }
   355  
   356  func (target *MySQLTarget) init() error {
   357  	return target.initOnce.Do(target.initMySQL)
   358  }
   359  
   360  func (target *MySQLTarget) initMySQL() error {
   361  	args := target.args
   362  
   363  	db, err := sql.Open("mysql", args.DSN)
   364  	if err != nil {
   365  		target.loggerOnce(context.Background(), err, target.ID().String())
   366  		return err
   367  	}
   368  	target.db = db
   369  
   370  	if args.MaxOpenConnections > 0 {
   371  		// Set the maximum connections limit
   372  		target.db.SetMaxOpenConns(args.MaxOpenConnections)
   373  	}
   374  
   375  	err = target.db.Ping()
   376  	if err != nil {
   377  		if !(xnet.IsConnRefusedErr(err) || xnet.IsConnResetErr(err)) {
   378  			target.loggerOnce(context.Background(), err, target.ID().String())
   379  		}
   380  	} else {
   381  		if err = target.executeStmts(); err != nil {
   382  			target.loggerOnce(context.Background(), err, target.ID().String())
   383  		} else {
   384  			target.firstPing = true
   385  		}
   386  	}
   387  
   388  	if err != nil {
   389  		target.db.Close()
   390  		return err
   391  	}
   392  
   393  	yes, err := target.isActive()
   394  	if err != nil {
   395  		return err
   396  	}
   397  	if !yes {
   398  		return store.ErrNotConnected
   399  	}
   400  
   401  	return nil
   402  }
   403  
   404  // NewMySQLTarget - creates new MySQL target.
   405  func NewMySQLTarget(id string, args MySQLArgs, loggerOnce logger.LogOnce) (*MySQLTarget, error) {
   406  	var queueStore store.Store[event.Event]
   407  	if args.QueueDir != "" {
   408  		queueDir := filepath.Join(args.QueueDir, storePrefix+"-mysql-"+id)
   409  		queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
   410  		if err := queueStore.Open(); err != nil {
   411  			return nil, fmt.Errorf("unable to initialize the queue store of MySQL `%s`: %w", id, err)
   412  		}
   413  	}
   414  
   415  	if args.DSN == "" {
   416  		config := mysql.Config{
   417  			User:                 args.User,
   418  			Passwd:               args.Password,
   419  			Net:                  "tcp",
   420  			Addr:                 args.Host.String() + ":" + args.Port,
   421  			DBName:               args.Database,
   422  			AllowNativePasswords: true,
   423  			CheckConnLiveness:    true,
   424  		}
   425  
   426  		args.DSN = config.FormatDSN()
   427  	}
   428  
   429  	target := &MySQLTarget{
   430  		id:         event.TargetID{ID: id, Name: "mysql"},
   431  		args:       args,
   432  		firstPing:  false,
   433  		store:      queueStore,
   434  		loggerOnce: loggerOnce,
   435  		quitCh:     make(chan struct{}),
   436  	}
   437  
   438  	if target.store != nil {
   439  		store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
   440  	}
   441  
   442  	return target, nil
   443  }