From 64cca78d24d8b36f620e49516cfdca47535be3a7 Mon Sep 17 00:00:00 2001 From: David Allen Date: Mon, 16 Jun 2025 16:19:43 -0600 Subject: [PATCH 01/10] refactor: updated cache editor implementation --- cmd/cache.go | 63 ++++++++++++++++++++++++----- cmd/list.go | 4 +- internal/cache/{edit => }/modify.go | 0 internal/cache/{edit => }/table.go | 4 ++ internal/util/print.go | 20 +++++++++ pkg/list.go | 15 ------- 6 files changed, 78 insertions(+), 28 deletions(-) rename internal/cache/{edit => }/modify.go (100%) rename internal/cache/{edit => }/table.go (95%) diff --git a/cmd/cache.go b/cmd/cache.go index f39bffd..aa35136 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -6,27 +6,25 @@ import ( "os" "strconv" + tea "github.com/charmbracelet/bubbletea" + "github.com/davidallendj/magellan/internal/cache" "github.com/davidallendj/magellan/internal/cache/sqlite" + "github.com/davidallendj/magellan/internal/util" magellan "github.com/davidallendj/magellan/pkg" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) var ( - withHosts []string - withPorts []int + cacheOutputFormat string + interactive bool + withHosts []string + withPorts []int ) var cacheCmd = &cobra.Command{ Use: "cache", Short: "Manage found assets in cache.", - Run: func(cmd *cobra.Command, args []string) { - // show the help for cache and exit - if len(args) <= 0 { - cmd.Help() - os.Exit(0) - } - }, } var cacheRemoveCmd = &cobra.Command{ @@ -88,9 +86,54 @@ var cacheRemoveCmd = &cobra.Command{ }, } +var cacheEditCmd = &cobra.Command{ + Use: "edit", + Short: "Modify cache data either interactively or non-interactively.", + Run: func(cmd *cobra.Command, args []string) { + // start the interactive editor + if interactive { + p := tea.NewProgram(cache.NewModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("failed to start the cache editor: %v", err) + os.Exit(1) + } + } else { + // only edit data with arguments + } + }, +} + +var cacheInfoCmd = &cobra.Command{ + Use: "info", + Short: "Show cache-related information.", + Run: func(cmd *cobra.Command, args []string) { + printCacheInfo(cacheOutputFormat) + }, +} + func init() { + // remove cacheRemoveCmd.Flags().StringSliceVar(&withHosts, "with-hosts", []string{}, "Remove all assets with specified hosts") cacheRemoveCmd.Flags().IntSliceVar(&withPorts, "with-ports", []int{}, "Remove all assets with specified ports") - cacheCmd.AddCommand(cacheRemoveCmd) + + // edit + cacheEditCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Edit cache data using interactive editor") + + cacheInfoCmd.Flags().StringVarP(&cacheOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") + + // commands + cacheCmd.AddCommand(cacheRemoveCmd, cacheEditCmd, cacheInfoCmd) rootCmd.AddCommand(cacheCmd) } + +func printCacheInfo(format string) { + assets, err := sqlite.GetScannedAssets(cachePath) + if err != nil { + log.Error().Err(err).Str("path", cachePath).Msg("failed to get assets to print cache info") + } + cacheData := map[string]any{ + "path": cachePath, + "assets": len(assets), + } + util.PrintMapWithFormat(cacheData, format) +} diff --git a/cmd/list.go b/cmd/list.go index e36ae43..3d26c16 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -42,9 +42,7 @@ var ListCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { // check if we just want to show cache-related info and exit if showCacheInfo { - magellan.PrintMapWithFormat(map[string]any{ - "path": cachePath, - }, listOutputFormat) + printCacheInfo(listOutputFormat) os.Exit(0) } diff --git a/internal/cache/edit/modify.go b/internal/cache/modify.go similarity index 100% rename from internal/cache/edit/modify.go rename to internal/cache/modify.go diff --git a/internal/cache/edit/table.go b/internal/cache/table.go similarity index 95% rename from internal/cache/edit/table.go rename to internal/cache/table.go index 7759bd7..777308a 100644 --- a/internal/cache/edit/table.go +++ b/internal/cache/table.go @@ -15,6 +15,10 @@ type Model struct { Table table.Model } +func NewModel() Model { + return Model{} +} + func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/internal/util/print.go b/internal/util/print.go index 0762ed1..a8102f4 100644 --- a/internal/util/print.go +++ b/internal/util/print.go @@ -3,6 +3,7 @@ package util import ( "encoding/json" "fmt" + "strings" "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" @@ -23,3 +24,22 @@ func PrintYAML(data any) { } fmt.Print(string(b)) } + +func PrintMap(data map[string]any) { + for k, v := range data { + fmt.Printf("%s: %v\n", k, v) + } +} + +func PrintMapWithFormat(data map[string]any, format string) { + switch strings.ToLower(format) { + case "json": + PrintJSON(data) + case "yaml": + PrintYAML(data) + case "list": + PrintMap(data) + default: + log.Error().Msg("PrintMapWithFormat: unrecognized format") + } +} diff --git a/pkg/list.go b/pkg/list.go index 3e7ea0b..a72748d 100644 --- a/pkg/list.go +++ b/pkg/list.go @@ -27,21 +27,6 @@ func PrintRemoteAssets(data []RemoteAsset, format string) { } } -func PrintMapWithFormat(data map[string]any, format string) { - switch strings.ToLower(format) { - case "json": - util.PrintJSON(data) - case "yaml": - util.PrintYAML(data) - case "list": - for k, v := range data { - fmt.Printf("%s: %v\n", k, v) - } - default: - log.Error().Msg("PrintMapWithFormat: unrecognized format") - } -} - func ListDrives(cc *crawler.CrawlerConfig) ([]*redfish.Drive, error) { user, err := cc.GetUserPass() if err != nil { From 5d4095a0b6fdc68284fbf760ec57371e46076f27 Mon Sep 17 00:00:00 2001 From: David Allen Date: Tue, 17 Jun 2025 22:05:47 -0600 Subject: [PATCH 02/10] Update cache cmd implementation --- cmd/cache.go | 93 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/cmd/cache.go b/cmd/cache.go index aa35136..a6f7162 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -6,8 +6,10 @@ import ( "os" "strconv" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" - "github.com/davidallendj/magellan/internal/cache" + "github.com/charmbracelet/lipgloss" + cache "github.com/davidallendj/magellan/internal/cache" "github.com/davidallendj/magellan/internal/cache/sqlite" "github.com/davidallendj/magellan/internal/util" magellan "github.com/davidallendj/magellan/pkg" @@ -69,6 +71,7 @@ var cacheRemoveCmd = &cobra.Command{ Port: -1, }) } + // Add all assets with specified ports (same port different hosts) // This should produce the following SQL: // DELETE FROM magellan_scanned_assets WHERE port=:port @@ -87,25 +90,79 @@ var cacheRemoveCmd = &cobra.Command{ } var cacheEditCmd = &cobra.Command{ - Use: "edit", - Short: "Modify cache data either interactively or non-interactively.", + Use: "edit", + Example: ` magellan cache edit + magellan cache edit --host https://172.16.0.101 --port 443 --protocol udp + magellan cache edit --host https://172.16.0.101 + `, + Args: cobra.ExactArgs(0), + Short: "Edit existing cache data.", Run: func(cmd *cobra.Command, args []string) { - // start the interactive editor + var ( + columns []table.Column + rows []table.Row + styles table.Styles + ) + if interactive { - p := tea.NewProgram(cache.NewModel()) - if _, err := p.Run(); err != nil { - fmt.Printf("failed to start the cache editor: %v", err) + // load the assets found from scan + scannedResults, err := sqlite.GetScannedAssets(cachePath) + if err != nil { + log.Error().Err(err).Str("path", cachePath).Msg("failed to get scanned assets from cache") + } + + // set columns to cache headers + columns = []table.Column{ + {Title: "hosts", Width: 20}, + {Title: "ports", Width: 5}, + {Title: "protocol", Width: 8}, + {Title: "timestamp", Width: 12}, + } + + // set rows to cache data + for _, asset := range scannedResults { + rows = append(rows, table.Row{ + asset.Host, + fmt.Sprintf("%d", asset.Port), + asset.Protocol, + fmt.Sprintf("%d", asset.Timestamp.Unix()), + }) + } + + // new table + assetsTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(10), + ) + + // set up table styling + styles = table.DefaultStyles() + styles.Header = styles.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + styles.Selected = styles.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + assetsTable.SetStyles(styles) + + m := cache.Model{Table: assetsTable} + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + fmt.Println("Error running program:", err) os.Exit(1) } - } else { - // only edit data with arguments } }, } var cacheInfoCmd = &cobra.Command{ - Use: "info", - Short: "Show cache-related information.", + Use: "info", + Short: "Show cache-related information and exit.", + Example: ` magellan cache info`, Run: func(cmd *cobra.Command, args []string) { printCacheInfo(cacheOutputFormat) }, @@ -116,13 +173,19 @@ func init() { cacheRemoveCmd.Flags().StringSliceVar(&withHosts, "with-hosts", []string{}, "Remove all assets with specified hosts") cacheRemoveCmd.Flags().IntSliceVar(&withPorts, "with-ports", []int{}, "Remove all assets with specified ports") - // edit - cacheEditCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Edit cache data using interactive editor") + cacheEditCmd.Flags().IntSliceVar(&ports, "port", nil, "Adds additional ports to scan for each host with unspecified ports.") + cacheEditCmd.Flags().StringVar(&scheme, "scheme", "https", "Set the default scheme to use if not specified in host URI. (default is 'https')") + cacheEditCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the default protocol to use in scan. (default is 'tcp')") + cacheEditCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Start an interactive TUI to edit cache data") cacheInfoCmd.Flags().StringVarP(&cacheOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") - // commands - cacheCmd.AddCommand(cacheRemoveCmd, cacheEditCmd, cacheInfoCmd) + cacheCmd.AddCommand( + cacheRemoveCmd, + cacheInfoCmd, + cacheEditCmd, + ListCmd, + ) rootCmd.AddCommand(cacheCmd) } From 35b1a162d9734330cf75391b76d3de784f7e4467 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:12:58 -0600 Subject: [PATCH 03/10] chore: updated go deps --- go.mod | 2 ++ go.sum | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/go.mod b/go.mod index a978a1b..a2dea00 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.1.0 @@ -33,6 +34,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect diff --git a/go.sum b/go.sum index 343003d..18780a5 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Cray-HPE/hms-xname v1.3.0 h1:DQmetMniubqcaL6Cxarz9+7KFfWGSEizIhfPHIgC3Gw= github.com/Cray-HPE/hms-xname v1.3.0/go.mod h1:XKdjQSzoTps5KDOE8yWojBTAWASGaS6LfRrVDxwTQO8= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -70,6 +74,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -92,6 +97,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -105,6 +111,7 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -124,6 +131,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= From ab7868acb116361f8d39d322b8447ba651faa463 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:13:19 -0600 Subject: [PATCH 04/10] chore: updated README.md --- README.md | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 575c601..416ad61 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ -# Magellan +# Magellan (Next Generation) -The `magellan` CLI tool is a Redfish-based, board management controller (BMC) discovery tool designed to scan networks and is written in Go. The tool collects information from BMC nodes using the provided Redfish RESTful API with [`gofish`](https://github.com/stmcginnis/gofish) and loads the queried data into an [SMD](https://github.com/davidallendj/smd/tree/master) instance. The tool strives to be more flexible by implementing multiple methods of discovery to work for a wider range of systems (WIP) and is capable of using independently of other tools or services. +The `magellan-ng` CLI tool is a Redfish-based, board management controller (BMC) discovery tool designed to scan networks and is written in Go. The tool collects information from BMC nodes using the provided Redfish RESTful API with [`gofish`](https://github.com/stmcginnis/gofish) and loads the queried data into an [SMD](https://github.com/davidallendj/smd/tree/master) instance. The tool strives to be more flexible by implementing multiple methods of discovery to work for a wider range of systems (WIP) and is capable of using independently of other tools or services. > [!NOTE] > The v0.1.0 version of `magellan` is incompatible with `smd` v2.15.3 and earlier due to `smd` lacking the inventory parsing code used with `magellan`'s output.** +> [!NOTICE] +> This is the enhanced version of the original OpenCHAMI tool that includes more features, refactoring, and bug fixes. This fork allows for more unconstrained experimentation that would not be possible in the original version. Some of the features added to this fork may be added to the original version in the future. + -- [Magellan](#magellan) +- [Magellan (Next Generation)](#magellan-next-generation) - [Main Features](#main-features) - [Getting Started](#getting-started) - [Building the Executable](#building-the-executable) @@ -22,7 +25,8 @@ The `magellan` CLI tool is a Redfish-based, board management controller (BMC) di - [Managing Secrets](#managing-secrets-1) - [Starting the Emulator](#starting-the-emulator-1) - [Updating Firmware](#updating-firmware) - - [Getting an Access Token (WIP)](#getting-an-access-token-wip) + - [Session Authentication](#session-authentication) + - [Cache Management](#cache-management) - [Running with Docker](#running-with-docker) - [How It Works](#how-it-works) - [TODO](#todo) @@ -30,7 +34,7 @@ The `magellan` CLI tool is a Redfish-based, board management controller (BMC) di - + ## Main Features @@ -43,6 +47,13 @@ The `magellan` tool comes packed with a handleful of features for doing discover - Write inventory data to JSON - Store and manage BMC secrets +Some features are unique to `magellan-ng` that the original version does not have, including: + +- Session authentication +- Cache management +- Storage included in inventory +- More robust scanning + See the [TODO](#todo) section for a list of soon-ish goals planned. ## Getting Started @@ -441,25 +452,13 @@ Then, the update status can be viewed by including the `--status` flag along wit watch -n 1 "./magellan update 172.16.0.110 --status --username $bmc_username --password $bmc_password | jq '.'" ``` -### Getting an Access Token (WIP) +### Session Authentication -The `magellan` tool has a `login` subcommand that works with the [`opaal`](https://github.com/davidallendj/opaal) service to obtain a token needed to access the SMD service. If the SMD instance requires authentication, set the `ACCESS_TOKEN` environment variable to have `magellan` include it in the header for HTTP requests to SMD. +TBD -```bash -# must have a running OPAAL instance -./magellan login --url https://opaal:4444/login +### Cache Management -# ...complete login flow to get token -export ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIs... -``` - -Alternatively, if you are running the davidallendj quickstart in the [deployment recipes](https://github.com/davidallendj/deployment-recipes), you can run the provided script to generate a token and set the environment variable that way. - -```bash -quickstart_dir=path/to/deployment/recipes/quickstart -source $quickstart_dir/bash_functions.sh -export ACCESS_TOKEN=$(gen_access_token) -``` +TBD ### Running with Docker From 17dbfa2b3b103674688e0a77318ef68f5a8a972d Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:13:55 -0600 Subject: [PATCH 05/10] chore: added example to cmd/root.go --- cmd/root.go | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 052b6c9..bc3620a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,7 +60,44 @@ var ( // The `root` command doesn't do anything on it's own except display // a help message and then exits. var rootCmd = &cobra.Command{ - Use: "magellan", + Use: "magellan", + Example: ` // a typical dynamic work flow with unknown hosts + magellan scan --subnet 172.16.0.0/24 + magellan collect -v -F yaml -o nodes.yaml -u admin -p password123 + $EDITOR nodes.yaml + magellan send -F yaml -d@nodes.yaml https://api.example.com + magellan collect -v | magellan send + + // show information in list + magellan list -F yaml + magellan list drives https://172.21.0.2:5000 -i -u admin -p password123 + + // performing a crawl with known hosts + magellan crawl https://172.16.0.100 -u admin -p password123 -i -F yaml -o nodes.yaml + + // manage secrets with secret store + export MASTER_KEY=(magellan secrets generatekey) + magellan secrets store https://172.16.0.100 root:password123 + magellan secrets list + magellan secrets retrieve https://172.16.0.100 + magellan secrets remove https://172.16.0.100 + + // manage sessions with session authentication (can be used with secret store) + magellan sessions login https://172.16.0.100 -u root -p password123 + magellan sessions list + magellan sessions delete --session-id $SESSION_ID --session-token $SESSION_TOKEN + + // manage cache with cache editting + magellan cache new /tmp/magellan/$USER/assets.db + magellan cache add https://172.16.0.100 443 tcp + magellan cache remove https://172.16.0.100 + magellan cache list + magellan cache info + + // manage cache interactively with editor + magellan cache new ./my/new/cache.db + magellan cache edit -i --cache ./my/new/cache.db +`, Short: "Redfish-based BMC discovery tool", Long: "Redfish-based BMC discovery tool with dynamic discovery features.", Run: func(cmd *cobra.Command, args []string) { From e71f33878eb4b26704a7bad3dbed8a1ae7174521 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:14:38 -0600 Subject: [PATCH 06/10] feat: updated cache implementation and fixed bugs --- cmd/cache.go | 177 ++++++++++++++++++------------ internal/cache/cache.go | 1 + internal/cache/edit.go | 176 ++++++++++++++++++++++++++++++ internal/cache/modify.go | 1 - internal/cache/sqlite/sqlite.go | 46 +++++++- internal/cache/state.go | 60 +++++++++++ internal/cache/table.go | 186 ++++++++++++++++++++++++++++---- 7 files changed, 550 insertions(+), 97 deletions(-) create mode 100644 internal/cache/edit.go delete mode 100644 internal/cache/modify.go create mode 100644 internal/cache/state.go diff --git a/cmd/cache.go b/cmd/cache.go index a6f7162..ef04376 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -2,10 +2,12 @@ package cmd import ( "fmt" - "net/url" "os" "strconv" + "strings" + "time" + "github.com/araddon/dateparse" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,6 +20,7 @@ import ( ) var ( + timestamp time.Time cacheOutputFormat string interactive bool withHosts []string @@ -26,74 +29,103 @@ var ( var cacheCmd = &cobra.Command{ Use: "cache", - Short: "Manage found assets in cache.", + Short: "Manage assets (new, add, remove, edit, etc.) in cache.", +} + +var cacheNewCmd = &cobra.Command{ + Use: "new", + Example: ` // create a new cache database + magellan cache new ./path/to/new_cache.db + `, + Short: "Create a new cache at specified path.", + Run: func(cmd *cobra.Command, args []string) { + + }, +} + +var cacheAddCmd = &cobra.Command{ + Use: "add [host] [port] [protocol] ", + Example: ` // create a new entry in cache + magellan cache add https://172.16.0.105 443,5000 tcp "06/25/2025" + + // create new entry with no timestamp specified with specific cache + magellan cache add https://172.16.0.111 443 tcp --cache ./assets.db + `, + Short: "Add an entry to the specified cache.", + Args: cobra.RangeArgs(3, 4), + Run: func(cmd *cobra.Command, args []string) { + // set positional args to vars + host = args[0] + protocol = args[2] + + // convert ports + for _, portStr := range strings.Split(args[1], ",") { + port, err := strconv.ParseInt(portStr, 10, 16) + if err != nil { + log.Error().Err(err).Msg("failed to convert port value") + continue + } + ports = append(ports, int(port)) + } + + // parse timestamp + if len(args) == 4 { + var err error + timestamp, err = dateparse.ParseAny(args[3]) + if err != nil { + log.Error().Err(err).Msg("failed to parse time arg") + os.Exit(1) + } + } else { + log.Warn().Msg("time not specified...using current time") + timestamp = time.Now() + } + + for _, port := range ports { + // create a new entry in cache using flag arguments + err := sqlite.InsertRemoteAssets(cachePath, magellan.RemoteAsset{ + Host: host, + Port: port, + Protocol: protocol, + Timestamp: timestamp, + }) + if err != nil { + log.Error().Err(err). + Str("path", cachePath). + Str("host", host). + Msg("failed to insert asset into cache") + } + } + + }, } var cacheRemoveCmd = &cobra.Command{ - Use: "remove", - Short: "Remove a host from a scanned cache list.", + Use: "remove [hosts...]", + Example: ` // remove an entry in cache + magellan cache remove https://172.16.0.113 + `, + Args: cobra.MinimumNArgs(1), + Short: "Remove hosts from cache.", Run: func(cmd *cobra.Command, args []string) { - assets := []magellan.RemoteAsset{} - - // add all assets directly from positional args - for _, arg := range args { - var ( - port int - uri *url.URL - err error - ) - uri, err = url.ParseRequestURI(arg) - if err != nil { - log.Error().Err(err).Msg("failed to parse arg") - } - - // convert port to its "proper" type - if uri.Port() == "" { - uri.Host += ":443" - } - port, err = strconv.Atoi(uri.Port()) - if err != nil { - log.Error().Err(err).Msg("failed to convert port to integer type") - } - asset := magellan.RemoteAsset{ - Host: fmt.Sprintf("%s://%s", uri.Scheme, uri.Hostname()), - Port: port, - } - assets = append(assets, asset) + err := sqlite.DeleteRemoteAssetsByHost(cachePath, args...) + if err != nil { + log.Error().Err(err).Str("path", cachePath).Msg("failed to delete assets in cache") } - // Add all assets with specified hosts (same host different different ports) - // This should produce the following SQL: - // DELETE FROM magellan_scanned_assets WHERE host=:host - for _, host := range withHosts { - assets = append(assets, magellan.RemoteAsset{ - Host: host, - Port: -1, - }) - } - - // Add all assets with specified ports (same port different hosts) - // This should produce the following SQL: - // DELETE FROM magellan_scanned_assets WHERE port=:port - for _, port := range withPorts { - assets = append(assets, magellan.RemoteAsset{ - Host: "", - Port: port, - }) - } - if len(assets) <= 0 { - log.Error().Msg("nothing to do") - os.Exit(1) - } - sqlite.DeleteScannedAssets(cachePath, assets...) }, } var cacheEditCmd = &cobra.Command{ Use: "edit", - Example: ` magellan cache edit - magellan cache edit --host https://172.16.0.101 --port 443 --protocol udp - magellan cache edit --host https://172.16.0.101 + Example: ` // star the cache editor + magellan cache edit -i + + // edit a single entry only changing values specified (e.g. port and protocol) + magellan cache edit https://172.16.0.101 --port 443 --protocol udp + + // edit two entries' time stamps + magellan cache edit https://172.16.0.101 https://172.16.0.102 --timestamp 06/25/2025 `, Args: cobra.ExactArgs(0), Short: "Edit existing cache data.", @@ -106,17 +138,17 @@ var cacheEditCmd = &cobra.Command{ if interactive { // load the assets found from scan - scannedResults, err := sqlite.GetScannedAssets(cachePath) + scannedResults, err := sqlite.GetRemoteAssets(cachePath) if err != nil { log.Error().Err(err).Str("path", cachePath).Msg("failed to get scanned assets from cache") } // set columns to cache headers columns = []table.Column{ - {Title: "hosts", Width: 20}, - {Title: "ports", Width: 5}, - {Title: "protocol", Width: 8}, - {Title: "timestamp", Width: 12}, + {Title: "host", Width: 30}, + {Title: "ports", Width: 8}, + {Title: "protocol", Width: 10}, + {Title: "timestamp", Width: 20}, } // set rows to cache data @@ -129,7 +161,7 @@ var cacheEditCmd = &cobra.Command{ }) } - // new table + // create a new table assetsTable := table.New( table.WithColumns(columns), table.WithRows(rows), @@ -150,11 +182,16 @@ var cacheEditCmd = &cobra.Command{ Bold(false) assetsTable.SetStyles(styles) - m := cache.Model{Table: assetsTable} + m := cache.NewModel(cachePath, &assetsTable) if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } + } else { + // non-interactive editting + // for _, host := range args { + + // } } }, } @@ -169,7 +206,7 @@ var cacheInfoCmd = &cobra.Command{ } func init() { - // remove + // remove row from cache cacheRemoveCmd.Flags().StringSliceVar(&withHosts, "with-hosts", []string{}, "Remove all assets with specified hosts") cacheRemoveCmd.Flags().IntSliceVar(&withPorts, "with-ports", []int{}, "Remove all assets with specified ports") @@ -181,16 +218,18 @@ func init() { cacheInfoCmd.Flags().StringVarP(&cacheOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") cacheCmd.AddCommand( + cacheNewCmd, + cacheAddCmd, cacheRemoveCmd, - cacheInfoCmd, cacheEditCmd, - ListCmd, + cacheInfoCmd, + ListCmd, // magellan cache list (alias for 'magellan list') ) rootCmd.AddCommand(cacheCmd) } func printCacheInfo(format string) { - assets, err := sqlite.GetScannedAssets(cachePath) + assets, err := sqlite.GetRemoteAssets(cachePath) if err != nil { log.Error().Err(err).Str("path", cachePath).Msg("failed to get assets to print cache info") } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 7b4eea1..b62a5f7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -8,6 +8,7 @@ import ( type Cache[T any] interface { CreateIfNotExists(path string) (driver.Connector, error) Insert(path string, data ...T) error + Update(path string, data ...T) error Delete(path string, data ...T) error Get(path string) ([]T, error) } diff --git a/internal/cache/edit.go b/internal/cache/edit.go new file mode 100644 index 0000000..bebfe73 --- /dev/null +++ b/internal/cache/edit.go @@ -0,0 +1,176 @@ +package cache + +import ( + "fmt" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/araddon/dateparse" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/cznic/mathutil" + "github.com/davidallendj/magellan/internal/cache/sqlite" + magellan "github.com/davidallendj/magellan/pkg" + "github.com/rs/zerolog/log" +) + +func (m *Model) editSelectedRow(value bool) tea.Cmd { + if value { + row := m.Table.SelectedRow() + for i := range m.inputs { + m.inputs[i].SetValue(row[i]) + } + m.Editor.StartEditting() + } else { + m.Editor.StopEditting() + } + return nil +} + +func (m *Model) updateRow() tea.Cmd { + // get updated values from inputs + updated := make(table.Row, len(m.inputs)) + for i, input := range m.inputs { + updated[i] = input.Value() + } + + // update table for selected row + rows := m.Table.Rows() + rows[m.Table.Cursor()] = updated + m.Table.SetRows(rows) + + // go back to selecting view + m.Editor.StopEditting() + m.displayMessage(fmt.Sprintf("updated row at index %d", m.Table.Cursor()), 3) + return nil +} + +func (m *Model) addRowAfterCursor() tea.Cmd { + position := mathutil.Clamp(m.Table.Cursor()+1, 0, len(m.Table.Rows())-1) + rows := slices.Insert(m.Table.Rows(), position, table.Row{ + "https://127.0.0.1", + "443", + "tcp", + fmt.Sprintf("%d", time.Now().Unix()), + }) + + m.Table.SetRows(rows) + m.displayMessage(fmt.Sprintf("add new row at index %d", m.Table.Cursor()+1), 3) + return nil +} + +func (m *Model) addRowAtEnd() tea.Cmd { + rows := append(m.Table.Rows(), table.Row{ + "https://127.0.0.1", + "443", + "tcp", + fmt.Sprintf("%d", time.Now().Unix()), + }) + m.Table.SetRows(rows) + m.displayMessage(fmt.Sprintf("add new row at index %d", len(m.Table.Rows())), 3) + return nil +} + +func (m *Model) deleteSelectedRow() tea.Cmd { + if len(m.Table.Rows()) == 0 { + m.displayMessage("nothing to delete...", 3) + return nil + } + m.Table.SetRows(slices.Delete(m.Table.Rows(), m.Table.Cursor(), m.Table.Cursor()+1)) + m.displayMessage(fmt.Sprintf("deleted row at index %d", m.Table.Cursor()), 3) + return nil +} + +func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + + // Only text inputs with Focus() set will respond, so it's safe to simply + // update all of them here without any further logic. + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (m *Model) editRowView() string { + display := fmt.Sprintf("Editting row %d...\n\n", m.Table.Cursor()) + + // update the values of each input + const lc = 9 + for i := range m.inputs { + diff := lc - len(m.Table.Columns()[i].Title) + spacing := strings.Repeat(" ", diff) + // m.inputs[i].SetValue(row[i]) + display += fmt.Sprintf("%d: ", i) + display += m.Table.Columns()[i].Title + ": " + display += spacing + m.inputs[i].View() + "\n" + } + + // submit button + // button := &blurredButton + // if m.focusIndex == len(m.inputs) { + // button = &focusedButton + // } + // display += fmt.Sprintf("\n\n%s\n\n", *button) + + // add helper info in footer + display += footerStyle.Render(` + ⬇/⬆: move cursor; + w, enter: save edit; esc: cancel; ctrl+c: quit w/o saving; +`) + + return display +} + +func (m *Model) updateCacheData() tea.Cmd { + // create assets from table data + assets := []magellan.RemoteAsset{} + for _, row := range m.Table.Rows() { + // convert port + port, err := strconv.ParseInt(row[1], 10, 32) + if err != nil { + log.Error().Err(err).Str("host", row[0]).Msg("failed to parse port value") + return nil + } + + // parse timestamp + timestamp, err := dateparse.ParseAny(row[3]) + if err != nil { + log.Error().Err(err).Str("host", row[0]).Msg("failed to parse date/time") + return nil + } + + assets = append(assets, magellan.RemoteAsset{ + Host: row[0], + Port: int(port), + Protocol: row[2], + Timestamp: timestamp, + }) + } + + // remove current database file + err := os.Remove(m.CachePath) + if err != nil { + log.Error().Err(err).Str("path", m.CachePath).Msg("failed to remove old cache") + return nil + } + + // create the file again... + _, err = sqlite.CreateRemoteAssetsIfNotExists(m.CachePath) + if err != nil { + log.Error().Err(err).Str("path", m.CachePath).Msg("failed to create new cache") + return nil + } + + // write assets to database + err = sqlite.InsertRemoteAssets(m.CachePath, assets...) + if err != nil { + log.Error().Err(err).Str("path", m.CachePath).Msg("failed to insert data into cache") + return nil + } + return nil +} diff --git a/internal/cache/modify.go b/internal/cache/modify.go deleted file mode 100644 index 08bf029..0000000 --- a/internal/cache/modify.go +++ /dev/null @@ -1 +0,0 @@ -package cache diff --git a/internal/cache/sqlite/sqlite.go b/internal/cache/sqlite/sqlite.go index bbbdff3..b2966a6 100644 --- a/internal/cache/sqlite/sqlite.go +++ b/internal/cache/sqlite/sqlite.go @@ -11,7 +11,17 @@ import ( const TABLE_NAME = "magellan_scanned_assets" -func CreateScannedAssetIfNotExists(path string) (*sqlx.DB, error) { +func GetColumns() []string { + return []string{ + "host", + "port", + "protocol", + "state", + "timestamp", + } +} + +func CreateRemoteAssetsIfNotExists(path string) (*sqlx.DB, error) { schema := fmt.Sprintf(` CREATE TABLE IF NOT EXISTS %s ( host TEXT NOT NULL, @@ -34,13 +44,13 @@ func CreateScannedAssetIfNotExists(path string) (*sqlx.DB, error) { return db, nil } -func InsertScannedAssets(path string, assets ...magellan.RemoteAsset) error { +func InsertRemoteAssets(path string, assets ...magellan.RemoteAsset) error { if assets == nil { return fmt.Errorf("states == nil") } // create database if it doesn't already exist - db, err := CreateScannedAssetIfNotExists(path) + db, err := CreateRemoteAssetsIfNotExists(path) if err != nil { return err } @@ -62,7 +72,33 @@ func InsertScannedAssets(path string, assets ...magellan.RemoteAsset) error { return nil } -func DeleteScannedAssets(path string, assets ...magellan.RemoteAsset) error { +func DeleteRemoteAssetsByHost(path string, hosts ...string) error { + var ( + db *sqlx.DB + tx *sqlx.Tx + err error + ) + db, err = sqlx.Open("sqlite3", path) + if err != nil { + return fmt.Errorf("failed to open database: %v", err) + } + tx = db.MustBegin() + for _, host := range hosts { + sql := fmt.Sprintf(`DELETE FROM %s WHERE host='%s'`, TABLE_NAME, host) + _, err := tx.Exec(sql, &host) + if err != nil { + fmt.Printf("failed to execute DELETE transaction: %v\n", err) + } + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("failed to commit transaction: %v", err) + } + return nil +} + +func DeleteRemoteAssets(path string, assets ...magellan.RemoteAsset) error { var ( db *sqlx.DB tx *sqlx.Tx @@ -91,7 +127,7 @@ func DeleteScannedAssets(path string, assets ...magellan.RemoteAsset) error { return nil } -func GetScannedAssets(path string) ([]magellan.RemoteAsset, error) { +func GetRemoteAssets(path string) ([]magellan.RemoteAsset, error) { // check if path exists first to prevent creating the database _, exists := util.PathExists(path) if !exists { diff --git a/internal/cache/state.go b/internal/cache/state.go new file mode 100644 index 0000000..447c88f --- /dev/null +++ b/internal/cache/state.go @@ -0,0 +1,60 @@ +package cache + +type editorState interface{} +type selecting struct{} +type editting struct{} + +type Editor struct { + state editorState +} + +// transition between state + +func NewEditor() Editor { + return Editor{state: selecting{}} +} + +func (e *Editor) StartEditting() editting { + e.state = editting{} + return e.state.(editting) +} + +func (e *Editor) StopEditting() selecting { + e.state = selecting{} + return e.state.(selecting) +} + +func (s editting) FinishEditting(e *Editor) selecting { + e.state = selecting{} + return e.state.(selecting) +} + +func (e *Editor) GetState() editorState { + return e.state +} + +func (e *Editor) GetStateString() string { + switch e.state.(type) { + case selecting: + return "selecting" + case editting: + return "editting" + } + return "" +} + +func (e *Editor) IsEditting() bool { + return e.GetStateString() == "editting" +} + +func (e *Editor) IsSelecting() bool { + return e.GetStateString() == "selecting" +} + +func test() { + editor := NewEditor() + editting := editor.StartEditting() + + // ...later on... + editting.FinishEditting(&editor) +} diff --git a/internal/cache/table.go b/internal/cache/table.go index 777308a..0dfdeea 100644 --- a/internal/cache/table.go +++ b/internal/cache/table.go @@ -1,22 +1,77 @@ package cache import ( + "fmt" + "time" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/cznic/mathutil" ) -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) +var ( + baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + cursorStyle = focusedStyle + noStyle = lipgloss.NewStyle() + helpStyle = blurredStyle + cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + + focusedButton = focusedStyle.Render("[ Update ]") + blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Update")) +) type Model struct { - selected int - Table table.Model + CachePath string + Editor Editor + Table table.Model + + // privates + inputs []textinput.Model + focusIndex int + statusMsg string + statusTimer time.Duration } -func NewModel() Model { - return Model{} +func NewModel(cachePath string, table *table.Model) Model { + m := Model{ + CachePath: cachePath, + Editor: NewEditor(), + Table: *table, + inputs: make([]textinput.Model, 4), + focusIndex: 0, + } + for i := range len(m.inputs) { + input := textinput.New() + input.Cursor.Style = cursorStyle + input.CharLimit = 256 + switch i { + case 0: + input.Placeholder = "host" + input.Focus() + input.PromptStyle = focusedStyle + input.TextStyle = focusedStyle + case 1: + input.Placeholder = "ports" + input.CharLimit = 5 + case 2: + input.Placeholder = "protocol" + input.CharLimit = 5 + + case 3: + input.Placeholder = "timestamp" + input.CharLimit = 10 + } + m.inputs[i] = input + } + + return m } func (m Model) Init() tea.Cmd { return nil } @@ -25,28 +80,115 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - // m.Table = m.Table.Width(msg.Width) - // m.Table = m.Table.Height(msg.Height) + // m.Table.SetWidth(msg.Width) + // m.Table.SetHeight(msg.Height) case tea.KeyMsg: + // do always regardless of state switch msg.String() { - case "esc": - if m.Table.Focused() { - m.Table.Blur() - } else { - m.Table.Focus() - } - case "q", "ctrl+c": + case "ctrl+c": return m, tea.Quit - case "enter": - return m, tea.Batch( - tea.Printf("Selected host '%s'", m.Table.SelectedRow()[0]), - ) } + + // do while editting table row + if m.Editor.IsEditting() { + switch msg.String() { + case "esc": + return m, m.editSelectedRow(false) + case "w", "enter": + return m, m.updateRow() + // case "enter": + // // Did the user press enter while the submit button was focused? + // // If so, exit. + // if m.focusIndex == len(m.inputs) { + // return m, tea.Quit + // } + + case "tab", "shift+tab", "up", "down": + s := msg.String() + switch s { + case "up", "shift+tab": + m.focusIndex = mathutil.Clamp(m.focusIndex-1, 0, len(m.inputs)) + case "down", "tab": + m.focusIndex = mathutil.Clamp(m.focusIndex+1, 0, len(m.inputs)) + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + if i == m.focusIndex { + // Set focused state + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + continue + } + // Remove focused state + m.inputs[i].Blur() + m.inputs[i].PromptStyle = noStyle + m.inputs[i].TextStyle = noStyle + } + return m, tea.Batch(cmds...) + } + cmd = m.updateInputs(msg) + return m, tea.Batch(cmd) + // do while making selection from table + } else if m.Editor.IsSelecting() { + switch msg.String() { + case "enter": + return m, m.editSelectedRow(true) + case "esc": + return m, tea.Quit + case "ctrl+w": + m.updateCacheData() + return m, tea.Quit + case "w", "ctrl+s": + return m, m.updateCacheData() + case "d": + return m, m.deleteSelectedRow() + case "a": + return m, m.addRowAfterCursor() + case "ctrl+a": + return m, m.addRowAtEnd() + case "q": + return m, tea.Quit + } + m.Table, cmd = m.Table.Update(msg) + } + } - m.Table, cmd = m.Table.Update(msg) return m, cmd } func (m Model) View() string { - return baseStyle.Render(m.Table.View()) + "\n" + if m.Editor.IsEditting() { + return m.editRowView() + } else if m.Editor.IsSelecting() { + display := m.Table.View() + "\n" + display += footerStyle.Render(fmt.Sprintf(` + j/k, ⬇/⬆ : move cursor; enter: choose; + a: add row after cursor; ctrl+a: add row at end + d: delete row at cursor; + w, ctrl+s: save data; + ctrl+w: save and quit; q, esc: quit w/o saving; +---------------------------------------------------------- + cache: %s + cursor: %d, rows: %d + %s +`, m.CachePath, m.Table.Cursor(), len(m.Table.Rows()), m.statusMsg)) + + return baseStyle.Render(display + "\n") + } + return "Something went wrong..." +} + +func (m *Model) displayMessage(message string, timeout time.Duration) { + + // show the message arg for alloted time + m.statusMsg = message + reset := make(chan string) + go func() { + time.Sleep(timeout) + reset <- "" + }() + m.statusMsg = <-reset + } From dadb51e40d96261d7eaa446fbe9f4fa8449b7382 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:15:20 -0600 Subject: [PATCH 07/10] fix: issue with list double prints --- cmd/list.go | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/cmd/list.go b/cmd/list.go index 3d26c16..6f5b711 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,11 +1,9 @@ package cmd import ( - "encoding/json" "fmt" "os" "strings" - "time" "github.com/davidallendj/magellan/internal/cache/sqlite" urlx "github.com/davidallendj/magellan/internal/urlx" @@ -13,7 +11,6 @@ import ( "github.com/davidallendj/magellan/pkg/crawler" "github.com/davidallendj/magellan/pkg/secrets" "github.com/rs/zerolog/log" - "gopkg.in/yaml.v3" "github.com/spf13/cobra" ) @@ -47,34 +44,13 @@ var ListCmd = &cobra.Command{ } // load the assets found from scan - scannedResults, err := sqlite.GetScannedAssets(cachePath) + scannedResults, err := sqlite.GetRemoteAssets(cachePath) if err != nil { log.Error().Err(err).Str("path", cachePath).Msg("failed to get scanned assets from cache") } - switch strings.ToLower(listOutputFormat) { - case FORMAT_JSON: - b, err := json.Marshal(scannedResults) - if err != nil { - log.Error().Err(err).Msgf("failed to unmarshal cached data to JSON") - } - fmt.Printf("%s\n", string(b)) - case FORMAT_YAML: - b, err := yaml.Marshal(scannedResults) - if err != nil { - log.Error().Err(err).Msgf("failed to unmarshal cached data to YAML") - } - fmt.Printf("%s\n", string(b)) - case FORMAT_LIST: - for _, r := range scannedResults { - fmt.Printf("%s:%d (%s) @%s\n", r.Host, r.Port, r.Protocol, r.Timestamp.Format(time.UnixDate)) - } - default: - log.Error().Msg("unrecognized format") - os.Exit(1) - } // print cache data in specified format - magellan.PrintRemoteAssets(scannedResults, listOutputFormat) + magellan.PrintRemoteAssets(scannedResults, strings.ToLower(listOutputFormat)) }, } @@ -111,18 +87,19 @@ var listDrivesCmd = &cobra.Command{ log.Error().Err(err).Msg("failed to get drives") os.Exit(1) } - magellan.PrintDrives(drives) + magellan.PrintDrives(drives, listOutputFormat) }, } func init() { - ListCmd.Flags().StringVarP(&listOutputFormat, "format", "F", "none", "Set the output format (list|json|yaml)") + ListCmd.Flags().StringVarP(&listOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") ListCmd.Flags().BoolVar(&showCacheInfo, "cache-info", false, "Alias for 'magellan cache info'") listDrivesCmd.Flags().StringVarP(&listUsername, "username", "u", "", "Set the username for BMC login") listDrivesCmd.Flags().StringVarP(&listPassword, "password", "p", "", "Set the password for BMC login") listDrivesCmd.Flags().BoolVarP(&insecure, "insecure", "i", false, "Skip TLS verification") listDrivesCmd.Flags().StringVarP(&secretsFile, "secrets-file", "f", "secrets.json", "Set the path to secrets store file to store credentials") + listDrivesCmd.Flags().StringVarP(&listOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") ListCmd.AddCommand(listDrivesCmd) rootCmd.AddCommand(ListCmd) From 5943854fc1500cd541d0cc3e4fa5908726b97c55 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:16:00 -0600 Subject: [PATCH 08/10] refactor: renamed funcs --- cmd/collect.go | 2 +- cmd/scan.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/collect.go b/cmd/collect.go index 4ee985a..f73de24 100644 --- a/cmd/collect.go +++ b/cmd/collect.go @@ -36,7 +36,7 @@ var CollectCmd = &cobra.Command{ Long: "Send request(s) to a collection of hosts running Redfish services found stored from the 'scan' in cache.\nSee the 'scan' command on how to perform a scan.", Run: func(cmd *cobra.Command, args []string) { // get probe states stored in db from scan - scannedResults, err := sqlite.GetScannedAssets(cachePath) + scannedResults, err := sqlite.GetRemoteAssets(cachePath) if err != nil { log.Error().Err(err).Msgf("failed to get scanned results from cache") } diff --git a/cmd/scan.go b/cmd/scan.go index a005dab..bfd5288 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -154,7 +154,7 @@ var ScanCmd = &cobra.Command{ // TODO: change this to use an extensible plugin system for storage solutions // (i.e. something like cache.InsertScannedAssets(path, assets) which implements a Cache interface) if len(foundAssets) > 0 { - err = sqlite.InsertScannedAssets(cachePath, foundAssets...) + err = sqlite.InsertRemoteAssets(cachePath, foundAssets...) if err != nil { log.Error().Err(err).Msg("failed to write scanned assets to cache") } From a7ae240bd32a269dae9fae2ddc9ec059750b0817 Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 14:16:20 -0600 Subject: [PATCH 09/10] refactor: updated PrintDrives() implementation --- pkg/list.go | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/pkg/list.go b/pkg/list.go index a72748d..43e1f23 100644 --- a/pkg/list.go +++ b/pkg/list.go @@ -72,14 +72,31 @@ func ListDrives(cc *crawler.CrawlerConfig) ([]*redfish.Drive, error) { return foundDrives, nil } -func PrintDrives(drives []*redfish.Drive) { - for i, drive := range drives { - fmt.Printf("Drive %d\n", i) - fmt.Printf("\tManufacturer: %s\n", drive.Manufacturer) - fmt.Printf("\tModel: %s\n", drive.Model) - fmt.Printf("\tSize: %d GiB\n", (drive.CapacityBytes / 1024 / 1024 / 1024)) - fmt.Printf("\tSerial number: %s\n", drive.SerialNumber) - fmt.Printf("\tPart number: %s\n", drive.PartNumber) - fmt.Printf("\tLocation: %s %d\n", drive.PhysicalLocation.PartLocation.LocationType, drive.PhysicalLocation.PartLocation.LocationOrdinalValue) +func PrintDrives(drives []*redfish.Drive, format string) { + switch format { + case "json": + util.PrintJSON(drives) + case "yaml": + util.PrintYAML(drives) + case "list": + for i, drive := range drives { + fmt.Printf(` +Drive %d +\tManufacturuer: %s +\tModel: %s +\tSize: %d GiB +\tSerial Number: %s +\tPart Number: %s +\tLocation: %s,%s +`, i, + drive.Manufacturer, + drive.Model, + (drive.CapacityBytes / 1024 / 1024 / 1024), + drive.SerialNumber, + drive.PartNumber, + drive.PhysicalLocation.PartLocation.LocationType, + drive.PhysicalLocation.PartLocation.LocationOrdinalValue, + ) + } } } From 2fdd65722dbba8bacf2a21fab54258497df73a2b Mon Sep 17 00:00:00 2001 From: David Allen Date: Fri, 20 Jun 2025 15:07:02 -0600 Subject: [PATCH 10/10] feat: add non-interactive cache editting --- cmd/cache.go | 87 ++++++++++++++++++++++++++++----- cmd/crawl.go | 2 +- internal/cache/sqlite/sqlite.go | 21 ++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/cmd/cache.go b/cmd/cache.go index ef04376..316be8d 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -20,11 +20,10 @@ import ( ) var ( + timestampf string timestamp time.Time cacheOutputFormat string interactive bool - withHosts []string - withPorts []int ) var cacheCmd = &cobra.Command{ @@ -127,7 +126,20 @@ var cacheEditCmd = &cobra.Command{ // edit two entries' time stamps magellan cache edit https://172.16.0.101 https://172.16.0.102 --timestamp 06/25/2025 `, - Args: cobra.ExactArgs(0), + Args: func(cmd *cobra.Command, args []string) error { + if interactive { + // must have no args if interactive + if err := cobra.ExactArgs(0)(cmd, args); err != nil { + return err + } + } else { + // must have at least one arg if not interactive + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + } + return nil + }, Short: "Edit existing cache data.", Run: func(cmd *cobra.Command, args []string) { var ( @@ -189,9 +201,62 @@ var cacheEditCmd = &cobra.Command{ } } else { // non-interactive editting - // for _, host := range args { + for _, host := range args { + // get the asset from cache for host + asset, err := sqlite.GetRemoteAsset(cachePath, host) + if err != nil { + log.Warn().Err(err). + Str("host", host). + Str("path", cachePath). + Msg("failed to get asset from cache") + continue + } + if asset == nil { + log.Warn().Err(err). + Str("host", host). + Str("path", cachePath). + Msg("found asset is not valid") + continue + } - // } + // only modify values that are set + if host != "" { + asset.Host = host + } + if protocol != "" { + asset.Protocol = protocol + } + if timestampf != "" { + newTimestamp, err := dateparse.ParseAny(timestampf) + if err != nil { + log.Error().Err(err).Msg("failed to parse timestamp value") + } else { + asset.Timestamp = newTimestamp + } + } + + // reinsert the asset into cache for each port + for _, port := range ports { + newAsset := *asset + newAsset.Port = port + err = sqlite.DeleteRemoteAssetsByHost(cachePath, host) + if err != nil { + log.Error().Err(err). + Str("host", host). + Str("path", cachePath). + Msg("failed to delete asset in cache") + continue + } + err = sqlite.InsertRemoteAssets(cachePath, newAsset) + if err != nil { + log.Error().Err(err). + Str("host", host). + Str("path", cachePath). + Msg("failed to re-insert asset into cache") + } + } + + } } }, } @@ -206,13 +271,11 @@ var cacheInfoCmd = &cobra.Command{ } func init() { - // remove row from cache - cacheRemoveCmd.Flags().StringSliceVar(&withHosts, "with-hosts", []string{}, "Remove all assets with specified hosts") - cacheRemoveCmd.Flags().IntSliceVar(&withPorts, "with-ports", []int{}, "Remove all assets with specified ports") - - cacheEditCmd.Flags().IntSliceVar(&ports, "port", nil, "Adds additional ports to scan for each host with unspecified ports.") - cacheEditCmd.Flags().StringVar(&scheme, "scheme", "https", "Set the default scheme to use if not specified in host URI. (default is 'https')") - cacheEditCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the default protocol to use in scan. (default is 'tcp')") + cacheEditCmd.Flags().StringVar(&host, "host", "", "Set the new host value.") + cacheEditCmd.Flags().IntSliceVar(&ports, "port", nil, "Set the new port values as comma-separated list.") + cacheEditCmd.Flags().StringVar(&scheme, "scheme", "https", "Set the new scheme value. (default is 'https')") + cacheEditCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the new protocol value. (default is 'tcp')") + cacheEditCmd.Flags().StringVar(×tampf, "timestamp", "", "Set the new timestamp value.") cacheEditCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Start an interactive TUI to edit cache data") cacheInfoCmd.Flags().StringVarP(&cacheOutputFormat, "format", "F", FORMAT_LIST, "Set the output format (list|json|yaml)") diff --git a/cmd/crawl.go b/cmd/crawl.go index 038e6be..4929978 100644 --- a/cmd/crawl.go +++ b/cmd/crawl.go @@ -30,7 +30,7 @@ var CrawlCmd = &cobra.Command{ Args: func(cmd *cobra.Command, args []string) error { // Validate that the only argument is a valid URI var err error - if err := cobra.ExactArgs(1)(cmd, args); err != nil { + if err = cobra.ExactArgs(1)(cmd, args); err != nil { return err } args[0], err = urlx.Sanitize(args[0]) diff --git a/internal/cache/sqlite/sqlite.go b/internal/cache/sqlite/sqlite.go index b2966a6..b7be903 100644 --- a/internal/cache/sqlite/sqlite.go +++ b/internal/cache/sqlite/sqlite.go @@ -147,3 +147,24 @@ func GetRemoteAssets(path string) ([]magellan.RemoteAsset, error) { } return results, nil } + +func GetRemoteAsset(path string, host string) (*magellan.RemoteAsset, error) { + // check if path exists first to prevent creating the database + _, exists := util.PathExists(path) + if !exists { + return nil, fmt.Errorf("no file found") + } + + // now check if the file is the SQLite database + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("failed to open database: %v", err) + } + + results := []magellan.RemoteAsset{} + err = db.Select(&results, fmt.Sprintf("SELECT * FROM %s ORDER BY host ASC, port ASC;", TABLE_NAME)) + if err != nil { + return nil, fmt.Errorf("failed to retrieve assets: %v", err) + } + return &results[0], nil +}