fix: require DSNs for legacy database notifications

Reject pre-KV PostgreSQL and MySQL targets that lack a canonical connection string, propagate the typed migration error to the fatal startup boundary, and stop emitting unregistered discrete connection keys.\n\nCloses the implementation for #53; release and issue closure remain separate gates.
This commit is contained in:
Feng Ruohang
2026-08-24 02:22:20 +08:00
parent 43f4bb7ed4
commit f1ba683582
6 changed files with 525 additions and 62 deletions
+6 -2
View File
@@ -167,7 +167,9 @@ func readConfigWithoutMigrate(ctx context.Context, objAPI ObjectLayer) (config.C
notify.SetNotifyMQTT(newCfg, k, args)
}
for k, args := range cfg.Notify.MySQL {
notify.SetNotifyMySQL(newCfg, k, args)
if err := notify.SetNotifyMySQL(newCfg, k, args); err != nil {
return nil, err
}
}
for k, args := range cfg.Notify.NATS {
notify.SetNotifyNATS(newCfg, k, args)
@@ -176,7 +178,9 @@ func readConfigWithoutMigrate(ctx context.Context, objAPI ObjectLayer) (config.C
notify.SetNotifyNSQ(newCfg, k, args)
}
for k, args := range cfg.Notify.PostgreSQL {
notify.SetNotifyPostgres(newCfg, k, args)
if err := notify.SetNotifyPostgres(newCfg, k, args); err != nil {
return nil, err
}
}
for k, args := range cfg.Notify.Redis {
notify.SetNotifyRedis(newCfg, k, args)
+264
View File
@@ -0,0 +1,264 @@
// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"reflect"
"strings"
"testing"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/config/notify"
"github.com/minio/minio/internal/event/target"
)
func installLegacyConfigFile(t *testing.T, configure func(*serverConfigV33)) (string, []byte) {
t.Helper()
cfg := &serverConfigV33{
Version: "33",
Notify: notify.NewConfig(),
}
configure(cfg)
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
oldConfigDir := globalConfigDir
globalConfigDir = &ConfigDir{path: t.TempDir()}
t.Cleanup(func() { globalConfigDir = oldConfigDir })
configFile := getConfigFile()
if err = os.WriteFile(configFile, data, 0o600); err != nil {
t.Fatal(err)
}
return configFile, data
}
func assertLegacyMigrationError(t *testing.T, err error, subsystem, name, key, secret string) {
t.Helper()
var targetErr *notify.LegacyDatabaseTargetError
if !errors.As(err, &targetErr) {
t.Fatalf("error = %v, want *notify.LegacyDatabaseTargetError", err)
}
msg := err.Error()
for _, want := range []string{subsystem + config.SubSystemSeparator + name, key} {
if !strings.Contains(msg, want) {
t.Errorf("error %q does not contain %q", msg, want)
}
}
if strings.Contains(msg, secret) {
t.Errorf("migration error leaks database password %q: %s", secret, msg)
}
}
func TestReadConfigWithoutMigrateRejectsLegacyDatabaseTargets(t *testing.T) {
tests := []struct {
name string
subsystem string
key string
secret string
configure func(*serverConfigV33)
}{
{
name: "postgres",
subsystem: config.NotifyPostgresSubSys,
key: target.PostgresConnectionString,
secret: "postgres-migration-secret",
configure: func(cfg *serverConfigV33) {
cfg.Notify.PostgreSQL["archive"] = target.PostgreSQLArgs{
Enable: true,
Port: "5432",
Username: "legacy-user",
Password: "postgres-migration-secret",
Database: "events",
}
},
},
{
name: "mysql",
subsystem: config.NotifyMySQLSubSys,
key: target.MySQLDSNString,
secret: "mysql-migration-secret",
configure: func(cfg *serverConfigV33) {
cfg.Notify.MySQL["archive"] = target.MySQLArgs{
Enable: true,
Port: "3306",
User: "legacy-user",
Password: "mysql-migration-secret",
Database: "events",
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configFile, original := installLegacyConfigFile(t, test.configure)
got, err := readConfigWithoutMigrate(t.Context(), nil)
if got != nil {
t.Fatalf("config = %v, want nil on failed migration", got)
}
assertLegacyMigrationError(t, err, test.subsystem, "archive", test.key, test.secret)
after, readErr := os.ReadFile(configFile)
if readErr != nil {
t.Fatal(readErr)
}
if !bytes.Equal(after, original) {
t.Fatal("failed migration rewrote the legacy source config")
}
if _, statErr := os.Stat(configFile + ".old"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("failed migration created a backup/persistence artifact: %v", statErr)
}
})
}
}
func TestReadConfigWithoutMigrateMigratesCanonicalDatabaseTargets(t *testing.T) {
const (
postgresConnection = "host=postgres.example port=5432 dbname=events user=app password=secret sslmode=disable"
mysqlDSN = "app:secret@tcp(mysql.example:3306)/events?parseTime=true"
discardedLegacyValue = "discarded-legacy-value"
)
installLegacyConfigFile(t, func(cfg *serverConfigV33) {
cfg.Notify.PostgreSQL["archive"] = target.PostgreSQLArgs{
Enable: true,
Format: "namespace",
ConnectionString: postgresConnection,
Table: "events",
Port: discardedLegacyValue,
Username: discardedLegacyValue,
Password: discardedLegacyValue,
Database: discardedLegacyValue,
}
cfg.Notify.MySQL["archive"] = target.MySQLArgs{
Enable: true,
Format: "namespace",
DSN: mysqlDSN,
Table: "events",
Port: discardedLegacyValue,
User: discardedLegacyValue,
Password: discardedLegacyValue,
Database: discardedLegacyValue,
}
})
got, err := readConfigWithoutMigrate(t.Context(), nil)
if err != nil {
t.Fatalf("readConfigWithoutMigrate: %v", err)
}
tests := []struct {
subsystem string
key string
want string
discarded string
}{
{config.NotifyPostgresSubSys, target.PostgresConnectionString, postgresConnection, discardedLegacyValue},
{config.NotifyMySQLSubSys, target.MySQLDSNString, mysqlDSN, discardedLegacyValue},
}
for _, test := range tests {
kvs := got[test.subsystem]["archive"]
if value := kvs.Get(test.key); value != test.want {
t.Errorf("%s %s = %q, want %q", test.subsystem, test.key, value, test.want)
}
if err := config.CheckValidKeys(test.subsystem+config.SubSystemSeparator+"archive", kvs, notify.DefaultNotificationKVS[test.subsystem]); err != nil {
t.Errorf("migrated %s target failed key validation: %v", test.subsystem, err)
}
for _, key := range []string{"host", "port", "username", "password", "database"} {
if _, ok := kvs.Lookup(key); ok {
t.Errorf("migrated %s target contains legacy key %q", test.subsystem, key)
}
}
for _, kv := range kvs {
if strings.Contains(kv.Value, test.discarded) {
t.Errorf("migrated %s target contains discarded legacy value in %q", test.subsystem, kv.Key)
}
}
}
postgresTargets, err := notify.GetNotifyPostgres(got[config.NotifyPostgresSubSys])
if err != nil {
t.Fatalf("GetNotifyPostgres: %v", err)
}
if value := postgresTargets["archive"].ConnectionString; value != postgresConnection {
t.Errorf("Postgres connection string = %q, want %q", value, postgresConnection)
}
mysqlTargets, err := notify.GetNotifyMySQL(got[config.NotifyMySQLSubSys])
if err != nil {
t.Fatalf("GetNotifyMySQL: %v", err)
}
if value := mysqlTargets["archive"].DSN; value != mysqlDSN {
t.Errorf("MySQL DSN = %q, want %q", value, mysqlDSN)
}
}
func TestInitConfigSubsystemReturnsLegacyDatabaseTargetError(t *testing.T) {
obj, fsDir, err := prepareFS(t.Context())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = obj.Shutdown(context.Background())
_ = os.RemoveAll(fsDir)
})
const secret = "startup-migration-secret"
installLegacyConfigFile(t, func(cfg *serverConfigV33) {
cfg.Notify.PostgreSQL["archive"] = target.PostgreSQLArgs{
Enable: true,
Port: "5432",
Username: "legacy-user",
Password: secret,
Database: "events",
}
})
globalServerConfigMu.RLock()
var before config.Config
if globalServerConfig != nil {
before = globalServerConfig.Clone()
}
globalServerConfigMu.RUnlock()
err = initConfigSubsystem(t.Context(), obj)
assertLegacyMigrationError(t, err, config.NotifyPostgresSubSys, "archive", target.PostgresConnectionString, secret)
if configRetriableErrors(err) {
t.Fatal("legacy database migration error must be startup-fatal, not retriable")
}
if !fatalServerConfigError(err) {
t.Fatal("legacy database migration error must abort server startup")
}
globalServerConfigMu.RLock()
var after config.Config
if globalServerConfig != nil {
after = globalServerConfig.Clone()
}
globalServerConfigMu.RUnlock()
if !reflect.DeepEqual(after, before) {
t.Fatal("failed migration activated a partial server configuration")
}
}
+15 -3
View File
@@ -48,6 +48,7 @@ import (
"github.com/minio/minio/internal/color"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/config/api"
"github.com/minio/minio/internal/config/notify"
"github.com/minio/minio/internal/handlers"
"github.com/minio/minio/internal/hash/sha256"
xhttp "github.com/minio/minio/internal/http"
@@ -535,6 +536,15 @@ func configRetriableErrors(err error) bool {
notInitialized
}
func fatalServerConfigError(err error) bool {
var configErr config.Err
if errors.As(err, &configErr) {
return true
}
var migrationErr *notify.LegacyDatabaseTargetError
return errors.As(err, &migrationErr)
}
func bootstrapTraceMsg(msg string) {
info := madmin.TraceInfo{
TraceType: madmin.TraceBootstrap,
@@ -632,7 +642,10 @@ func initConfigSubsystem(ctx context.Context, newObject ObjectLayer) error {
// Initialize config system.
if err := globalConfigSys.Init(newObject); err != nil {
if configRetriableErrors(err) {
var migrationErr *notify.LegacyDatabaseTargetError
// Do not use fatalServerConfigError here: existing config.Err values
// retain the historical log-and-continue behavior at this boundary.
if configRetriableErrors(err) || errors.As(err, &migrationErr) {
return fmt.Errorf("Unable to initialize config system: %w", err)
}
@@ -965,10 +978,9 @@ func serverMain(ctx *cli.Context) {
var err error
bootstrapTrace("initServerConfig", func() {
if err = initServerConfig(GlobalContext, newObject); err != nil {
var cerr config.Err
// For any config error, we don't need to drop into safe-mode
// instead its a user error and should be fixed by user.
if errors.As(err, &cerr) {
if fatalServerConfigError(err) {
logger.FatalIf(err, "Unable to initialize the server")
}