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  }