- Use parameterized query for INFORMATION_SCHEMA lookup (SQL injection) - Add isValidIdentifier() to validate database names before USE statement - Add password support via BEADS_DOLT_PASSWORD env var - Remove unused variable declaration - Add unit tests for identifier validation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
416 lines
12 KiB
Go
416 lines
12 KiB
Go
package doctor
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
// Import MySQL driver for server mode connections
|
|
_ "github.com/go-sql-driver/mysql"
|
|
|
|
"github.com/steveyegge/beads/internal/configfile"
|
|
)
|
|
|
|
// ServerHealthResult holds the results of all server health checks
|
|
type ServerHealthResult struct {
|
|
Checks []DoctorCheck `json:"checks"`
|
|
OverallOK bool `json:"overall_ok"`
|
|
}
|
|
|
|
// RunServerHealthChecks runs all server-mode health checks and returns the result.
|
|
// This is called when `bd doctor --server` is used.
|
|
func RunServerHealthChecks(path string) ServerHealthResult {
|
|
result := ServerHealthResult{
|
|
OverallOK: true,
|
|
}
|
|
|
|
// Load config to check if server mode is configured
|
|
_, beadsDir := getBackendAndBeadsDir(path)
|
|
cfg, err := configfile.Load(beadsDir)
|
|
if err != nil {
|
|
result.Checks = append(result.Checks, DoctorCheck{
|
|
Name: "Server Config",
|
|
Status: StatusError,
|
|
Message: "Failed to load config",
|
|
Detail: err.Error(),
|
|
Category: CategoryFederation,
|
|
})
|
|
result.OverallOK = false
|
|
return result
|
|
}
|
|
|
|
if cfg == nil {
|
|
result.Checks = append(result.Checks, DoctorCheck{
|
|
Name: "Server Config",
|
|
Status: StatusError,
|
|
Message: "No metadata.json found",
|
|
Fix: "Run 'bd init' to initialize beads",
|
|
Category: CategoryFederation,
|
|
})
|
|
result.OverallOK = false
|
|
return result
|
|
}
|
|
|
|
// Check if Dolt backend is configured
|
|
if cfg.GetBackend() != configfile.BackendDolt {
|
|
result.Checks = append(result.Checks, DoctorCheck{
|
|
Name: "Server Config",
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("Backend is '%s', not Dolt", cfg.GetBackend()),
|
|
Detail: "Server mode health checks are only relevant for Dolt backend",
|
|
Fix: "Set backend: dolt in metadata.json to use Dolt server mode",
|
|
Category: CategoryFederation,
|
|
})
|
|
result.OverallOK = false
|
|
return result
|
|
}
|
|
|
|
// Check if server mode is configured
|
|
if !cfg.IsDoltServerMode() {
|
|
result.Checks = append(result.Checks, DoctorCheck{
|
|
Name: "Server Config",
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("Dolt mode is '%s' (not server)", cfg.GetDoltMode()),
|
|
Detail: "Server health checks require dolt_mode: server in metadata.json",
|
|
Fix: "Set dolt_mode: server in metadata.json and start dolt sql-server",
|
|
Category: CategoryFederation,
|
|
})
|
|
result.OverallOK = false
|
|
return result
|
|
}
|
|
|
|
// Server mode is configured - run health checks
|
|
host := cfg.GetDoltServerHost()
|
|
port := cfg.GetDoltServerPort()
|
|
|
|
// Check 1: Server reachability (TCP connect)
|
|
reachCheck := checkServerReachable(host, port)
|
|
result.Checks = append(result.Checks, reachCheck)
|
|
if reachCheck.Status == StatusError {
|
|
result.OverallOK = false
|
|
// Can't continue without connectivity
|
|
return result
|
|
}
|
|
|
|
// Check 2: Connect and verify it's Dolt (get version)
|
|
versionCheck, db := checkDoltVersion(cfg)
|
|
result.Checks = append(result.Checks, versionCheck)
|
|
if versionCheck.Status == StatusError {
|
|
result.OverallOK = false
|
|
if db != nil {
|
|
_ = db.Close()
|
|
}
|
|
return result
|
|
}
|
|
defer func() {
|
|
if db != nil {
|
|
_ = db.Close()
|
|
}
|
|
}()
|
|
|
|
// Get database name from config (default: "beads")
|
|
database := "beads" // Default database name for Dolt server mode
|
|
|
|
// Check 3: Database exists and is queryable
|
|
dbExistsCheck := checkDatabaseExists(db, database)
|
|
result.Checks = append(result.Checks, dbExistsCheck)
|
|
if dbExistsCheck.Status == StatusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 4: Schema compatible (can query beads tables)
|
|
schemaCheck := checkSchemaCompatible(db, database)
|
|
result.Checks = append(result.Checks, schemaCheck)
|
|
if schemaCheck.Status == StatusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 5: Connection pool health
|
|
poolCheck := checkConnectionPool(db)
|
|
result.Checks = append(result.Checks, poolCheck)
|
|
if poolCheck.Status == StatusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// checkServerReachable checks if the server is reachable via TCP
|
|
func checkServerReachable(host string, port int) DoctorCheck {
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Server Reachable",
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Cannot connect to %s", addr),
|
|
Detail: err.Error(),
|
|
Fix: "Ensure dolt sql-server is running and accessible",
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
_ = conn.Close()
|
|
|
|
return DoctorCheck{
|
|
Name: "Server Reachable",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Connected to %s", addr),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// checkDoltVersion connects to the server and checks if it's a Dolt server
|
|
// Returns the DoctorCheck and an open database connection (caller must close)
|
|
func checkDoltVersion(cfg *configfile.Config) (DoctorCheck, *sql.DB) {
|
|
host := cfg.GetDoltServerHost()
|
|
port := cfg.GetDoltServerPort()
|
|
user := cfg.GetDoltServerUser()
|
|
|
|
// Get password from environment (more secure than config file)
|
|
password := os.Getenv("BEADS_DOLT_PASSWORD")
|
|
|
|
// Build DSN without database (just to test server connectivity)
|
|
var connStr string
|
|
if password != "" {
|
|
connStr = fmt.Sprintf("%s:%s@tcp(%s:%d)/?parseTime=true&timeout=5s",
|
|
user, password, host, port)
|
|
} else {
|
|
connStr = fmt.Sprintf("%s@tcp(%s:%d)/?parseTime=true&timeout=5s",
|
|
user, host, port)
|
|
}
|
|
|
|
db, err := sql.Open("mysql", connStr)
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Dolt Version",
|
|
Status: StatusError,
|
|
Message: "Failed to open connection",
|
|
Detail: err.Error(),
|
|
Fix: "Check MySQL driver and connection settings",
|
|
Category: CategoryFederation,
|
|
}, nil
|
|
}
|
|
|
|
// Set connection pool limits
|
|
db.SetMaxOpenConns(2)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(30 * time.Second)
|
|
|
|
// Test connectivity
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return DoctorCheck{
|
|
Name: "Dolt Version",
|
|
Status: StatusError,
|
|
Message: "Server not responding",
|
|
Detail: err.Error(),
|
|
Fix: "Ensure dolt sql-server is running",
|
|
Category: CategoryFederation,
|
|
}, nil
|
|
}
|
|
|
|
// Query Dolt version
|
|
var version string
|
|
err = db.QueryRowContext(ctx, "SELECT dolt_version()").Scan(&version)
|
|
if err != nil {
|
|
// If dolt_version() doesn't exist, it's not a Dolt server
|
|
if strings.Contains(err.Error(), "Unknown") || strings.Contains(err.Error(), "doesn't exist") {
|
|
_ = db.Close()
|
|
return DoctorCheck{
|
|
Name: "Dolt Version",
|
|
Status: StatusError,
|
|
Message: "Server is not Dolt",
|
|
Detail: "dolt_version() function not found - this may be a MySQL server, not Dolt",
|
|
Fix: "Ensure you're connecting to a Dolt sql-server, not vanilla MySQL",
|
|
Category: CategoryFederation,
|
|
}, nil
|
|
}
|
|
_ = db.Close()
|
|
return DoctorCheck{
|
|
Name: "Dolt Version",
|
|
Status: StatusError,
|
|
Message: "Failed to query version",
|
|
Detail: err.Error(),
|
|
Category: CategoryFederation,
|
|
}, nil
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Dolt Version",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Dolt %s", version),
|
|
Category: CategoryFederation,
|
|
}, db
|
|
}
|
|
|
|
// checkDatabaseExists checks if the beads database exists
|
|
func checkDatabaseExists(db *sql.DB, database string) DoctorCheck {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Validate database name (alphanumeric and underscore only)
|
|
if !isValidIdentifier(database) {
|
|
return DoctorCheck{
|
|
Name: "Database Exists",
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Invalid database name '%s'", database),
|
|
Detail: "Database name must be alphanumeric with underscores only",
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// Check if database exists using parameterized query
|
|
var exists int
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
|
|
database).Scan(&exists)
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Database Exists",
|
|
Status: StatusError,
|
|
Message: "Failed to query databases",
|
|
Detail: err.Error(),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
if exists == 0 {
|
|
return DoctorCheck{
|
|
Name: "Database Exists",
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Database '%s' not found", database),
|
|
Fix: fmt.Sprintf("Run 'bd init --backend dolt' to create the '%s' database", database),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// Switch to the database
|
|
// Note: USE cannot use parameterized queries, but we validated the identifier above
|
|
_, err = db.ExecContext(ctx, "USE "+database) // #nosec G201 - database validated by isValidIdentifier
|
|
if err != nil {
|
|
return DoctorCheck{
|
|
Name: "Database Exists",
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Cannot access database '%s'", database),
|
|
Detail: err.Error(),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Database Exists",
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("Database '%s' accessible", database),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// isValidIdentifier checks if a string is a valid SQL identifier
|
|
// (alphanumeric and underscore only, doesn't start with a number)
|
|
func isValidIdentifier(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
for i, c := range s {
|
|
if i == 0 && c >= '0' && c <= '9' {
|
|
return false // Can't start with a number
|
|
}
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// checkSchemaCompatible checks if the beads tables are queryable
|
|
func checkSchemaCompatible(db *sql.DB, database string) DoctorCheck {
|
|
// Note: database parameter reserved for future use (e.g., multi-database support)
|
|
_ = database
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Try to query the issues table
|
|
var count int
|
|
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues").Scan(&count)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "Unknown table") {
|
|
return DoctorCheck{
|
|
Name: "Schema Compatible",
|
|
Status: StatusError,
|
|
Message: "Issues table not found",
|
|
Fix: "Run 'bd init --backend dolt' to create schema",
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
return DoctorCheck{
|
|
Name: "Schema Compatible",
|
|
Status: StatusError,
|
|
Message: "Cannot query issues table",
|
|
Detail: err.Error(),
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// Query metadata table for bd_version
|
|
var bdVersion string
|
|
err = db.QueryRowContext(ctx, "SELECT value FROM metadata WHERE `key` = 'bd_version'").Scan(&bdVersion)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "Unknown table") {
|
|
return DoctorCheck{
|
|
Name: "Schema Compatible",
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d issues found (no metadata table)", count),
|
|
Fix: "Run 'bd migrate' to update schema",
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
}
|
|
|
|
detail := fmt.Sprintf("%d issues", count)
|
|
if bdVersion != "" {
|
|
detail = fmt.Sprintf("%d issues (bd %s)", count, bdVersion)
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Schema Compatible",
|
|
Status: StatusOK,
|
|
Message: detail,
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|
|
|
|
// checkConnectionPool checks the connection pool health
|
|
func checkConnectionPool(db *sql.DB) DoctorCheck {
|
|
stats := db.Stats()
|
|
|
|
// Report pool statistics
|
|
detail := fmt.Sprintf("open: %d, in_use: %d, idle: %d",
|
|
stats.OpenConnections,
|
|
stats.InUse,
|
|
stats.Idle,
|
|
)
|
|
|
|
// Check for connection errors
|
|
if stats.MaxIdleClosed > 0 || stats.MaxLifetimeClosed > 0 {
|
|
detail += fmt.Sprintf("\nclosed: idle=%d, lifetime=%d",
|
|
stats.MaxIdleClosed,
|
|
stats.MaxLifetimeClosed,
|
|
)
|
|
}
|
|
|
|
return DoctorCheck{
|
|
Name: "Connection Pool",
|
|
Status: StatusOK,
|
|
Message: "Pool healthy",
|
|
Detail: detail,
|
|
Category: CategoryFederation,
|
|
}
|
|
}
|