Files
beads/cmd/bd/doctor/server.go
beads/crew/emma 3bcbca41fe fix(doctor): address code review issues in --server health checks
- 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>
2026-01-23 20:35:57 -08:00

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,
}
}