// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.enterprise for license information. package opensearch import ( "bytes" "context" "encoding/json" "net/http" "slices" "strings" "sync" "sync/atomic" "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/app/platform" "github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch/common" "github.com/mattermost/mattermost/server/v8/platform/services/searchengine" "github.com/elastic/go-elasticsearch/v8/typedapi/core/deletebyquery" "github.com/elastic/go-elasticsearch/v8/typedapi/core/search" "github.com/elastic/go-elasticsearch/v8/typedapi/types" "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/highlighterencoder" "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/operator" "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/sortorder" "github.com/opensearch-project/opensearch-go/v4" "github.com/opensearch-project/opensearch-go/v4/opensearchapi" ) const opensearchMaxVersion = 2 var ( purgeIndexListAllowedIndexes = []string{common.IndexBaseChannels} ) type OpensearchInterfaceImpl struct { client *opensearchapi.Client mutex sync.RWMutex ready int32 version int fullVersion string plugins []string bulkProcessor *Bulk syncBulkProcessor *Bulk Platform *platform.PlatformService } func getJSONOrErrorStr(obj any) string { b, err := json.Marshal(obj) if err != nil { return err.Error() } return string(b) } func (*OpensearchInterfaceImpl) UpdateConfig(cfg *model.Config) { // Not needed, it uses the `Server` stored internally to get always the last version } func (*OpensearchInterfaceImpl) GetName() string { return "opensearch" } func (os *OpensearchInterfaceImpl) IsEnabled() bool { return *os.Platform.Config().ElasticsearchSettings.EnableIndexing } func (os *OpensearchInterfaceImpl) IsActive() bool { return *os.Platform.Config().ElasticsearchSettings.EnableIndexing && atomic.LoadInt32(&os.ready) == 1 } func (os *OpensearchInterfaceImpl) IsIndexingEnabled() bool { return *os.Platform.Config().ElasticsearchSettings.EnableIndexing } func (os *OpensearchInterfaceImpl) IsSearchEnabled() bool { return *os.Platform.Config().ElasticsearchSettings.EnableSearching } func (os *OpensearchInterfaceImpl) IsAutocompletionEnabled() bool { return *os.Platform.Config().ElasticsearchSettings.EnableAutocomplete } func (os *OpensearchInterfaceImpl) IsIndexingSync() bool { return *os.Platform.Config().ElasticsearchSettings.LiveIndexingBatchSize <= 1 } func (os *OpensearchInterfaceImpl) Start() *model.AppError { if license := os.Platform.License(); license == nil || !*license.Features.Elasticsearch || !*os.Platform.Config().ElasticsearchSettings.EnableIndexing { return nil } os.mutex.Lock() defer os.mutex.Unlock() if atomic.LoadInt32(&os.ready) != 0 { // Elasticsearch is already started. We don't return an error // because "Test Connection" already re-initializes the client. So this // can be a valid scenario. return nil } var appErr *model.AppError if os.client, appErr = createClient(os.Platform.Log(), os.Platform.Config(), os.Platform.FileBackend(), true); appErr != nil { return appErr } version, major, appErr := checkMaxVersion(os.client) if appErr != nil { return appErr } // Since we are only retrieving plugins for the Support Packet generation, it doesn't make sense to kill the process if we get an error // Instead, we will log it and move forward resp, err := os.client.Cat.Plugins(context.Background(), nil) if err != nil { os.Platform.Log().Warn("Error retrieving opensearch plugins", mlog.Err(err)) } else { for _, p := range resp.Plugins { os.plugins = append(os.plugins, p.Component) } } os.version = major os.fullVersion = version ctx := context.Background() esSettings := os.Platform.Config().ElasticsearchSettings if *esSettings.LiveIndexingBatchSize > 1 { os.bulkProcessor = NewBulk( common.BulkSettings{ FlushBytes: 0, FlushInterval: common.BulkFlushInterval, FlushNumReqs: *esSettings.LiveIndexingBatchSize, }, os.client, time.Duration(*esSettings.RequestTimeoutSeconds)*time.Second, os.Platform.Log()) } os.syncBulkProcessor = NewBulk( common.BulkSettings{ FlushBytes: common.BulkFlushBytes, FlushInterval: 0, FlushNumReqs: 0, }, os.client, time.Duration(*esSettings.RequestTimeoutSeconds)*time.Second, os.Platform.Log()) // Set up posts index template. templateBuf, err := json.Marshal(common.GetPostTemplate(os.Platform.Config())) if err != nil { return model.NewAppError("Opensearch.start", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.IndexTemplate.Create(ctx, opensearchapi.IndexTemplateCreateReq{ IndexTemplate: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts, Body: bytes.NewReader(templateBuf), }) if err != nil { return model.NewAppError("Opensearch.start", "ent.elasticsearch.create_template_posts_if_not_exists.template_create_failed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } // Set up channels index template. templateBuf, err = json.Marshal(common.GetChannelTemplate(os.Platform.Config())) if err != nil { return model.NewAppError("Opensearch.start", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.IndexTemplate.Create(ctx, opensearchapi.IndexTemplateCreateReq{ IndexTemplate: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseChannels, Body: bytes.NewReader(templateBuf), }) if err != nil { return model.NewAppError("Opensearch.start", "ent.elasticsearch.create_template_channels_if_not_exists.template_create_failed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } // Set up users index template. templateBuf, err = json.Marshal(common.GetUserTemplate(os.Platform.Config())) if err != nil { return model.NewAppError("Opensearch.start", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.IndexTemplate.Create(ctx, opensearchapi.IndexTemplateCreateReq{ IndexTemplate: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseUsers, Body: bytes.NewReader(templateBuf), }) if err != nil { return model.NewAppError("Opensearch.start", "ent.elasticsearch.create_template_users_if_not_exists.template_create_failed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } // Set up files index template. templateBuf, err = json.Marshal(common.GetFileInfoTemplate(os.Platform.Config())) if err != nil { return model.NewAppError("Opensearch.start", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.IndexTemplate.Create(ctx, opensearchapi.IndexTemplateCreateReq{ IndexTemplate: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles, Body: bytes.NewReader(templateBuf), }) if err != nil { return model.NewAppError("Opensearch.start", "ent.elasticsearch.create_template_file_info_if_not_exists.template_create_failed", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } atomic.StoreInt32(&os.ready, 1) return nil } func (os *OpensearchInterfaceImpl) Stop() *model.AppError { os.mutex.Lock() defer os.mutex.Unlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.start", "ent.elasticsearch.stop.already_stopped.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } // Flushing any pending requests if os.bulkProcessor != nil { if err := os.bulkProcessor.Stop(); err != nil { os.Platform.Log().Warn("Error stopping bulk processor", mlog.Err(err)) } os.bulkProcessor = nil } os.client = nil atomic.StoreInt32(&os.ready, 0) return nil } func (os *OpensearchInterfaceImpl) GetVersion() int { return os.version } func (os *OpensearchInterfaceImpl) GetFullVersion() string { return os.fullVersion } func (os *OpensearchInterfaceImpl) GetPlugins() []string { return os.plugins } func (os *OpensearchInterfaceImpl) IndexPost(post *model.Post, teamId string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.IndexPost", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexName := common.BuildPostIndexName(*os.Platform.Config().ElasticsearchSettings.AggregatePostsAfterDays, *os.Platform.Config().ElasticsearchSettings.IndexPrefix+common.IndexBasePosts, *os.Platform.Config().ElasticsearchSettings.IndexPrefix+common.IndexBasePosts_MONTH, time.Now(), post.CreateAt) searchPost, err := common.ESPostFromPost(post, teamId) if err != nil { return model.NewAppError("Opensearch.IndexPost", "ent.elasticsearch.index_post.error", nil, "", http.StatusInternalServerError).Wrap(err) } var postBuf []byte if os.bulkProcessor != nil { err = os.bulkProcessor.IndexOp(&types.IndexOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(searchPost.Id), }, searchPost) if err != nil { return model.NewAppError("Opensearch.IndexPost", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() postBuf, err = json.Marshal(searchPost) if err != nil { return model.NewAppError("Opensearch.start", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.Index(ctx, opensearchapi.IndexReq{ Index: indexName, DocumentID: post.Id, Body: bytes.NewReader(postBuf), }) } if err != nil { return model.NewAppError("Opensearch.IndexPost", "ent.elasticsearch.index_post.error", nil, "", http.StatusInternalServerError).Wrap(err) } metrics := os.Platform.Metrics() if metrics != nil { metrics.IncrementPostIndexCounter() } return nil } func (os *OpensearchInterfaceImpl) getPostIndexNames() ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() indexes, err := os.client.Indices.Get(ctx, opensearchapi.IndicesGetReq{ Indices: []string{"_all"}, }) if err != nil { return nil, err } postIndexes := make([]string, 0) for name := range indexes.Indices { if strings.HasPrefix(name, *os.Platform.Config().ElasticsearchSettings.IndexPrefix+common.IndexBasePosts) { postIndexes = append(postIndexes, name) } } return postIndexes, nil } func (os *OpensearchInterfaceImpl) SearchPosts(channels model.ChannelList, searchParams []*model.SearchParams, page, perPage int) ([]string, model.PostSearchMatches, *model.AppError) { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return []string{}, nil, model.NewAppError("Opensearch.SearchPosts", "ent.elasticsearch.search_posts.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } var channelIds []string for _, channel := range channels { channelIds = append(channelIds, channel.Id) } var termQueries, notTermQueries, highlightQueries []types.Query var filters, notFilters []types.Query for i, params := range searchParams { newTerms := []string{} for term := range strings.SplitSeq(params.Terms, " ") { if searchengine.EmailRegex.MatchString(term) { term = `"` + term + `"` } newTerms = append(newTerms, term) } params.Terms = strings.Join(newTerms, " ") termOperator := operator.And if searchParams[0].OrTerms { termOperator = operator.Or } // Date, channels and FromUsers filters come in all // searchParams iteration, and as they are global to the // query, we only need to process them once if i == 0 { if len(params.InChannels) > 0 { filters = append(filters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"channel_id": params.InChannels}}, }) } if len(params.ExcludedChannels) > 0 { notFilters = append(notFilters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"channel_id": params.ExcludedChannels}}, }) } if len(params.FromUsers) > 0 { filters = append(filters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"user_id": params.FromUsers}}, }) } if len(params.ExcludedUsers) > 0 { notFilters = append(notFilters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"user_id": params.ExcludedUsers}}, }) } if params.OnDate != "" { before, after := params.GetOnDateMillis() filters = append(filters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(before)), Lte: model.NewPointer(types.Float64(after)), }, }, }) } else { if params.AfterDate != "" || params.BeforeDate != "" { nrQuery := types.NumberRangeQuery{} if params.AfterDate != "" { nrQuery.Gte = model.NewPointer(types.Float64(params.GetAfterDateMillis())) } if params.BeforeDate != "" { nrQuery.Lte = model.NewPointer(types.Float64(params.GetBeforeDateMillis())) } query := types.Query{ Range: map[string]types.RangeQuery{ "create_at": nrQuery, }, } filters = append(filters, query) } if params.ExcludedAfterDate != "" || params.ExcludedBeforeDate != "" || params.ExcludedDate != "" { if params.ExcludedDate != "" { before, after := params.GetExcludedDateMillis() notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(before)), Lte: model.NewPointer(types.Float64(after)), }, }, }) } if params.ExcludedAfterDate != "" { notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(params.GetExcludedAfterDateMillis())), }, }, }) } if params.ExcludedBeforeDate != "" { notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Lte: model.NewPointer(types.Float64(params.GetExcludedBeforeDateMillis())), }, }, }) } } } } if params.IsHashtag { if params.Terms != "" { query := types.Query{ SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.Terms, Fields: []string{"hashtags"}, DefaultOperator: &termOperator, }, } termQueries = append(termQueries, query) highlightQueries = append(highlightQueries, query) } else if params.ExcludedTerms != "" { query := types.Query{ SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.ExcludedTerms, Fields: []string{"hashtags"}, DefaultOperator: &termOperator, }, } notTermQueries = append(notTermQueries, query) } } else { if params.Terms != "" { elements := []types.Query{ { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.Terms, Fields: []string{"message"}, DefaultOperator: &termOperator, }, }, { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.Terms, Fields: []string{"attachments"}, DefaultOperator: &termOperator, }, }, { Term: map[string]types.TermQuery{ "urls": {Value: params.Terms}, }, }, } query := types.Query{ Bool: &types.BoolQuery{Should: append([]types.Query(nil), elements...)}, } termQueries = append(termQueries, query) hashtagTerms := []string{} for term := range strings.SplitSeq(params.Terms, " ") { hashtagTerms = append(hashtagTerms, "#"+term) } hashtagQuery := types.Query{ SimpleQueryString: &types.SimpleQueryStringQuery{ Query: strings.Join(hashtagTerms, " "), Fields: []string{"hashtags"}, DefaultOperator: &termOperator, }, } highlightQuery := types.Query{ Bool: &types.BoolQuery{Should: append(elements, hashtagQuery)}, } highlightQueries = append(highlightQueries, highlightQuery) } if params.ExcludedTerms != "" { query := types.Query{ Bool: &types.BoolQuery{Should: []types.Query{ { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.ExcludedTerms, Fields: []string{"message"}, DefaultOperator: &termOperator, }, }, { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.ExcludedTerms, Fields: []string{"attachments"}, DefaultOperator: &termOperator, }, }, { Term: map[string]types.TermQuery{ "urls": {Value: params.ExcludedTerms}, }, }, }}, } notTermQueries = append(notTermQueries, query) } } } allTermsQuery := &types.BoolQuery{ MustNot: append([]types.Query(nil), notTermQueries...), } if searchParams[0].OrTerms { allTermsQuery.Should = append([]types.Query(nil), termQueries...) } else { allTermsQuery.Must = append([]types.Query(nil), termQueries...) } fullHighlightsQuery := &types.BoolQuery{ Filter: append([]types.Query(nil), filters...), MustNot: append([]types.Query(nil), notFilters...), } if searchParams[0].OrTerms { fullHighlightsQuery.Should = append([]types.Query(nil), highlightQueries...) } else { fullHighlightsQuery.Must = append([]types.Query(nil), highlightQueries...) } filters = append(filters, types.Query{ Terms: &types.TermsQuery{ TermsQuery: map[string]types.TermsQueryField{"channel_id": channelIds}, }, }, types.Query{ Bool: &types.BoolQuery{ Should: []types.Query{ { Term: map[string]types.TermQuery{"type": {Value: "default"}}, }, { Term: map[string]types.TermQuery{"type": {Value: "slack_attachment"}}, }, }, }, }, ) highlight := &types.Highlight{ HighlightQuery: &types.Query{ Bool: fullHighlightsQuery, }, Fields: map[string]types.HighlightField{ "message": {}, "attachments": {}, "url": {}, "hashtag": {}, }, Encoder: &highlighterencoder.Html, } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: append([]types.Query(nil), filters...), Must: []types.Query{{Bool: allTermsQuery}}, MustNot: append([]types.Query(nil), notFilters...), }, } searchBuf, err := json.Marshal(search.Request{ Query: query, Highlight: highlight, Sort: []types.SortCombinations{types.SortOptions{ SortOptions: map[string]types.FieldSort{"create_at": {Order: &sortorder.Desc}}, }}, }) if err != nil { return []string{}, nil, model.NewAppError("Opensearch.SearchPosts", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } // We need to declare the response structs because // OS client doesn't have the highlight field in the struct. type SearchHit struct { Index string `json:"_index"` ID string `json:"_id"` Score float32 `json:"_score"` Source json.RawMessage `json:"_source"` Fields json.RawMessage `json:"fields"` Type string `json:"_type"` // Deprecated field Sort []any `json:"sort"` Highlight map[string][]string `json:"highlight,omitempty"` } type searchResp struct { Took int `json:"took"` Timeout bool `json:"timed_out"` Hits struct { Total struct { Value int `json:"value"` Relation string `json:"relation"` } `json:"total"` MaxScore float32 `json:"max_score"` Hits []SearchHit `json:"hits"` } `json:"hits"` Errors bool `json:"errors"` } var searchResult searchResp _, err = os.client.Client.Do(ctx, &opensearchapi.SearchReq{ Indices: []string{common.SearchIndexName(os.Platform.Config().ElasticsearchSettings, common.IndexBasePosts+"*")}, Body: bytes.NewReader(searchBuf), Params: opensearchapi.SearchParams{ From: model.NewPointer(page * perPage), Size: model.NewPointer(perPage), }, }, &searchResult) if err != nil { errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr } return []string{}, nil, model.NewAppError("Opensearch.SearchPosts", "ent.elasticsearch.search_posts.search_failed", nil, errorStr, http.StatusInternalServerError) } postIds := make([]string, len(searchResult.Hits.Hits)) matches := make(model.PostSearchMatches, len(searchResult.Hits.Hits)) for i, hit := range searchResult.Hits.Hits { var post common.ESPost err := json.Unmarshal(hit.Source, &post) if err != nil { return postIds, matches, model.NewAppError("Opensearch.SearchPosts", "ent.elasticsearch.search_posts.unmarshall_post_failed", nil, "", http.StatusInternalServerError).Wrap(err) } postIds[i] = post.Id matchesForPost, err := common.GetMatchesForHit(hit.Highlight) if err != nil { return postIds, matches, model.NewAppError("Opensearch.SearchPosts", "ent.elasticsearch.search_posts.parse_matches_failed", nil, "", http.StatusInternalServerError).Wrap(err) } matches[post.Id] = matchesForPost } return postIds, matches, nil } func (os *OpensearchInterfaceImpl) DeletePost(post *model.Post) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeletePost", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } // This is racy with index aggregation, but since the posts are verified in the database when returning search // results, there's no risk of deleted posts getting sent back to the user in response to a search query, and even // then the race is very unlikely because it would only occur when someone deletes a post that's due to be // aggregated but hasn't been yet, which makes the time window small and the post likelihood very low. indexName := common.BuildPostIndexName(*os.Platform.Config().ElasticsearchSettings.AggregatePostsAfterDays, *os.Platform.Config().ElasticsearchSettings.IndexPrefix+common.IndexBasePosts, *os.Platform.Config().ElasticsearchSettings.IndexPrefix+common.IndexBasePosts_MONTH, time.Now(), post.CreateAt) if err := os.deletePost(indexName, post.Id); err != nil { return err } return nil } func (os *OpensearchInterfaceImpl) DeleteChannelPosts(rctx request.CTX, channelID string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteChannelPosts", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } postIndexes, err := os.getPostIndexNames() if err != nil { return model.NewAppError("Opensearch.DeleteChannelPosts", "ent.elasticsearch.delete_channel_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Term: map[string]types.TermQuery{"channel_id": {Value: channelID}}, }}, }, } queryBuf, err := json.Marshal(deletebyquery.Request{ Query: query, }) if err != nil { return model.NewAppError("Opensearch.SearchPosts", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{ Indices: postIndexes, Body: bytes.NewReader(queryBuf), }) if err != nil { return model.NewAppError("Opensearch.DeleteChannelPosts", "ent.elasticsearch.delete_channel_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Posts for channel deleted", mlog.String("channel_id", channelID), mlog.Int("deleted", response.Deleted)) return nil } func (os *OpensearchInterfaceImpl) DeleteUserPosts(rctx request.CTX, userID string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } postIndexes, err := os.getPostIndexNames() if err != nil { return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Term: map[string]types.TermQuery{"user_id": {Value: userID}}, }}, }, } queryBuf, err := json.Marshal(deletebyquery.Request{ Query: query, }) if err != nil { return model.NewAppError("Opensearch.SearchPosts", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{ Indices: postIndexes, Body: bytes.NewReader(queryBuf), }) if err != nil { return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Posts for user deleted", mlog.String("user_id", userID), mlog.Int("deleted", response.Deleted)) return nil } func (os *OpensearchInterfaceImpl) deletePost(indexName, postID string) *model.AppError { var err error if os.bulkProcessor != nil { err = os.bulkProcessor.DeleteOp(&types.DeleteOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(postID), }) if err != nil { return model.NewAppError("Opensearch.IndexPost", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err = os.client.Document.Delete(ctx, opensearchapi.DocumentDeleteReq{ Index: indexName, DocumentID: postID, }) } if err != nil { return model.NewAppError("Opensearch.DeletePost", "ent.elasticsearch.delete_post.error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) IndexChannel(rctx request.CTX, channel *model.Channel, userIDs, teamMemberIDs []string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.IndexChannel", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexName := *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseChannels searchChannel := common.ESChannelFromChannel(channel, userIDs, teamMemberIDs) var err error var buf []byte if os.bulkProcessor != nil { err = os.bulkProcessor.IndexOp(&types.IndexOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(searchChannel.Id), }, searchChannel) if err != nil { return model.NewAppError("Opensearch.IndexChannel", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() buf, err = json.Marshal(searchChannel) if err != nil { return model.NewAppError("Opensearch.IndexChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.Index(ctx, opensearchapi.IndexReq{ Index: indexName, DocumentID: searchChannel.Id, Body: bytes.NewReader(buf), }) } if err != nil { return model.NewAppError("Opensearch.IndexChannel", "ent.elasticsearch.index_channel.error", nil, "", http.StatusInternalServerError).Wrap(err) } metrics := os.Platform.Metrics() if metrics != nil { metrics.IncrementChannelIndexCounter() } return nil } func (os *OpensearchInterfaceImpl) SyncBulkIndexChannels(rctx request.CTX, channels []*model.Channel, getUserIDsForChannel func(channel *model.Channel) ([]string, error), teamMemberIDs []string) *model.AppError { if len(channels) == 0 { return nil } os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.SyncBulkIndexChannels", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexName := *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseChannels metrics := os.Platform.Metrics() for _, channel := range channels { userIDs, err := getUserIDsForChannel(channel) if err != nil { return model.NewAppError("Opensearch.SyncBulkIndexChannels", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } searchChannel := common.ESChannelFromChannel(channel, userIDs, teamMemberIDs) err = os.syncBulkProcessor.IndexOp(&types.IndexOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(searchChannel.Id), }, searchChannel) if err != nil { return model.NewAppError("Opensearch.SyncBulkIndexChannels", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } if metrics != nil { metrics.IncrementChannelIndexCounter() } } if err := os.syncBulkProcessor.Flush(); err != nil { return model.NewAppError("Opensearch.SyncBulkIndexChannels", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) SearchChannels(teamId, userID string, term string, isGuest, includeDeleted bool) ([]string, *model.AppError) { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return []string{}, model.NewAppError("Opensearch.SearchChannels", "ent.elasticsearch.search_channels.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() boolNotPrivate := types.Query{ Bool: &types.BoolQuery{ MustNot: []types.Query{{ Term: map[string]types.TermQuery{"type": {Value: model.ChannelTypePrivate}}, }}, }, } userQ := types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Term: map[string]types.TermQuery{"user_ids": {Value: userID}}, }}, Must: []types.Query{{ Term: map[string]types.TermQuery{"type": {Value: model.ChannelTypePrivate}}, }}, }, } query := &types.BoolQuery{} if teamId != "" { query.Filter = append(query.Filter, types.Query{Term: map[string]types.TermQuery{"team_id": {Value: teamId}}}) } else { query.Filter = append(query.Filter, types.Query{Term: map[string]types.TermQuery{"team_member_ids": {Value: userID}}}) } if !isGuest { query.Filter = append(query.Filter, types.Query{ Bool: &types.BoolQuery{ Should: []types.Query{ boolNotPrivate, userQ, }, Must: []types.Query{{ Prefix: map[string]types.PrefixQuery{ "name_suggestions": {Value: strings.ToLower(term)}, }, }}, MinimumShouldMatch: 1, }, }) } else { query.Filter = append(query.Filter, types.Query{ Bool: &types.BoolQuery{ Must: []types.Query{ boolNotPrivate, { Prefix: map[string]types.PrefixQuery{ "name_suggestions": {Value: strings.ToLower(term)}, }, }}, }, }) } if !includeDeleted { query.Filter = append(query.Filter, types.Query{ Term: map[string]types.TermQuery{ "delete_at": {Value: 0}, }, }) } buf, err := json.Marshal(search.Request{ Query: &types.Query{Bool: query}, }) if err != nil { return []string{}, model.NewAppError("Opensearch.SearchChannels", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } searchResult, err := os.client.Search(ctx, &opensearchapi.SearchReq{ Indices: []string{common.SearchIndexName(os.Platform.Config().ElasticsearchSettings, common.IndexBaseChannels)}, Body: bytes.NewReader(buf), Params: opensearchapi.SearchParams{ Size: model.NewPointer(model.ChannelSearchDefaultLimit), }, }) if err != nil { errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr } return nil, model.NewAppError("Opensearch.SearchChannels", "ent.elasticsearch.search_channels.search_failed", nil, errorStr, http.StatusInternalServerError) } channelIds := []string{} for _, hit := range searchResult.Hits.Hits { var channel common.ESChannel err := json.Unmarshal(hit.Source, &channel) if err != nil { return nil, model.NewAppError("Opensearch.SearchChannels", "ent.elasticsearch.search_channels.unmarshall_channel_failed", nil, "", http.StatusInternalServerError).Wrap(err) } channelIds = append(channelIds, channel.Id) } return channelIds, nil } func (os *OpensearchInterfaceImpl) DeleteChannel(channel *model.Channel) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteChannel", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } var err error if os.bulkProcessor != nil { err = os.bulkProcessor.DeleteOp(&types.DeleteOperation{ Index_: model.NewPointer(*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseChannels), Id_: model.NewPointer(channel.Id), }) if err != nil { return model.NewAppError("Opensearch.IndexPost", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err = os.client.Document.Delete(ctx, opensearchapi.DocumentDeleteReq{ Index: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseChannels, DocumentID: channel.Id, }) } if err != nil { return model.NewAppError("Opensearch.DeleteChannel", "ent.elasticsearch.delete_channel.error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) IndexUser(rctx request.CTX, user *model.User, teamsIds, channelsIds []string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.IndexUser", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexName := *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseUsers searchUser := common.ESUserFromUserAndTeams(user, teamsIds, channelsIds) var err error var buf []byte if os.bulkProcessor != nil { err = os.bulkProcessor.IndexOp(&types.IndexOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(searchUser.Id), }, searchUser) if err != nil { return model.NewAppError("Opensearch.IndexUser", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() buf, err = json.Marshal(searchUser) if err != nil { return model.NewAppError("Opensearch.IndexUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.Index(ctx, opensearchapi.IndexReq{ Index: indexName, DocumentID: searchUser.Id, Body: bytes.NewReader(buf), }) } if err != nil { return model.NewAppError("Opensearch.IndexUser", "ent.elasticsearch.index_user.error", nil, "", http.StatusInternalServerError).Wrap(err) } metrics := os.Platform.Metrics() if metrics != nil { metrics.IncrementUserIndexCounter() } return nil } func (os *OpensearchInterfaceImpl) autocompleteUsers(contextCategory string, categoryIds []string, term string, options *model.UserSearchOptions) ([]common.ESUser, *model.AppError) { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return nil, model.NewAppError("Opensearch.autocompleteUsers", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.BoolQuery{} if term != "" { var suggestionField string if options.AllowFullNames { suggestionField = "suggestions_with_fullname" } else { suggestionField = "suggestions_without_fullname" } query.Must = append(query.Must, types.Query{ Prefix: map[string]types.PrefixQuery{ suggestionField: {Value: strings.ToLower(term)}, }, }) } if len(categoryIds) > 0 { var iCategoryIds []string for _, id := range categoryIds { if id != "" { iCategoryIds = append(iCategoryIds, id) } } if len(iCategoryIds) > 0 { query.Filter = append(query.Filter, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{contextCategory: iCategoryIds}}, }) } } if !options.AllowInactive { query.Filter = append(query.Filter, types.Query{ Bool: &types.BoolQuery{ Should: []types.Query{ { Range: map[string]types.RangeQuery{ "delete_at": types.DateRangeQuery{ Lte: model.NewPointer("0"), }, }, }, { Bool: &types.BoolQuery{ MustNot: []types.Query{{ Exists: &types.ExistsQuery{Field: "delete_at"}, }}, }, }, }, }, }) } if options.Role != "" { query.Filter = append(query.Filter, types.Query{ Term: map[string]types.TermQuery{ "roles": {Value: options.Role}, }, }) } buf, err := json.Marshal(search.Request{ Query: &types.Query{Bool: query}, }) if err != nil { return nil, model.NewAppError("Opensearch.autocompleteUsers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } searchResults, err := os.client.Search(ctx, &opensearchapi.SearchReq{ Indices: []string{common.SearchIndexName(os.Platform.Config().ElasticsearchSettings, common.IndexBaseUsers)}, Body: bytes.NewReader(buf), Params: opensearchapi.SearchParams{ Size: model.NewPointer(options.Limit), }, }) if err != nil { errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr } return nil, model.NewAppError("Opensearch.autocompleteUsers", "ent.elasticsearch.search_users.search_failed", nil, errorStr, http.StatusInternalServerError) } users := []common.ESUser{} for _, hit := range searchResults.Hits.Hits { var user common.ESUser err := json.Unmarshal(hit.Source, &user) if err != nil { return nil, model.NewAppError("Opensearch.autocompleteUsers", "ent.elasticsearch.search_users.unmarshall_user_failed", nil, "", http.StatusInternalServerError).Wrap(err) } users = append(users, user) } return users, nil } func (os *OpensearchInterfaceImpl) autocompleteUsersInChannel(channelId, term string, options *model.UserSearchOptions) ([]common.ESUser, *model.AppError) { return os.autocompleteUsers("channel_id", []string{channelId}, term, options) } func (os *OpensearchInterfaceImpl) autocompleteUsersInChannels(channelIds []string, term string, options *model.UserSearchOptions) ([]common.ESUser, *model.AppError) { return os.autocompleteUsers("channel_id", channelIds, term, options) } func (os *OpensearchInterfaceImpl) autocompleteUsersInTeam(teamId, term string, options *model.UserSearchOptions) ([]common.ESUser, *model.AppError) { return os.autocompleteUsers("team_id", []string{teamId}, term, options) } func (os *OpensearchInterfaceImpl) autocompleteUsersNotInChannel(teamId, channelId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]common.ESUser, *model.AppError) { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return nil, model.NewAppError("Opensearch.autocompleteUsersNotInChannel", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() filterMust := []types.Query{{Term: map[string]types.TermQuery{ "team_id": {Value: teamId}, }}} if len(restrictedToChannels) > 0 { filterMust = append(filterMust, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"channel_id": restrictedToChannels}}, }) } query := &types.BoolQuery{ Filter: []types.Query{{ Bool: &types.BoolQuery{ Must: filterMust, }, }}, MustNot: []types.Query{{ Term: map[string]types.TermQuery{ "channel_id": {Value: channelId}, }, }}, } if term != "" { var suggestionField string if options.AllowFullNames { suggestionField = "suggestions_with_fullname" } else { suggestionField = "suggestions_without_fullname" } query.Must = append(query.Must, types.Query{ Prefix: map[string]types.PrefixQuery{ suggestionField: {Value: strings.ToLower(term)}, }, }) } if !options.AllowInactive { notExistField := types.Query{ Bool: &types.BoolQuery{ MustNot: []types.Query{{ Exists: &types.ExistsQuery{Field: "delete_at"}, }}, }, } deleteRangeQuery := types.Query{ Range: map[string]types.RangeQuery{ "delete_at": types.DateRangeQuery{ Lte: model.NewPointer("0"), }, }, } inactiveQuery := types.Query{ Bool: &types.BoolQuery{ Should: []types.Query{deleteRangeQuery, notExistField}, }, } query.Filter = append(query.Filter, inactiveQuery) } if options.Role != "" { query.Filter = append(query.Filter, types.Query{ Term: map[string]types.TermQuery{ "roles": {Value: options.Role}, }, }) } buf, err := json.Marshal(search.Request{ Query: &types.Query{Bool: query}, }) if err != nil { return nil, model.NewAppError("Opensearch.autocompleteUsersNotInChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } searchResults, err := os.client.Search(ctx, &opensearchapi.SearchReq{ Indices: []string{common.SearchIndexName(os.Platform.Config().ElasticsearchSettings, common.IndexBaseUsers)}, Body: bytes.NewReader(buf), Params: opensearchapi.SearchParams{ Size: model.NewPointer(options.Limit), }, }) if err != nil { errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr } return nil, model.NewAppError("Opensearch.autocompleteUsersNotInChannel", "ent.elasticsearch.search_users.search_failed", nil, errorStr, http.StatusInternalServerError) } users := []common.ESUser{} for _, hit := range searchResults.Hits.Hits { var user common.ESUser err := json.Unmarshal(hit.Source, &user) if err != nil { return nil, model.NewAppError("Opensearch.autocompleteUsersNotInChannel", "ent.elasticsearch.search_users.unmarshall_user_failed", nil, "", http.StatusInternalServerError).Wrap(err) } users = append(users, user) } return users, nil } func (os *OpensearchInterfaceImpl) SearchUsersInChannel(teamId, channelId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, []string, *model.AppError) { if restrictedToChannels != nil && len(restrictedToChannels) == 0 { return []string{}, []string{}, nil } uchan, err := os.autocompleteUsersInChannel(channelId, term, options) if err != nil { return nil, nil, err } var nuchan []common.ESUser nuchan, err = os.autocompleteUsersNotInChannel(teamId, channelId, restrictedToChannels, term, options) if err != nil { return nil, nil, err } uchanIds := []string{} for _, user := range uchan { uchanIds = append(uchanIds, user.Id) } nuchanIds := []string{} for _, user := range nuchan { nuchanIds = append(nuchanIds, user.Id) } return uchanIds, nuchanIds, nil } func (os *OpensearchInterfaceImpl) SearchUsersInTeam(teamId string, restrictedToChannels []string, term string, options *model.UserSearchOptions) ([]string, *model.AppError) { if restrictedToChannels != nil && len(restrictedToChannels) == 0 { return []string{}, nil } var users []common.ESUser var err *model.AppError if restrictedToChannels == nil { users, err = os.autocompleteUsersInTeam(teamId, term, options) } else { users, err = os.autocompleteUsersInChannels(restrictedToChannels, term, options) } if err != nil { return nil, err } usersIds := []string{} if len(users) >= options.Limit { users = users[:options.Limit] } for _, user := range users { usersIds = append(usersIds, user.Id) } return usersIds, nil } func (os *OpensearchInterfaceImpl) DeleteUser(user *model.User) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteUser", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } var err error if os.bulkProcessor != nil { err = os.bulkProcessor.DeleteOp(&types.DeleteOperation{ Index_: model.NewPointer(*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseUsers), Id_: model.NewPointer(user.Id), }) if err != nil { return model.NewAppError("Opensearch.DeleteUser", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err = os.client.Document.Delete(ctx, opensearchapi.DocumentDeleteReq{ Index: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseUsers, DocumentID: user.Id, }) } if err != nil { return model.NewAppError("Opensearch.DeleteUser", "ent.elasticsearch.delete_user.error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) TestConfig(rctx request.CTX, cfg *model.Config) *model.AppError { if license := os.Platform.License(); license == nil || !*license.Features.Elasticsearch { return model.NewAppError("Opensearch.TestConfig", "ent.elasticsearch.test_config.license.error", nil, "", http.StatusNotImplemented) } if !*cfg.ElasticsearchSettings.EnableIndexing { return model.NewAppError("Opensearch.TestConfig", "ent.elasticsearch.test_config.indexing_disabled.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusNotImplemented) } client, appErr := createClient(rctx.Logger(), cfg, os.Platform.FileBackend(), true) if appErr != nil { return appErr } _, _, appErr = checkMaxVersion(client) if appErr != nil { return appErr } // Resetting the state. if atomic.CompareAndSwapInt32(&os.ready, 0, 1) { // Re-assign the client. // This is necessary in case opensearch was started // after server start. os.mutex.Lock() os.client = client os.mutex.Unlock() } return nil } func (os *OpensearchInterfaceImpl) PurgeIndexes(rctx request.CTX) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if license := os.Platform.License(); license == nil || !*license.Features.Elasticsearch { return model.NewAppError("Opensearch.PurgeIndexes", "ent.elasticsearch.test_config.license.error", nil, "", http.StatusNotImplemented) } if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.PurgeIndexes", "ent.elasticsearch.generic.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexPrefix := *os.Platform.Config().ElasticsearchSettings.IndexPrefix indexesToDelete := []string{indexPrefix + "*"} if ignorePurgeIndexes := *os.Platform.Config().ElasticsearchSettings.IgnoredPurgeIndexes; ignorePurgeIndexes != "" { // we are checking if provided indexes exist. If an index doesn't exist, // opensearch returns an error while trying to purge it even we intend to // ignore it. for ignorePurgeIndex := range strings.SplitSeq(ignorePurgeIndexes, ",") { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err := os.client.Indices.Get(ctx, opensearchapi.IndicesGetReq{ Indices: []string{ignorePurgeIndex}, }) if err != nil { rctx.Logger().Warn("Opensearch index get error", mlog.String("index", ignorePurgeIndex), mlog.Err(err)) continue } indexesToDelete = append(indexesToDelete, "-"+strings.TrimSpace(ignorePurgeIndex)) } } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err := os.client.Indices.Delete(ctx, opensearchapi.IndicesDeleteReq{ Indices: indexesToDelete, }) if err != nil { rctx.Logger().Error("Opensearch PurgeIndexes Error", mlog.Err(err)) return model.NewAppError("Opensearch.PurgeIndexes", "ent.elasticsearch.purge_index.delete_failed", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } // PurgeIndexList purges a list of specified indexes. // For now it only allows purging the channels index as thats all that's needed, // but the code is written in generic fashion to allow it to purge any index. // It needs more logic around post indexes as their name isn't the same, but rather follow a pattern // containing the date as well. func (os *OpensearchInterfaceImpl) PurgeIndexList(rctx request.CTX, indexes []string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if license := os.Platform.License(); license == nil || !*license.Features.Elasticsearch { return model.NewAppError("Opensearch.PurgeIndexList", "ent.elasticsearch.test_config.license.error", nil, "", http.StatusNotImplemented) } if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.PurgeIndexList", "ent.elasticsearch.generic.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexPrefix := *os.Platform.Config().ElasticsearchSettings.IndexPrefix indexToDeleteMap := map[string]bool{} for _, index := range indexes { isKnownIndex := slices.Contains(purgeIndexListAllowedIndexes, index) if !isKnownIndex { return model.NewAppError("Opensearch.PurgeIndexList", "ent.elasticsearch.purge_indexes.unknown_index", map[string]any{"unknown_index": index}, "", http.StatusBadRequest) } indexToDeleteMap[indexPrefix+index] = true } if ign := *os.Platform.Config().ElasticsearchSettings.IgnoredPurgeIndexes; ign != "" { // make sure we're not purging any index configured to be ignored for ix := range strings.SplitSeq(ign, ",") { delete(indexToDeleteMap, ix) } } indexToDelete := []string{} for key := range indexToDeleteMap { indexToDelete = append(indexToDelete, key) } if len(indexToDelete) > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err := os.client.Indices.Delete(ctx, opensearchapi.IndicesDeleteReq{ Indices: indexToDelete, }) if err != nil { openErr, ok := err.(*opensearch.StructError) if !ok || openErr.Status != http.StatusNotFound { rctx.Logger().Error("Opensearch PurgeIndex Error", mlog.Err(err)) return model.NewAppError("Opensearch.PurgeIndexList", "ent.elasticsearch.purge_index.delete_failed", nil, "", http.StatusInternalServerError).Wrap(err) } } } return nil } func (os *OpensearchInterfaceImpl) RefreshIndexes(rctx request.CTX) *model.AppError { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err := os.client.Indices.Refresh(ctx, nil) if err != nil { rctx.Logger().Error("Opensearch RefreshIndexes Error", mlog.Err(err)) return model.NewAppError("Opensearch.RefreshIndexes", "ent.elasticsearch.refresh_indexes.refresh_failed", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) DataRetentionDeleteIndexes(rctx request.CTX, cutoff time.Time) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if license := os.Platform.License(); license == nil || !*license.Features.Elasticsearch { return model.NewAppError("Opensearch.DataRetentionDeleteIndexes", "ent.elasticsearch.test_config.license.error", nil, "", http.StatusNotImplemented) } if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DataRetentionDeleteIndexes", "ent.elasticsearch.generic.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx := context.Background() dateFormat := *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_2006_01_02" postIndexesResult, err := os.client.Indices.Get(ctx, opensearchapi.IndicesGetReq{ Indices: []string{*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBasePosts + "_*"}, }) if err != nil { return model.NewAppError("Opensearch.DataRetentionDeleteIndexes", "ent.elasticsearch.data_retention_delete_indexes.get_indexes.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } for index := range postIndexesResult.Indices { if indexDate, err := time.Parse(dateFormat, index); err != nil { rctx.Logger().Warn("Failed to parse date from posts index. Ignoring index.", mlog.String("index", index)) } else { if indexDate.Before(cutoff) || indexDate.Equal(cutoff) { if _, err := os.client.Indices.Delete(ctx, opensearchapi.IndicesDeleteReq{ Indices: []string{index}, }); err != nil { return model.NewAppError("Opensearch.DataRetentionDeleteIndexes", "ent.elasticsearch.data_retention_delete_indexes.delete_index.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } } } } return nil } func (os *OpensearchInterfaceImpl) IndexFile(file *model.FileInfo, channelId string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.IndexFile", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } indexName := *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles searchFile := common.ESFileFromFileInfo(file, channelId) var err error var fileBuf []byte if os.bulkProcessor != nil { err = os.bulkProcessor.IndexOp(&types.IndexOperation{ Index_: model.NewPointer(indexName), Id_: model.NewPointer(searchFile.Id), }, searchFile) if err != nil { return model.NewAppError("Opensearch.IndexFile", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() fileBuf, err = json.Marshal(searchFile) if err != nil { return model.NewAppError("Opensearch.SearchPosts", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } _, err = os.client.Index(ctx, opensearchapi.IndexReq{ Index: indexName, DocumentID: file.Id, Body: bytes.NewReader(fileBuf), }) } if err != nil { return model.NewAppError("Opensearch.IndexFile", "ent.elasticsearch.index_file.error", nil, "", http.StatusInternalServerError).Wrap(err) } if metrics := os.Platform.Metrics(); metrics != nil { metrics.IncrementFileIndexCounter() } return nil } func (os *OpensearchInterfaceImpl) SearchFiles(channels model.ChannelList, searchParams []*model.SearchParams, page, perPage int) ([]string, *model.AppError) { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return []string{}, model.NewAppError("Opensearch.SearchPosts", "ent.elasticsearch.search_files.disabled", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } var channelIds []string for _, channel := range channels { channelIds = append(channelIds, channel.Id) } var termQueries, notTermQueries []types.Query var filters, notFilters []types.Query for i, params := range searchParams { newTerms := []string{} for term := range strings.SplitSeq(params.Terms, " ") { if searchengine.EmailRegex.MatchString(term) { term = `"` + term + `"` } newTerms = append(newTerms, term) } params.Terms = strings.Join(newTerms, " ") termOperator := operator.And if searchParams[0].OrTerms { termOperator = operator.Or } // Date, channels and FromUsers filters come in all // searchParams iteration, and as they are global to the // query, we only need to process them once if i == 0 { if len(params.InChannels) > 0 { filters = append(filters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"channel_id": params.InChannels}}, }) } if len(params.ExcludedChannels) > 0 { notFilters = append(notFilters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"channel_id": params.ExcludedChannels}}, }) } if len(params.FromUsers) > 0 { filters = append(filters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"creator_id": params.FromUsers}}, }) } if len(params.ExcludedUsers) > 0 { notFilters = append(notFilters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"creator_id": params.ExcludedUsers}}, }) } if len(params.Extensions) > 0 { filters = append(filters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"extension": params.Extensions}}, }) } if len(params.ExcludedExtensions) > 0 { notFilters = append(notFilters, types.Query{ Terms: &types.TermsQuery{TermsQuery: map[string]types.TermsQueryField{"extension": params.ExcludedExtensions}}, }) } if params.OnDate != "" { before, after := params.GetOnDateMillis() filters = append(filters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(before)), Lte: model.NewPointer(types.Float64(after)), }, }, }) } else { if params.AfterDate != "" || params.BeforeDate != "" { nrQuery := types.NumberRangeQuery{} if params.AfterDate != "" { nrQuery.Gte = model.NewPointer(types.Float64(params.GetAfterDateMillis())) } if params.BeforeDate != "" { nrQuery.Lte = model.NewPointer(types.Float64(params.GetBeforeDateMillis())) } query := types.Query{ Range: map[string]types.RangeQuery{ "create_at": nrQuery, }, } filters = append(filters, query) } if params.ExcludedAfterDate != "" || params.ExcludedBeforeDate != "" || params.ExcludedDate != "" { if params.ExcludedDate != "" { before, after := params.GetExcludedDateMillis() notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(before)), Lte: model.NewPointer(types.Float64(after)), }, }, }) } if params.ExcludedAfterDate != "" { notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Gte: model.NewPointer(types.Float64(params.GetExcludedAfterDateMillis())), }, }, }) } if params.ExcludedBeforeDate != "" { notFilters = append(notFilters, types.Query{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Lte: model.NewPointer(types.Float64(params.GetExcludedBeforeDateMillis())), }, }, }) } } } } if params.Terms != "" { elements := []types.Query{ { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.Terms, Fields: []string{"content"}, DefaultOperator: &termOperator, }, }, { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.Terms, Fields: []string{"name"}, DefaultOperator: &termOperator, }, }, } query := types.Query{ Bool: &types.BoolQuery{Should: append([]types.Query(nil), elements...)}, } termQueries = append(termQueries, query) } if params.ExcludedTerms != "" { elements := []types.Query{ { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.ExcludedTerms, Fields: []string{"content"}, DefaultOperator: &termOperator, }, }, { SimpleQueryString: &types.SimpleQueryStringQuery{ Query: params.ExcludedTerms, Fields: []string{"name"}, DefaultOperator: &termOperator, }, }, } query := types.Query{ Bool: &types.BoolQuery{Should: append([]types.Query(nil), elements...)}, } notTermQueries = append(notTermQueries, query) } } allTermsQuery := &types.BoolQuery{ MustNot: append([]types.Query(nil), notTermQueries...), } if searchParams[0].OrTerms { allTermsQuery.Should = append([]types.Query(nil), termQueries...) } else { allTermsQuery.Must = append([]types.Query(nil), termQueries...) } filters = append(filters, types.Query{ Terms: &types.TermsQuery{ TermsQuery: map[string]types.TermsQueryField{"channel_id": channelIds}, }, }, ) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: append([]types.Query(nil), filters...), Must: []types.Query{{Bool: allTermsQuery}}, MustNot: append([]types.Query(nil), notFilters...), }, } searchBuf, err := json.Marshal(search.Request{ Query: query, Sort: []types.SortCombinations{types.SortOptions{ SortOptions: map[string]types.FieldSort{"create_at": {Order: &sortorder.Desc}}, }}, }) if err != nil { return []string{}, model.NewAppError("Opensearch.SearchFiles", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } searchResult, err := os.client.Search(ctx, &opensearchapi.SearchReq{ Indices: []string{common.SearchIndexName(os.Platform.Config().ElasticsearchSettings, common.IndexBaseFiles)}, Body: bytes.NewReader(searchBuf), Params: opensearchapi.SearchParams{ From: model.NewPointer(page * perPage), Size: model.NewPointer(perPage), }, }) if err != nil { errorStr := "err=" + err.Error() if *os.Platform.Config().ElasticsearchSettings.Trace == "error" { errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr } return []string{}, model.NewAppError("Opensearch.SearchFiles", "ent.elasticsearch.search_files.search_failed", nil, errorStr, http.StatusInternalServerError) } fileIds := make([]string, len(searchResult.Hits.Hits)) for i, hit := range searchResult.Hits.Hits { var file common.ESFile if err := json.Unmarshal(hit.Source, &file); err != nil { return fileIds, model.NewAppError("Opensearch.SearchFiles", "ent.elasticsearch.search_files.unmarshall_file_failed", nil, "", http.StatusInternalServerError).Wrap(err) } fileIds[i] = file.Id } return fileIds, nil } func (os *OpensearchInterfaceImpl) DeleteFile(fileID string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteFile", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } var err error if os.bulkProcessor != nil { err = os.bulkProcessor.DeleteOp(&types.DeleteOperation{ Index_: model.NewPointer(*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles), Id_: model.NewPointer(fileID), }) if err != nil { return model.NewAppError("Opensearch.DeleteFile", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err) } } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() _, err = os.client.Document.Delete(ctx, opensearchapi.DocumentDeleteReq{ Index: *os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles, DocumentID: fileID, }) } if err != nil { return model.NewAppError("Opensearch.DeleteFile", "ent.elasticsearch.delete_file.error", nil, "", http.StatusInternalServerError).Wrap(err) } return nil } func (os *OpensearchInterfaceImpl) DeleteUserFiles(rctx request.CTX, userID string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteFilesBatch", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Term: map[string]types.TermQuery{"creator_id": {Value: userID}}, }}, }, } queryBuf, err := json.Marshal(deletebyquery.Request{ Query: query, }) if err != nil { return model.NewAppError("Opensearch.DeleteUserFiles", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{ Indices: []string{*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles}, Body: bytes.NewReader(queryBuf), }) if err != nil { return model.NewAppError("Opensearch.DeleteUserFiles", "ent.elasticsearch.delete_user_files.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("User files deleted", mlog.String("user_id", userID), mlog.Int("deleted", response.Deleted)) return nil } func (os *OpensearchInterfaceImpl) DeletePostFiles(rctx request.CTX, postID string) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteFilesBatch", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Term: map[string]types.TermQuery{"post_id": {Value: postID}}, }}, }, } queryBuf, err := json.Marshal(deletebyquery.Request{ Query: query, }) if err != nil { return model.NewAppError("Opensearch.DeletePostFiles", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{ Indices: []string{*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles}, Body: bytes.NewReader(queryBuf), }) if err != nil { return model.NewAppError("Opensearch.DeletePostFiles", "ent.elasticsearch.delete_post_files.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Post files deleted", mlog.String("post_id", postID), mlog.Int("deleted", response.Deleted)) return nil } func (os *OpensearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime, limit int64) *model.AppError { os.mutex.RLock() defer os.mutex.RUnlock() if atomic.LoadInt32(&os.ready) == 0 { return model.NewAppError("Opensearch.DeleteFilesBatch", "ent.elasticsearch.not_started.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second) defer cancel() query := &types.Query{ Bool: &types.BoolQuery{ Filter: []types.Query{{ Range: map[string]types.RangeQuery{ "create_at": types.NumberRangeQuery{ Lte: model.NewPointer(types.Float64(endTime)), }, }, }}, }, } queryBuf, err := json.Marshal(deletebyquery.Request{ Query: query, }) if err != nil { return model.NewAppError("Opensearch.DeleteUserFiles", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{ Indices: []string{*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles}, Body: bytes.NewReader(queryBuf), Params: opensearchapi.DocumentDeleteByQueryParams{ // Note that max_docs is slightly different than size. // Size will just limit the number of elements returned, which is not // what we want. We want to limit the number of elements to be deleted. MaxDocs: model.NewPointer(int(limit)), }, }) if err != nil { return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err) } rctx.Logger().Info("Files batch deleted", mlog.Int("end_time", endTime), mlog.Int("limit", limit), mlog.Int("deleted", response.Deleted)) return nil } func checkMaxVersion(client *opensearchapi.Client) (string, int, *model.AppError) { resp, err := client.Info(context.Background(), nil) if err != nil { return "", 0, model.NewAppError("Opensearch.checkMaxVersion", "ent.elasticsearch.start.get_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } major, _, _, esErr := common.GetVersionComponents(resp.Version.Number) if esErr != nil { return "", 0, model.NewAppError("Opensearch.checkMaxVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err) } if major > opensearchMaxVersion { return "", 0, model.NewAppError("Opensearch.checkMaxVersion", "ent.elasticsearch.max_version.app_error", map[string]any{"Version": major, "MaxVersion": opensearchMaxVersion, "Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusBadRequest) } return resp.Version.Number, major, nil }