diff --git a/cmd/mithril/configcmd/configcmd.go b/cmd/mithril/configcmd/configcmd.go index a1fb9b76..35517d59 100644 --- a/cmd/mithril/configcmd/configcmd.go +++ b/cmd/mithril/configcmd/configcmd.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/Overclock-Validator/mithril/pkg/config" "github.com/Overclock-Validator/mithril/pkg/tui" "github.com/spf13/cobra" ) @@ -116,7 +117,11 @@ func runConfigInit() { } func generateStarterConfig() string { - return `# Mithril Configuration + // Pick storage paths that work for the current environment: production + // /mnt/mithril-* when scripts/disk-setup.sh has been run, ~/.mithril/* + // otherwise. See pkg/config/defaults.go for detection details. + s := config.DefaultStoragePaths() + return fmt.Sprintf(`# Mithril Configuration # Generated by: mithril config init # See config.example.toml for detailed documentation of all options. @@ -126,10 +131,10 @@ name = "mithril" mode = "auto" # "auto" | "snapshot" | "new-snapshot" | "accountsdb" [storage] -accounts = "/mnt/mithril-accounts" # AccountsDB (~500GB, use fastest NVMe) -shredstore = "/mnt/mithril-ledger/shredstore" # Lightbringer shred storage -snapshots = "/mnt/mithril-ledger/snapshots" # ~100GB for full + incremental -logs = "/mnt/mithril-logs" # Log files (created if missing) +accounts = %q # AccountsDB (~500GB, use fastest NVMe) +shredstore = %q # Lightbringer shred storage +snapshots = %q # ~100GB for full + incremental +logs = %q # Log files (created if missing) [network] cluster = "mainnet-beta" # Required: "mainnet-beta" | "testnet" | "devnet" @@ -155,7 +160,7 @@ txpar = 24 # Recommended: 2x your CPU core count port = 8899 # Mithril's RPC server (binds to all interfaces) [log] -dir = "/mnt/mithril-logs" # Log files (created if missing) +dir = %q # Log files (created if missing) level = "info" # "debug" | "info" | "warn" | "error" to_stdout = true # Also write to stdout max_size_mb = 100 # Max log file size before rotation @@ -163,7 +168,7 @@ max_age_days = 7 # Delete logs older than this # Advanced options (defaults work well for most setups) # See config.example.toml for: [tuning], [debug], [snapshot], [reporting] -` +`, s.Accounts, s.Shredstore, s.Snapshots, s.Logs, s.Logs) } // runConfigSet updates a key in the config file diff --git a/cmd/mithril/configcmd/edit.go b/cmd/mithril/configcmd/edit.go index 43a1cd36..fc1a5545 100644 --- a/cmd/mithril/configcmd/edit.go +++ b/cmd/mithril/configcmd/edit.go @@ -49,6 +49,7 @@ const ( edScrRPC edScrLightbringer edScrGossip + edScrLightbringerQuiet edScrStorage edScrAccountsPath edScrSnapshotsPath @@ -89,6 +90,7 @@ type editModel struct { rpcEndpoint string lbEnabled bool gossipEntry string + lbQuiet bool accountsPath string snapshotsPath string logsPath string @@ -162,6 +164,7 @@ func newEditModel(cf string, v *viper.Viper) editModel { txparWasSet: txparWasSet, lbEnabled: v.GetBool("lightbringer.enabled"), gossipEntry: v.GetString("lightbringer.gossip_entrypoint"), + lbQuiet: v.GetBool("lightbringer.quiet"), accountsPath: v.GetString("storage.accounts"), snapshotsPath: v.GetString("storage.snapshots"), logsPath: logsPath, @@ -253,6 +256,9 @@ func (m editModel) currentItems() []edItem { lbStatus := "disabled" if m.lbEnabled { lbStatus = "enabled" + if m.lbQuiet { + lbStatus += ", quiet" + } } return []edItem{ {label: "Network", value: "network", desc: fmt.Sprintf("cluster=%s rpc=%s", m.cluster, truncate(m.rpcEndpoint, 35))}, @@ -275,9 +281,23 @@ func (m editModel) currentItems() []edItem { {label: "← Back", value: "_back"}, } case edScrLightbringer: - return []edItem{ + items := []edItem{ {label: "Disable", value: "disable", desc: "Use RPC only"}, {label: "Enable", value: "enable", desc: "Sidecar for lower-latency block streaming"}, + } + if m.lbEnabled { + quietDesc := "off" + if m.lbQuiet { + quietDesc = "on (only warn/error in lightbringer.log)" + } + items = append(items, edItem{label: "Quiet logs", value: "quiet", desc: quietDesc}) + } + items = append(items, edItem{isSep: true}, edItem{label: "← Back", value: "_back"}) + return items + case edScrLightbringerQuiet: + return []edItem{ + {label: "Normal logs", value: "false", desc: "Show all info messages (default)"}, + {label: "Quiet mode", value: "true", desc: "Only warnings and errors — recommended for long runs"}, {isSep: true}, {label: "← Back", value: "_back"}, } @@ -415,13 +435,22 @@ func (m *editModel) handleSelect(value string) { m.pushInput(edScrRPC) case edScrLightbringer: - m.lbEnabled = value == "enable" - if m.lbEnabled { + switch value { + case "enable": + m.lbEnabled = true m.pushInput(edScrGossip) - } else { + case "disable": + m.lbEnabled = false + m.lbQuiet = false // Reset dependent state so disable→re-enable starts clean. m.goBack() + case "quiet": + m.pushMenu(edScrLightbringerQuiet) } + case edScrLightbringerQuiet: + m.lbQuiet = value == "true" + m.goBack() + case edScrStorage: switch value { case "accounts": @@ -638,6 +667,11 @@ func (m *editModel) saveConfig() { content = setTomlValue(content, "lightbringer", "gossip_entrypoint", fmt.Sprintf("%q", m.gossipEntry)) } } + if m.lbQuiet { + content = setTomlValue(content, "lightbringer", "quiet", "true") + } else { + content = setTomlValue(content, "lightbringer", "quiet", "false") + } } else { // Only force block.source="rpc" if no external lightbringer_endpoint is configured. // External LB mode (enabled=false + endpoint set) is a valid runtime config. @@ -693,6 +727,8 @@ func (m editModel) menuTitleDesc() (string, string) { return "Solana Cluster", "" case edScrLightbringer: return "Lightbringer Sidecar", "" + case edScrLightbringerQuiet: + return "Lightbringer Log Verbosity", "Quiet mode suppresses Lightbringer info/debug logs (only warnings and errors)." case edScrStorage: return "Storage Paths", "" case edScrLogLevel: diff --git a/cmd/mithril/configcmd/edit_test.go b/cmd/mithril/configcmd/edit_test.go new file mode 100644 index 00000000..8717736f --- /dev/null +++ b/cmd/mithril/configcmd/edit_test.go @@ -0,0 +1,68 @@ +package configcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEditor_ScrLightbringerQuiet sets m.lbQuiet from a menu selection. +func TestEditor_ScrLightbringerQuiet_True(t *testing.T) { + m := &editModel{screen: edScrLightbringerQuiet, lbEnabled: true, lbQuiet: false} + m.handleSelect("true") + assert.True(t, m.lbQuiet, "picking 'true' should set lbQuiet=true") +} + +func TestEditor_ScrLightbringerQuiet_False(t *testing.T) { + m := &editModel{screen: edScrLightbringerQuiet, lbEnabled: true, lbQuiet: true} + m.handleSelect("false") + assert.False(t, m.lbQuiet, "picking 'false' should set lbQuiet=false") +} + +// TestEditor_DisableLB_ResetsQuiet verifies that "disable" resets m.lbQuiet +// so a later re-enable starts clean (no stale quiet state carried over). +func TestEditor_DisableLB_ResetsQuiet(t *testing.T) { + m := &editModel{ + screen: edScrLightbringer, + lbEnabled: true, + lbQuiet: true, // previously enabled quiet + } + m.handleSelect("disable") + assert.False(t, m.lbEnabled, "disable should set lbEnabled=false") + assert.False(t, m.lbQuiet, "disable should reset lbQuiet to avoid stale state on re-enable") +} + +// TestEditor_EnableLB_PreservesQuiet ensures enable does not clobber quiet. +func TestEditor_EnableLB_PreservesQuiet(t *testing.T) { + m := &editModel{ + screen: edScrLightbringer, + lbEnabled: false, + lbQuiet: true, + } + m.handleSelect("enable") + assert.True(t, m.lbEnabled) + assert.True(t, m.lbQuiet, "enable should not modify lbQuiet") +} + +// TestEditor_QuietMenuItem_ShownOnlyWhenLBEnabled verifies the conditional +// menu rendering — the "Quiet logs" entry must not appear when LB is off. +func TestEditor_QuietMenuItem_HiddenWhenLBDisabled(t *testing.T) { + m := editModel{screen: edScrLightbringer, lbEnabled: false} + items := m.currentItems() + for _, it := range items { + assert.NotEqual(t, "quiet", it.value, "Quiet logs entry must not appear when lbEnabled=false") + } +} + +func TestEditor_QuietMenuItem_ShownWhenLBEnabled(t *testing.T) { + m := editModel{screen: edScrLightbringer, lbEnabled: true} + items := m.currentItems() + found := false + for _, it := range items { + if it.value == "quiet" { + found = true + break + } + } + assert.True(t, found, "Quiet logs entry should appear when lbEnabled=true") +} diff --git a/cmd/mithril/dashboardcmd/dashboard.go b/cmd/mithril/dashboardcmd/dashboard.go index 3e909959..b310b3f2 100644 --- a/cmd/mithril/dashboardcmd/dashboard.go +++ b/cmd/mithril/dashboardcmd/dashboard.go @@ -221,6 +221,7 @@ func newModel(cf string) model { {section: "lightbringer", key: "gossip_entrypoint", label: "Gossip Entrypoint"}, {section: "lightbringer", key: "grpc_addr", label: "LB gRPC Address"}, {section: "lightbringer", key: "rpc_addr", label: "LB HTTP Address"}, + {section: "lightbringer", key: "quiet", label: "LB Quiet Logs"}, {isSep: true}, {section: "tuning", key: "txpar", label: "TX Parallelism"}, {section: "rpc", key: "port", label: "RPC Port"}, @@ -728,6 +729,11 @@ func (m model) getFieldValue(f editFieldDef) string { return m.cfg.lbGrpcAddr case "lightbringer.rpc_addr": return m.cfg.lbRpcAddr + case "lightbringer.quiet": + if m.cfg.lbQuiet { + return "true" + } + return "false" case "tuning.txpar": return m.cfg.txpar case "rpc.port": @@ -759,6 +765,11 @@ func menuOptionsFor(section, key string) []editOption { {label: "false", value: "false", desc: "Disabled"}, {label: "true", value: "true", desc: "Enabled"}, } + case "lightbringer.quiet": + return []editOption{ + {label: "false", value: "false", desc: "Show all info messages (default)"}, + {label: "true", value: "true", desc: "Only warnings and errors — recommended for long runs"}, + } case "log.level": return []editOption{ {label: "debug", value: "debug"}, diff --git a/cmd/mithril/dashboardcmd/data.go b/cmd/mithril/dashboardcmd/data.go index 8f2704c9..fb675d13 100644 --- a/cmd/mithril/dashboardcmd/data.go +++ b/cmd/mithril/dashboardcmd/data.go @@ -69,6 +69,7 @@ type configData struct { lbRpcAddr string lbExternalEndpoint string // block.lightbringer_endpoint for external LB mode lbBinaryPath string + lbQuiet bool accountsPath string snapshotsPath string shredstorePath string @@ -112,6 +113,7 @@ func readConfig(configFile string) *configData { lbGossip: v.GetString("lightbringer.gossip_entrypoint"), lbGrpcAddr: v.GetString("lightbringer.grpc_addr"), lbRpcAddr: v.GetString("lightbringer.rpc_addr"), + lbQuiet: v.GetBool("lightbringer.quiet"), lbExternalEndpoint: v.GetString("block.lightbringer_endpoint"), lbBinaryPath: v.GetString("lightbringer.binary_path"), accountsPath: v.GetString("storage.accounts"), @@ -375,6 +377,13 @@ func runDoctorChecks(configFile string, cfg *configData) []checkResult { } else { results = append(results, checkResult{"Gossip entrypoint", "fail", "not set"}) } + + // Quiet mode (informational) + if cfg.lbQuiet { + results = append(results, checkResult{"Lightbringer logs", "pass", "quiet (warn/error only)"}) + } else { + results = append(results, checkResult{"Lightbringer logs", "pass", "normal (info)"}) + } } else if cfg.blockSource == "lightbringer" && cfg.lbExternalEndpoint != "" { // External Lightbringer mode — sidecar disabled but endpoint configured results = append(results, checkResult{"Lightbringer", "pass", "external at " + cfg.lbExternalEndpoint}) @@ -476,7 +485,7 @@ func saveConfigValue(configFile, section, key, value string) error { case fullKey == "block.max_rps" || fullKey == "block.max_inflight" || fullKey == "tuning.txpar" || fullKey == "rpc.port": tomlValue = value // numeric — no quoting - case fullKey == "lightbringer.enabled": + case fullKey == "lightbringer.enabled" || fullKey == "lightbringer.quiet": tomlValue = value // boolean — no quoting case fullKey == "network.rpc": // Preserve failover endpoints — read existing array, update first element diff --git a/cmd/mithril/node/node.go b/cmd/mithril/node/node.go index cd9d044c..7169c2ea 100644 --- a/cmd/mithril/node/node.go +++ b/cmd/mithril/node/node.go @@ -110,6 +110,7 @@ var ( lightbringerInfluxdbToken string lightbringerBlockConfirmHTTP string lightbringerBlockConfirmWS string + lightbringerQuiet bool ) func init() { @@ -426,6 +427,7 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { lightbringerInfluxdbToken = config.GetString("lightbringer.influxdb_token") lightbringerBlockConfirmHTTP = config.GetString("lightbringer.block_confirmation_rpc_http") lightbringerBlockConfirmWS = config.GetString("lightbringer.block_confirmation_rpc_websocket") + lightbringerQuiet = config.GetBool("lightbringer.quiet") // Auto-sync: when lightbringer is enabled, override block source settings if lightbringerEnabled { @@ -747,6 +749,7 @@ func runLive(c *cobra.Command, args []string) { InfluxdbToken: lightbringerInfluxdbToken, BlockConfirmRpcHTTP: lightbringerBlockConfirmHTTP, BlockConfirmRpcWS: lightbringerBlockConfirmWS, + Quiet: lightbringerQuiet, }, LogWriter: lbLogWriter, }) diff --git a/cmd/mithril/setupcmd/setup.go b/cmd/mithril/setupcmd/setup.go index b8eb0f76..872f8e79 100644 --- a/cmd/mithril/setupcmd/setup.go +++ b/cmd/mithril/setupcmd/setup.go @@ -75,6 +75,7 @@ const ( scrRPC scrLightbringer scrGossip + scrLightbringerQuiet // log verbosity for managed Lightbringer (only shown when Lightbringer enabled) scrStorage // accountsPath scrStorageSnap // snapshotsPath scrStorageLogs // logsPath @@ -112,9 +113,11 @@ type setupModel struct { rpcEndpoint string enableLB bool gossipEntry string - accountsPath string - snapshotsPath string - logsPath string + lbQuiet bool // suppress Lightbringer info/debug logs + accountsPath string + snapshotsPath string + logsPath string + shredstorePath string bootstrapMode string blockMaxRPS string blockInflight string @@ -134,15 +137,17 @@ type setupModel struct { func newSetupModel() setupModel { absPath, _ := filepath.Abs(outputPath) + storage := config.DefaultStoragePaths() return setupModel{ - screen: scrMode, - cpuCores: runtime.NumCPU(), - disks: DetectDisks(), - cluster: "mainnet-beta", - rpcEndpoint: "https://api.mainnet-beta.solana.com", - accountsPath: "/mnt/mithril-accounts", - snapshotsPath: "/mnt/mithril-ledger/snapshots", - logsPath: "/mnt/mithril-logs", + screen: scrMode, + cpuCores: runtime.NumCPU(), + disks: DetectDisks(), + cluster: "mainnet-beta", + rpcEndpoint: "https://api.mainnet-beta.solana.com", + accountsPath: storage.Accounts, + snapshotsPath: storage.Snapshots, + logsPath: storage.Logs, + shredstorePath: storage.Shredstore, bootstrapMode: "auto", blockMaxRPS: "8", blockInflight: "8", @@ -274,6 +279,13 @@ func (m setupModel) currentItems() []menuItem { menuSeparator(), menuBack(), } + case scrLightbringerQuiet: + return []menuItem{ + menuOptionDesc("Normal logs", "false", "Show all info messages (default)"), + menuOptionDesc("Quiet mode", "true", "Only warnings and errors — recommended for long runs"), + menuSeparator(), + menuBack(), + } case scrLogLevel: return []menuItem{ menuOption("debug", "debug"), @@ -390,6 +402,9 @@ func (m setupModel) handleSelect(value string) (tea.Model, tea.Cmd) { case scrLightbringer: m.enableLB = value == "enable" + if !m.enableLB { + m.lbQuiet = false // Reset dependent state so disable→re-enable starts clean. + } if m.enableLB { m.pushInput(scrGossip) } else if m.mode == "quick" { @@ -410,6 +425,10 @@ func (m setupModel) handleSelect(value string) (tea.Model, tea.Cmd) { m.snapshotKeep = value m.pushMenu(scrLogLevel) + case scrLightbringerQuiet: + m.lbQuiet = value == "true" + m.pushInput(scrStorage) + case scrLogLevel: m.logLevel = value m.pushInput(scrRPCPort) @@ -593,7 +612,7 @@ func (m *setupModel) advanceFromInput() { if m.mode == "quick" { m.pushMenu(scrReview) } else { - m.pushInput(scrStorage) + m.pushMenu(scrLightbringerQuiet) } case scrStorage: m.pushInput(scrStorageSnap) @@ -645,6 +664,16 @@ func (m setupModel) View() string { case scrStorage: desc := "AccountsDB stores all ~500M on-chain accounts · needs fastest NVMe\n" + "Heavy random I/O — put this on your best drive" + if config.IsProductionLayout(config.StoragePaths{ + Accounts: m.accountsPath, + Snapshots: m.snapshotsPath, + Logs: m.logsPath, + Shredstore: m.shredstorePath, + }) { + desc += "\nDefault: production /mnt/* paths (run scripts/disk-setup.sh first)" + } else { + desc += "\nDefault: home directory (no /mnt setup detected) — see scripts/disk-setup.sh for production NVMe layout" + } if len(m.disks) > 0 { desc += "\n" for _, d := range m.disks { @@ -696,7 +725,11 @@ func (m setupModel) View() string { {"RPC", m.rpcEndpoint}, } if m.enableLB { - rows = append(rows, []string{"Lightbringer", "enabled (gossip: " + m.gossipEntry + ")"}) + summary := "enabled (gossip: " + m.gossipEntry + ")" + if m.lbQuiet { + summary += " · quiet logs" + } + rows = append(rows, []string{"Lightbringer", summary}) } else { rows = append(rows, []string{"Lightbringer", "disabled"}) } @@ -705,6 +738,7 @@ func (m setupModel) View() string { rows = append(rows, []string{"Parallelism", m.txpar + " workers (auto)"}) } else { rows = append(rows, []string{"AccountsDB", m.accountsPath}) + rows = append(rows, []string{"Shredstore", m.shredstorePath}) rows = append(rows, []string{"Snapshots", m.snapshotsPath}) rows = append(rows, []string{"Logs", m.logsPath}) rows = append(rows, []string{"Parallelism", m.txpar + " workers"}) @@ -744,6 +778,9 @@ func (m setupModel) View() string { case scrLightbringer: title = "Lightbringer Sidecar" desc = "Lightbringer sidecar for lower-latency block streaming." + case scrLightbringerQuiet: + title = "Lightbringer Log Verbosity" + desc = "Quiet mode suppresses Lightbringer info/debug logs (only warnings and errors)." case scrBootstrap: title = "Bootstrap Mode" desc = "How Mithril initializes on startup." @@ -781,7 +818,7 @@ func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { cfg.WriteString("[storage]\n") fmt.Fprintf(&cfg, "accounts = %q\n", filepath.Clean(m.accountsPath)) - cfg.WriteString("shredstore = \"/mnt/mithril-ledger/shredstore\"\n") + fmt.Fprintf(&cfg, "shredstore = %q\n", filepath.Clean(m.shredstorePath)) fmt.Fprintf(&cfg, "snapshots = %q\n", filepath.Clean(m.snapshotsPath)) fmt.Fprintf(&cfg, "logs = %q\n\n", filepath.Clean(m.logsPath)) @@ -804,7 +841,11 @@ func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { cfg.WriteString("binary_path = \"./lightbringer\"\n") fmt.Fprintf(&cfg, "gossip_entrypoint = %q\n", m.gossipEntry) cfg.WriteString("grpc_addr = \"127.0.0.1:3001\"\n") - cfg.WriteString("rpc_addr = \"127.0.0.1:3000\"\n\n") + cfg.WriteString("rpc_addr = \"127.0.0.1:3000\"\n") + if m.lbQuiet { + cfg.WriteString("quiet = true\n") + } + cfg.WriteString("\n") } cfg.WriteString("[tuning]\n") diff --git a/cmd/mithril/setupcmd/setup_test.go b/cmd/mithril/setupcmd/setup_test.go new file mode 100644 index 00000000..2793f89f --- /dev/null +++ b/cmd/mithril/setupcmd/setup_test.go @@ -0,0 +1,66 @@ +package setupcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestHandleSelect_ScrLightbringerQuiet verifies that picking an option on +// the new quiet-mode screen sets m.lbQuiet correctly. +func TestHandleSelect_ScrLightbringerQuiet_True(t *testing.T) { + m := setupModel{screen: scrLightbringerQuiet, mode: "full"} + out, _ := m.handleSelect("true") + model := out.(setupModel) + assert.True(t, model.lbQuiet, "picking 'true' should set lbQuiet=true") +} + +func TestHandleSelect_ScrLightbringerQuiet_False(t *testing.T) { + m := setupModel{screen: scrLightbringerQuiet, mode: "full", lbQuiet: true} + out, _ := m.handleSelect("false") + model := out.(setupModel) + assert.False(t, model.lbQuiet, "picking 'false' should set lbQuiet=false") +} + +// TestHandleSelect_DisableLB_ResetsQuiet verifies that picking "disable" on +// Lightbringer clears m.lbQuiet so a later re-enable starts clean. +func TestHandleSelect_DisableLB_ResetsQuiet(t *testing.T) { + m := setupModel{ + screen: scrLightbringer, + mode: "full", + enableLB: true, + lbQuiet: true, // user previously picked Quiet + } + out, _ := m.handleSelect("disable") + model := out.(setupModel) + assert.False(t, model.enableLB, "disable should set enableLB=false") + assert.False(t, model.lbQuiet, "disable should reset lbQuiet=false to avoid stale state on re-enable") +} + +// TestHandleSelect_EnableLB_DoesNotChangeQuiet ensures picking "enable" +// preserves whatever quiet value the user had (so toggling enable->enable +// doesn't reset it). +func TestHandleSelect_EnableLB_PreservesQuiet(t *testing.T) { + m := setupModel{ + screen: scrLightbringer, + mode: "full", + enableLB: false, + lbQuiet: true, // shouldn't be true if enableLB was false, but test the invariant + } + out, _ := m.handleSelect("enable") + model := out.(setupModel) + assert.True(t, model.enableLB) + assert.True(t, model.lbQuiet, "enable should not modify lbQuiet") +} + +// TestNewSetupModel_StoragePathsPopulated verifies the model picks up storage +// defaults from config.DefaultStoragePaths() at construction. +func TestNewSetupModel_StoragePathsPopulated(t *testing.T) { + m := newSetupModel() + assert.NotEmpty(t, m.accountsPath) + assert.NotEmpty(t, m.snapshotsPath) + assert.NotEmpty(t, m.logsPath) + assert.NotEmpty(t, m.shredstorePath) + // Specific paths depend on the host's /mnt/mithril-accounts writability; + // the all-or-nothing invariant is covered by pkg/config tests. +} diff --git a/cmd/mithril/statuscmd/status.go b/cmd/mithril/statuscmd/status.go index 74cea115..7515c801 100644 --- a/cmd/mithril/statuscmd/status.go +++ b/cmd/mithril/statuscmd/status.go @@ -58,7 +58,7 @@ func runStatus() { searchPaths := []string{accountsPath} if accountsPath == "" { searchPaths = []string{ - "/mnt/mithril-accounts", + config.DefaultStoragePaths().Accounts, "./data/accounts", ".", } @@ -171,6 +171,9 @@ func runStatus() { } else { fmt.Printf(" %s Lightbringer HTTP not responding on %s\n", dimStyle.Render("-"), lbHTTP) } + if config.GetBool("lightbringer.quiet") { + fmt.Printf(" %s Lightbringer quiet mode: enabled (warn/error only)\n", dimStyle.Render("·")) + } } } diff --git a/config.example.toml b/config.example.toml index 38e070c0..25e5eff5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -267,6 +267,9 @@ name = "mithril" # block_confirmation_rpc_http = "http://localhost:8899" # block_confirmation_rpc_websocket = "ws://localhost:8899" + # Suppress Lightbringer info/debug log lines (only warn/error). Default: false. + # quiet = true + # ============================================================================ # [rpc] - Mithril RPC Server # ============================================================================ diff --git a/pkg/config/config.go b/pkg/config/config.go index bf2817e1..4cbf1aeb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -152,6 +152,10 @@ type LightbringerConfig struct { // Optional: Block confirmation — written as [block_confirmation] section in generated Lightbringer.toml BlockConfirmRpcHTTP string `toml:"block_confirmation_rpc_http" mapstructure:"block_confirmation_rpc_http"` BlockConfirmRpcWS string `toml:"block_confirmation_rpc_websocket" mapstructure:"block_confirmation_rpc_websocket"` + + // Quiet suppresses Lightbringer info/debug logs when true. Written as + // [log] quiet = true in the generated Lightbringer.toml. + Quiet bool `toml:"quiet" mapstructure:"quiet"` } // LogConfig holds logging configuration diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go new file mode 100644 index 00000000..cd554574 --- /dev/null +++ b/pkg/config/defaults.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" +) + +// StoragePaths holds the four Mithril storage path defaults. +type StoragePaths struct { + Accounts string + Snapshots string + Logs string + Shredstore string +} + +// productionStoragePaths is the /mnt/mithril-* layout from scripts/disk-setup.sh. +func productionStoragePaths() StoragePaths { + return StoragePaths{ + Accounts: "/mnt/mithril-accounts", + Snapshots: "/mnt/mithril-ledger/snapshots", + Logs: "/mnt/mithril-logs", + Shredstore: "/mnt/mithril-ledger/shredstore", + } +} + +// DefaultStoragePaths returns /mnt/mithril-* when /mnt/mithril-accounts is +// writable, otherwise ~/.mithril/*. Detection is all-or-nothing on the +// /mnt/mithril-accounts probe so path roots are never mixed. +func DefaultStoragePaths() StoragePaths { + if isWritable("/mnt/mithril-accounts") { + return productionStoragePaths() + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + home = "." + } + base := filepath.Join(home, ".mithril") + return StoragePaths{ + Accounts: filepath.Join(base, "accounts"), + Snapshots: filepath.Join(base, "snapshots"), + Logs: filepath.Join(base, "logs"), + Shredstore: filepath.Join(base, "shredstore"), + } +} + +// IsProductionLayout reports whether all four paths match productionStoragePaths(). +// Returns false for mixed sets so user-edited configs are not mislabeled. +func IsProductionLayout(p StoragePaths) bool { + prod := productionStoragePaths() + return p == prod +} + +// isWritable reports whether path (or its nearest existing parent) is writable. +func isWritable(path string) bool { + p := path + for { + info, err := os.Stat(p) + if err == nil { + if !info.IsDir() { + return false + } + tmp, err := os.CreateTemp(p, ".mithril-writecheck.*") + if err != nil { + return false + } + tmpName := tmp.Name() + _ = tmp.Close() + _ = os.Remove(tmpName) + return true + } + parent := filepath.Dir(p) + if parent == p { + return false // reached filesystem root + } + p = parent + } +} diff --git a/pkg/config/defaults_test.go b/pkg/config/defaults_test.go new file mode 100644 index 00000000..e5ebd5e8 --- /dev/null +++ b/pkg/config/defaults_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsWritable_ExistingWritableDir(t *testing.T) { + dir := t.TempDir() + assert.True(t, isWritable(dir), "tempdir should be writable") +} + +func TestIsWritable_NonExistentUnderWritableParent(t *testing.T) { + parent := t.TempDir() + child := filepath.Join(parent, "does-not-exist-yet") + // Path doesn't exist but parent is writable — Mithril may create it at + // runtime, so we treat it as writable. + assert.True(t, isWritable(child)) +} + +func TestIsWritable_File(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "regular-file") + require.NoError(t, os.WriteFile(f, []byte("x"), 0600)) + // A file (not a dir) is never a valid storage path. + assert.False(t, isWritable(f)) +} + +func TestIsWritable_ReadOnlyDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission semantics differ on Windows") + } + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission checks") + } + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0500)) // r-x, no write + t.Cleanup(func() { _ = os.Chmod(dir, 0700) }) + assert.False(t, isWritable(dir)) +} + +func TestIsWritable_NonExistentNoWritableParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("path semantics differ on Windows") + } + // /proc/1/nonexistent: parent /proc/1 exists but is not writable for non-root. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission checks") + } + assert.False(t, isWritable("/proc/1/mithril-test-nonexistent")) +} + +func TestDefaultStoragePaths_AllFieldsPopulated(t *testing.T) { + p := DefaultStoragePaths() + assert.NotEmpty(t, p.Accounts) + assert.NotEmpty(t, p.Snapshots) + assert.NotEmpty(t, p.Logs) + assert.NotEmpty(t, p.Shredstore) +} + +func TestDefaultStoragePaths_AllOrNothingRoot(t *testing.T) { + p := DefaultStoragePaths() + // Either all paths are under /mnt (production) or all are under HOME + // (fallback). We never mix to avoid configs that span multiple roots. + allMnt := strings.HasPrefix(p.Accounts, "/mnt/") && + strings.HasPrefix(p.Snapshots, "/mnt/") && + strings.HasPrefix(p.Logs, "/mnt/") && + strings.HasPrefix(p.Shredstore, "/mnt/") + allHome := strings.Contains(p.Accounts, ".mithril") && + strings.Contains(p.Snapshots, ".mithril") && + strings.Contains(p.Logs, ".mithril") && + strings.Contains(p.Shredstore, ".mithril") + assert.True(t, allMnt || allHome, "paths should be all /mnt or all under .mithril, got %+v", p) +} + +func TestIsProductionLayout(t *testing.T) { + // Exact match -> true + assert.True(t, IsProductionLayout(productionStoragePaths())) + + // All home paths -> false + assert.False(t, IsProductionLayout(StoragePaths{ + Accounts: "/home/user/.mithril/accounts", + Snapshots: "/home/user/.mithril/snapshots", + Logs: "/home/user/.mithril/logs", + Shredstore: "/home/user/.mithril/shredstore", + })) + + // Mixed: production Accounts but home dir for Logs -> false (no mislabel) + mixed := productionStoragePaths() + mixed.Logs = "/home/user/.mithril/logs" + assert.False(t, IsProductionLayout(mixed), + "mixed root config should not be labeled production") +} + +func TestProductionStoragePaths_Stable(t *testing.T) { + // These are documented in README.md and config.example.toml — changes + // here would silently break operators who follow the docs. + p := productionStoragePaths() + assert.Equal(t, "/mnt/mithril-accounts", p.Accounts) + assert.Equal(t, "/mnt/mithril-ledger/snapshots", p.Snapshots) + assert.Equal(t, "/mnt/mithril-logs", p.Logs) + assert.Equal(t, "/mnt/mithril-ledger/shredstore", p.Shredstore) +} diff --git a/pkg/lightbringer/config.go b/pkg/lightbringer/config.go index 63d78e82..d53c407c 100644 --- a/pkg/lightbringer/config.go +++ b/pkg/lightbringer/config.go @@ -26,6 +26,9 @@ type LightbringerTOML struct { BlockConfirmRpcHTTP string BlockConfirmRpcWS string + + // Quiet emits [log] quiet = true so Lightbringer logs at Warn level only. + Quiet bool } // Validate checks that required fields are present and well-formed. @@ -91,6 +94,12 @@ func (c *LightbringerTOML) GenerateTOML() string { fmt.Fprintf(&b, "rpc_websocket = %q\n", c.BlockConfirmRpcWS) } + // Emit [log] section only when quiet mode is enabled. + // Lightbringer's default (info) applies when the section is absent, preserving backward compatibility. + if c.Quiet { + b.WriteString("\n[log]\nquiet = true\n") + } + return b.String() } diff --git a/pkg/lightbringer/config_test.go b/pkg/lightbringer/config_test.go index 4ec59887..4f4015cd 100644 --- a/pkg/lightbringer/config_test.go +++ b/pkg/lightbringer/config_test.go @@ -104,6 +104,61 @@ func TestGenerateTOML_WithBlockConfirmation(t *testing.T) { assert.NotContains(t, toml, "[influxdb]") } +func TestGenerateTOML_QuietOmittedByDefault(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "/data/shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + } + + toml := cfg.GenerateTOML() + + // When Quiet is false (zero value), no [log] section should be emitted. + // This preserves backward compatibility with older Lightbringer binaries. + assert.NotContains(t, toml, "[log]") + assert.NotContains(t, toml, "quiet") +} + +func TestGenerateTOML_QuietEmitsLogSection(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "/data/shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + Quiet: true, + } + + toml := cfg.GenerateTOML() + + assert.Contains(t, toml, "[log]") + assert.Contains(t, toml, "quiet = true") +} + +func TestGenerateTOML_QuietWithOtherOptionalSections(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "./shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + InfluxdbHost: "http://localhost:8181", + InfluxdbDatabase: "db", + InfluxdbToken: "tok", + BlockConfirmRpcHTTP: "http://localhost:8899", + BlockConfirmRpcWS: "ws://localhost:8899", + Quiet: true, + } + + toml := cfg.GenerateTOML() + + // All three optional sections should be present and ordered consistently. + assert.Contains(t, toml, "[influxdb]") + assert.Contains(t, toml, "[block_confirmation]") + assert.Contains(t, toml, "[log]") + assert.Less(t, strings.Index(toml, "[influxdb]"), strings.Index(toml, "[block_confirmation]")) + assert.Less(t, strings.Index(toml, "[block_confirmation]"), strings.Index(toml, "[log]")) +} + func TestGenerateTOML_BothOptionalSections(t *testing.T) { cfg := LightbringerTOML{ GossipEntrypoint: "1.2.3.4:8000",