github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/event/target/elasticsearch.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 "bytes" 22 "context" 23 "encoding/base64" 24 "encoding/json" 25 "fmt" 26 "net/http" 27 "net/url" 28 "os" 29 "path/filepath" 30 "strconv" 31 "strings" 32 "time" 33 34 elasticsearch7 "github.com/elastic/go-elasticsearch/v7" 35 "github.com/minio/highwayhash" 36 "github.com/minio/minio/internal/event" 37 xhttp "github.com/minio/minio/internal/http" 38 "github.com/minio/minio/internal/logger" 39 "github.com/minio/minio/internal/once" 40 "github.com/minio/minio/internal/store" 41 xnet "github.com/minio/pkg/v2/net" 42 "github.com/pkg/errors" 43 ) 44 45 // Elastic constants 46 const ( 47 ElasticFormat = "format" 48 ElasticURL = "url" 49 ElasticIndex = "index" 50 ElasticQueueDir = "queue_dir" 51 ElasticQueueLimit = "queue_limit" 52 ElasticUsername = "username" 53 ElasticPassword = "password" 54 55 EnvElasticEnable = "MINIO_NOTIFY_ELASTICSEARCH_ENABLE" 56 EnvElasticFormat = "MINIO_NOTIFY_ELASTICSEARCH_FORMAT" 57 EnvElasticURL = "MINIO_NOTIFY_ELASTICSEARCH_URL" 58 EnvElasticIndex = "MINIO_NOTIFY_ELASTICSEARCH_INDEX" 59 EnvElasticQueueDir = "MINIO_NOTIFY_ELASTICSEARCH_QUEUE_DIR" 60 EnvElasticQueueLimit = "MINIO_NOTIFY_ELASTICSEARCH_QUEUE_LIMIT" 61 EnvElasticUsername = "MINIO_NOTIFY_ELASTICSEARCH_USERNAME" 62 EnvElasticPassword = "MINIO_NOTIFY_ELASTICSEARCH_PASSWORD" 63 ) 64 65 // ESSupportStatus is a typed string representing the support status for 66 // Elasticsearch 67 type ESSupportStatus string 68 69 const ( 70 // ESSUnknown is default value 71 ESSUnknown ESSupportStatus = "ESSUnknown" 72 // ESSDeprecated -> support will be removed in future 73 ESSDeprecated ESSupportStatus = "ESSDeprecated" 74 // ESSUnsupported -> we won't work with this ES server 75 ESSUnsupported ESSupportStatus = "ESSUnsupported" 76 // ESSSupported -> all good! 77 ESSSupported ESSupportStatus = "ESSSupported" 78 ) 79 80 func getESVersionSupportStatus(version string) (res ESSupportStatus, err error) { 81 parts := strings.Split(version, ".") 82 if len(parts) < 1 { 83 err = fmt.Errorf("bad ES version string: %s", version) 84 return 85 } 86 87 majorVersion, err := strconv.Atoi(parts[0]) 88 if err != nil { 89 err = fmt.Errorf("bad ES version string: %s", version) 90 return 91 } 92 93 switch { 94 case majorVersion <= 6: 95 res = ESSUnsupported 96 default: 97 res = ESSSupported 98 } 99 return 100 } 101 102 // magic HH-256 key as HH-256 hash of the first 100 decimals of π as utf-8 string with a zero key. 103 var magicHighwayHash256Key = []byte("\x4b\xe7\x34\xfa\x8e\x23\x8a\xcd\x26\x3e\x83\xe6\xbb\x96\x85\x52\x04\x0f\x93\x5d\xa3\x9f\x44\x14\x97\xe0\x9d\x13\x22\xde\x36\xa0") 104 105 // Interface for elasticsearch client objects 106 type esClient interface { 107 isAtleastV7() bool 108 createIndex(ElasticsearchArgs) error 109 ping(context.Context, ElasticsearchArgs) (bool, error) 110 stop() 111 entryExists(context.Context, string, string) (bool, error) 112 removeEntry(context.Context, string, string) error 113 updateEntry(context.Context, string, string, event.Event) error 114 addEntry(context.Context, string, event.Event) error 115 } 116 117 // ElasticsearchArgs - Elasticsearch target arguments. 118 type ElasticsearchArgs struct { 119 Enable bool `json:"enable"` 120 Format string `json:"format"` 121 URL xnet.URL `json:"url"` 122 Index string `json:"index"` 123 QueueDir string `json:"queueDir"` 124 QueueLimit uint64 `json:"queueLimit"` 125 Transport *http.Transport `json:"-"` 126 Username string `json:"username"` 127 Password string `json:"password"` 128 } 129 130 // Validate ElasticsearchArgs fields 131 func (a ElasticsearchArgs) Validate() error { 132 if !a.Enable { 133 return nil 134 } 135 if a.URL.IsEmpty() { 136 return errors.New("empty URL") 137 } 138 if a.Format != "" { 139 f := strings.ToLower(a.Format) 140 if f != event.NamespaceFormat && f != event.AccessFormat { 141 return errors.New("format value unrecognized") 142 } 143 } 144 if a.Index == "" { 145 return errors.New("empty index value") 146 } 147 148 if (a.Username == "" && a.Password != "") || (a.Username != "" && a.Password == "") { 149 return errors.New("username and password should be set in pairs") 150 } 151 152 return nil 153 } 154 155 // ElasticsearchTarget - Elasticsearch target. 156 type ElasticsearchTarget struct { 157 initOnce once.Init 158 159 id event.TargetID 160 args ElasticsearchArgs 161 client esClient 162 store store.Store[event.Event] 163 loggerOnce logger.LogOnce 164 quitCh chan struct{} 165 } 166 167 // ID - returns target ID. 168 func (target *ElasticsearchTarget) ID() event.TargetID { 169 return target.id 170 } 171 172 // Name - returns the Name of the target. 173 func (target *ElasticsearchTarget) Name() string { 174 return target.ID().String() 175 } 176 177 // Store returns any underlying store if set. 178 func (target *ElasticsearchTarget) Store() event.TargetStore { 179 return target.store 180 } 181 182 // IsActive - Return true if target is up and active 183 func (target *ElasticsearchTarget) IsActive() (bool, error) { 184 if err := target.init(); err != nil { 185 return false, err 186 } 187 return target.isActive() 188 } 189 190 func (target *ElasticsearchTarget) isActive() (bool, error) { 191 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 192 defer cancel() 193 194 err := target.checkAndInitClient(ctx) 195 if err != nil { 196 return false, err 197 } 198 199 return target.client.ping(ctx, target.args) 200 } 201 202 // Save - saves the events to the store if queuestore is configured, which will be replayed when the elasticsearch connection is active. 203 func (target *ElasticsearchTarget) Save(eventData event.Event) error { 204 if target.store != nil { 205 return target.store.Put(eventData) 206 } 207 if err := target.init(); err != nil { 208 return err 209 } 210 211 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 212 defer cancel() 213 214 err := target.checkAndInitClient(ctx) 215 if err != nil { 216 return err 217 } 218 219 err = target.send(eventData) 220 if xnet.IsNetworkOrHostDown(err, false) { 221 return store.ErrNotConnected 222 } 223 return err 224 } 225 226 // send - sends the event to the target. 227 func (target *ElasticsearchTarget) send(eventData event.Event) error { 228 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 229 defer cancel() 230 231 if target.args.Format == event.NamespaceFormat { 232 objectName, err := url.QueryUnescape(eventData.S3.Object.Key) 233 if err != nil { 234 return err 235 } 236 237 // Calculate a hash of the key for the id of the ES document. 238 // Id's are limited to 512 bytes in V7+, so we need to do this. 239 var keyHash string 240 { 241 key := eventData.S3.Bucket.Name + "/" + objectName 242 if target.client.isAtleastV7() { 243 hh, _ := highwayhash.New(magicHighwayHash256Key) // New will never return error since key is 256 bit 244 hh.Write([]byte(key)) 245 hashBytes := hh.Sum(nil) 246 keyHash = base64.URLEncoding.EncodeToString(hashBytes) 247 } else { 248 keyHash = key 249 } 250 } 251 252 if eventData.EventName == event.ObjectRemovedDelete { 253 err = target.client.removeEntry(ctx, target.args.Index, keyHash) 254 } else { 255 err = target.client.updateEntry(ctx, target.args.Index, keyHash, eventData) 256 } 257 return err 258 } 259 260 if target.args.Format == event.AccessFormat { 261 return target.client.addEntry(ctx, target.args.Index, eventData) 262 } 263 264 return nil 265 } 266 267 // SendFromStore - reads an event from store and sends it to Elasticsearch. 268 func (target *ElasticsearchTarget) SendFromStore(key store.Key) error { 269 if err := target.init(); err != nil { 270 return err 271 } 272 273 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 274 defer cancel() 275 276 err := target.checkAndInitClient(ctx) 277 if err != nil { 278 return err 279 } 280 281 eventData, eErr := target.store.Get(key.Name) 282 if eErr != nil { 283 // The last event key in a successful batch will be sent in the channel atmost once by the replayEvents() 284 // Such events will not exist and wouldve been already been sent successfully. 285 if os.IsNotExist(eErr) { 286 return nil 287 } 288 return eErr 289 } 290 291 if err := target.send(eventData); err != nil { 292 if xnet.IsNetworkOrHostDown(err, false) { 293 return store.ErrNotConnected 294 } 295 return err 296 } 297 298 // Delete the event from store. 299 return target.store.Del(key.Name) 300 } 301 302 // Close - does nothing and available for interface compatibility. 303 func (target *ElasticsearchTarget) Close() error { 304 close(target.quitCh) 305 if target.client != nil { 306 // Stops the background processes that the client is running. 307 target.client.stop() 308 } 309 return nil 310 } 311 312 func (target *ElasticsearchTarget) checkAndInitClient(ctx context.Context) error { 313 if target.client != nil { 314 return nil 315 } 316 317 clientV7, err := newClientV7(target.args) 318 if err != nil { 319 return err 320 } 321 322 // Check es version to confirm if it is supported. 323 serverSupportStatus, version, err := clientV7.getServerSupportStatus(ctx) 324 if err != nil { 325 return err 326 } 327 328 switch serverSupportStatus { 329 case ESSUnknown: 330 return errors.New("unable to determine support status of ES (should not happen)") 331 332 case ESSDeprecated: 333 return errors.New("there is no currently deprecated version of ES in MinIO") 334 335 case ESSSupported: 336 target.client = clientV7 337 338 default: 339 // ESSUnsupported case 340 return fmt.Errorf("Elasticsearch version '%s' is not supported! Please use at least version 7.x.", version) 341 } 342 343 target.client.createIndex(target.args) 344 return nil 345 } 346 347 func (target *ElasticsearchTarget) init() error { 348 return target.initOnce.Do(target.initElasticsearch) 349 } 350 351 func (target *ElasticsearchTarget) initElasticsearch() error { 352 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 353 defer cancel() 354 355 err := target.checkAndInitClient(ctx) 356 if err != nil { 357 if err != store.ErrNotConnected { 358 target.loggerOnce(context.Background(), err, target.ID().String()) 359 } 360 return err 361 } 362 363 return nil 364 } 365 366 // NewElasticsearchTarget - creates new Elasticsearch target. 367 func NewElasticsearchTarget(id string, args ElasticsearchArgs, loggerOnce logger.LogOnce) (*ElasticsearchTarget, error) { 368 var queueStore store.Store[event.Event] 369 if args.QueueDir != "" { 370 queueDir := filepath.Join(args.QueueDir, storePrefix+"-elasticsearch-"+id) 371 queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension) 372 if err := queueStore.Open(); err != nil { 373 return nil, fmt.Errorf("unable to initialize the queue store of Elasticsearch `%s`: %w", id, err) 374 } 375 } 376 377 target := &ElasticsearchTarget{ 378 id: event.TargetID{ID: id, Name: "elasticsearch"}, 379 args: args, 380 store: queueStore, 381 loggerOnce: loggerOnce, 382 quitCh: make(chan struct{}), 383 } 384 385 if target.store != nil { 386 store.StreamItems(target.store, target, target.quitCh, target.loggerOnce) 387 } 388 389 return target, nil 390 } 391 392 // ES Client definitions and methods 393 394 type esClientV7 struct { 395 *elasticsearch7.Client 396 } 397 398 func newClientV7(args ElasticsearchArgs) (*esClientV7, error) { 399 // Client options 400 elasticConfig := elasticsearch7.Config{ 401 Addresses: []string{args.URL.String()}, 402 Transport: args.Transport, 403 MaxRetries: 10, 404 } 405 // Set basic auth 406 if args.Username != "" && args.Password != "" { 407 elasticConfig.Username = args.Username 408 elasticConfig.Password = args.Password 409 } 410 // Create a client 411 client, err := elasticsearch7.NewClient(elasticConfig) 412 if err != nil { 413 return nil, err 414 } 415 clientV7 := &esClientV7{client} 416 return clientV7, nil 417 } 418 419 func (c *esClientV7) getServerSupportStatus(ctx context.Context) (ESSupportStatus, string, error) { 420 resp, err := c.Info( 421 c.Info.WithContext(ctx), 422 ) 423 if err != nil { 424 return ESSUnknown, "", store.ErrNotConnected 425 } 426 427 defer resp.Body.Close() 428 429 m := make(map[string]interface{}) 430 err = json.NewDecoder(resp.Body).Decode(&m) 431 if err != nil { 432 return ESSUnknown, "", fmt.Errorf("unable to get ES Server version - json parse error: %v", err) 433 } 434 435 if v, ok := m["version"].(map[string]interface{}); ok { 436 if ver, ok := v["number"].(string); ok { 437 status, err := getESVersionSupportStatus(ver) 438 return status, ver, err 439 } 440 } 441 return ESSUnknown, "", fmt.Errorf("Unable to get ES Server Version - got INFO response: %v", m) 442 } 443 444 func (c *esClientV7) isAtleastV7() bool { 445 return true 446 } 447 448 // createIndex - creates the index if it does not exist. 449 func (c *esClientV7) createIndex(args ElasticsearchArgs) error { 450 res, err := c.Indices.ResolveIndex([]string{args.Index}) 451 if err != nil { 452 return err 453 } 454 defer res.Body.Close() 455 456 var v map[string]interface{} 457 found := false 458 if err := json.NewDecoder(res.Body).Decode(&v); err != nil { 459 return fmt.Errorf("Error parsing response body: %v", err) 460 } 461 462 indices, ok := v["indices"].([]interface{}) 463 if ok { 464 for _, index := range indices { 465 name := index.(map[string]interface{})["name"] 466 if name == args.Index { 467 found = true 468 break 469 } 470 } 471 } 472 473 if !found { 474 resp, err := c.Indices.Create(args.Index) 475 if err != nil { 476 return err 477 } 478 defer xhttp.DrainBody(resp.Body) 479 if resp.IsError() { 480 return fmt.Errorf("Create index err: %v", res) 481 } 482 return nil 483 } 484 return nil 485 } 486 487 func (c *esClientV7) ping(ctx context.Context, _ ElasticsearchArgs) (bool, error) { 488 resp, err := c.Ping( 489 c.Ping.WithContext(ctx), 490 ) 491 if err != nil { 492 return false, store.ErrNotConnected 493 } 494 xhttp.DrainBody(resp.Body) 495 return !resp.IsError(), nil 496 } 497 498 func (c *esClientV7) entryExists(ctx context.Context, index string, key string) (bool, error) { 499 res, err := c.Exists( 500 index, 501 key, 502 c.Exists.WithContext(ctx), 503 ) 504 if err != nil { 505 return false, err 506 } 507 xhttp.DrainBody(res.Body) 508 return !res.IsError(), nil 509 } 510 511 func (c *esClientV7) removeEntry(ctx context.Context, index string, key string) error { 512 exists, err := c.entryExists(ctx, index, key) 513 if err == nil && exists { 514 res, err := c.Delete( 515 index, 516 key, 517 c.Delete.WithContext(ctx), 518 ) 519 if err != nil { 520 return err 521 } 522 defer xhttp.DrainBody(res.Body) 523 if res.IsError() { 524 return fmt.Errorf("Delete err: %s", res.String()) 525 } 526 return nil 527 } 528 return err 529 } 530 531 func (c *esClientV7) updateEntry(ctx context.Context, index string, key string, eventData event.Event) error { 532 doc := map[string]interface{}{ 533 "Records": []event.Event{eventData}, 534 } 535 var buf bytes.Buffer 536 enc := json.NewEncoder(&buf) 537 err := enc.Encode(doc) 538 if err != nil { 539 return err 540 } 541 res, err := c.Index( 542 index, 543 &buf, 544 c.Index.WithDocumentID(key), 545 c.Index.WithContext(ctx), 546 ) 547 if err != nil { 548 return err 549 } 550 defer xhttp.DrainBody(res.Body) 551 if res.IsError() { 552 return fmt.Errorf("Update err: %s", res.String()) 553 } 554 555 return nil 556 } 557 558 func (c *esClientV7) addEntry(ctx context.Context, index string, eventData event.Event) error { 559 doc := map[string]interface{}{ 560 "Records": []event.Event{eventData}, 561 } 562 var buf bytes.Buffer 563 enc := json.NewEncoder(&buf) 564 err := enc.Encode(doc) 565 if err != nil { 566 return err 567 } 568 res, err := c.Index( 569 index, 570 &buf, 571 c.Index.WithContext(ctx), 572 ) 573 if err != nil { 574 return err 575 } 576 defer xhttp.DrainBody(res.Body) 577 if res.IsError() { 578 return fmt.Errorf("Add err: %s", res.String()) 579 } 580 return nil 581 } 582 583 func (c *esClientV7) stop() { 584 }