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 }