Merge pull request 'Add cache command with interactive editor' (#15) from cache-cmd into main

Reviewed-on: towk/magellan-ng#15
This commit is contained in:
David Allen 2025-06-20 15:09:39 -06:00
commit 193733a8e3
18 changed files with 884 additions and 194 deletions

View file

@ -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.
<!-- TOC start (generated with https://github.com/derlin/bitdowntoc) -->
- [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
<!-- TOC end -->
<!-- TOC --><a name="openchami-magellan"></a>
<!-- TOC --><a name="magellan-ng"></a>
## 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

View file

@ -2,95 +2,303 @@ 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"
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"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
withHosts []string
withPorts []int
timestampf string
timestamp time.Time
cacheOutputFormat string
interactive bool
)
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)
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] <timestamp>",
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{}
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 directly from positional args
for _, arg := range args {
},
}
var cacheEditCmd = &cobra.Command{
Use: "edit",
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: 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 (
port int
uri *url.URL
err error
columns []table.Column
rows []table.Row
styles table.Styles
)
uri, err = url.ParseRequestURI(arg)
if interactive {
// load the assets found from scan
scannedResults, err := sqlite.GetRemoteAssets(cachePath)
if err != nil {
log.Error().Err(err).Msg("failed to parse arg")
log.Error().Err(err).Str("path", cachePath).Msg("failed to get scanned assets from cache")
}
// 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)
// set columns to cache headers
columns = []table.Column{
{Title: "host", Width: 30},
{Title: "ports", Width: 8},
{Title: "protocol", Width: 10},
{Title: "timestamp", Width: 20},
}
// 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,
// 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()),
})
}
// 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")
// create a 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.NewModel(cachePath, &assetsTable)
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
sqlite.DeleteScannedAssets(cachePath, assets...)
} else {
// non-interactive editting
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")
}
}
}
}
},
}
var cacheInfoCmd = &cobra.Command{
Use: "info",
Short: "Show cache-related information and exit.",
Example: ` magellan cache info`,
Run: func(cmd *cobra.Command, args []string) {
printCacheInfo(cacheOutputFormat)
},
}
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")
cacheCmd.AddCommand(cacheRemoveCmd)
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(&timestampf, "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)")
cacheCmd.AddCommand(
cacheNewCmd,
cacheAddCmd,
cacheRemoveCmd,
cacheEditCmd,
cacheInfoCmd,
ListCmd, // magellan cache list (alias for 'magellan list')
)
rootCmd.AddCommand(cacheCmd)
}
func printCacheInfo(format string) {
assets, err := sqlite.GetRemoteAssets(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)
}

View file

@ -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")
}

View file

@ -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])

View file

@ -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"
)
@ -42,41 +39,18 @@ 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)
}
// 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))
},
}
@ -113,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)

View file

@ -61,6 +61,43 @@ var (
// a help message and then exits.
var rootCmd = &cobra.Command{
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) {

View file

@ -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")
}

2
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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)
}

176
internal/cache/edit.go vendored Normal file
View file

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

View file

@ -1 +0,0 @@
package cache

View file

@ -1,48 +0,0 @@
package cache
import (
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
type Model struct {
selected int
Table table.Model
}
func (m Model) Init() tea.Cmd { return nil }
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)
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.Table.Focused() {
m.Table.Blur()
} else {
m.Table.Focus()
}
case "q", "ctrl+c":
return m, tea.Quit
case "enter":
return m, tea.Batch(
tea.Printf("Selected host '%s'", m.Table.SelectedRow()[0]),
)
}
}
m.Table, cmd = m.Table.Update(msg)
return m, cmd
}
func (m Model) View() string {
return baseStyle.Render(m.Table.View()) + "\n"
}

View file

@ -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 {
@ -111,3 +147,24 @@ func GetScannedAssets(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
}

60
internal/cache/state.go vendored Normal file
View file

@ -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)
}

194
internal/cache/table.go vendored Normal file
View file

@ -0,0 +1,194 @@
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"))
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 {
CachePath string
Editor Editor
Table table.Model
// privates
inputs []textinput.Model
focusIndex int
statusMsg string
statusTimer time.Duration
}
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 }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// m.Table.SetWidth(msg.Width)
// m.Table.SetHeight(msg.Height)
case tea.KeyMsg:
// do always regardless of state
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
// 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)
}
}
return m, cmd
}
func (m Model) View() string {
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
}

View file

@ -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")
}
}

View file

@ -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 {
@ -87,14 +72,31 @@ func ListDrives(cc *crawler.CrawlerConfig) ([]*redfish.Drive, error) {
return foundDrives, nil
}
func PrintDrives(drives []*redfish.Drive) {
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\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)
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,
)
}
}
}