Log an error instead of refusing to start on unsupported Postgres, Elasticsearch, and OpenSearch versions (#37929)

This commit is contained in:
Jesse Hallam
2026-08-12 17:55:41 -03:00
committed by GitHub
parent d0be8f408e
commit 27a5abe2d4
8 changed files with 283 additions and 91 deletions
+1 -2
View File
@@ -47,8 +47,7 @@ func NewMigrator(settings model.SqlSettings, logger mlog.LoggerIFace, dryRun boo
return nil, fmt.Errorf("error while getting DB version: %w", err)
}
ok, err := ss.ensureMinimumDBVersion(ver)
if !ok {
if err = ss.checkVersion(ver); err != nil {
return nil, fmt.Errorf("error while checking DB version: %w", err)
}
+21 -11
View File
@@ -228,8 +228,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
return nil, errors.Wrap(err, "error while getting DB version")
}
ok, err := store.ensureMinimumDBVersion(ver)
if !ok {
if err = store.checkVersion(ver); err != nil {
return nil, errors.Wrap(err, "error while checking DB version")
}
@@ -1044,27 +1043,38 @@ func IsDuplicate(err error) bool {
return false
}
// ensureMinimumDBVersion gets the DB version and ensures it is
// above the required minimum version requirements.
func (ss *SqlStore) ensureMinimumDBVersion(ver string) (bool, error) {
// checkVersion returns an error if the given Postgres version cannot be
// determined. An unsupported version is only logged: running one is discouraged,
// but not prevented.
func (ss *SqlStore) checkVersion(ver string) error {
intVer, err := strconv.Atoi(ver)
if err != nil {
return false, fmt.Errorf("cannot parse DB version: %v", err)
return fmt.Errorf("cannot parse DB version: %v", err)
}
if intVer < minimumRequiredPostgresVersion {
return false, fmt.Errorf("minimum Postgres version requirements not met. Found: %s, Wanted: %s", versionString(intVer), versionString(minimumRequiredPostgresVersion))
ss.logger.Error("Unsupported Postgres version. Running an unsupported version may lead to unexpected behaviour.",
mlog.String("version", versionString(intVer)),
mlog.String("min_version", versionString(minimumRequiredPostgresVersion)),
)
}
return true, nil
return nil
}
// versionString converts an integer representation of a Postgres DB version
// to a pretty-printed string.
// Postgres doesn't follow three-part version numbers from 10.0 onwards:
// From 10.0 onwards, the version is the major version multiplied by 10000 plus
// the minor version, e.g. 10.1 is 100001. Prior to 10, the version used two
// digits for each of the three parts, e.g. 9.1.5 is 90105:
// https://www.postgresql.org/docs/13/libpq-status.html#LIBPQ-PQSERVERVERSION.
func versionString(v int) string {
minor := v % 10000
major := v / 10000
return strconv.Itoa(major) + "." + strconv.Itoa(minor)
if major < 10 {
return fmt.Sprintf("%d.%d.%d", major, (v/100)%100, v%100)
}
return fmt.Sprintf("%d.%d", major, v%10000)
}
func (ss *SqlStore) toReserveCase(str string) string {
+76 -42
View File
@@ -525,58 +525,48 @@ func TestGetDbVersion(t *testing.T) {
}
}
func TestEnsureMinimumDBVersion(t *testing.T) {
func TestCheckVersion(t *testing.T) {
if enableFullyParallelTests {
t.Parallel()
}
tests := []struct {
driver string
ver string
ok bool
err string
ver string
wantErr string
wantLog string
wantVersion string
wantMinVersion string
}{
{
driver: model.DatabaseDriverPostgres,
ver: "110001",
ok: false,
err: "",
ver: "110001",
wantLog: "Unsupported Postgres version",
wantVersion: "11.1",
wantMinVersion: "14.0",
},
{
driver: model.DatabaseDriverPostgres,
ver: "130001",
ok: false,
err: "",
ver: "130001",
wantLog: "Unsupported Postgres version",
wantVersion: "13.1",
wantMinVersion: "14.0",
},
{
driver: model.DatabaseDriverPostgres,
ver: "140000",
ok: true,
err: "",
ver: "140000",
},
{
driver: model.DatabaseDriverPostgres,
ver: "141900",
ok: true,
err: "",
ver: "140019",
},
{
driver: model.DatabaseDriverPostgres,
ver: "150000",
ok: true,
err: "",
ver: "150000",
},
{
driver: model.DatabaseDriverPostgres,
ver: "90603",
ok: false,
err: "minimum Postgres version requirements not met",
ver: "90603",
wantLog: "Unsupported Postgres version",
wantVersion: "9.6.3",
wantMinVersion: "14.0",
},
{
driver: model.DatabaseDriverPostgres,
ver: "12.34.1",
ok: false,
err: "cannot parse DB version",
ver: "12.34.1",
wantErr: "cannot parse DB version",
},
}
@@ -585,13 +575,36 @@ func TestEnsureMinimumDBVersion(t *testing.T) {
DriverName: &pg,
}
for _, tc := range tests {
store := &SqlStore{}
store.settings = pgSettings
ok, err := store.ensureMinimumDBVersion(tc.ver)
assert.Equal(t, tc.ok, ok, "driver: %s, version: %s", tc.driver, tc.ver)
if tc.err != "" {
assert.Contains(t, err.Error(), tc.err)
}
t.Run(tc.ver, func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
store := &SqlStore{}
store.settings = pgSettings
store.logger = logger
err := store.checkVersion(tc.ver)
require.NoError(t, logger.Flush())
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
if tc.wantLog == "" {
assert.Empty(t, buf.String())
return
}
assert.Contains(t, buf.String(), tc.wantLog)
assert.Contains(t, buf.String(), fmt.Sprintf(`"version":%q`, tc.wantVersion))
if tc.wantMinVersion != "" {
assert.Contains(t, buf.String(), fmt.Sprintf(`"min_version":%q`, tc.wantMinVersion))
}
})
}
}
@@ -773,17 +786,38 @@ func TestVersionString(t *testing.T) {
},
{
input: 90603,
output: "9.603",
output: "9.6.3",
},
{
input: 120005,
output: "12.5",
},
// Examples given by the PQserverVersion documentation.
{
input: 100001,
output: "10.1",
},
{
input: 110000,
output: "11.0",
},
{
input: 90105,
output: "9.1.5",
},
{
input: 90200,
output: "9.2.0",
},
{
input: 140019,
output: "14.19",
},
}
for _, v := range versions {
out := versionString(v.input)
assert.Equal(t, v.output, out)
assert.Equal(t, v.output, out, "input: %d", v.input)
}
}
@@ -13,6 +13,8 @@ import (
elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func newTestClient(t *testing.T, handler http.Handler) *elastic.TypedClient {
@@ -37,11 +39,12 @@ func infoHandler(version string) http.HandlerFunc {
func TestCheckVersion(t *testing.T) {
tests := []struct {
name string
version string
wantVersion string
wantMajor int
wantErrID string
name string
version string
wantVersion string
wantMajor int
wantErrID string
wantUnsupported bool
}{
{
name: "ES 8 is supported",
@@ -56,14 +59,18 @@ func TestCheckVersion(t *testing.T) {
wantMajor: 9,
},
{
name: "ES 7 is too old",
version: "7.17.0",
wantErrID: "ent.elasticsearch.min_version.app_error",
name: "ES 7 is too old, but allowed",
version: "7.17.0",
wantVersion: "7.17.0",
wantMajor: 7,
wantUnsupported: true,
},
{
name: "ES 10 is too new",
version: "10.0.0",
wantErrID: "ent.elasticsearch.max_version.app_error",
name: "ES 10 is too new, but allowed",
version: "10.0.0",
wantVersion: "10.0.0",
wantMajor: 10,
wantUnsupported: true,
},
{
name: "invalid version string",
@@ -74,15 +81,30 @@ func TestCheckVersion(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
client := newTestClient(t, infoHandler(tc.version))
version, major, appErr := checkVersion(context.Background(), client)
version, major, appErr := checkVersion(context.Background(), client, logger)
require.NoError(t, logger.Flush())
if tc.wantErrID != "" {
require.NotNil(t, appErr)
assert.Equal(t, tc.wantErrID, appErr.Id)
return
}
require.Nil(t, appErr)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantMajor, major)
if tc.wantUnsupported {
assert.Contains(t, buf.String(), "Unsupported Elasticsearch version")
assert.Contains(t, buf.String(), fmt.Sprintf(`"version":%q`, tc.wantVersion))
assert.Contains(t, buf.String(), `"min_version":8`)
assert.Contains(t, buf.String(), `"max_version":9`)
} else {
require.Nil(t, appErr)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantMajor, major)
assert.Empty(t, buf.String())
}
})
}
@@ -98,7 +120,7 @@ func TestCheckVersionConnectionError(t *testing.T) {
})
require.NoError(t, err)
_, _, appErr := checkVersion(context.Background(), client)
_, _, appErr := checkVersion(context.Background(), client, mlog.CreateConsoleTestLogger(t))
require.NotNil(t, appErr)
assert.Equal(t, "ent.elasticsearch.start.get_server_version.app_error", appErr.Id)
}
@@ -147,7 +147,7 @@ func (es *ElasticsearchInterfaceImpl) IsIndexingSync() bool {
// fetchServerInfo retrieves and stores the server version and plugins from the given client.
func (es *ElasticsearchInterfaceImpl) fetchServerInfo(ctx context.Context, client *elastic.TypedClient) *model.AppError {
version, major, appErr := checkVersion(ctx, client)
version, major, appErr := checkVersion(ctx, client, es.Platform.Log())
if appErr != nil {
return appErr
}
@@ -2240,7 +2240,10 @@ func (es *ElasticsearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime
return nil
}
func checkVersion(ctx context.Context, client *elastic.TypedClient) (string, int, *model.AppError) {
// checkVersion returns the version of the connected Elasticsearch server. An
// unsupported version is logged but not treated as fatal, allowing the server to
// start regardless.
func checkVersion(ctx context.Context, client *elastic.TypedClient, logger mlog.LoggerIFace) (string, int, *model.AppError) {
resp, err := client.API.Core.Info().Do(ctx)
if err != nil {
return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.start.get_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err)
@@ -2251,11 +2254,13 @@ func checkVersion(ctx context.Context, client *elastic.TypedClient) (string, int
return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(esErr)
}
if major < elasticsearchMinVersion {
return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.min_version.app_error", map[string]any{"Version": major, "MinVersion": elasticsearchMinVersion, "Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusBadRequest)
}
if major > elasticsearchMaxVersion {
return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.max_version.app_error", map[string]any{"Version": major, "MaxVersion": elasticsearchMaxVersion, "Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusBadRequest)
if major < elasticsearchMinVersion || major > elasticsearchMaxVersion {
logger.Error("Unsupported Elasticsearch version. Running an unsupported version may lead to unexpected behaviour.",
mlog.String("version", resp.Version.Int),
mlog.Int("min_version", elasticsearchMinVersion),
mlog.Int("max_version", elasticsearchMaxVersion),
)
}
return resp.Version.Int, major, nil
}
@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package opensearch
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/opensearch-project/opensearch-go/v4"
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func newTestClient(t *testing.T, handler http.Handler) *opensearchapi.Client {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{ts.URL},
MaxRetries: 0,
},
})
require.NoError(t, err)
return client
}
func infoHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"node","cluster_name":"test","cluster_uuid":"abc","version":{"distribution":"opensearch","number":%q,"build_type":"tar","build_hash":"abc","build_date":"2024-01-01","build_snapshot":false,"lucene_version":"9.7.0","minimum_wire_compatibility_version":"7.10.0","minimum_index_compatibility_version":"7.0.0"},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, version)
}
}
func TestCheckVersion(t *testing.T) {
tests := []struct {
name string
version string
wantVersion string
wantMajor int
wantErrID string
wantUnsupported bool
}{
{
name: "OpenSearch 2 is supported",
version: "2.11.0",
wantVersion: "2.11.0",
wantMajor: 2,
},
{
name: "OpenSearch 3 is supported",
version: "3.0.0",
wantVersion: "3.0.0",
wantMajor: 3,
},
{
name: "OpenSearch 4 is too new, but allowed",
version: "4.0.0",
wantVersion: "4.0.0",
wantMajor: 4,
wantUnsupported: true,
},
{
name: "invalid version string",
version: "invalid",
wantErrID: "ent.elasticsearch.start.parse_server_version.app_error",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
client := newTestClient(t, infoHandler(tc.version))
version, major, appErr := checkVersion(context.Background(), client, logger)
require.NoError(t, logger.Flush())
if tc.wantErrID != "" {
require.NotNil(t, appErr)
assert.Equal(t, tc.wantErrID, appErr.Id)
return
}
require.Nil(t, appErr)
assert.Equal(t, tc.wantVersion, version)
assert.Equal(t, tc.wantMajor, major)
if tc.wantUnsupported {
assert.Contains(t, buf.String(), "Unsupported OpenSearch version")
assert.Contains(t, buf.String(), fmt.Sprintf(`"version":%q`, tc.wantVersion))
assert.Contains(t, buf.String(), `"max_version":3`)
} else {
assert.Empty(t, buf.String())
}
})
}
}
func TestCheckVersionConnectionError(t *testing.T) {
ts := httptest.NewServer(http.NotFoundHandler())
ts.Close() // close immediately to force connection error
client, err := opensearchapi.NewClient(opensearchapi.Config{
Client: opensearch.Config{
Addresses: []string{ts.URL},
MaxRetries: 0,
},
})
require.NoError(t, err)
_, _, appErr := checkVersion(context.Background(), client, mlog.CreateConsoleTestLogger(t))
require.NotNil(t, appErr)
assert.Equal(t, "ent.elasticsearch.start.get_server_version.app_error", appErr.Id)
}
@@ -117,7 +117,7 @@ func (os *OpensearchInterfaceImpl) IsIndexingSync() bool {
// fetchServerInfo retrieves and stores the server version and plugins from the given client.
func (os *OpensearchInterfaceImpl) fetchServerInfo(ctx context.Context, client *opensearchapi.Client) *model.AppError {
version, major, appErr := checkMaxVersion(ctx, client)
version, major, appErr := checkVersion(ctx, client, os.Platform.Log())
if appErr != nil {
return appErr
}
@@ -2355,19 +2355,26 @@ func (os *OpensearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime, l
return nil
}
func checkMaxVersion(ctx context.Context, client *opensearchapi.Client) (string, int, *model.AppError) {
// checkVersion returns the version of the connected OpenSearch server. An
// unsupported version is logged but not treated as fatal, allowing the server to
// start regardless.
func checkVersion(ctx context.Context, client *opensearchapi.Client, logger mlog.LoggerIFace) (string, int, *model.AppError) {
resp, err := client.Info(ctx, 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)
return "", 0, model.NewAppError("Opensearch.checkVersion", "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(esErr)
return "", 0, model.NewAppError("Opensearch.checkVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(esErr)
}
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)
logger.Error("Unsupported OpenSearch version. Running an unsupported version may lead to unexpected behaviour.",
mlog.String("version", resp.Version.Number),
mlog.Int("max_version", opensearchMaxVersion),
)
}
return resp.Version.Number, major, nil
}
-8
View File
@@ -10518,14 +10518,6 @@
"id": "ent.elasticsearch.indexer.index_batch.nothing_left_to_index.error",
"translation": "Trying to index a new batch when all the entities are completed"
},
{
"id": "ent.elasticsearch.max_version.app_error",
"translation": "{{.Backend}} version {{.Version}} is higher than max supported version of {{.MaxVersion}}"
},
{
"id": "ent.elasticsearch.min_version.app_error",
"translation": "{{.Backend}} version {{.Version}} is lower than min supported version of {{.MinVersion}}"
},
{
"id": "ent.elasticsearch.not_started.error",
"translation": "{{.Backend}} is not started"