From 016e0b3f4a25561e47cc453e25e9fa62147ac523 Mon Sep 17 00:00:00 2001 From: caffeinated92 Date: Tue, 6 Jan 2026 15:54:16 +0000 Subject: [PATCH 1/9] feat: Add MySQL and Preserved Variables CNF management endpoints and UI components - Implemented API endpoints for retrieving and saving MySQL defaults CNF and preserved variables CNF in `api_cluster.go`. - Added new React components `MySQLDefaultsEditor` and `PreservedVariablesEditor` for managing CNF files. - Created a generic `CnfFileEditor` component to handle loading and saving of CNF file content. - Updated Redux slice to include actions for fetching and saving MySQL and preserved variables configurations. - Enhanced the dashboard UI to include sections for editing MySQL defaults and preserved variables configurations. - Embedded `mysql_defaults.cnf` in the application for access. --- cluster/cluster.go | 22 + cluster/cluster_acl.go | 12 + cluster/cluster_cnf.go | 784 ++++++++++++ cluster/cluster_cnf_test.go | 437 +++++++ cluster/srv_cnf.go | 208 +++- cluster/srv_cnf_delta_test.go | 1091 +++++++++++++++++ config/config.go | 2 - docs/docs.go | 291 ++++- docs/swagger.json | 291 ++++- docs/swagger.yaml | 210 +++- server/api_cluster.go | 218 ++++ .../components/ClusterDBTabContent/index.jsx | 1 + .../ClusterDB/components/Variables/index.jsx | 23 +- .../Pages/Configs/components/DBConfigs.jsx | 16 + .../src/components/CnfFileEditor.jsx | 260 ++++ .../src/components/MySQLDefaultsEditor.jsx | 138 +++ .../components/PreservedVariablesEditor.jsx | 146 +++ .../dashboard_react/src/redux/configSlice.js | 44 + .../src/services/configService.js | 20 + share/embed.go | 2 +- 20 files changed, 4171 insertions(+), 45 deletions(-) create mode 100644 cluster/cluster_cnf.go create mode 100644 cluster/cluster_cnf_test.go create mode 100644 cluster/srv_cnf_delta_test.go create mode 100644 share/dashboard_react/src/components/CnfFileEditor.jsx create mode 100644 share/dashboard_react/src/components/MySQLDefaultsEditor.jsx create mode 100644 share/dashboard_react/src/components/PreservedVariablesEditor.jsx diff --git a/cluster/cluster.go b/cluster/cluster.go index 723fb91ac..85f350267 100644 --- a/cluster/cluster.go +++ b/cluster/cluster.go @@ -258,6 +258,14 @@ type Cluster struct { SessionManager *tty.SessionManager `json:"-"` SysBenchTpcMResults []SysBenchTpcResultPerMinute OpenSVCStats atomic.Value `json:"-"` + // Per-cluster MySQL defaults (each cluster can have its own defaults file) + mysqlDefaultValues map[string]string `json:"-"` + mysqlDefaultValuesLoaded bool `json:"-"` + mysqlDefaultsMutex sync.RWMutex `json:"-"` + // Per-cluster preserved variables (replaces ProvDBConfigPreserveVars mechanism) + preservedVars map[string]string `json:"-"` + preservedVarsLoaded bool `json:"-"` + preservedVarsMutex sync.RWMutex `json:"-"` } type SlavesOldestMasterFile struct { @@ -551,6 +559,20 @@ func (cluster *Cluster) InitFromConf() { cluster.LoadAppConfigs() + // Initialize MySQL defaults before server initialization + // This ensures defaults are available during server configuration generation + if err := cluster.initMySQLDefaults(); err != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to pre-initialize MySQL defaults: %v (will retry on demand)", err) + } + + // Initialize preserved variables before server initialization + // This replaces the old ProvDBConfigPreserveVars mechanism + if err := cluster.initPreservedVars(); err != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to pre-initialize preserved variables: %v (will retry on demand)", err) + } + err = cluster.newServerList() if err != nil { cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlErr, "Could not set server list %s", err) diff --git a/cluster/cluster_acl.go b/cluster/cluster_acl.go index eff590290..76c4a084f 100644 --- a/cluster/cluster_acl.go +++ b/cluster/cluster_acl.go @@ -805,6 +805,18 @@ func (cluster *Cluster) IsURLPassACL(strUser string, URL string, errorPrint bool } } if cluster.APIUsers[strUser].Grants[config.GrantClusterSettings] { + if strings.Contains(URL, "/api/clusters/"+cluster.Name+"/settings/mysql-defaults-cnf") { + return true + } + if strings.Contains(URL, "/api/clusters/"+cluster.Name+"/settings/actions/save-mysql-defaults-cnf") { + return true + } + if strings.Contains(URL, "/api/clusters/"+cluster.Name+"/settings/preserved-variables-cnf") { + return true + } + if strings.Contains(URL, "/api/clusters/"+cluster.Name+"/settings/actions/save-preserved-variables-cnf") { + return true + } if strings.Contains(URL, "/api/clusters/"+cluster.Name+"/settings/actions/reload") { return true } diff --git a/cluster/cluster_cnf.go b/cluster/cluster_cnf.go new file mode 100644 index 000000000..0eeea0a88 --- /dev/null +++ b/cluster/cluster_cnf.go @@ -0,0 +1,784 @@ +// replication-manager - Replication Manager Monitoring and CLI for MariaDB and MySQL +// Copyright 2017 Signal 18 Cloud SAS +// Authors: Guillaume Lefranc +// Stephane Varoqui +// This source code is licensed under the GNU General Public License, version 3. + +package cluster + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/signal18/replication-manager/config" + "github.com/signal18/replication-manager/share" +) + +// GetMySQLDefaultsPath returns the path to the cluster's MySQL defaults file +// The file is stored in the cluster's working directory +func (cluster *Cluster) GetMySQLDefaultsPath() string { + return filepath.Join(cluster.Conf.WorkingDir, cluster.Name, "mysql_defaults.cnf") +} + +// GetMySQLDefaultsInfo returns information about currently loaded MySQL defaults +func (cluster *Cluster) GetMySQLDefaultsInfo() map[string]interface{} { + cluster.mysqlDefaultsMutex.RLock() + defer cluster.mysqlDefaultsMutex.RUnlock() + + info := make(map[string]interface{}) + info["loaded"] = cluster.mysqlDefaultValuesLoaded + info["count"] = len(cluster.mysqlDefaultValues) + info["path"] = cluster.GetMySQLDefaultsPath() + + // Check if file exists + defaultsPath := cluster.GetMySQLDefaultsPath() + if _, err := os.Stat(defaultsPath); err == nil { + info["exists"] = true + if finfo, err := os.Stat(defaultsPath); err == nil { + info["modified"] = finfo.ModTime() + info["size"] = finfo.Size() + } + } else { + info["exists"] = false + } + + return info +} + +// ReloadMySQLDefaults forces a reload of MySQL defaults from cluster working directory +// Useful after manually editing the mysql_defaults.cnf file +func (cluster *Cluster) ReloadMySQLDefaults() error { + cluster.mysqlDefaultsMutex.Lock() + defer cluster.mysqlDefaultsMutex.Unlock() + + cluster.mysqlDefaultValuesLoaded = false + cluster.mysqlDefaultValues = nil + + return cluster.reloadMySQLDefaultsUnsafe() +} + +// SaveMySQLDefaults saves the current defaults to the cluster's mysql_defaults.cnf file +// This allows programmatic updates to the defaults +func (cluster *Cluster) SaveMySQLDefaults() error { + cluster.mysqlDefaultsMutex.RLock() + defer cluster.mysqlDefaultsMutex.RUnlock() + + return cluster.saveMySQLDefaultsToFile() +} + +// GetMySQLDefaultsCnfContent reads and returns the content of the mysql_defaults.cnf file +// Returns the raw file content as a string +// If the file doesn't exist, creates it from embedded defaults first +func (cluster *Cluster) GetMySQLDefaultsCnfContent() (string, error) { + defaultsPath := cluster.GetMySQLDefaultsPath() + + content, err := os.ReadFile(defaultsPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - create it from embedded defaults + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "MySQL defaults file not found at %s, creating from embedded defaults", defaultsPath) + + embeddedContent, embedErr := share.EmbededDbModuleFS.ReadFile("mysql_defaults.cnf") + if embedErr != nil { + return "", fmt.Errorf("failed to load embedded MySQL defaults: %v", embedErr) + } + + // Parse embedded content and save it + cluster.mysqlDefaultsMutex.Lock() + cluster.mysqlDefaultValues = loadMySQLDefaultsFromCNF(string(embeddedContent)) + cluster.mysqlDefaultValuesLoaded = true + + // Save to file using the internal function (we already have the lock) + saveErr := cluster.saveMySQLDefaultsToFile() + cluster.mysqlDefaultsMutex.Unlock() + + if saveErr != nil { + return "", fmt.Errorf("failed to save embedded defaults to %s: %v", defaultsPath, saveErr) + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Created mysql_defaults.cnf from embedded defaults at %s", defaultsPath) + + return string(embeddedContent), nil + } + return "", fmt.Errorf("failed to read mysql_defaults.cnf: %v", err) + } + + return string(content), nil +} + +// WriteMySQLDefaultsCnfContent writes the provided content to the mysql_defaults.cnf file +// and automatically reloads the MySQL defaults +func (cluster *Cluster) WriteMySQLDefaultsCnfContent(content string) error { + defaultsPath := cluster.GetMySQLDefaultsPath() + + // Ensure directory exists + dir := filepath.Dir(defaultsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", dir, err) + } + + // Write the content to file + if err := os.WriteFile(defaultsPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write mysql_defaults.cnf: %v", err) + } + + // Reload the MySQL defaults to apply the changes + if err := cluster.ReloadMySQLDefaults(); err != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to reload MySQL defaults after writing: %v", err) + // Don't return error - file was written successfully + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Successfully wrote mysql_defaults.cnf to %s", defaultsPath) + + return nil +} + +// reloadMySQLDefaultsUnsafe reloads defaults without locking (internal use) +// Caller must hold cluster.mysqlDefaultsMutex with Lock (not RLock) +// First tries to load from cluster's working directory file +// If file doesn't exist, loads from embedded defaults and saves to file +func (cluster *Cluster) reloadMySQLDefaultsUnsafe() error { + var content []byte + var err error + var source string + + defaultsPath := cluster.GetMySQLDefaultsPath() + + // Try to load from cluster's working directory file + content, err = os.ReadFile(defaultsPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - load from embedded and save it + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "MySQL defaults file not found at %s, creating from embedded defaults", defaultsPath) + + content, err = share.EmbededDbModuleFS.ReadFile("mysql_defaults.cnf") + if err != nil { + return fmt.Errorf("failed to load embedded MySQL defaults: %v", err) + } + + // Parse embedded content first + cluster.mysqlDefaultValues = loadMySQLDefaultsFromCNF(string(content)) + cluster.mysqlDefaultValuesLoaded = true + + // Save to cluster directory for future editing + // We already have Lock, so we can directly save the file without calling SaveMySQLDefaults + // which would try to acquire RLock and cause issues + if saveErr := cluster.saveMySQLDefaultsToFile(); saveErr != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to save embedded defaults to %s: %v", defaultsPath, saveErr) + } else { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Saved embedded defaults to %s for future editing", defaultsPath) + } + + source = "embedded mysql_defaults.cnf (saved to " + defaultsPath + ")" + } else { + return fmt.Errorf("failed to read MySQL defaults from %s: %v", defaultsPath, err) + } + } else { + // File exists - parse it + cluster.mysqlDefaultValues = loadMySQLDefaultsFromCNF(string(content)) + cluster.mysqlDefaultValuesLoaded = true + source = defaultsPath + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Loaded %d MySQL default values from %s", len(cluster.mysqlDefaultValues), source) + + return nil +} + +// saveMySQLDefaultsToFile saves defaults to file without locking +// Caller must hold cluster.mysqlDefaultsMutex +func (cluster *Cluster) saveMySQLDefaultsToFile() error { + if !cluster.mysqlDefaultValuesLoaded || cluster.mysqlDefaultValues == nil { + return fmt.Errorf("no defaults loaded to save") + } + + defaultsPath := cluster.GetMySQLDefaultsPath() + + // Build CNF content + var content strings.Builder + content.WriteString("# MySQL Default Variables\n") + content.WriteString("# This file is automatically generated from embedded defaults\n") + content.WriteString("# You can edit this file to customize defaults for this cluster\n") + content.WriteString("# Changes take effect after calling ReloadMySQLDefaults() or restarting\n\n") + content.WriteString("[mysqld]\n") + + // Sort keys for consistent output + keys := make([]string, 0, len(cluster.mysqlDefaultValues)) + for k := range cluster.mysqlDefaultValues { + keys = append(keys, k) + } + sort.Strings(keys) + + // Write sorted variables + for _, key := range keys { + value := cluster.mysqlDefaultValues[key] + // Convert back to lowercase with dashes for readability + readableKey := strings.ToLower(strings.ReplaceAll(key, "_", "-")) + content.WriteString(fmt.Sprintf("%s = %s\n", readableKey, value)) + } + + // Ensure directory exists + dir := filepath.Dir(defaultsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", dir, err) + } + + // Write file + if err := os.WriteFile(defaultsPath, []byte(content.String()), 0644); err != nil { + return fmt.Errorf("failed to write defaults file: %v", err) + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Saved %d MySQL defaults to %s", len(cluster.mysqlDefaultValues), defaultsPath) + + return nil +} + +// loadMySQLDefaultsFromCNF parses a CNF file and loads default values +// Format: variable_name = value +// Supports comments (#) and section headers ([mysqld], etc.) +func loadMySQLDefaultsFromCNF(content string) map[string]string { + defaults := make(map[string]string) + lines := strings.Split(content, "\n") + + for _, line := range lines { + // Trim whitespace + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Skip section headers [mysqld], [mysql], [mariadb] + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + continue + } + + // Parse key = value + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Normalize variable name to uppercase with underscores + normalizedKey := strings.ToUpper(strings.ReplaceAll(key, "-", "_")) + defaults[normalizedKey] = value + } + } + + return defaults +} + +// initMySQLDefaults loads MySQL default values on first use +// First tries to load from cluster's working directory file +// If file doesn't exist, loads from embedded and saves asynchronously for future editing +// Only loads once per cluster initialization +func (cluster *Cluster) initMySQLDefaults() error { + // First check without lock (fast path) + if cluster.mysqlDefaultValuesLoaded { + return nil + } + + // Acquire lock for initialization + cluster.mysqlDefaultsMutex.Lock() + defer cluster.mysqlDefaultsMutex.Unlock() + + // Double-check after acquiring lock (another goroutine may have initialized) + if cluster.mysqlDefaultValuesLoaded { + return nil + } + + defaultsPath := cluster.GetMySQLDefaultsPath() + var content []byte + var err error + var source string + + // Try to load from cluster's working directory file + content, err = os.ReadFile(defaultsPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - load from embedded (fast) + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "MySQL defaults file not found at %s, loading from embedded defaults", defaultsPath) + + content, err = share.EmbededDbModuleFS.ReadFile("mysql_defaults.cnf") + if err != nil { + return fmt.Errorf("failed to load embedded MySQL defaults: %v", err) + } + + // Parse embedded content first (fast) + cluster.mysqlDefaultValues = loadMySQLDefaultsFromCNF(string(content)) + cluster.mysqlDefaultValuesLoaded = true + + // Save to cluster directory asynchronously to avoid blocking startup + go func() { + if saveErr := cluster.SaveMySQLDefaults(); saveErr != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to save embedded defaults to %s: %v", defaultsPath, saveErr) + } else { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Saved embedded defaults to %s for future editing", defaultsPath) + } + }() + + source = "embedded mysql_defaults.cnf" + } else { + return fmt.Errorf("failed to read MySQL defaults from %s: %v", defaultsPath, err) + } + } else { + // File exists - parse it + cluster.mysqlDefaultValues = loadMySQLDefaultsFromCNF(string(content)) + cluster.mysqlDefaultValuesLoaded = true + source = defaultsPath + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Loaded %d MySQL default values from %s", len(cluster.mysqlDefaultValues), source) + + return nil +} + +// getMySQLDefaultForVar returns default value for any variable from the defaults file +// Returns empty string if variable is not in the defaults file +// Layer 5: This is only used when server is failed and no deployed/runtime values exist +// Note: MySQL defaults should already be initialized during cluster.InitFromConf() +func (cluster *Cluster) getMySQLDefaultForVar(varName string) string { + // Fast path: check if already loaded (no lock needed for atomic bool read) + if !cluster.mysqlDefaultValuesLoaded { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "MySQL defaults not initialized yet for variable %s, attempting lazy initialization", varName) + + // Fallback: lazy initialization if not already done + if err := cluster.initMySQLDefaults(); err != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlErr, + "Failed to initialize MySQL defaults: %v", err) + return "" + } + } + + // Now read the value with RLock + cluster.mysqlDefaultsMutex.RLock() + defer cluster.mysqlDefaultsMutex.RUnlock() + + upperName := strings.ToUpper(varName) + + // Return value if it exists in loaded defaults + if val, ok := cluster.mysqlDefaultValues[upperName]; ok { + return val + } + + return "" +} + +// GetPreservedVarsPath returns the path to the cluster's preserved variables file +// The file is stored in the cluster's working directory +func (cluster *Cluster) GetPreservedVarsPath() string { + return filepath.Join(cluster.Conf.WorkingDir, cluster.Name, "preserved_variables.cnf") +} + +// GetPreservedVarsInfo returns information about currently loaded preserved variables +func (cluster *Cluster) GetPreservedVarsInfo() map[string]interface{} { + cluster.preservedVarsMutex.RLock() + defer cluster.preservedVarsMutex.RUnlock() + + info := make(map[string]interface{}) + info["loaded"] = cluster.preservedVarsLoaded + info["count"] = len(cluster.preservedVars) + info["path"] = cluster.GetPreservedVarsPath() + + // Check if file exists + preservedPath := cluster.GetPreservedVarsPath() + if _, err := os.Stat(preservedPath); err == nil { + info["exists"] = true + if finfo, err := os.Stat(preservedPath); err == nil { + info["modified"] = finfo.ModTime() + info["size"] = finfo.Size() + } + } else { + info["exists"] = false + } + + return info +} + +// ReloadPreservedVars forces a reload of preserved variables from cluster working directory +// Useful after manually editing the preserved_variables.cnf file +func (cluster *Cluster) ReloadPreservedVars() error { + cluster.preservedVarsMutex.Lock() + defer cluster.preservedVarsMutex.Unlock() + + cluster.preservedVarsLoaded = false + cluster.preservedVars = nil + + return cluster.reloadPreservedVarsUnsafe() +} + +// SavePreservedVars saves the current preserved variables to the cluster's preserved_variables.cnf file +func (cluster *Cluster) SavePreservedVars() error { + cluster.preservedVarsMutex.RLock() + defer cluster.preservedVarsMutex.RUnlock() + + return cluster.savePreservedVarsToFile() +} + +// GetPreservedVarsCnfContent reads and returns the content of the preserved_variables.cnf file +// Returns the raw file content as a string +// If the file doesn't exist, creates an empty template +func (cluster *Cluster) GetPreservedVarsCnfContent() (string, error) { + preservedPath := cluster.GetPreservedVarsPath() + + content, err := os.ReadFile(preservedPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - create empty template + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Preserved variables file not found at %s, creating empty template", preservedPath) + + template := cluster.getPreservedVarsTemplate() + + // Parse empty content and save it + cluster.preservedVarsMutex.Lock() + cluster.preservedVars = make(map[string]string) + cluster.preservedVarsLoaded = true + + // Save to file using the internal function (we already have the lock) + saveErr := cluster.savePreservedVarsToFile() + cluster.preservedVarsMutex.Unlock() + + if saveErr != nil { + return "", fmt.Errorf("failed to save preserved variables template to %s: %v", preservedPath, saveErr) + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Created preserved_variables.cnf template at %s", preservedPath) + + return template, nil + } + return "", fmt.Errorf("failed to read preserved_variables.cnf: %v", err) + } + + return string(content), nil +} + +// WritePreservedVarsCnfContent writes the provided content to the preserved_variables.cnf file +// and automatically reloads the preserved variables +func (cluster *Cluster) WritePreservedVarsCnfContent(content string) error { + preservedPath := cluster.GetPreservedVarsPath() + + // Ensure directory exists + dir := filepath.Dir(preservedPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", dir, err) + } + + // Write the content to file + if err := os.WriteFile(preservedPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write preserved_variables.cnf: %v", err) + } + + // Reload the preserved variables to apply the changes + if err := cluster.ReloadPreservedVars(); err != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to reload preserved variables after writing: %v", err) + // Don't return error - file was written successfully + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Successfully wrote preserved_variables.cnf to %s", preservedPath) + + return nil +} + +// reloadPreservedVarsUnsafe reloads preserved variables without locking (internal use) +// Caller must hold cluster.preservedVarsMutex with Lock (not RLock) +func (cluster *Cluster) reloadPreservedVarsUnsafe() error { + var content []byte + var err error + var source string + + preservedPath := cluster.GetPreservedVarsPath() + + // Try to load from cluster's working directory file + content, err = os.ReadFile(preservedPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - create empty template + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Preserved variables file not found at %s, creating empty template", preservedPath) + + cluster.preservedVars = make(map[string]string) + cluster.preservedVarsLoaded = true + + // Save empty template to cluster directory + if saveErr := cluster.savePreservedVarsToFile(); saveErr != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to save preserved variables template to %s: %v", preservedPath, saveErr) + } else { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Saved preserved variables template to %s", preservedPath) + } + + source = "empty template (saved to " + preservedPath + ")" + } else { + return fmt.Errorf("failed to read preserved variables from %s: %v", preservedPath, err) + } + } else { + // File exists - parse it + cluster.preservedVars = loadPreservedVarsFromCNF(string(content)) + cluster.preservedVarsLoaded = true + source = preservedPath + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Loaded %d preserved variables from %s", len(cluster.preservedVars), source) + + return nil +} + +// savePreservedVarsToFile saves preserved variables to file without locking +// Caller must hold cluster.preservedVarsMutex +func (cluster *Cluster) savePreservedVarsToFile() error { + preservedPath := cluster.GetPreservedVarsPath() + + // Build CNF content + var content strings.Builder + content.WriteString("# Cluster-Level Preserved Variables\n") + content.WriteString("# This file defines variables that should be preserved across all servers in the cluster\n") + content.WriteString("# These variables can be overridden by server-specific files:\n") + content.WriteString("# - 01_preserved.cnf (server-specific preserved values)\n") + content.WriteString("# - 02_delta.cnf (calculated delta values)\n") + content.WriteString("# - 03_agreed.cnf (manually agreed values)\n") + content.WriteString("#\n") + content.WriteString("# Format:\n") + content.WriteString("# variable_name = value # Set a specific value to preserve\n") + content.WriteString("# variable_name = # Preserve the current value (empty = preserve whatever is deployed)\n") + content.WriteString("#\n") + content.WriteString("# Examples:\n") + content.WriteString("# innodb_buffer_pool_size = 1G\n") + content.WriteString("# max_connections = 500\n") + content.WriteString("# datadir = # Preserve current datadir value\n") + content.WriteString("\n") + content.WriteString("[mysqld]\n") + + if len(cluster.preservedVars) == 0 { + content.WriteString("# No preserved variables defined yet\n") + content.WriteString("# Add variables here in the format: variable_name = value\n") + } else { + // Sort keys for consistent output + keys := make([]string, 0, len(cluster.preservedVars)) + for k := range cluster.preservedVars { + keys = append(keys, k) + } + sort.Strings(keys) + + // Write sorted variables + for _, key := range keys { + value := cluster.preservedVars[key] + // Convert back to lowercase with dashes for readability + readableKey := strings.ToLower(strings.ReplaceAll(key, "_", "-")) + content.WriteString(fmt.Sprintf("%s = %s\n", readableKey, value)) + } + } + + // Ensure directory exists + dir := filepath.Dir(preservedPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", dir, err) + } + + // Write file + if err := os.WriteFile(preservedPath, []byte(content.String()), 0644); err != nil { + return fmt.Errorf("failed to write preserved variables file: %v", err) + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlInfo, + "Saved %d preserved variables to %s", len(cluster.preservedVars), preservedPath) + + return nil +} + +// loadPreservedVarsFromCNF parses a CNF file and loads preserved variables +// Format: variable_name = value +// Supports comments (#) and section headers ([mysqld], etc.) +func loadPreservedVarsFromCNF(content string) map[string]string { + preserved := make(map[string]string) + lines := strings.Split(content, "\n") + + for _, line := range lines { + // Trim whitespace + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Skip section headers [mysqld], [mysql], [mariadb] + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + continue + } + + // Parse key = value (value can be empty) + parts := strings.SplitN(line, "=", 2) + if len(parts) >= 1 { + key := strings.TrimSpace(parts[0]) + value := "" + if len(parts) == 2 { + value = strings.TrimSpace(parts[1]) + } + + // Normalize variable name to uppercase with underscores + normalizedKey := strings.ToUpper(strings.ReplaceAll(key, "-", "_")) + preserved[normalizedKey] = value + } + } + + return preserved +} + +// initPreservedVars loads preserved variables on first use +// Called during cluster initialization +func (cluster *Cluster) initPreservedVars() error { + // First check without lock (fast path) + if cluster.preservedVarsLoaded { + return nil + } + + // Acquire lock for initialization + cluster.preservedVarsMutex.Lock() + defer cluster.preservedVarsMutex.Unlock() + + // Double-check after acquiring lock (another goroutine may have initialized) + if cluster.preservedVarsLoaded { + return nil + } + + preservedPath := cluster.GetPreservedVarsPath() + var content []byte + var err error + var source string + + // Try to load from cluster's working directory file + content, err = os.ReadFile(preservedPath) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - create empty template (fast) + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Preserved variables file not found at %s, creating empty template", preservedPath) + + // Initialize with empty map + cluster.preservedVars = make(map[string]string) + cluster.preservedVarsLoaded = true + + // Save to cluster directory asynchronously to avoid blocking startup + go func() { + if saveErr := cluster.SavePreservedVars(); saveErr != nil { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Failed to save preserved variables template to %s: %v", preservedPath, saveErr) + } else { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Saved preserved variables template to %s", preservedPath) + } + }() + + source = "empty template" + } else { + return fmt.Errorf("failed to read preserved variables from %s: %v", preservedPath, err) + } + } else { + // File exists - parse it + cluster.preservedVars = loadPreservedVarsFromCNF(string(content)) + cluster.preservedVarsLoaded = true + source = preservedPath + } + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Loaded %d preserved variables from %s", len(cluster.preservedVars), source) + + return nil +} + +// getPreservedValueForVar returns the preserved value for a variable from cluster-level config +// Returns empty string if variable is not preserved at cluster level +// This is checked BEFORE server-specific preserved files (01_preserved.cnf, etc.) +func (cluster *Cluster) getPreservedValueForVar(varName string) (string, bool) { + cluster.preservedVarsMutex.RLock() + defer cluster.preservedVarsMutex.RUnlock() + + upperName := strings.ToUpper(varName) + + // Return value if it exists in loaded preserved variables + if val, ok := cluster.preservedVars[upperName]; ok { + return val, true + } + + return "", false +} + +// AddPreservedVar adds or updates a preserved variable +func (cluster *Cluster) AddPreservedVar(varName string, value string) error { + cluster.preservedVarsMutex.Lock() + defer cluster.preservedVarsMutex.Unlock() + + if !cluster.preservedVarsLoaded { + return fmt.Errorf("preserved variables not loaded") + } + + upperName := strings.ToUpper(varName) + cluster.preservedVars[upperName] = value + + return nil +} + +// RemovePreservedVar removes a preserved variable +func (cluster *Cluster) RemovePreservedVar(varName string) error { + cluster.preservedVarsMutex.Lock() + defer cluster.preservedVarsMutex.Unlock() + + if !cluster.preservedVarsLoaded { + return fmt.Errorf("preserved variables not loaded") + } + + upperName := strings.ToUpper(varName) + delete(cluster.preservedVars, upperName) + + return nil +} + +// getPreservedVarsTemplate returns the template content for an empty preserved variables file +func (cluster *Cluster) getPreservedVarsTemplate() string { + var content strings.Builder + content.WriteString("# Cluster-Level Preserved Variables\n") + content.WriteString("# This file defines variables that should be preserved across all servers in the cluster\n") + content.WriteString("# These variables can be overridden by server-specific files:\n") + content.WriteString("# - 01_preserved.cnf (server-specific preserved values)\n") + content.WriteString("# - 02_delta.cnf (calculated delta values)\n") + content.WriteString("# - 03_agreed.cnf (manually agreed values)\n") + content.WriteString("#\n") + content.WriteString("# Format:\n") + content.WriteString("# variable_name = value # Set a specific value to preserve\n") + content.WriteString("# variable_name = # Preserve the current value (empty = preserve whatever is deployed)\n") + content.WriteString("#\n") + content.WriteString("# Examples:\n") + content.WriteString("# innodb_buffer_pool_size = 1G\n") + content.WriteString("# max_connections = 500\n") + content.WriteString("# datadir = # Preserve current datadir value\n") + content.WriteString("\n") + content.WriteString("[mysqld]\n") + content.WriteString("# No preserved variables defined yet\n") + content.WriteString("# Add variables here in the format: variable_name = value\n") + + return content.String() +} diff --git a/cluster/cluster_cnf_test.go b/cluster/cluster_cnf_test.go new file mode 100644 index 000000000..5c7fca088 --- /dev/null +++ b/cluster/cluster_cnf_test.go @@ -0,0 +1,437 @@ +// replication-manager - Replication Manager Monitoring and CLI for MariaDB and MySQL +// Copyright 2017 Signal 18 Cloud SAS +// Authors: Guillaume Lefranc +// Stephane Varoqui +// This source code is licensed under the GNU General Public License, version 3. + +package cluster + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/signal18/replication-manager/config" +) + +// TestMySQLDefaultsNoDeadlock tests for potential deadlocks in concurrent operations +func TestMySQLDefaultsNoDeadlock(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "mysql-defaults-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create a mock cluster + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: make(map[string]string), + mysqlDefaultValuesLoaded: false, + } + + // Test 1: Concurrent reads should not deadlock + t.Run("ConcurrentReads", func(t *testing.T) { + done := make(chan bool, 10) + timeout := time.After(5 * time.Second) + + for i := 0; i < 10; i++ { + go func() { + _, _ = cluster.GetMySQLDefaultsCnfContent() + done <- true + }() + } + + for i := 0; i < 10; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in concurrent reads") + } + } + }) + + // Test 2: Concurrent writes should not deadlock + t.Run("ConcurrentWrites", func(t *testing.T) { + done := make(chan bool, 5) + timeout := time.After(5 * time.Second) + + for i := 0; i < 5; i++ { + go func(idx int) { + content := "# Test content\n[mysqld]\nmax_connections = 100\n" + _ = cluster.WriteMySQLDefaultsCnfContent(content) + done <- true + }(i) + } + + for i := 0; i < 5; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in concurrent writes") + } + } + }) + + // Test 3: Mixed reads and writes should not deadlock + t.Run("MixedReadWrite", func(t *testing.T) { + done := make(chan bool, 20) + timeout := time.After(5 * time.Second) + + // Start 10 readers + for i := 0; i < 10; i++ { + go func() { + _, _ = cluster.GetMySQLDefaultsCnfContent() + done <- true + }() + } + + // Start 10 writers + for i := 0; i < 10; i++ { + go func() { + content := "# Test content\n[mysqld]\nmax_connections = 100\n" + _ = cluster.WriteMySQLDefaultsCnfContent(content) + done <- true + }() + } + + for i := 0; i < 20; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in mixed read/write operations") + } + } + }) + + // Test 4: GetInfo and Reload operations should not deadlock + t.Run("InfoAndReload", func(t *testing.T) { + done := make(chan bool, 20) + timeout := time.After(5 * time.Second) + + for i := 0; i < 10; i++ { + go func() { + _ = cluster.GetMySQLDefaultsInfo() + done <- true + }() + } + + for i := 0; i < 10; i++ { + go func() { + _ = cluster.ReloadMySQLDefaults() + done <- true + }() + } + + for i := 0; i < 20; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in GetInfo/Reload operations") + } + } + }) + + // Test 5: SaveMySQLDefaults should not deadlock + t.Run("SaveDefaults", func(t *testing.T) { + // Initialize defaults first + _ = cluster.initMySQLDefaults() + + done := make(chan bool, 5) + timeout := time.After(5 * time.Second) + + for i := 0; i < 5; i++ { + go func() { + _ = cluster.SaveMySQLDefaults() + done <- true + }() + } + + for i := 0; i < 5; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in SaveMySQLDefaults") + } + } + }) + + // Test 6: All operations together (stress test) + t.Run("StressTest", func(t *testing.T) { + var wg sync.WaitGroup + timeout := time.After(10 * time.Second) + done := make(chan bool) + + operations := []func(){ + func() { _, _ = cluster.GetMySQLDefaultsCnfContent() }, + func() { _ = cluster.WriteMySQLDefaultsCnfContent("# Test\n[mysqld]\nmax_connections = 100\n") }, + func() { _ = cluster.GetMySQLDefaultsInfo() }, + func() { _ = cluster.ReloadMySQLDefaults() }, + func() { _ = cluster.SaveMySQLDefaults() }, + func() { _ = cluster.getMySQLDefaultForVar("max_connections") }, + } + + // Run 30 random operations concurrently + wg.Add(30) + for i := 0; i < 30; i++ { + go func(idx int) { + defer wg.Done() + op := operations[idx%len(operations)] + op() + }(i) + } + + go func() { + wg.Wait() + done <- true + }() + + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in stress test") + } + }) +} + +// TestReloadMySQLDefaultsUnsafe tests the specific deadlock scenario in reloadMySQLDefaultsUnsafe +func TestReloadMySQLDefaultsUnsafe(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mysql-defaults-unsafe-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: make(map[string]string), + mysqlDefaultValuesLoaded: false, + } + + // Test the specific scenario where reloadMySQLDefaultsUnsafe + // temporarily releases RLock to call SaveMySQLDefaults (which takes RLock) + t.Run("UnlockRelockScenario", func(t *testing.T) { + timeout := time.After(5 * time.Second) + done := make(chan bool) + + go func() { + // This should trigger the unlock/relock path in reloadMySQLDefaultsUnsafe + // when the file doesn't exist initially + _ = cluster.ReloadMySQLDefaults() + done <- true + }() + + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in unlock/relock scenario") + } + }) +} + +// TestGetMySQLDefaultsCnfContentAutoCreate tests auto-creation deadlock scenario +func TestGetMySQLDefaultsCnfContentAutoCreate(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mysql-defaults-autocreate-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: make(map[string]string), + mysqlDefaultValuesLoaded: false, + } + + // Test concurrent auto-creation from embedded defaults + t.Run("ConcurrentAutoCreate", func(t *testing.T) { + done := make(chan bool, 5) + timeout := time.After(5 * time.Second) + + // Multiple goroutines try to read non-existent file at the same time + // This triggers auto-creation from embedded defaults + for i := 0; i < 5; i++ { + go func() { + _, _ = cluster.GetMySQLDefaultsCnfContent() + done <- true + }() + } + + for i := 0; i < 5; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in concurrent auto-create") + } + } + }) +} + +// TestSaveMySQLDefaultsWithRLock tests SaveMySQLDefaults which uses RLock +func TestSaveMySQLDefaultsWithRLock(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mysql-defaults-rlock-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: map[string]string{ + "MAX_CONNECTIONS": "100", + }, + mysqlDefaultValuesLoaded: true, + } + + // Test that SaveMySQLDefaults (RLock) doesn't deadlock with other operations + t.Run("SaveWithConcurrentReads", func(t *testing.T) { + done := make(chan bool, 10) + timeout := time.After(5 * time.Second) + + // 5 Save operations (RLock) + for i := 0; i < 5; i++ { + go func() { + _ = cluster.SaveMySQLDefaults() + done <- true + }() + } + + // 5 GetInfo operations (RLock) + for i := 0; i < 5; i++ { + go func() { + _ = cluster.GetMySQLDefaultsInfo() + done <- true + }() + } + + for i := 0; i < 10; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected with SaveMySQLDefaults and concurrent reads") + } + } + }) +} + +// TestMutexOperationOrder tests the order of mutex operations +func TestMutexOperationOrder(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mysql-defaults-order-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: make(map[string]string), + mysqlDefaultValuesLoaded: false, + } + + // Create the file first to avoid auto-creation path + defaultsPath := cluster.GetMySQLDefaultsPath() + _ = os.MkdirAll(filepath.Dir(defaultsPath), 0755) + _ = os.WriteFile(defaultsPath, []byte("# Test\n[mysqld]\nmax_connections = 100\n"), 0644) + + t.Run("OperationSequence", func(t *testing.T) { + timeout := time.After(5 * time.Second) + done := make(chan bool) + + go func() { + // Sequence that might cause deadlock: + // 1. GetMySQLDefaultsCnfContent (no lock, but reads file) + // 2. WriteMySQLDefaultsCnfContent (writes file, calls ReloadMySQLDefaults with Lock) + // 3. GetMySQLDefaultsInfo (RLock) + // 4. SaveMySQLDefaults (RLock) + // 5. ReloadMySQLDefaults (Lock) + + _, _ = cluster.GetMySQLDefaultsCnfContent() + _ = cluster.WriteMySQLDefaultsCnfContent("# Test\n[mysqld]\nmax_connections = 200\n") + _ = cluster.GetMySQLDefaultsInfo() + _ = cluster.SaveMySQLDefaults() + _ = cluster.ReloadMySQLDefaults() + + done <- true + }() + + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in operation sequence") + } + }) +} + +// TestWriteThenReadRace tests the race between write and subsequent read +func TestWriteThenReadRace(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mysql-defaults-race-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + cluster := &Cluster{ + Name: "test-cluster", + Conf: &config.Config{ + WorkingDir: tempDir, + Verbose: false, + }, + mysqlDefaultValues: make(map[string]string), + mysqlDefaultValuesLoaded: false, + } + + t.Run("WriteReadRace", func(t *testing.T) { + timeout := time.After(5 * time.Second) + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func(idx int) { + // Write then immediately read + _ = cluster.WriteMySQLDefaultsCnfContent("# Test\n[mysqld]\nmax_connections = 100\n") + _, _ = cluster.GetMySQLDefaultsCnfContent() + done <- true + }(i) + } + + for i := 0; i < 10; i++ { + select { + case <-done: + // Success + case <-timeout: + t.Fatal("Deadlock detected in write-then-read race") + } + } + }) +} diff --git a/cluster/srv_cnf.go b/cluster/srv_cnf.go index 6d6b8ccff..e3c251d40 100644 --- a/cluster/srv_cnf.go +++ b/cluster/srv_cnf.go @@ -629,44 +629,164 @@ func (server *ServerMonitor) ReadPreservedVariables() error { server.VariablesMap = config.NewVariablesMap() } + // PRIORITY 1: Load server-specific 01_preserved.cnf (highest priority) server.VariablesMap.LoadFromConfigFile(filepath.Join(server.Datadir, "01_preserved.cnf"), "preserved") - // Read the preserved variables from config - list := strings.Split(cluster.Conf.ProvDBConfigPreserveVars, ";") - for _, opt := range list { - opt = strings.TrimSpace(opt) - if opt == "" { - continue - } - - value := "" - parts := strings.SplitN(opt, "=", 2) - if len(parts) == 2 { - opt = parts[0] - value = parts[1] - } - - isMap := strings.Contains(value, "=") + // PRIORITY 2: Load cluster-level preserved variables (can be overridden by server-specific) + // This replaces the old ProvDBConfigPreserveVars mechanism + cluster.preservedVarsMutex.RLock() + for varName, value := range cluster.preservedVars { + // Only apply if not already set by server-specific 01_preserved.cnf + if _, exists := server.VariablesMap.CheckAndGet(varName); !exists { + isMap := strings.Contains(value, "=") - key := strings.ToUpper(opt) - if v, ok := server.VariablesMap.CheckAndGet(key); ok { - v.SetPreservedValue(value) - } else if value != "" { - v = new(config.VariableState) + v := new(config.VariableState) if isMap { v.Preserved = make(config.MapValue) } else { v.Preserved = new(config.SingleValue) } - v.SetPreservedValue(value) - server.VariablesMap.Store(key, v) + if value != "" { + v.SetPreservedValue(value) + } + + server.VariablesMap.Store(varName, v) + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlDbg, + "Applied cluster-level preserved variable %s=%s to server %s", varName, value, server.URL) + } + } + cluster.preservedVarsMutex.RUnlock() + + // LEGACY SUPPORT: Still honor ProvDBConfigPreserveVars for backward compatibility + // This will be deprecated in future versions + if cluster.Conf.ProvDBConfigPreserveVars != "" { + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "ProvDBConfigPreserveVars is deprecated. Please migrate to cluster-level preserved_variables.cnf") + + list := strings.Split(cluster.Conf.ProvDBConfigPreserveVars, ";") + for _, opt := range list { + opt = strings.TrimSpace(opt) + if opt == "" { + continue + } + + value := "" + parts := strings.SplitN(opt, "=", 2) + if len(parts) == 2 { + opt = parts[0] + value = parts[1] + } + + isMap := strings.Contains(value, "=") + + key := strings.ToUpper(opt) + if v, ok := server.VariablesMap.CheckAndGet(key); ok { + v.SetPreservedValue(value) + } else if value != "" { + v = new(config.VariableState) + if isMap { + v.Preserved = make(config.MapValue) + } else { + v.Preserved = new(config.SingleValue) + } + v.SetPreservedValue(value) + + server.VariablesMap.Store(key, v) + } } } return nil } +// Read-only variables that should never be written to config files +var readOnlyVariables = []string{ + "VERSION", + "VERSION_COMMENT", + "VERSION_COMPILE_MACHINE", + "VERSION_COMPILE_OS", + "HOSTNAME", + "SERVER_UUID", + "BASEDIR", + "LOG_BIN_BASENAME", + "LOG_BIN_INDEX", + "RELAY_LOG_BASENAME", + "RELAY_LOG_INDEX", + "DATADIR", // Paths should not be changed via runtime fallback +} + +// Variables safe for runtime fallback (whitelist) +var safeRuntimeFallbackVariables = []string{ + "INNODB_BUFFER_POOL_SIZE", + "MAX_CONNECTIONS", + "INNODB_LOG_FILE_SIZE", + "INNODB_FLUSH_LOG_AT_TRX_COMMIT", + "SYNC_BINLOG", + "BINLOG_FORMAT", + "SLOW_QUERY_LOG", + "SLOW_QUERY_LOG_FILE", + "LONG_QUERY_TIME", + "LOG_QUERIES_NOT_USING_INDEXES", + "GENERAL_LOG", + "GENERAL_LOG_FILE", + "LOG_ERROR", + "LOG_WARNINGS", + "EXPIRE_LOGS_DAYS", + "BINLOG_EXPIRE_LOGS_SECONDS", + "MAX_ALLOWED_PACKET", + "MAX_BINLOG_SIZE", + "MAX_RELAY_LOG_SIZE", + "RELAY_LOG_SPACE_LIMIT", + "INNODB_IO_CAPACITY", + "INNODB_IO_CAPACITY_MAX", + "INNODB_FLUSH_METHOD", + "INNODB_FILE_PER_TABLE", + "INNODB_BUFFER_POOL_INSTANCES", + "INNODB_LOG_BUFFER_SIZE", + "INNODB_WRITE_IO_THREADS", + "INNODB_READ_IO_THREADS", + "INNODB_PURGE_THREADS", + "INNODB_PAGE_CLEANERS", + "TABLE_OPEN_CACHE", + "TABLE_DEFINITION_CACHE", + "OPEN_FILES_LIMIT", + "THREAD_CACHE_SIZE", + "QUERY_CACHE_SIZE", + "QUERY_CACHE_TYPE", + "TMP_TABLE_SIZE", + "MAX_HEAP_TABLE_SIZE", + "JOIN_BUFFER_SIZE", + "SORT_BUFFER_SIZE", + "READ_BUFFER_SIZE", + "READ_RND_BUFFER_SIZE", + "BINLOG_CACHE_SIZE", + "BINLOG_STMT_CACHE_SIZE", +} + +// isReadOnlyVariable checks if a variable is read-only and should never be changed +func isReadOnlyVariable(varName string) bool { + upperName := strings.ToUpper(varName) + for _, readOnly := range readOnlyVariables { + if upperName == readOnly { + return true + } + } + return false +} + +// isSafeForRuntimeFallback checks if a variable is whitelisted for runtime fallback +func isSafeForRuntimeFallback(varName string) bool { + upperName := strings.ToUpper(varName) + for _, safe := range safeRuntimeFallbackVariables { + if upperName == safe { + return true + } + } + return false +} + func (server *ServerMonitor) WriteDeltaVariables() error { cluster := server.ClusterGroup deltapath := filepath.Join(server.Datadir, "02_delta.cnf") @@ -682,6 +802,48 @@ func (server *ServerMonitor) WriteDeltaVariables() error { if v.Preserved != nil { continue } + + // 4-LAYER SAFETY FRAMEWORK FOR RUNTIME FALLBACK + // Only use runtime value if deployed is nil and all safety checks pass + if v.Deployed == nil && v.Runtime != nil { + runtimeStr := v.Runtime.String() + + // Layer 1: Empty value check - never write empty values + // Layer 2: Read-only check - never write read-only variables + // Layer 3: Whitelist check - only write whitelisted variables + // Layer 4: Server status check - only if server is running (not failed) + if runtimeStr != "" && + !isReadOnlyVariable(v.VariableName) && + isSafeForRuntimeFallback(v.VariableName) && + !server.IsFailed() { + // Safe to use runtime value as fallback + v.Deployed = v.Runtime + content.WriteString(v.PrintDeployedDelta() + "\n") + v.Deployed = nil // Reset to avoid persisting the change + } + // If any check fails, skip this variable (don't write it) + continue + } + + // LAYER 5: MYSQL DEFAULT FALLBACK + // Last resort when server failed and no deployed/runtime values exist + // Uses values from embedded mysql_defaults.cnf or custom file if provided + if v.Deployed == nil && v.Runtime == nil && server.IsFailed() { + defaultValue := cluster.getMySQLDefaultForVar(v.VariableName) + if defaultValue != "" { + sv := config.SingleValue(defaultValue) + v.Deployed = &sv + content.WriteString(v.PrintDeployedDelta() + "\n") + v.Deployed = nil // Reset to avoid persisting the change + + cluster.LogModulePrintf(cluster.Conf.Verbose, config.ConstLogModGeneral, config.LvlWarn, + "Using MySQL default for variable %s=%s (server failed, no deployed/runtime values)", + v.VariableName, defaultValue) + } + continue + } + + // Normal case: deployed value exists content.WriteString(v.PrintDeployedDelta() + "\n") } diff --git a/cluster/srv_cnf_delta_test.go b/cluster/srv_cnf_delta_test.go new file mode 100644 index 000000000..31fd83e2e --- /dev/null +++ b/cluster/srv_cnf_delta_test.go @@ -0,0 +1,1091 @@ +// replication-manager - Replication Manager Monitoring and CLI for MariaDB and MySQL +// Copyright 2017-2021 SIGNAL18 CLOUD SAS +// Authors: Guillaume Lefranc +// Stephane Varoqui +// This source code is licensed under the GNU General Public License, version 3. + +package cluster + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/signal18/replication-manager/config" +) + +// Helper: Create single value for testing +func createSingleValue(val string) config.VariableValue { + sv := config.SingleValue(val) + return &sv +} + +// Helper: Setup test server for delta tests +func setupTestServerForDelta(t *testing.T) *ServerMonitor { + tempDir := t.TempDir() + + server := &ServerMonitor{ + Datadir: tempDir, + Host: "127.0.0.1", + Port: "3306", + State: stateMaster, // Default to running state + VariablesMap: config.NewVariablesMap(), + ClusterGroup: &Cluster{ + Conf: &config.Config{ + Verbose: false, + }, + }, + } + + return server +} + +// Helper: Read delta file content +func readDeltaFile(t *testing.T, server *ServerMonitor) string { + deltaPath := filepath.Join(server.Datadir, "02_delta.cnf") + content, err := os.ReadFile(deltaPath) + if err != nil { + t.Fatalf("Failed to read delta file: %v", err) + } + return string(content) +} + +// Helper: Cleanup test server +func cleanupDeltaTestServer(t *testing.T, server *ServerMonitor) { + // Temp directory is automatically cleaned up by t.TempDir() +} + +// TestWriteDeltaVariables_EmptyValueCheck tests Layer 1: Empty value protection +func TestWriteDeltaVariables_EmptyValueCheck(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + // Create variable with empty runtime value using NewVariableState + state := config.NewVariableState("INNODB_BUFFER_POOL_SIZE") + state.SetRuntimeValue("") // Empty value + state.SetConfigValue("128M") // Create diff + server.VariablesMap.Set("INNODB_BUFFER_POOL_SIZE", state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify empty value not written + content := readDeltaFile(t, server) + if strings.Contains(strings.ToUpper(content), "INNODB_BUFFER_POOL_SIZE=") { + t.Error("Layer 1 FAILED: Empty value should not be written to delta") + } + + t.Log("✓ Layer 1 (Empty Check): PASS - Empty values blocked") +} + +// TestWriteDeltaVariables_ReadOnlyCheck tests Layer 2: Read-only variable protection +func TestWriteDeltaVariables_ReadOnlyCheck(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + readOnlyTests := []struct { + varName string + value string + }{ + {"VERSION", "10.11.6-MariaDB"}, + {"HOSTNAME", "db-server-01"}, + {"LOG_BIN_BASENAME", "/var/lib/mysql/binlog"}, + {"BASEDIR", "/usr"}, + {"VERSION_COMMENT", "MariaDB Server"}, + } + + for _, tt := range readOnlyTests { + t.Run(tt.varName, func(t *testing.T) { + // Use NewVariableState for cleaner code + state := config.NewVariableState(tt.varName) + state.SetRuntimeValue(tt.value) + state.SetConfigValue("different") // Create diff + server.VariablesMap.Set(tt.varName, state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify read-only variable not written + content := readDeltaFile(t, server) + varUpper := strings.ToUpper(tt.varName) + if strings.Contains(strings.ToUpper(content), varUpper+"=") { + t.Errorf("Layer 2 FAILED: Read-only variable %s should not be written", tt.varName) + } + + t.Logf("✓ Layer 2 (Read-Only Check): %s blocked", tt.varName) + }) + } +} + +// TestWriteDeltaVariables_WhitelistCheck tests Layer 3: Whitelist protection +func TestWriteDeltaVariables_WhitelistCheck(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + tests := []struct { + varName string + value string + whitelisted bool + }{ + {"INNODB_BUFFER_POOL_SIZE", "134217728", true}, // Whitelisted + {"MAX_CONNECTIONS", "151", true}, // Whitelisted + {"SLOW_QUERY_LOG_FILE", "/var/log/slow.log", true}, // Whitelisted + {"SOME_RANDOM_VARIABLE", "value", false}, // NOT whitelisted + {"UNKNOWN_SETTING", "123", false}, // NOT whitelisted + } + + for _, tt := range tests { + t.Run(tt.varName, func(t *testing.T) { + // Clear previous variables + server.VariablesMap = config.NewVariablesMap() + + // Use NewVariableState + state := config.NewVariableState(tt.varName) + state.SetRuntimeValue(tt.value) + state.SetConfigValue("different") // Create diff + server.VariablesMap.Set(tt.varName, state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify whitelist enforcement + content := readDeltaFile(t, server) + varUpper := strings.ToUpper(tt.varName) + hasVar := strings.Contains(strings.ToUpper(content), varUpper+"=") + + if tt.whitelisted && !hasVar { + t.Errorf("Layer 3 FAILED: Whitelisted variable %s should be written", tt.varName) + } else if !tt.whitelisted && hasVar { + t.Errorf("Layer 3 FAILED: Non-whitelisted variable %s should not be written", tt.varName) + } + + if tt.whitelisted { + t.Logf("✓ Layer 3 (Whitelist): %s allowed", tt.varName) + } else { + t.Logf("✓ Layer 3 (Whitelist): %s blocked", tt.varName) + } + }) + } +} + +// TestWriteDeltaVariables_ServerStatusCheck tests Layer 4: Server status protection +func TestWriteDeltaVariables_ServerStatusCheck(t *testing.T) { + tests := []struct { + name string + serverState string + shouldWrite bool + }{ + {"Server_Running", stateMaster, true}, + {"Server_Failed", stateFailed, false}, + {"Server_AuthError", stateErrorAuth, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + // Set server state + server.State = tt.serverState + + // Create whitelisted variable using NewVariableState + state := config.NewVariableState("INNODB_BUFFER_POOL_SIZE") + state.SetRuntimeValue("134217728") + state.SetConfigValue("128M") // Create diff + server.VariablesMap.Set("INNODB_BUFFER_POOL_SIZE", state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify server status enforcement + content := readDeltaFile(t, server) + hasVar := strings.Contains(strings.ToUpper(content), "INNODB_BUFFER_POOL_SIZE=134217728") + + if tt.shouldWrite && !hasVar { + t.Errorf("Layer 4 FAILED: Variable should be written when server is %s", tt.serverState) + } else if !tt.shouldWrite && hasVar { + t.Errorf("Layer 4 FAILED: Variable should NOT be written when server is %s", tt.serverState) + } + + if tt.shouldWrite { + t.Logf("✓ Layer 4 (Server Status): Running server allows runtime fallback") + } else { + t.Logf("✓ Layer 4 (Server Status): %s blocks runtime fallback", tt.serverState) + } + }) + } +} + +// TestWriteDeltaVariables_AllLayersCombined tests all 4 layers working together +func TestWriteDeltaVariables_AllLayersCombined(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateMaster // Running server + + testVars := []struct { + name string + value string + expectWrite bool + reason string + }{ + {"INNODB_BUFFER_POOL_SIZE", "134217728", true, "Whitelisted + Running"}, + {"MAX_CONNECTIONS", "151", true, "Whitelisted + Running"}, + {"SLOW_QUERY_LOG_FILE", "/var/log/slow.log", true, "Whitelisted + Running"}, + {"VERSION", "10.11.6-MariaDB", false, "Read-only (Layer 2)"}, + {"HOSTNAME", "db01", false, "Read-only (Layer 2)"}, + {"RANDOM_VAR", "value", false, "Not whitelisted (Layer 3)"}, + {"EMPTY_VAR", "", false, "Empty value (Layer 1)"}, + } + + // Load all variables using NewVariableState + for _, tv := range testVars { + state := config.NewVariableState(tv.name) + state.SetRuntimeValue(tv.value) + state.SetConfigValue("different") // Create diff + server.VariablesMap.Set(tv.name, state) + } + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify each variable + content := readDeltaFile(t, server) + + for _, tv := range testVars { + varUpper := strings.ToUpper(tv.name) + hasVar := strings.Contains(strings.ToUpper(content), varUpper+"=") + + if tv.expectWrite && !hasVar { + t.Errorf("FAILED: %s should be written (%s)", tv.name, tv.reason) + } else if !tv.expectWrite && hasVar { + t.Errorf("FAILED: %s should NOT be written (%s)", tv.name, tv.reason) + } else { + if tv.expectWrite { + t.Logf("✓ %s: Written correctly (%s)", tv.name, tv.reason) + } else { + t.Logf("✓ %s: Blocked correctly (%s)", tv.name, tv.reason) + } + } + } +} + +// TestWriteDeltaVariables_DeployedValueAlwaysWins tests deployed values bypass runtime fallback +func TestWriteDeltaVariables_DeployedValueAlwaysWins(t *testing.T) { + tests := []struct { + name string + serverState string + expectValue string + }{ + {"Running_DeployedWins", stateMaster, "268435456"}, + {"Failed_DeployedStillWins", stateFailed, "268435456"}, + {"AuthError_DeployedStillWins", stateErrorAuth, "268435456"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = tt.serverState + + // Use NewVariableState with deployed value + state := config.NewVariableState("INNODB_BUFFER_POOL_SIZE") + state.SetDeployedValue(tt.expectValue) + state.SetRuntimeValue("134217728") + state.SetConfigValue("512M") // Create diff + server.VariablesMap.Set("INNODB_BUFFER_POOL_SIZE", state) + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + expected := "INNODB_BUFFER_POOL_SIZE=" + tt.expectValue + + if !strings.Contains(strings.ToUpper(content), strings.ToUpper(expected)) { + t.Errorf("Expected deployed value '%s' in delta, got:\n%s", expected, content) + } + + t.Logf("✓ Deployed value wins regardless of server state: %s", tt.expectValue) + }) + } +} + +// TestWriteDeltaVariables_PreservedVariablesSkipped tests preserved variables are skipped +func TestWriteDeltaVariables_PreservedVariablesSkipped(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + // Use NewVariableState with preserved value + state := config.NewVariableState("INNODB_BUFFER_POOL_SIZE") + state.SetDeployedValue("128M") + state.SetRuntimeValue("128M") + state.SetConfigValue("256M") + state.SetPreservedValue("256M") + server.VariablesMap.Set("INNODB_BUFFER_POOL_SIZE", state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify preserved variable not in delta + content := readDeltaFile(t, server) + if strings.Contains(strings.ToUpper(content), "INNODB_BUFFER_POOL_SIZE=") { + t.Error("Preserved variables should be skipped in delta") + } + + t.Log("✓ Preserved variables correctly skipped from delta") +} + +// TestWriteDeltaVariables_AtomicWrite tests atomic file writing +func TestWriteDeltaVariables_AtomicWrite(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateMaster + + // Use NewVariableState + state := config.NewVariableState("MAX_CONNECTIONS") + state.SetDeployedValue("150") + state.SetRuntimeValue("151") + state.SetConfigValue("200") + server.VariablesMap.Set("MAX_CONNECTIONS", state) + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify file exists and is readable + deltaPath := filepath.Join(server.Datadir, "02_delta.cnf") + info, err := os.Stat(deltaPath) + if err != nil { + t.Fatalf("Delta file should exist: %v", err) + } + + if info.Size() == 0 { + t.Error("Delta file should not be empty") + } + + // Verify no temp files left behind + tempFiles, _ := filepath.Glob(filepath.Join(server.Datadir, ".tmp-*.cnf")) + if len(tempFiles) > 0 { + t.Errorf("Temp files should be cleaned up, found: %v", tempFiles) + } + + t.Log("✓ Atomic write successful, no temp files left") +} + +// TestHelperFunctions tests the helper functions +func TestHelperFunctions(t *testing.T) { + t.Run("isReadOnlyVariable", func(t *testing.T) { + tests := []struct { + varName string + expected bool + }{ + {"VERSION", true}, + {"HOSTNAME", true}, + {"LOG_BIN_BASENAME", true}, + {"INNODB_BUFFER_POOL_SIZE", false}, + {"MAX_CONNECTIONS", false}, + {"version", true}, // Case insensitive + {"VeRsIoN", true}, // Mixed case + } + + for _, tt := range tests { + result := isReadOnlyVariable(tt.varName) + if result != tt.expected { + t.Errorf("isReadOnlyVariable(%s) = %v, want %v", tt.varName, result, tt.expected) + } + } + }) + + t.Run("isSafeForRuntimeFallback", func(t *testing.T) { + tests := []struct { + varName string + expected bool + }{ + {"SLOW_QUERY_LOG_FILE", true}, + {"INNODB_BUFFER_POOL_SIZE", true}, + {"MAX_CONNECTIONS", true}, + {"VERSION", false}, + {"RANDOM_VAR", false}, + {"max_connections", true}, // Case insensitive + {"InNoDB_BuFfEr_PoOl_SiZe", true}, // Mixed case + } + + for _, tt := range tests { + result := isSafeForRuntimeFallback(tt.varName) + if result != tt.expected { + t.Errorf("isSafeForRuntimeFallback(%s) = %v, want %v", tt.varName, result, tt.expected) + } + } + }) +} + +// TestWriteDeltaVariables_RealWorldScenario tests a realistic scenario +func TestWriteDeltaVariables_RealWorldScenario(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateMaster + + // Simulate real-world variable mix using NewVariableState + variables := map[string]struct { + runtime string + deployed string + }{ + "INNODB_BUFFER_POOL_SIZE": {"134217728", ""}, + "MAX_CONNECTIONS": {"151", ""}, + "SLOW_QUERY_LOG_FILE": {"/var/log/mysql/slow.log", ""}, + "VERSION": {"10.11.6-MariaDB", ""}, + "HOSTNAME": {"db-server-01", ""}, + "CUSTOM_VAR": {"custom_value", ""}, + "LOG_ERROR": {"/var/log/mysql/error.log", "/var/log/mysql/error.log"}, // Has deployed + } + + for name, values := range variables { + state := config.NewVariableState(name) + if values.deployed != "" { + state.SetDeployedValue(values.deployed) + } + state.SetRuntimeValue(values.runtime) + state.SetConfigValue("different") // Create diff + server.VariablesMap.Set(name, state) + } + + // Write delta + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + // Verify content + content := readDeltaFile(t, server) + + expectedVars := []string{ + "INNODB_BUFFER_POOL_SIZE=134217728", + "MAX_CONNECTIONS=151", + "SLOW_QUERY_LOG_FILE=/var/log/mysql/slow.log", + "LOG_ERROR=/var/log/mysql/error.log", + } + + blockedVars := []string{ + "VERSION=", + "HOSTNAME=", + "CUSTOM_VAR=", + } + + for _, expected := range expectedVars { + if !strings.Contains(strings.ToUpper(content), strings.ToUpper(expected)) { + t.Errorf("Expected to find '%s' in delta", expected) + } + } + + for _, blocked := range blockedVars { + if strings.Contains(strings.ToUpper(content), strings.ToUpper(blocked)) { + t.Errorf("Should not find '%s' in delta", blocked) + } + } + + t.Log("✓ Real-world scenario: All safety layers working correctly") + t.Logf("Delta content:\n%s", content) +} + +// TestWriteDeltaVariables_Layer5_CriticalDefaultFallback tests Layer 5: MySQL default fallback +func TestWriteDeltaVariables_Layer5_CriticalDefaultFallback(t *testing.T) { + tests := []struct { + name string + varName string + configValue string + expectedValue string + inFile bool // Whether variable exists in defaults file + }{ + {"InFile_MaxConnections", "MAX_CONNECTIONS", "500", "151", true}, + {"InFile_InnoDBBufferPool", "INNODB_BUFFER_POOL_SIZE", "2147483648", "134217728", true}, + {"InFile_BinlogFormat", "BINLOG_FORMAT", "MIXED", "ROW", true}, + {"InFile_LogError", "LOG_ERROR", "/custom/error.log", "error.log", true}, + {"InFile_SlowQueryLog", "SLOW_QUERY_LOG", "ON", "OFF", true}, // Now uses file defaults + {"NotInFile_CustomVar", "CUSTOM_VARIABLE", "custom_value", "", false}, // Not in file + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateFailed // Server is failed + + // Variable with config but no deployed/runtime + state := config.NewVariableState(tt.varName) + state.SetConfigValue(tt.configValue) + // No deployed, no runtime values + server.VariablesMap.Set(tt.varName, state) + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + varUpper := strings.ToUpper(tt.varName) + + if tt.inFile { + expectedLine := varUpper + "=" + strings.ToUpper(tt.expectedValue) + if !strings.Contains(strings.ToUpper(content), expectedLine) { + t.Errorf("Layer 5 FAILED: Variable %s (in defaults file) should use MySQL default %s, got:\n%s", + tt.varName, tt.expectedValue, content) + } else { + t.Logf("✓ Layer 5: Variable %s uses MySQL default from file: %s", tt.varName, tt.expectedValue) + } + } else { + if strings.Contains(strings.ToUpper(content), varUpper+"=") { + t.Errorf("Layer 5 FAILED: Variable %s (not in defaults file) should NOT get default", tt.varName) + } else { + t.Logf("✓ Layer 5: Variable %s not in file, correctly skipped", tt.varName) + } + } + }) + } +} + +// TestWriteDeltaVariables_Layer5_OnlyForFailedServers tests Layer 5 only activates for failed servers +func TestWriteDeltaVariables_Layer5_OnlyForFailedServers(t *testing.T) { + tests := []struct { + name string + serverState string + shouldUseL5 bool + }{ + {"RunningServer_NoLayer5", stateMaster, false}, + {"FailedServer_UseLayer5", stateFailed, true}, + {"AuthErrorServer_UseLayer5", stateErrorAuth, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = tt.serverState + + // Critical variable with config but no deployed/runtime + state := config.NewVariableState("MAX_CONNECTIONS") + state.SetConfigValue("500") + // No deployed, no runtime + server.VariablesMap.Set("MAX_CONNECTIONS", state) + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + hasDefault := strings.Contains(strings.ToUpper(content), "MAX_CONNECTIONS=151") + + if tt.shouldUseL5 && !hasDefault { + t.Errorf("Layer 5 should activate for %s server", tt.serverState) + } else if !tt.shouldUseL5 && hasDefault { + t.Errorf("Layer 5 should NOT activate for %s server", tt.serverState) + } else { + if tt.shouldUseL5 { + t.Logf("✓ Layer 5: Activated for %s server", tt.serverState) + } else { + t.Logf("✓ Layer 5: Correctly skipped for %s server", tt.serverState) + } + } + }) + } +} + +// TestWriteDeltaVariables_Layer5_WithRuntimePreferred tests Layer 4 takes precedence over Layer 5 +func TestWriteDeltaVariables_Layer5_WithRuntimePreferred(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateFailed // Server failed + + // Critical variable with both config and runtime (no deployed) + state := config.NewVariableState("MAX_CONNECTIONS") + state.SetConfigValue("500") + state.SetRuntimeValue("200") // Runtime exists but server failed + // No deployed + server.VariablesMap.Set("MAX_CONNECTIONS", state) + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + + // Layer 4 blocks runtime fallback when server failed + // Layer 5 should NOT activate because runtime exists (even though blocked) + if strings.Contains(strings.ToUpper(content), "MAX_CONNECTIONS=") { + t.Error("When runtime exists, Layer 5 should not activate (Layer 4 handles it)") + } + + t.Log("✓ Layer 4 precedence: Runtime exists → Layer 5 skipped (even if Layer 4 blocks it)") +} + +// TestWriteDeltaVariables_Layer5_AllLayersCombined tests all 5 layers working together +func TestWriteDeltaVariables_AllFiveLayersCombined(t *testing.T) { + server := setupTestServerForDelta(t) + defer cleanupDeltaTestServer(t, server) + + server.State = stateFailed // Failed server + + testVars := []struct { + name string + config string + runtime string + deployed string + expectWrite bool + expectValue string + reason string + }{ + {"MAX_CONNECTIONS", "500", "", "", true, "151", "Layer 5: MySQL default from file"}, + {"INNODB_BUFFER_POOL_SIZE", "2G", "", "", true, "134217728", "Layer 5: MySQL default from file"}, + {"SLOW_QUERY_LOG", "ON", "", "", true, "OFF", "Layer 5: MySQL default from file (all vars now)"}, + {"VERSION", "10.11", "10.11.6", "", false, "", "Layer 2: Read-only blocked"}, + {"BINLOG_FORMAT", "MIXED", "ROW", "", false, "", "Layer 4: Runtime blocked (failed)"}, + {"LOG_ERROR", "/different/path", "", "/custom/path", true, "/custom/path", "Deployed wins"}, + } + + for _, tv := range testVars { + state := config.NewVariableState(tv.name) + if tv.config != "" { + state.SetConfigValue(tv.config) + } + if tv.runtime != "" { + state.SetRuntimeValue(tv.runtime) + } + if tv.deployed != "" { + state.SetDeployedValue(tv.deployed) + } + server.VariablesMap.Set(tv.name, state) + } + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + + for _, tv := range testVars { + varUpper := strings.ToUpper(tv.name) + hasVar := strings.Contains(strings.ToUpper(content), varUpper+"=") + + if tv.expectWrite { + expectedLine := varUpper + "=" + strings.ToUpper(tv.expectValue) + if !strings.Contains(strings.ToUpper(content), expectedLine) { + t.Errorf("FAILED: %s should write %s (%s)", tv.name, tv.expectValue, tv.reason) + } else { + t.Logf("✓ %s: Written %s (%s)", tv.name, tv.expectValue, tv.reason) + } + } else { + if hasVar { + t.Errorf("FAILED: %s should NOT be written (%s)", tv.name, tv.reason) + } else { + t.Logf("✓ %s: Correctly skipped (%s)", tv.name, tv.reason) + } + } + } + + t.Log("✓ All 5 layers working together correctly") +} + +// TestHelperFunctions_Layer5 tests the Layer 5 helper function +func TestHelperFunctions_Layer5(t *testing.T) { + // Create new cluster (per-cluster defaults) + cluster := &Cluster{ + Conf: &config.Config{ + Verbose: false, + }, + } + + t.Run("getMySQLDefaultForVar", func(t *testing.T) { + tests := []struct { + varName string + expected string + }{ + {"MAX_CONNECTIONS", "151"}, + {"INNODB_BUFFER_POOL_SIZE", "134217728"}, + {"BINLOG_FORMAT", "ROW"}, + {"LOG_ERROR", "error.log"}, + {"SLOW_QUERY_LOG", "OFF"}, // Now returns value from file (not critical list) + {"RANDOM_VAR", ""}, // Not in file + {"max_connections", "151"}, // Case insensitive + {"InNoDb_BuFfEr_PoOl_SiZe", "134217728"}, // Mixed case + } + + for _, tt := range tests { + result := cluster.getMySQLDefaultForVar(tt.varName) + if result != tt.expected { + t.Errorf("getMySQLDefaultForVar(%s) = %v, want %v", tt.varName, result, tt.expected) + } + } + }) +} + +// ============================================================ +// TEST SUITE 15: File-Based MySQL Defaults Loading +// ============================================================ + +func TestLoadMySQLDefaultsFromCNF(t *testing.T) { + t.Run("ParseValidCNFContent", func(t *testing.T) { + cnfContent := `# Comment line +[mysqld] +max_connections = 151 +innodb-buffer-pool-size = 134217728 + +# Another comment +binlog_format = ROW + +[mariadb] +log_bin_compress = OFF +` + defaults := loadMySQLDefaultsFromCNF(cnfContent) + + // Check that variables are normalized to uppercase with underscores + if defaults["MAX_CONNECTIONS"] != "151" { + t.Errorf("Expected MAX_CONNECTIONS=151, got %s", defaults["MAX_CONNECTIONS"]) + } + if defaults["INNODB_BUFFER_POOL_SIZE"] != "134217728" { + t.Errorf("Expected INNODB_BUFFER_POOL_SIZE=134217728, got %s", defaults["INNODB_BUFFER_POOL_SIZE"]) + } + if defaults["BINLOG_FORMAT"] != "ROW" { + t.Errorf("Expected BINLOG_FORMAT=ROW, got %s", defaults["BINLOG_FORMAT"]) + } + if defaults["LOG_BIN_COMPRESS"] != "OFF" { + t.Errorf("Expected LOG_BIN_COMPRESS=OFF, got %s", defaults["LOG_BIN_COMPRESS"]) + } + }) + + t.Run("HandleEmptyLines", func(t *testing.T) { + cnfContent := ` +max_connections = 100 + +binlog_format = MIXED + +` + defaults := loadMySQLDefaultsFromCNF(cnfContent) + if len(defaults) != 2 { + t.Errorf("Expected 2 defaults, got %d", len(defaults)) + } + }) + + t.Run("HandleCommentsAndSections", func(t *testing.T) { + cnfContent := `# This is a comment +[mysql] +max_connections = 100 +# Another comment +[mysqld] +binlog_format = MIXED +[mariadb-10.6] +wsrep_on = ON +` + defaults := loadMySQLDefaultsFromCNF(cnfContent) + if len(defaults) != 3 { + t.Errorf("Expected 3 defaults, got %d", len(defaults)) + } + }) + + t.Run("HandleDashesAndUnderscores", func(t *testing.T) { + cnfContent := `innodb-buffer-pool-size = 1000 +innodb_log_file_size = 2000 +max-connections = 3000 +` + defaults := loadMySQLDefaultsFromCNF(cnfContent) + + // All should be normalized to uppercase with underscores + if defaults["INNODB_BUFFER_POOL_SIZE"] != "1000" { + t.Errorf("Expected INNODB_BUFFER_POOL_SIZE=1000, got %s", defaults["INNODB_BUFFER_POOL_SIZE"]) + } + if defaults["INNODB_LOG_FILE_SIZE"] != "2000" { + t.Errorf("Expected INNODB_LOG_FILE_SIZE=2000, got %s", defaults["INNODB_LOG_FILE_SIZE"]) + } + if defaults["MAX_CONNECTIONS"] != "3000" { + t.Errorf("Expected MAX_CONNECTIONS=3000, got %s", defaults["MAX_CONNECTIONS"]) + } + }) + + t.Run("HandleValuesWithSpaces", func(t *testing.T) { + cnfContent := `log_error = /var/log/mysql/error.log +max_connections=100 +binlog_format = ROW +` + defaults := loadMySQLDefaultsFromCNF(cnfContent) + + if defaults["LOG_ERROR"] != "/var/log/mysql/error.log" { + t.Errorf("Expected LOG_ERROR=/var/log/mysql/error.log, got %s", defaults["LOG_ERROR"]) + } + if defaults["MAX_CONNECTIONS"] != "100" { + t.Errorf("Expected MAX_CONNECTIONS=100, got %s", defaults["MAX_CONNECTIONS"]) + } + }) +} + +func TestInitMySQLDefaults(t *testing.T) { + t.Run("LoadEmbeddedDefaults", func(t *testing.T) { + cluster := &Cluster{ + Conf: &config.Config{ + Verbose: false, + }, + } + + err := cluster.initMySQLDefaults() + if err != nil { + t.Fatalf("Failed to initialize MySQL defaults: %v", err) + } + + if !cluster.mysqlDefaultValuesLoaded { + t.Error("Expected mysqlDefaultValuesLoaded to be true") + } + + if len(cluster.mysqlDefaultValues) == 0 { + t.Error("Expected mysqlDefaultValues to have entries") + } + + // Check some expected defaults from embedded file + if cluster.mysqlDefaultValues["MAX_CONNECTIONS"] != "151" { + t.Errorf("Expected MAX_CONNECTIONS=151, got %s", cluster.mysqlDefaultValues["MAX_CONNECTIONS"]) + } + }) + + t.Run("LoadOnlyOnce", func(t *testing.T) { + cluster := &Cluster{ + Conf: &config.Config{ + Verbose: false, + }, + } + + // First call + err := cluster.initMySQLDefaults() + if err != nil { + t.Fatalf("Failed to initialize MySQL defaults: %v", err) + } + firstCount := len(cluster.mysqlDefaultValues) + + // Second call should not reload + err = cluster.initMySQLDefaults() + if err != nil { + t.Fatalf("Failed to initialize MySQL defaults on second call: %v", err) + } + secondCount := len(cluster.mysqlDefaultValues) + + if firstCount != secondCount { + t.Errorf("Expected same count on second load, got %d vs %d", firstCount, secondCount) + } + }) + + t.Run("LoadCustomFile", func(t *testing.T) { + // Create temporary directory structure for cluster + tempDir := t.TempDir() + clusterName := "test-cluster" + clusterDir := filepath.Join(tempDir, clusterName) + os.MkdirAll(clusterDir, 0755) + + // Create custom CNF file in cluster directory + customCnfPath := filepath.Join(clusterDir, "mysql_defaults.cnf") + customContent := `[mysqld] +max_connections = 999 +custom_variable = custom_value +` + err := os.WriteFile(customCnfPath, []byte(customContent), 0644) + if err != nil { + t.Fatalf("Failed to create custom CNF file: %v", err) + } + + cluster := &Cluster{ + Name: clusterName, + Conf: &config.Config{ + Verbose: false, + WorkingDir: tempDir, + }, + } + + err = cluster.initMySQLDefaults() + if err != nil { + t.Fatalf("Failed to initialize MySQL defaults: %v", err) + } + + // Check custom values are loaded + if cluster.mysqlDefaultValues["MAX_CONNECTIONS"] != "999" { + t.Errorf("Expected MAX_CONNECTIONS=999, got %s", cluster.mysqlDefaultValues["MAX_CONNECTIONS"]) + } + if cluster.mysqlDefaultValues["CUSTOM_VARIABLE"] != "custom_value" { + t.Errorf("Expected CUSTOM_VARIABLE=custom_value, got %s", cluster.mysqlDefaultValues["CUSTOM_VARIABLE"]) + } + }) + + t.Run("FallbackToEmbeddedOnCustomFileError", func(t *testing.T) { + // Create temporary directory structure for cluster (but no defaults file) + tempDir := t.TempDir() + clusterName := "test-cluster" + + cluster := &Cluster{ + Name: clusterName, + Conf: &config.Config{ + Verbose: false, + WorkingDir: tempDir, + }, + } + + err := cluster.initMySQLDefaults() + if err != nil { + t.Fatalf("Should load from embedded when file doesn't exist, but got error: %v", err) + } + + // Should have loaded embedded defaults and saved them + if !cluster.mysqlDefaultValuesLoaded { + t.Error("Expected mysqlDefaultValuesLoaded to be true") + } + if len(cluster.mysqlDefaultValues) == 0 { + t.Error("Expected mysqlDefaultValues to have entries from embedded file") + } + + // Check that file was created + defaultsPath := cluster.GetMySQLDefaultsPath() + if _, err := os.Stat(defaultsPath); os.IsNotExist(err) { + t.Errorf("Expected defaults file to be created at %s", defaultsPath) + } + }) +} + +func TestGetMySQLDefaultForVar_FileBased(t *testing.T) { + cluster := &Cluster{ + Conf: &config.Config{ + Verbose: false, + }, + } + + t.Run("ReturnsValueFromLoadedDefaults", func(t *testing.T) { + result := cluster.getMySQLDefaultForVar("MAX_CONNECTIONS") + if result != "151" { + t.Errorf("Expected 151, got %s", result) + } + + result = cluster.getMySQLDefaultForVar("INNODB_BUFFER_POOL_SIZE") + if result != "134217728" { + t.Errorf("Expected 134217728, got %s", result) + } + + // Non-critical variables should also work now + result = cluster.getMySQLDefaultForVar("SLOW_QUERY_LOG") + if result != "OFF" { + t.Errorf("Expected OFF, got %s", result) + } + }) + + t.Run("ReturnsEmptyForNonExistentVariable", func(t *testing.T) { + result := cluster.getMySQLDefaultForVar("NONEXISTENT_VARIABLE") + if result != "" { + t.Errorf("Expected empty string, got %s", result) + } + }) + + t.Run("NormalizesVariableName", func(t *testing.T) { + tests := []struct { + varName string + expected string + }{ + {"max_connections", "151"}, + {"MAX_CONNECTIONS", "151"}, + {"Max_Connections", "151"}, + {"innodb_buffer_pool_size", "134217728"}, + {"INNODB_BUFFER_POOL_SIZE", "134217728"}, + } + + for _, tt := range tests { + result := cluster.getMySQLDefaultForVar(tt.varName) + if result != tt.expected { + t.Errorf("getMySQLDefaultForVar(%s) = %s, want %s", + tt.varName, result, tt.expected) + } + } + }) +} + +func TestWriteDeltaVariables_WithFileBased(t *testing.T) { + t.Run("UsesFileBasedDefaults", func(t *testing.T) { + server := setupTestServerForDelta(t) + server.State = stateFailed // Server is failed + + // Add variable with config but no deployed/runtime values + varState := config.NewVariableState("MAX_CONNECTIONS") + varState.SetConfigValue("200") // Config wants 200 + // No deployed, no runtime - should fallback to MySQL default + server.VariablesMap.Set("MAX_CONNECTIONS", varState) + + err := server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + // Check for variable in case-insensitive manner (could be max_connections or MAX_CONNECTIONS) + if !strings.Contains(strings.ToUpper(content), "MAX_CONNECTIONS=151") { + t.Errorf("Expected MAX_CONNECTIONS=151 from file-based defaults, got:\n%s", content) + } + }) + + t.Run("CustomFileOverridesEmbedded", func(t *testing.T) { + // Create custom CNF in cluster directory with different value + tempDir := t.TempDir() + clusterName := "test-cluster" + clusterDir := filepath.Join(tempDir, clusterName) + os.MkdirAll(clusterDir, 0755) + + customCnfPath := filepath.Join(clusterDir, "mysql_defaults.cnf") + customContent := `[mysqld] +max_connections = 500` + err := os.WriteFile(customCnfPath, []byte(customContent), 0644) + if err != nil { + t.Fatalf("Failed to create custom CNF: %v", err) + } + + server := setupTestServerForDelta(t) + server.State = stateFailed + server.ClusterGroup.Name = clusterName + server.ClusterGroup.Conf.WorkingDir = tempDir + + // Add variable with config but no deployed/runtime values + varState := config.NewVariableState("MAX_CONNECTIONS") + varState.SetConfigValue("300") // Config wants 300 + // No deployed, no runtime - should fallback to custom file default + server.VariablesMap.Set("MAX_CONNECTIONS", varState) + + err = server.WriteDeltaVariables() + if err != nil { + t.Fatalf("WriteDeltaVariables failed: %v", err) + } + + content := readDeltaFile(t, server) + // Check for variable in case-insensitive manner (could be max_connections or MAX_CONNECTIONS) + if !strings.Contains(strings.ToUpper(content), "MAX_CONNECTIONS=500") { + t.Errorf("Expected MAX_CONNECTIONS=500 from custom file, got:\n%s", content) + } + }) +} diff --git a/config/config.go b/config/config.go index b4cb21e99..8e848dd31 100644 --- a/config/config.go +++ b/config/config.go @@ -2370,8 +2370,6 @@ func GetGrantType() map[string]string { GrantProvDBProvision: GrantProvDBProvision, GrantProvProxyProvision: GrantProvProxyProvision, GrantProvProxyUnprovision: GrantProvProxyUnprovision, - GrantProvAppProvision: GrantProvAppProvision, - GrantProvAppUnprovision: GrantProvAppUnprovision, GrantAppConfig: GrantAppConfig, GrantAppDocker: GrantAppDocker, GrantAppDeployment: GrantAppDeployment, diff --git a/docs/docs.go b/docs/docs.go index a1c7964aa..0e0fd2704 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -8499,7 +8499,7 @@ const docTemplate = `{ }, "/api/clusters/{clusterName}/servers/{serverName}/actions/restart": { "post": { - "description": "Restarts a specified server within a cluster, optionally on a specific node and/or specific resource ID. Only OpenSVC orchestrator is supported. Only 'container#jobs' is allowed for rid parameter.", + "description": "Restarts a specified server within a cluster (queues restart asynchronously), optionally on a specific node and/or specific resource ID. Only OpenSVC orchestrator is supported. Only 'container#jobs' is allowed for rid parameter.", "produces": [ "application/json" ], @@ -8545,9 +8545,10 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Server restarted successfully", + "description": "Restart queued successfully", "schema": { - "type": "string" + "type": "object", + "additionalProperties": true } }, "400": { @@ -8562,8 +8563,14 @@ const docTemplate = `{ "type": "string" } }, - "500": { - "description": "Cluster Not Found\" or \"Server Not Found\" or \"Orchestrator not supported", + "404": { + "description": "Cluster Not Found or Server Not Found", + "schema": { + "type": "string" + } + }, + "501": { + "description": "Orchestrator not supported", "schema": { "type": "string" } @@ -14960,6 +14967,140 @@ const docTemplate = `{ } } }, + "/api/clusters/{clusterName}/settings/actions/save-mysql-defaults-cnf": { + "post": { + "description": "This endpoint saves the updated content to the mysql_defaults.cnf file in the cluster's working directory", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Save MySQL defaults CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + }, + { + "description": "CNF file content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.MySQLDefaultsCnfRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully saved MySQL defaults CNF", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error saving file", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/clusters/{clusterName}/settings/actions/save-preserved-variables-cnf": { + "post": { + "description": "This endpoint saves the updated content to the preserved_variables.cnf file in the cluster's working directory", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Save preserved variables CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + }, + { + "description": "CNF file content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.PreservedVarsCnfRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully saved preserved variables CNF", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error saving file", + "schema": { + "type": "string" + } + } + } + } + }, "/api/clusters/{clusterName}/settings/actions/set-cron/{settingName}/{settingValue}": { "post": { "description": "This endpoint sets the cron jobs for the specified cluster.", @@ -15284,6 +15425,122 @@ const docTemplate = `{ } } }, + "/api/clusters/{clusterName}/settings/mysql-defaults-cnf": { + "get": { + "description": "This endpoint retrieves the content of the mysql_defaults.cnf file from the cluster's working directory", + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Get MySQL defaults CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CNF file content in JSON format with 'content' key", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "404": { + "description": "File not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error reading file", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/clusters/{clusterName}/settings/preserved-variables-cnf": { + "get": { + "description": "This endpoint retrieves the content of the preserved_variables.cnf file from the cluster's working directory", + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Get preserved variables CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CNF file content in JSON format with 'content' key", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "404": { + "description": "File not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error reading file", + "schema": { + "type": "string" + } + } + } + } + }, "/api/clusters/{clusterName}/shardclusters": { "get": { "description": "This endpoint retrieves the shard clusters for the specified cluster.", @@ -20200,6 +20457,14 @@ const docTemplate = `{ "$ref": "#/definitions/dbhelper.SlaveStatus" } }, + "restartNode": { + "description": "RestartNode stores node parameter for restart cookie (owned by cookie mechanism, single writer assumption)", + "type": "string" + }, + "restartRid": { + "description": "RestartRid stores rid parameter for restart cookie (owned by cookie mechanism, single writer assumption)", + "type": "string" + }, "semiSyncMasterStatus": { "type": "boolean" }, @@ -24446,6 +24711,22 @@ const docTemplate = `{ } } }, + "server.MySQLDefaultsCnfRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + }, + "server.PreservedVarsCnfRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + }, "server.ReplicationManager": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 559a055ae..d77189d57 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8488,7 +8488,7 @@ }, "/api/clusters/{clusterName}/servers/{serverName}/actions/restart": { "post": { - "description": "Restarts a specified server within a cluster, optionally on a specific node and/or specific resource ID. Only OpenSVC orchestrator is supported. Only 'container#jobs' is allowed for rid parameter.", + "description": "Restarts a specified server within a cluster (queues restart asynchronously), optionally on a specific node and/or specific resource ID. Only OpenSVC orchestrator is supported. Only 'container#jobs' is allowed for rid parameter.", "produces": [ "application/json" ], @@ -8534,9 +8534,10 @@ ], "responses": { "200": { - "description": "Server restarted successfully", + "description": "Restart queued successfully", "schema": { - "type": "string" + "type": "object", + "additionalProperties": true } }, "400": { @@ -8551,8 +8552,14 @@ "type": "string" } }, - "500": { - "description": "Cluster Not Found\" or \"Server Not Found\" or \"Orchestrator not supported", + "404": { + "description": "Cluster Not Found or Server Not Found", + "schema": { + "type": "string" + } + }, + "501": { + "description": "Orchestrator not supported", "schema": { "type": "string" } @@ -14949,6 +14956,140 @@ } } }, + "/api/clusters/{clusterName}/settings/actions/save-mysql-defaults-cnf": { + "post": { + "description": "This endpoint saves the updated content to the mysql_defaults.cnf file in the cluster's working directory", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Save MySQL defaults CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + }, + { + "description": "CNF file content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.MySQLDefaultsCnfRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully saved MySQL defaults CNF", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error saving file", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/clusters/{clusterName}/settings/actions/save-preserved-variables-cnf": { + "post": { + "description": "This endpoint saves the updated content to the preserved_variables.cnf file in the cluster's working directory", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Save preserved variables CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + }, + { + "description": "CNF file content", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.PreservedVarsCnfRequest" + } + } + ], + "responses": { + "200": { + "description": "Successfully saved preserved variables CNF", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error saving file", + "schema": { + "type": "string" + } + } + } + } + }, "/api/clusters/{clusterName}/settings/actions/set-cron/{settingName}/{settingValue}": { "post": { "description": "This endpoint sets the cron jobs for the specified cluster.", @@ -15273,6 +15414,122 @@ } } }, + "/api/clusters/{clusterName}/settings/mysql-defaults-cnf": { + "get": { + "description": "This endpoint retrieves the content of the mysql_defaults.cnf file from the cluster's working directory", + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Get MySQL defaults CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CNF file content in JSON format with 'content' key", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "404": { + "description": "File not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error reading file", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/clusters/{clusterName}/settings/preserved-variables-cnf": { + "get": { + "description": "This endpoint retrieves the content of the preserved_variables.cnf file from the cluster's working directory", + "produces": [ + "application/json" + ], + "tags": [ + "ClusterSettings" + ], + "summary": "Get preserved variables CNF content", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster Name", + "name": "clusterName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CNF file content in JSON format with 'content' key", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "No valid ACL", + "schema": { + "type": "string" + } + }, + "404": { + "description": "File not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Error reading file", + "schema": { + "type": "string" + } + } + } + } + }, "/api/clusters/{clusterName}/shardclusters": { "get": { "description": "This endpoint retrieves the shard clusters for the specified cluster.", @@ -20189,6 +20446,14 @@ "$ref": "#/definitions/dbhelper.SlaveStatus" } }, + "restartNode": { + "description": "RestartNode stores node parameter for restart cookie (owned by cookie mechanism, single writer assumption)", + "type": "string" + }, + "restartRid": { + "description": "RestartRid stores rid parameter for restart cookie (owned by cookie mechanism, single writer assumption)", + "type": "string" + }, "semiSyncMasterStatus": { "type": "boolean" }, @@ -24435,6 +24700,22 @@ } } }, + "server.MySQLDefaultsCnfRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + }, + "server.PreservedVarsCnfRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + }, "server.ReplicationManager": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1c91a7d7c..cd27fa81e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -960,6 +960,14 @@ definitions: items: $ref: '#/definitions/dbhelper.SlaveStatus' type: array + restartNode: + description: RestartNode stores node parameter for restart cookie (owned by + cookie mechanism, single writer assumption) + type: string + restartRid: + description: RestartRid stores rid parameter for restart cookie (owned by + cookie mechanism, single writer assumption) + type: string semiSyncMasterStatus: type: boolean semiSyncSlaveStatus: @@ -3789,6 +3797,16 @@ definitions: message: type: string type: object + server.MySQLDefaultsCnfRequest: + properties: + content: + type: string + type: object + server.PreservedVarsCnfRequest: + properties: + content: + type: string + type: object server.ReplicationManager: properties: agents: @@ -10779,9 +10797,9 @@ paths: - DatabaseReplication /api/clusters/{clusterName}/servers/{serverName}/actions/restart: post: - description: Restarts a specified server within a cluster, optionally on a specific - node and/or specific resource ID. Only OpenSVC orchestrator is supported. - Only 'container#jobs' is allowed for rid parameter. + description: Restarts a specified server within a cluster (queues restart asynchronously), + optionally on a specific node and/or specific resource ID. Only OpenSVC orchestrator + is supported. Only 'container#jobs' is allowed for rid parameter. parameters: - default: Bearer description: Insert your access token @@ -10812,9 +10830,10 @@ paths: - application/json responses: "200": - description: Server restarted successfully + description: Restart queued successfully schema: - type: string + additionalProperties: true + type: object "400": description: Invalid rid parameter schema: @@ -10823,9 +10842,12 @@ paths: description: No valid ACL schema: type: string - "500": - description: Cluster Not Found" or "Server Not Found" or "Orchestrator not - supported + "404": + description: Cluster Not Found or Server Not Found + schema: + type: string + "501": + description: Orchestrator not supported schema: type: string summary: Restart a server @@ -14052,6 +14074,98 @@ paths: summary: Reset Graphite filter list for a specific cluster tags: - ClusterGraphite + /api/clusters/{clusterName}/settings/actions/save-mysql-defaults-cnf: + post: + consumes: + - application/json + description: This endpoint saves the updated content to the mysql_defaults.cnf + file in the cluster's working directory + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - description: Cluster Name + in: path + name: clusterName + required: true + type: string + - description: CNF file content + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.MySQLDefaultsCnfRequest' + produces: + - application/json + responses: + "200": + description: Successfully saved MySQL defaults CNF + schema: + type: string + "400": + description: Invalid request body + schema: + type: string + "403": + description: No valid ACL + schema: + type: string + "500": + description: Error saving file + schema: + type: string + summary: Save MySQL defaults CNF content + tags: + - ClusterSettings + /api/clusters/{clusterName}/settings/actions/save-preserved-variables-cnf: + post: + consumes: + - application/json + description: This endpoint saves the updated content to the preserved_variables.cnf + file in the cluster's working directory + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - description: Cluster Name + in: path + name: clusterName + required: true + type: string + - description: CNF file content + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.PreservedVarsCnfRequest' + produces: + - application/json + responses: + "200": + description: Successfully saved preserved variables CNF + schema: + type: string + "400": + description: Invalid request body + schema: + type: string + "403": + description: No valid ACL + schema: + type: string + "500": + description: Error saving file + schema: + type: string + summary: Save preserved variables CNF content + tags: + - ClusterSettings /api/clusters/{clusterName}/settings/actions/set-cron/{settingName}/{settingValue}: post: consumes: @@ -14272,6 +14386,86 @@ paths: summary: Switch settings for a specific cluster tags: - ClusterSettings + /api/clusters/{clusterName}/settings/mysql-defaults-cnf: + get: + description: This endpoint retrieves the content of the mysql_defaults.cnf file + from the cluster's working directory + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - description: Cluster Name + in: path + name: clusterName + required: true + type: string + produces: + - application/json + responses: + "200": + description: CNF file content in JSON format with 'content' key + schema: + additionalProperties: + type: string + type: object + "403": + description: No valid ACL + schema: + type: string + "404": + description: File not found + schema: + type: string + "500": + description: Error reading file + schema: + type: string + summary: Get MySQL defaults CNF content + tags: + - ClusterSettings + /api/clusters/{clusterName}/settings/preserved-variables-cnf: + get: + description: This endpoint retrieves the content of the preserved_variables.cnf + file from the cluster's working directory + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - description: Cluster Name + in: path + name: clusterName + required: true + type: string + produces: + - application/json + responses: + "200": + description: CNF file content in JSON format with 'content' key + schema: + additionalProperties: + type: string + type: object + "403": + description: No valid ACL + schema: + type: string + "404": + description: File not found + schema: + type: string + "500": + description: Error reading file + schema: + type: string + summary: Get preserved variables CNF content + tags: + - ClusterSettings /api/clusters/{clusterName}/shardclusters: get: description: This endpoint retrieves the shard clusters for the specified cluster. diff --git a/server/api_cluster.go b/server/api_cluster.go index db12a16d4..3bdde3533 100644 --- a/server/api_cluster.go +++ b/server/api_cluster.go @@ -270,6 +270,22 @@ func (repman *ReplicationManager) apiClusterProtectedHandler(router *mux.Router) negroni.HandlerFunc(repman.validateTokenMiddleware), negroni.Wrap(http.HandlerFunc(repman.handlerMuxDropTag)), )) + router.Handle("/api/clusters/{clusterName}/settings/mysql-defaults-cnf", negroni.New( + negroni.HandlerFunc(repman.validateTokenMiddleware), + negroni.Wrap(http.HandlerFunc(repman.handlerMuxGetMySQLDefaultsCnf)), + )) + router.Handle("/api/clusters/{clusterName}/settings/actions/save-mysql-defaults-cnf", negroni.New( + negroni.HandlerFunc(repman.validateTokenMiddleware), + negroni.Wrap(http.HandlerFunc(repman.handlerMuxSaveMySQLDefaultsCnf)), + )) + router.Handle("/api/clusters/{clusterName}/settings/preserved-variables-cnf", negroni.New( + negroni.HandlerFunc(repman.validateTokenMiddleware), + negroni.Wrap(http.HandlerFunc(repman.handlerMuxGetPreservedVarsCnf)), + )) + router.Handle("/api/clusters/{clusterName}/settings/actions/save-preserved-variables-cnf", negroni.New( + negroni.HandlerFunc(repman.validateTokenMiddleware), + negroni.Wrap(http.HandlerFunc(repman.handlerMuxSavePreservedVarsCnf)), + )) router.Handle("/api/clusters/{clusterName}/settings/actions/add-proxy-tag/{tagValue}", negroni.New( negroni.HandlerFunc(repman.validateTokenMiddleware), negroni.Wrap(http.HandlerFunc(repman.handlerMuxAddProxyTag)), @@ -7899,3 +7915,205 @@ func (repman *ReplicationManager) handlerMuxClusterCheckJobLogLevel(w http.Respo http.Error(w, "No cluster", 500) } } + +// handlerMuxGetMySQLDefaultsCnf retrieves the content of the MySQL defaults CNF file +// @Summary Get MySQL defaults CNF content +// @Description This endpoint retrieves the content of the mysql_defaults.cnf file from the cluster's working directory +// @Tags ClusterSettings +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param clusterName path string true "Cluster Name" +// @Success 200 {object} map[string]string "CNF file content in JSON format with 'content' key" +// @Failure 403 {string} string "No valid ACL" +// @Failure 404 {string} string "File not found" +// @Failure 500 {string} string "Error reading file" +// @Router /api/clusters/{clusterName}/settings/mysql-defaults-cnf [get] +func (repman *ReplicationManager) handlerMuxGetMySQLDefaultsCnf(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + vars := mux.Vars(r) + mycluster := repman.getClusterByName(vars["clusterName"]) + if mycluster == nil { + http.Error(w, "No cluster", http.StatusInternalServerError) + return + } + + if valid, _ := repman.IsValidClusterACL(r, mycluster); !valid { + http.Error(w, "No valid ACL", http.StatusForbidden) + return + } + + // Read the MySQL defaults CNF content using cluster method + content, err := mycluster.GetMySQLDefaultsCnfContent() + if err != nil { + if strings.Contains(err.Error(), "not found") { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Error reading MySQL defaults file: %v", err), http.StatusInternalServerError) + return + } + + // Return JSON response with content + response := map[string]string{ + "content": content, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// MySQLDefaultsCnfRequest represents the request body for saving MySQL defaults CNF +type MySQLDefaultsCnfRequest struct { + Content string `json:"content"` +} + +// handlerMuxSaveMySQLDefaultsCnf saves the updated content to the MySQL defaults CNF file +// @Summary Save MySQL defaults CNF content +// @Description This endpoint saves the updated content to the mysql_defaults.cnf file in the cluster's working directory +// @Tags ClusterSettings +// @Accept json +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param clusterName path string true "Cluster Name" +// @Param body body MySQLDefaultsCnfRequest true "CNF file content" +// @Success 200 {string} string "Successfully saved MySQL defaults CNF" +// @Failure 403 {string} string "No valid ACL" +// @Failure 400 {string} string "Invalid request body" +// @Failure 500 {string} string "Error saving file" +// @Router /api/clusters/{clusterName}/settings/actions/save-mysql-defaults-cnf [post] +func (repman *ReplicationManager) handlerMuxSaveMySQLDefaultsCnf(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + vars := mux.Vars(r) + mycluster := repman.getClusterByName(vars["clusterName"]) + if mycluster == nil { + http.Error(w, "No cluster", http.StatusInternalServerError) + return + } + + if valid, _ := repman.IsValidClusterACL(r, mycluster); !valid { + http.Error(w, "No valid ACL", http.StatusForbidden) + return + } + + // Decode the request body + var req MySQLDefaultsCnfRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + // Write the content using cluster method + err = mycluster.WriteMySQLDefaultsCnfContent(req.Content) + if err != nil { + http.Error(w, fmt.Sprintf("Error saving MySQL defaults file: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("Successfully saved MySQL defaults CNF to %s", mycluster.GetMySQLDefaultsPath()))) +} + +// handlerMuxGetPreservedVarsCnf retrieves the content of the preserved variables CNF file +// @Summary Get preserved variables CNF content +// @Description This endpoint retrieves the content of the preserved_variables.cnf file from the cluster's working directory +// @Tags ClusterSettings +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param clusterName path string true "Cluster Name" +// @Success 200 {object} map[string]string "CNF file content in JSON format with 'content' key" +// @Failure 403 {string} string "No valid ACL" +// @Failure 404 {string} string "File not found" +// @Failure 500 {string} string "Error reading file" +// @Router /api/clusters/{clusterName}/settings/preserved-variables-cnf [get] +func (repman *ReplicationManager) handlerMuxGetPreservedVarsCnf(w http.ResponseWriter, r *http.Request) { +w.Header().Set("Access-Control-Allow-Origin", "*") + +vars := mux.Vars(r) +mycluster := repman.getClusterByName(vars["clusterName"]) +if mycluster == nil { +http.Error(w, "No cluster", http.StatusInternalServerError) +return +} + +if valid, _ := repman.IsValidClusterACL(r, mycluster); !valid { +http.Error(w, "No valid ACL", http.StatusForbidden) +return +} + +// Read the preserved variables CNF content using cluster method +content, err := mycluster.GetPreservedVarsCnfContent() +if err != nil { +if strings.Contains(err.Error(), "not found") { +http.Error(w, err.Error(), http.StatusNotFound) +return +} +http.Error(w, fmt.Sprintf("Error reading preserved variables file: %v", err), http.StatusInternalServerError) +return +} + +// Return JSON response with content +response := map[string]string{ +"content": content, +} + +w.Header().Set("Content-Type", "application/json") +w.WriteHeader(http.StatusOK) +json.NewEncoder(w).Encode(response) +} + +// PreservedVarsCnfRequest represents the request body for saving preserved variables CNF +type PreservedVarsCnfRequest struct { +Content string `json:"content"` +} + +// handlerMuxSavePreservedVarsCnf saves the updated content to the preserved variables CNF file +// @Summary Save preserved variables CNF content +// @Description This endpoint saves the updated content to the preserved_variables.cnf file in the cluster's working directory +// @Tags ClusterSettings +// @Accept json +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param clusterName path string true "Cluster Name" +// @Param body body PreservedVarsCnfRequest true "CNF file content" +// @Success 200 {string} string "Successfully saved preserved variables CNF" +// @Failure 403 {string} string "No valid ACL" +// @Failure 400 {string} string "Invalid request body" +// @Failure 500 {string} string "Error saving file" +// @Router /api/clusters/{clusterName}/settings/actions/save-preserved-variables-cnf [post] +func (repman *ReplicationManager) handlerMuxSavePreservedVarsCnf(w http.ResponseWriter, r *http.Request) { +w.Header().Set("Access-Control-Allow-Origin", "*") + +vars := mux.Vars(r) +mycluster := repman.getClusterByName(vars["clusterName"]) +if mycluster == nil { +http.Error(w, "No cluster", http.StatusInternalServerError) +return +} + +if valid, _ := repman.IsValidClusterACL(r, mycluster); !valid { +http.Error(w, "No valid ACL", http.StatusForbidden) +return +} + +// Decode the request body +var req PreservedVarsCnfRequest +err := json.NewDecoder(r.Body).Decode(&req) +if err != nil { +http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) +return +} + +// Write the content using cluster method +err = mycluster.WritePreservedVarsCnfContent(req.Content) +if err != nil { +http.Error(w, fmt.Sprintf("Error saving preserved variables file: %v", err), http.StatusInternalServerError) +return +} + +w.WriteHeader(http.StatusOK) +w.Write([]byte(fmt.Sprintf("Successfully saved preserved variables CNF to %s", mycluster.GetPreservedVarsPath()))) +} diff --git a/share/dashboard_react/src/Pages/ClusterDB/components/ClusterDBTabContent/index.jsx b/share/dashboard_react/src/Pages/ClusterDB/components/ClusterDBTabContent/index.jsx index 67a4e3aa9..b9c4dc636 100644 --- a/share/dashboard_react/src/Pages/ClusterDB/components/ClusterDBTabContent/index.jsx +++ b/share/dashboard_react/src/Pages/ClusterDB/components/ClusterDBTabContent/index.jsx @@ -95,6 +95,7 @@ function ClusterDBTabContent({ tab, dbId, clusterName, digestMode, toggleDigestM variableMode={variableMode} onNavigateToPFSInstruments={onNavigateToPFSInstruments} searchFilter={variablesSearchFilter} + user={user} /> ) : currentTab === 'opensvc' ? ( diff --git a/share/dashboard_react/src/Pages/ClusterDB/components/Variables/index.jsx b/share/dashboard_react/src/Pages/ClusterDB/components/Variables/index.jsx index 71f4c9a85..99bff47b0 100644 --- a/share/dashboard_react/src/Pages/ClusterDB/components/Variables/index.jsx +++ b/share/dashboard_react/src/Pages/ClusterDB/components/Variables/index.jsx @@ -13,6 +13,9 @@ import { TbShield, TbTrash, TbCheck, TbAlertCircle, TbZoomIn, TbExternalLink, Tb import ConfirmModal from '../../../../components/Modals/ConfirmModal' import ComplexVariableModal from './ComplexVariableModal' import EditVariableModal from './EditVariableModal' +import MySQLDefaultsEditor from '../../../../components/MySQLDefaultsEditor' +import PreservedVariablesEditor from '../../../../components/PreservedVariablesEditor' +import AccordionComponent from '../../../../components/AccordionComponent' const defaultState = { showCfg: true, @@ -208,7 +211,7 @@ const areValuesEqual = (val1, val2, row) => { return String(val1) === String(val2) } -function Variables({ clusterName, dbId, toggleVariableMode, variableMode, onNavigateToPFSInstruments, searchFilter }) { +function Variables({ clusterName, dbId, toggleVariableMode, variableMode, onNavigateToPFSInstruments, searchFilter, user }) { const [ vState, vDispatch ] = useReducer(reducer, defaultState) const dispatch = useDispatch() const variables = useSelector((state) => state.cluster.database.variables) @@ -772,6 +775,24 @@ function Variables({ clusterName, dbId, toggleVariableMode, variableMode, onNavi + + {user?.grants['cluster-settings'] && ( + + } + /> + + } + /> + + + )} + {isOpen && } {complexVariableModal.isOpen && ( } /> + } + /> + } + /> )} diff --git a/share/dashboard_react/src/components/CnfFileEditor.jsx b/share/dashboard_react/src/components/CnfFileEditor.jsx new file mode 100644 index 000000000..908094b26 --- /dev/null +++ b/share/dashboard_react/src/components/CnfFileEditor.jsx @@ -0,0 +1,260 @@ +import { Box, Button, VStack, HStack, Textarea, Text, useToast, Spinner, Alert, AlertIcon, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, useDisclosure } from '@chakra-ui/react' +import { useState } from 'react' +import { TbDownload, TbDeviceFloppy, TbRefresh } from 'react-icons/tb' +import { useDispatch } from 'react-redux' +import ConfirmModal from './Modals/ConfirmModal' +import PropTypes from 'prop-types' + +/** + * Generic CNF file editor component that can be used for different configuration file types + * + * @param {Object} props + * @param {string} props.clusterName - Name of the cluster + * @param {Object} props.user - User object with grants + * @param {string} props.className - Additional CSS classes + * @param {string} props.fileType - Type of file being edited (e.g., 'MySQL Defaults', 'Preserved Variables') + * @param {string} props.fileName - Name of the configuration file (e.g., 'mysql_defaults.cnf', 'preserved_variables.cnf') + * @param {Function} props.loadAction - Redux action to load the file content + * @param {Function} props.saveAction - Redux action to save the file content + * @param {string} props.placeholder - Placeholder text for the textarea + * @param {Object} props.infoContent - Content for the information modal (optional) + * @param {React.ReactNode} props.warningAlert - Warning alert content to show before loading + * @param {React.ReactNode} props.scopeInfo - Scope information to show below the editor + * @param {React.ReactNode} props.confirmModalContent - Content for the save confirmation modal + */ +function CnfFileEditor({ + clusterName, + user, + className, + fileType, + fileName, + loadAction, + saveAction, + placeholder, + infoContent, + warningAlert, + scopeInfo, + confirmModalContent +}) { + const [content, setContent] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [hasChanges, setHasChanges] = useState(false) + const [originalContent, setOriginalContent] = useState('') + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false) + const { isOpen: isInfoOpen, onOpen: onInfoOpen, onClose: onInfoClose } = useDisclosure() + + const dispatch = useDispatch() + const toast = useToast() + + const handleLoadContent = async () => { + setIsLoading(true) + try { + const result = await dispatch(loadAction({ clusterName })).unwrap() + setContent(result.content || '') + setOriginalContent(result.content || '') + setIsLoaded(true) + setHasChanges(false) + toast({ + title: 'Success', + description: `${fileType} configuration loaded successfully`, + status: 'success', + duration: 3000, + isClosable: true + }) + } catch (error) { + toast({ + title: 'Error', + description: error.message || `Failed to load ${fileType} configuration`, + status: 'error', + duration: 5000, + isClosable: true + }) + } finally { + setIsLoading(false) + } + } + + const handleContentChange = (e) => { + setContent(e.target.value) + setHasChanges(e.target.value !== originalContent) + } + + const handleSave = async () => { + setIsSaving(true) + try { + await dispatch(saveAction({ clusterName, content })).unwrap() + setOriginalContent(content) + setHasChanges(false) + setIsConfirmModalOpen(false) + toast({ + title: 'Success', + description: `${fileType} configuration saved and reloaded successfully`, + status: 'success', + duration: 3000, + isClosable: true + }) + } catch (error) { + toast({ + title: 'Error', + description: error.message || `Failed to save ${fileType} configuration`, + status: 'error', + duration: 5000, + isClosable: true + }) + } finally { + setIsSaving(false) + } + } + + const handleReset = () => { + setContent(originalContent) + setHasChanges(false) + } + + const handleSaveClick = () => { + setIsConfirmModalOpen(true) + } + + const closeConfirmModal = () => { + setIsConfirmModalOpen(false) + } + + const isDisabled = user?.grants['cluster-settings'] === false + + return ( + + {!isLoaded ? ( + + {warningAlert && ( + + + + {warningAlert} + + + )} + + + ) : ( + <> + + + + + + + {hasChanges && ( + + Unsaved changes + + )} + + + +