From 58033a110892ae2e39e94650b19804aa3a1fc455 Mon Sep 17 00:00:00 2001 From: Bryce Palmer Date: Mon, 15 Jan 2024 20:14:55 -0500 Subject: [PATCH] (testing): refactor + add initial set of unit tests (#48) * large refactor to prep for easier unit tests Signed-off-by: everettraven * add initial set of unit tests Signed-off-by: everettraven * fix linter error Signed-off-by: everettraven --------- Signed-off-by: everettraven --- .gitignore | 4 +- Makefile | 2 +- go.mod | 2 + internal/cli/root.go | 27 +++- pkg/charm/models/dashboard.go | 4 +- pkg/charm/models/dashboard_test.go | 36 +++++ pkg/charm/models/panels/item.go | 28 +++- pkg/charm/models/panels/item_test.go | 32 +++++ pkg/charm/models/panels/logs.go | 24 +++- pkg/charm/models/panels/logs_test.go | 89 ++++++++++++ pkg/charm/models/panels/table.go | 78 ++++++----- pkg/charm/models/panels/table_test.go | 105 ++++++++++++++ pkg/charm/models/tabber.go | 6 +- pkg/charm/models/tabber_test.go | 29 ++++ pkg/charm/styles/styles.go | 9 +- pkg/factories/datastream/informerfactory.go | 86 ++++++++++++ pkg/factories/datastream/item.go | 114 +++++++++++++++ pkg/factories/datastream/logs.go | 131 ++++++++++++++++++ pkg/factories/datastream/table.go | 75 ++++++++++ pkg/factories/panel/item.go | 33 +++++ pkg/factories/panel/logs.go | 27 ++++ pkg/factories/panel/panelfactory.go | 36 +++++ pkg/factories/panel/panelfactory_test.go | 103 ++++++++++++++ pkg/factories/panel/table.go | 27 ++++ pkg/paneler/item.go | 145 -------------------- pkg/paneler/logs.go | 136 ------------------ pkg/paneler/paneler.go | 60 -------- pkg/paneler/table.go | 115 ---------------- 28 files changed, 1051 insertions(+), 512 deletions(-) create mode 100644 pkg/charm/models/dashboard_test.go create mode 100644 pkg/charm/models/panels/item_test.go create mode 100644 pkg/charm/models/panels/logs_test.go create mode 100644 pkg/charm/models/panels/table_test.go create mode 100644 pkg/charm/models/tabber_test.go create mode 100644 pkg/factories/datastream/informerfactory.go create mode 100644 pkg/factories/datastream/item.go create mode 100644 pkg/factories/datastream/logs.go create mode 100644 pkg/factories/datastream/table.go create mode 100644 pkg/factories/panel/item.go create mode 100644 pkg/factories/panel/logs.go create mode 100644 pkg/factories/panel/panelfactory.go create mode 100644 pkg/factories/panel/panelfactory_test.go create mode 100644 pkg/factories/panel/table.go delete mode 100644 pkg/paneler/item.go delete mode 100644 pkg/paneler/logs.go delete mode 100644 pkg/paneler/paneler.go delete mode 100644 pkg/paneler/table.go diff --git a/.gitignore b/.gitignore index e0786aa..be2e872 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ buoy -dist/ \ No newline at end of file +dist/ + +cover.out \ No newline at end of file diff --git a/Makefile b/Makefile index afab973..5f73cc2 100644 --- a/Makefile +++ b/Makefile @@ -23,5 +23,5 @@ GOLANGCI_LINT_ARGS ?= lint: $(GOLANGCI_LINT) $(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) unit: - go test ./... + go test ./... -coverprofile=cover.out -covermode=atomic diff --git a/go.mod b/go.mod index c38d856..0388323 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.7.0 + github.com/stretchr/testify v1.8.2 github.com/tidwall/gjson v1.17.0 k8s.io/api v0.28.1 k8s.io/apimachinery v0.28.1 @@ -21,6 +22,7 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect ) diff --git a/internal/cli/root.go b/internal/cli/root.go index fb60820..4c1e1ee 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,7 +13,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/everettraven/buoy/pkg/charm/models" "github.com/everettraven/buoy/pkg/charm/styles" - "github.com/everettraven/buoy/pkg/paneler" + "github.com/everettraven/buoy/pkg/factories/datastream" + "github.com/everettraven/buoy/pkg/factories/panel" "github.com/everettraven/buoy/pkg/types" "github.com/spf13/cobra" "sigs.k8s.io/controller-runtime/pkg/client/config" @@ -38,6 +39,10 @@ func init() { rootCommand.Flags().String("theme", styles.DefaultThemePath, "path to theme file") } +type ErrorSetter interface { + SetError(err error) +} + func run(path string, themePath string) error { var raw []byte var ext string @@ -76,21 +81,35 @@ func run(path string, themePath string) error { log.Fatalf("loading theme: %s", err) } + p := panel.NewPanelFactory(theme) + cfg := config.GetConfigOrDie() - p, err := paneler.NewDefaultPaneler(cfg, theme) + df, err := datastream.NewDatastreamFactory(cfg) if err != nil { - log.Fatalf("configuring paneler: %s", err) + log.Fatalf("configuring datastream factory: %s", err) } panelModels := []tea.Model{} for _, panel := range dash.Panels { - mod, err := p.Model(panel) + mod, err := p.ModelForPanel(panel) if err != nil { log.Fatalf("getting model for panel %q: %s", panel.Name, err) } panelModels = append(panelModels, mod) } + for _, panel := range panelModels { + dataStream, err := df.DatastreamForModel(panel) + if err != nil { + if errSetter, ok := panel.(ErrorSetter); ok { + errSetter.SetError(err) + } else { + log.Fatalf("getting datastream for model: %s", err) + } + } + go dataStream.Run(make(<-chan struct{})) + } + m := models.NewDashboard(models.DefaultDashboardKeys, theme, panelModels...) if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { fmt.Println("Error running program:", err) diff --git a/pkg/charm/models/dashboard.go b/pkg/charm/models/dashboard.go index 56df035..a2ecfc7 100644 --- a/pkg/charm/models/dashboard.go +++ b/pkg/charm/models/dashboard.go @@ -53,10 +53,10 @@ type Dashboard struct { width int help help.Model keys DashboardKeyMap - theme *styles.Theme + theme styles.Theme } -func NewDashboard(keys DashboardKeyMap, theme *styles.Theme, panels ...tea.Model) *Dashboard { +func NewDashboard(keys DashboardKeyMap, theme styles.Theme, panels ...tea.Model) *Dashboard { tabs := []Tab{} for _, panel := range panels { if namer, ok := panel.(Namer); ok { diff --git a/pkg/charm/models/dashboard_test.go b/pkg/charm/models/dashboard_test.go new file mode 100644 index 0000000..1e04f74 --- /dev/null +++ b/pkg/charm/models/dashboard_test.go @@ -0,0 +1,36 @@ +package models + +import ( + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestDashboardUpdate(t *testing.T) { + panels := []tea.Model{ + panels.NewItem(types.Item{ + PanelBase: types.PanelBase{ + Name: "test", + }, + }, viewport.New(10, 10), styles.Theme{}), + } + + d := NewDashboard(DefaultDashboardKeys, styles.Theme{}, panels...) + + t.Log("WindowSizeUpdate") + d.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, d.width) + + t.Log("toggle detailed help") + d.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ctrl+h")}) + assert.True(t, d.help.ShowAll) + + t.Log("quit the program") + _, cmd := d.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + assert.Equal(t, cmd(), tea.Quit()) +} diff --git a/pkg/charm/models/panels/item.go b/pkg/charm/models/panels/item.go index 215dc52..b253b52 100644 --- a/pkg/charm/models/panels/item.go +++ b/pkg/charm/models/panels/item.go @@ -5,21 +5,26 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" ) // Item is a tea.Model implementation // that represents an item panel type Item struct { viewport viewport.Model - name string mutex *sync.Mutex + item types.Item + theme styles.Theme + err error } -func NewItem(name string, viewport viewport.Model) *Item { +func NewItem(item types.Item, viewport viewport.Model, theme styles.Theme) *Item { return &Item{ viewport: viewport, - name: name, mutex: &sync.Mutex{}, + item: item, + theme: theme, } } @@ -37,6 +42,9 @@ func (m *Item) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Item) View() string { + if m.err != nil { + return m.err.Error() + } return m.viewport.View() } @@ -47,5 +55,17 @@ func (m *Item) SetContent(content string) { } func (m *Item) Name() string { - return m.name + return m.item.Name +} + +func (m *Item) ItemDefinition() types.Item { + return m.item +} + +func (m *Item) Theme() styles.Theme { + return m.theme +} + +func (m *Item) SetError(err error) { + m.err = err } diff --git a/pkg/charm/models/panels/item_test.go b/pkg/charm/models/panels/item_test.go new file mode 100644 index 0000000..dad0c2c --- /dev/null +++ b/pkg/charm/models/panels/item_test.go @@ -0,0 +1,32 @@ +package panels + +import ( + "errors" + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestItemUpdate(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(10, 10), styles.Theme{}) + item.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, item.viewport.Width) + assert.Equal(t, 25, item.viewport.Height) +} + +func TestItemViewWithError(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(10, 10), styles.Theme{}) + err := errors.New("some error") + item.SetError(err) + assert.Equal(t, err.Error(), item.View()) +} + +func TestViewWithContent(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(50, 50), styles.Theme{}) + item.SetContent("some content") + assert.Contains(t, item.View(), "some content") +} diff --git a/pkg/charm/models/panels/logs.go b/pkg/charm/models/panels/logs.go index 6020885..bb396ce 100644 --- a/pkg/charm/models/panels/logs.go +++ b/pkg/charm/models/panels/logs.go @@ -12,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" "github.com/muesli/reflow/wrap" "github.com/sahilm/fuzzy" ) @@ -65,17 +66,18 @@ const modeSearched = "searched" type Logs struct { viewport viewport.Model searchbar textinput.Model - name string mutex *sync.Mutex content string contentUpdated bool mode string keys LogsKeyMap strictSearch bool - theme *styles.Theme + theme styles.Theme + log *types.Logs + err error } -func NewLogs(keys LogsKeyMap, name string, theme *styles.Theme) *Logs { +func NewLogs(keys LogsKeyMap, log *types.Logs, theme styles.Theme) *Logs { searchbar := textinput.New() searchbar.Prompt = "> " searchbar.Placeholder = "search term" @@ -83,7 +85,7 @@ func NewLogs(keys LogsKeyMap, name string, theme *styles.Theme) *Logs { return &Logs{ viewport: vp, searchbar: searchbar, - name: name, + log: log, mutex: &sync.Mutex{}, content: "", mode: modeLogs, @@ -149,6 +151,10 @@ func (m *Logs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Logs) View() string { + if m.err != nil { + return m.err.Error() + } + searchMode := "fuzzy" if m.strictSearch { searchMode = "strict" @@ -184,7 +190,15 @@ func (m *Logs) AddContent(content string) { } func (m *Logs) Name() string { - return m.name + return m.log.Name +} + +func (m *Logs) LogDefinition() *types.Logs { + return m.log +} + +func (m *Logs) SetError(err error) { + m.err = err } // searchLogs searches the logs for the term in the searchbar diff --git a/pkg/charm/models/panels/logs_test.go b/pkg/charm/models/panels/logs_test.go new file mode 100644 index 0000000..721f221 --- /dev/null +++ b/pkg/charm/models/panels/logs_test.go @@ -0,0 +1,89 @@ +package panels + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/stretchr/testify/assert" +) + +func TestEnterSearchMode(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + assert.Equal(t, logs.mode, modeSearching) +} + +func TestExecuteSearch(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + logs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, logs.mode, modeSearched) +} + +func TestExitSearchMode(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.mode = modeSearching + logs.searchbar.Focus() + logs.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, logs.mode, modeLogs) + assert.False(t, logs.searchbar.Focused()) + + logs.mode = modeSearched + logs.searchbar.Focus() + logs.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, logs.mode, modeLogs) + assert.False(t, logs.searchbar.Focused()) +} + +func TestSearchModeToggle(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + logs.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.True(t, logs.strictSearch) + logs.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.False(t, logs.strictSearch) +} + +func TestSearchLogs(t *testing.T) { + t.Log("strict search") + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.strictSearch = true + logs.content = "some log line\nlog line with a search term\n" + logs.viewport.Width = 50 + logs.searchbar.SetValue("search") + match := logs.searchLogs() + assert.Equal(t, "log line with a search term\n", match) + + t.Log("fuzzy search") + logs.searchbar.SetValue("sll") + logs.strictSearch = false + match = logs.searchLogs() + assert.Equal(t, "some log line\n", match) +} + +func TestLogsWindowSizeUpdate(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.WindowSizeMsg{Width: 100, Height: 100}) + assert.Equal(t, logs.viewport.Width, 100) + assert.Equal(t, logs.viewport.Height, 50) +} + +func TestLogsAddContent(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.AddContent("some log line\n") + assert.Equal(t, "\nsome log line\n", logs.content) + assert.True(t, logs.contentUpdated) + + logs.Update(tea.KeyMsg{Type: tea.KeyDown}) + assert.False(t, logs.contentUpdated) +} + +func TestLogsViewWithError(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + err := errors.New("some error") + logs.SetError(err) + assert.Equal(t, err, logs.err) + assert.Equal(t, err.Error(), logs.View()) +} diff --git a/pkg/charm/models/panels/table.go b/pkg/charm/models/panels/table.go index 0104d8f..f0e7163 100644 --- a/pkg/charm/models/panels/table.go +++ b/pkg/charm/models/panels/table.go @@ -62,22 +62,22 @@ type RowInfo struct { // Table is a tea.Model implementation // that represents a table panel type Table struct { - table tbl.Model - lister cache.GenericLister - scope meta.RESTScopeName - viewport viewport.Model - mode string - name string - mutex *sync.Mutex - rows map[types.UID]*RowInfo - columns []buoytypes.Column - err error - tempRows []tbl.Row - keys TableKeyMap - theme *styles.Theme + tableModel tbl.Model + lister cache.GenericLister + scope meta.RESTScopeName + viewport viewport.Model + mode string + mutex *sync.Mutex + rows map[types.UID]*RowInfo + columns []buoytypes.Column + err error + tempRows []tbl.Row + keys TableKeyMap + theme styles.Theme + table *buoytypes.Table } -func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericLister, scope meta.RESTScopeName, theme *styles.Theme) *Table { +func NewTable(keys TableKeyMap, table *buoytypes.Table, theme styles.Theme) *Table { tblColumns := []string{} width := 0 for _, column := range table.Columns { @@ -89,17 +89,15 @@ func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericList tab.Styles.SelectedRow = theme.TableSelectedRowStyle() return &Table{ - table: tab, - viewport: viewport.New(0, 0), - scope: scope, - lister: lister, - mode: modeTable, - name: table.Name, - mutex: &sync.Mutex{}, - rows: map[types.UID]*RowInfo{}, - columns: table.Columns, - keys: keys, - theme: theme, + tableModel: tab, + viewport: viewport.New(0, 0), + mode: modeTable, + mutex: &sync.Mutex{}, + rows: map[types.UID]*RowInfo{}, + columns: table.Columns, + keys: keys, + theme: theme, + table: table, } } @@ -111,7 +109,7 @@ func (m *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - m.table.SetSize(msg.Width, msg.Height/2) + m.tableModel.SetSize(msg.Width, msg.Height/2) m.viewport.Width = msg.Width m.viewport.Height = msg.Height / 2 case tea.KeyMsg: @@ -120,7 +118,7 @@ func (m *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.mode { case modeTable: m.mode = modeView - vpContent, err := m.FetchContentForIndex(m.table.Cursor()) + vpContent, err := m.FetchContentForIndex(m.tableModel.Cursor()) if err != nil { m.viewport.SetContent(err.Error()) } else { @@ -134,13 +132,13 @@ func (m *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if len(m.tempRows) > 0 { - m.table.SetRows(m.tempRows) + m.tableModel.SetRows(m.tempRows) m.tempRows = []tbl.Row{} } switch m.mode { case modeTable: - m.table, cmd = m.table.Update(msg) + m.tableModel, cmd = m.tableModel.Update(msg) case modeView: m.viewport, cmd = m.viewport.Update(msg) } @@ -153,11 +151,11 @@ func (m *Table) View() string { } switch m.mode { case modeTable: - return m.table.View() + return m.tableModel.View() case modeView: return m.viewport.View() default: - return "?" + return fmt.Sprintf("unknown table state. table.mode=%q", m.mode) } } @@ -206,7 +204,19 @@ func (m *Table) Columns() []buoytypes.Column { } func (m *Table) Name() string { - return m.name + return m.table.Name +} + +func (m *Table) TableDefinition() *buoytypes.Table { + return m.table +} + +func (m *Table) SetLister(lister cache.GenericLister) { + m.lister = lister +} + +func (m *Table) SetScope(scope meta.RESTScopeName) { + m.scope = scope } func (m *Table) SetError(err error) { @@ -224,6 +234,10 @@ func (m *Table) FetchContentForIndex(index int) (string, error) { } } + if rowInfo == nil { + return "", fmt.Errorf("no row data found for selected row %d", index) + } + name := rowInfo.Identifier.String() if m.scope == meta.RESTScopeNameRoot { name = rowInfo.Identifier.Name diff --git a/pkg/charm/models/panels/table_test.go b/pkg/charm/models/panels/table_test.go new file mode 100644 index 0000000..74d27dd --- /dev/null +++ b/pkg/charm/models/panels/table_test.go @@ -0,0 +1,105 @@ +package panels + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + buoytypes "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +func TestTableUpdate(t *testing.T) { + t.Log("WindowSizeUpdate") + table := NewTable(DefaultTableKeys, &buoytypes.Table{}, styles.Theme{}) + table.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, table.viewport.Width) + assert.Equal(t, 25, table.viewport.Height) + + t.Log("toggle view mode on") + table.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) + assert.Equal(t, modeView, table.mode) + + t.Log("toggle view mode off") + table.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) + assert.Equal(t, modeTable, table.mode) +} + +func TestAddOrUpdate(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{ + Columns: []buoytypes.Column{ + {Header: "Name", Width: 10, Path: "metadata.name"}, + }, + }, styles.Theme{}) + + t.Log("add a row") + u := &unstructured.Unstructured{} + u.SetName("test") + u.SetNamespace("test-ns") + u.SetUID(types.UID("test")) + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + assert.NotNil(t, table.rows[types.UID("test")].Row) + assert.Equal(t, &types.NamespacedName{Namespace: "test-ns", Name: "test"}, table.rows[types.UID("test")].Identifier) + + t.Log("update a row") + u.SetName("test2") + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + assert.NotNil(t, table.rows[types.UID("test")].Row) + assert.Equal(t, &types.NamespacedName{Namespace: "test-ns", Name: "test2"}, table.rows[types.UID("test")].Identifier) +} + +func TestDeleteRow(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{ + Columns: []buoytypes.Column{ + {Header: "Name", Width: 10, Path: "metadata.name"}, + }, + }, styles.Theme{}) + + t.Log("add a row") + u := &unstructured.Unstructured{} + u.SetName("test") + u.SetNamespace("test-ns") + u.SetUID(types.UID("test")) + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + + t.Log("delete a row") + table.DeleteRow(types.UID("test")) + assert.Len(t, table.rows, 0) +} + +func TestTableView(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{}, styles.Theme{}) + + t.Log("view with error state") + err := errors.New("some error") + table.SetError(err) + assert.Equal(t, err.Error(), table.View()) + + t.Log("view with table mode") + table.SetError(nil) + table.mode = modeTable + assert.Equal(t, table.tableModel.View(), table.View()) + + t.Log("view with view mode toggled on") + table.mode = modeView + table.viewport.SetContent("floof") + assert.Equal(t, table.viewport.View(), table.View()) +} + +func TestGetDotNotationValueNonExistentValue(t *testing.T) { + obj := map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + } + + val, err := getDotNotationValue(obj, "foo.baz") + assert.NoError(t, err) + assert.Equal(t, "n/a", val) +} diff --git a/pkg/charm/models/tabber.go b/pkg/charm/models/tabber.go index cab40f1..0d3311d 100644 --- a/pkg/charm/models/tabber.go +++ b/pkg/charm/models/tabber.go @@ -24,10 +24,10 @@ type Tabber struct { selected int keyMap TabberKeyMap width int - theme *styles.Theme + theme styles.Theme } -func NewTabber(keyMap TabberKeyMap, theme *styles.Theme, tabs ...Tab) *Tabber { +func NewTabber(keyMap TabberKeyMap, theme styles.Theme, tabs ...Tab) *Tabber { return &Tabber{ tabs: tabs, keyMap: keyMap, @@ -144,7 +144,7 @@ type pager struct { pages []page tabRightArrow string tabLeftArrow string - theme *styles.Theme + theme styles.Theme } func (p *pager) renderForSelectedTab(selected int) string { diff --git a/pkg/charm/models/tabber_test.go b/pkg/charm/models/tabber_test.go new file mode 100644 index 0000000..2b0f01e --- /dev/null +++ b/pkg/charm/models/tabber_test.go @@ -0,0 +1,29 @@ +package models + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/stretchr/testify/assert" +) + +func TestTabberUpdate(t *testing.T) { + tabber := NewTabber(DefaultTabberKeys, styles.Theme{}, Tab{Name: "test", Model: nil}, Tab{Name: "test2", Model: nil}) + + t.Log("navigate to next tab") + tabber.Update(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, 1, tabber.selected) + + t.Log("navigate to previous tab") + tabber.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+tab")}) + assert.Equal(t, 0, tabber.selected) + + t.Log("navigate to previous tab (out of bounds -> last tab)") + tabber.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+tab")}) + assert.Equal(t, 1, tabber.selected) + + t.Log("navigate to next tab (out of bounds -> first tab)") + tabber.Update(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, 0, tabber.selected) +} diff --git a/pkg/charm/styles/styles.go b/pkg/charm/styles/styles.go index 56b72c8..cf98d49 100644 --- a/pkg/charm/styles/styles.go +++ b/pkg/charm/styles/styles.go @@ -31,8 +31,8 @@ const DefaultThemePath = "~/.config/buoy/themes/default.json" var DefaultColor = lipgloss.AdaptiveColor{Light: "63", Dark: "117"} -func LoadTheme(themePath string) (*Theme, error) { - t := &Theme{ +func LoadTheme(themePath string) (Theme, error) { + t := Theme{ TabColor: DefaultColor, SelectedRowHighlightColor: DefaultColor, LogSearchHighlightColor: DefaultColor, @@ -49,12 +49,13 @@ func LoadTheme(themePath string) (*Theme, error) { return t, fmt.Errorf("reading theme file: %w", err) } - err = json.Unmarshal(raw, t) + customTheme := &Theme{} + err = json.Unmarshal(raw, customTheme) if err != nil { return t, fmt.Errorf("unmarshalling theme: %w", err) } - return t, nil + return *customTheme, nil } func (t *Theme) SelectedTabStyle() lipgloss.Style { diff --git a/pkg/factories/datastream/informerfactory.go b/pkg/factories/datastream/informerfactory.go new file mode 100644 index 0000000..590e9bb --- /dev/null +++ b/pkg/factories/datastream/informerfactory.go @@ -0,0 +1,86 @@ +package datastream + +import ( + "errors" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" +) + +type Datastream interface { + Run(stopCh <-chan struct{}) +} + +type DatastreamFactory interface { + DatastreamForModel(tea.Model) (Datastream, error) +} + +type InvalidPanelType struct { + error +} + +type DatastreamFactoryFunc func(tea.Model) (Datastream, error) + +type datastreamFactory struct { + // informerFactoryFuncs is a list of functions that return informers + // for a given model. The first informer returned is used. Returning an error + // of type InvalidPanelType will cause the next function to be called. If any other + // error is returned, the error is returned to the caller. + datastreamFactoryFuncs []DatastreamFactoryFunc +} + +var _ DatastreamFactory = &datastreamFactory{} + +func (i *datastreamFactory) DatastreamForModel(model tea.Model) (Datastream, error) { + invalidErr := &InvalidPanelType{} + var stream Datastream + for _, f := range i.datastreamFactoryFuncs { + s, err := f(model) + if err != nil { + if errors.As(err, &invalidErr) { + continue + } + return nil, err + } + stream = s + break + } + + return stream, nil +} + +func NewDatastreamFactory(cfg *rest.Config) (DatastreamFactory, error) { + dClient, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("error creating dynamic client: %w", err) + } + + kubeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("creating kubernetes.Clientset: %w", err) + } + + di, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("error creating discovery client: %w", err) + } + + gr, err := restmapper.GetAPIGroupResources(di) + if err != nil { + return nil, fmt.Errorf("error getting API group resources: %w", err) + } + rm := restmapper.NewDiscoveryRESTMapper(gr) + + return &datastreamFactory{ + datastreamFactoryFuncs: []DatastreamFactoryFunc{ + ItemDatastreamFunc(dClient, rm), + TableDatastreamFunc(dClient, rm), + LogsDatastreamFunc(kubeClient, dClient, rm), + }, + }, nil +} diff --git a/pkg/factories/datastream/item.go b/pkg/factories/datastream/item.go new file mode 100644 index 0000000..759c926 --- /dev/null +++ b/pkg/factories/datastream/item.go @@ -0,0 +1,114 @@ +package datastream + +import ( + "bytes" + "fmt" + "io" + "time" + + "github.com/alecthomas/chroma/quick" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/yaml" +) + +func ItemDatastreamFunc(dynamicClient *dynamic.DynamicClient, restMapper meta.RESTMapper) DatastreamFactoryFunc { + return func(m tea.Model) (Datastream, error) { + if _, ok := m.(*panels.Item); !ok { + return nil, &InvalidPanelType{fmt.Errorf("model is not of type *panels.Item")} + } + panel := m.(*panels.Item) + item := panel.ItemDefinition() + theme := panel.Theme().SyntaxHighlightDarkTheme + if !lipgloss.HasDarkBackground() { + theme = panel.Theme().SyntaxHighlightLightTheme + } + // create informer and event handler + infFact := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicClient, 1*time.Minute, item.Key.Namespace, func(lo *v1.ListOptions) { + lo.FieldSelector = fmt.Sprintf("metadata.name=%s", item.Key.Name) + }) + gvk := schema.GroupVersionKind{ + Group: item.Group, + Version: item.Version, + Kind: item.Kind, + } + mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("error creating resource mapping: %w", err) + } + + inf := infFact.ForResource(mapping.Resource) + _, err = inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + u := obj.(*unstructured.Unstructured) + itemJSON, err := u.MarshalJSON() + if err != nil { + panel.SetContent(fmt.Sprintf("error marshalling item %q", item.Key.String())) + return + } + + itemYAML, err := yaml.JSONToYAML(itemJSON) + if err != nil { + panel.SetContent(fmt.Sprintf("converting JSON to YAML for item %q", item.Key.String())) + return + } + rw := &bytes.Buffer{} + err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme) + if err != nil { + panel.SetContent(fmt.Sprintf("highlighting YAML for item %q", item.Key.String())) + return + } + highlighted, err := io.ReadAll(rw) + if err != nil { + panel.SetContent(fmt.Sprintf("reading highlighted YAML for item %q", item.Key.String())) + return + } + panel.SetContent(string(highlighted)) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + u := newObj.(*unstructured.Unstructured) + itemJSON, err := u.MarshalJSON() + if err != nil { + panel.SetContent(fmt.Sprintf("error marshalling item %q", item.Key.String())) + return + } + + itemYAML, err := yaml.JSONToYAML(itemJSON) + if err != nil { + panel.SetContent(fmt.Sprintf("converting JSON to YAML for item %q", item.Key.String())) + return + } + rw := &bytes.Buffer{} + err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme) + if err != nil { + panel.SetContent(fmt.Sprintf("highlighting YAML for item %q", item.Key.String())) + return + } + highlighted, err := io.ReadAll(rw) + if err != nil { + panel.SetContent(fmt.Sprintf("reading highlighted YAML for item %q", item.Key.String())) + return + } + panel.SetContent(string(highlighted)) + }, + DeleteFunc: func(obj interface{}) { + panel.SetContent("") + }, + }) + + if err != nil { + return nil, fmt.Errorf("adding event handler to informer: %w", err) + } + + return inf.Informer(), nil + } + +} diff --git a/pkg/factories/datastream/logs.go b/pkg/factories/datastream/logs.go new file mode 100644 index 0000000..16def0e --- /dev/null +++ b/pkg/factories/datastream/logs.go @@ -0,0 +1,131 @@ +package datastream + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +var _ Datastream = &logDatastream{} + +type logDatastream struct { + logReadCloser io.ReadCloser + logPanel *panels.Logs +} + +func (l *logDatastream) Run(stopCh <-chan struct{}) { + go streamLogs(l.logReadCloser, l.logPanel) +} + +func LogsDatastreamFunc(typedClient *kubernetes.Clientset, dynamicClient *dynamic.DynamicClient, restMapper meta.RESTMapper) DatastreamFactoryFunc { + return func(m tea.Model) (Datastream, error) { + if _, ok := m.(*panels.Logs); !ok { + return nil, &InvalidPanelType{fmt.Errorf("model is not of type *panels.Logs")} + } + logs := m.(*panels.Logs) + logsPanel := logs.LogDefinition() + gvk := schema.GroupVersionKind{ + Group: logsPanel.Group, + Version: logsPanel.Version, + Kind: logsPanel.Kind, + } + + if gvk == v1.SchemeGroupVersion.WithKind("Pod") { + pod, err := typedClient.CoreV1().Pods(logsPanel.Key.Namespace).Get(context.Background(), logsPanel.Key.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("error getting pod: %w", err) + } + rc, err := logsForPod(typedClient, pod, logsPanel.Container) + if err != nil { + return nil, fmt.Errorf("error getting logs for pod: %w", err) + } + return &logDatastream{ + logReadCloser: rc, + logPanel: logs, + }, nil + } + + mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("error creating resource mapping: %w", err) + } + u, err := dynamicClient.Resource(mapping.Resource).Namespace(logsPanel.Key.Namespace).Get(context.Background(), logsPanel.Key.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("error getting object: %w", err) + } + + selector, err := getPodSelectorForUnstructured(u) + if err != nil { + return nil, fmt.Errorf("error getting pod selector for object: %w", err) + } + pods, err := typedClient.CoreV1().Pods(u.GetNamespace()).List(context.Background(), metav1.ListOptions{LabelSelector: selector.String()}) + if err != nil { + return nil, fmt.Errorf("error getting pods for object: %w", err) + } + if len(pods.Items) == 0 { + return nil, fmt.Errorf("no pods found for object") + } + pod := &pods.Items[0] + rc, err := logsForPod(typedClient, pod, logsPanel.Container) + if err != nil { + return nil, fmt.Errorf("error getting logs for pod: %w", err) + } + return &logDatastream{ + logReadCloser: rc, + logPanel: logs, + }, nil + } +} + +func getPodSelectorForUnstructured(u *unstructured.Unstructured) (labels.Selector, error) { + selector, found, err := unstructured.NestedFieldCopy(u.Object, "spec", "selector") + if !found { + return nil, fmt.Errorf("no pod label selector found in object spec: %s", u.Object) + } + if err != nil { + return nil, fmt.Errorf("error getting pod label selector from object spec: %w", err) + } + sel := &metav1.LabelSelector{} + bytes, err := json.Marshal(selector) + if err != nil { + return nil, fmt.Errorf("error marshalling selector: %w", err) + } + err = json.Unmarshal(bytes, sel) + if err != nil { + return nil, fmt.Errorf("error unmarshalling selector: %w", err) + } + return metav1.LabelSelectorAsSelector(sel) +} + +func streamLogs(rc io.ReadCloser, logPanel *panels.Logs) { + scanner := bufio.NewScanner(rc) + for scanner.Scan() { + logPanel.AddContent(scanner.Text()) + } +} + +func logsForPod(kc *kubernetes.Clientset, pod *v1.Pod, container string) (io.ReadCloser, error) { + req := kc.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &v1.PodLogOptions{ + Container: container, + Follow: true, + }) + + rc, err := req.Stream(context.Background()) + if err != nil { + return nil, fmt.Errorf("fetching logs for %s/%s: %w", pod.Namespace, pod.Name, err) + } + return rc, nil +} diff --git a/pkg/factories/datastream/table.go b/pkg/factories/datastream/table.go new file mode 100644 index 0000000..9bbf599 --- /dev/null +++ b/pkg/factories/datastream/table.go @@ -0,0 +1,75 @@ +package datastream + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" +) + +func TableDatastreamFunc(dynamicClient *dynamic.DynamicClient, restMapper meta.RESTMapper) DatastreamFactoryFunc { + return func(m tea.Model) (Datastream, error) { + if _, ok := m.(*panels.Table); !ok { + return nil, &InvalidPanelType{fmt.Errorf("model is not of type *panels.Table")} + } + table := m.(*panels.Table) + tableDef := table.TableDefinition() + // create informer and event handler + gvk := schema.GroupVersionKind{ + Group: tableDef.Group, + Version: tableDef.Version, + Kind: tableDef.Kind, + } + mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("error creating resource mapping: %w", err) + } + + ns := tableDef.Namespace + if mapping.Scope.Name() == meta.RESTScopeNameRoot { + ns = "" + } + infFact := dynamicinformer.NewFilteredDynamicSharedInformerFactory( + dynamicClient, + 1*time.Minute, + ns, + dynamicinformer.TweakListOptionsFunc(func(options *v1.ListOptions) { + ls := labels.SelectorFromSet(tableDef.LabelSelector) + options.LabelSelector = ls.String() + }), + ) + + inf := infFact.ForResource(mapping.Resource) + _, err = inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + u := obj.(*unstructured.Unstructured) + table.AddOrUpdate(u) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + u := newObj.(*unstructured.Unstructured) + table.AddOrUpdate(u) + }, + DeleteFunc: func(obj interface{}) { + u := obj.(*unstructured.Unstructured) + table.DeleteRow(u.GetUID()) + }, + }) + + if err != nil { + return nil, err + } + + table.SetLister(inf.Lister()) + table.SetScope(mapping.Scope.Name()) + return inf.Informer(), nil + } +} diff --git a/pkg/factories/panel/item.go b/pkg/factories/panel/item.go new file mode 100644 index 0000000..d8da189 --- /dev/null +++ b/pkg/factories/panel/item.go @@ -0,0 +1,33 @@ +package panel + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" +) + +var _ PanelFactory = &Item{} + +type Item struct { + theme styles.Theme +} + +func (t *Item) ModelForPanel(panel types.Panel) (tea.Model, error) { + item := types.Item{} + err := json.Unmarshal(panel.Blob, &item) + if err != nil { + return nil, fmt.Errorf("unmarshalling panel to item type: %s", err) + } + iw := t.modelWrapperForItemPanel(item) + return iw, nil +} + +func (t *Item) modelWrapperForItemPanel(itemPanel types.Item) *panels.Item { + vp := viewport.New(100, 20) + return panels.NewItem(itemPanel, vp, t.theme) +} diff --git a/pkg/factories/panel/logs.go b/pkg/factories/panel/logs.go new file mode 100644 index 0000000..d2a4bb1 --- /dev/null +++ b/pkg/factories/panel/logs.go @@ -0,0 +1,27 @@ +package panel + +import ( + "encoding/json" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" +) + +var _ PanelFactory = &Log{} + +type Log struct { + theme styles.Theme +} + +func (t *Log) ModelForPanel(panel types.Panel) (tea.Model, error) { + log := &types.Logs{} + err := json.Unmarshal(panel.Blob, log) + if err != nil { + return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) + } + logPanel := panels.NewLogs(panels.DefaultLogsKeys, log, t.theme) + return logPanel, nil +} diff --git a/pkg/factories/panel/panelfactory.go b/pkg/factories/panel/panelfactory.go new file mode 100644 index 0000000..b67738a --- /dev/null +++ b/pkg/factories/panel/panelfactory.go @@ -0,0 +1,36 @@ +package panel + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" +) + +type PanelFactory interface { + ModelForPanel(types.Panel) (tea.Model, error) +} + +type paneler struct { + panelerRegistry map[string]PanelFactory +} + +var _ PanelFactory = &paneler{} + +func (p *paneler) ModelForPanel(panel types.Panel) (tea.Model, error) { + if p, ok := p.panelerRegistry[panel.Type]; ok { + return p.ModelForPanel(panel) + } + return nil, fmt.Errorf("panel %q has unknown panel type: %q", panel.Name, panel.Type) +} + +func NewPanelFactory(theme styles.Theme) PanelFactory { + return &paneler{ + panelerRegistry: map[string]PanelFactory{ + types.PanelTypeTable: &Table{theme: theme}, + types.PanelTypeItem: &Item{theme: theme}, + types.PanelTypeLogs: &Log{theme: theme}, + }, + } +} diff --git a/pkg/factories/panel/panelfactory_test.go b/pkg/factories/panel/panelfactory_test.go new file mode 100644 index 0000000..923699d --- /dev/null +++ b/pkg/factories/panel/panelfactory_test.go @@ -0,0 +1,103 @@ +package panel + +import ( + "testing" + + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestUnknownPanelType(t *testing.T) { + panelFactory := NewPanelFactory(styles.Theme{}) + _, err := panelFactory.ModelForPanel(types.Panel{ + PanelBase: types.PanelBase{ + Name: "test", + Type: "unknown", + }, + }) + assert.Error(t, err) +} + +func TestTablePanel(t *testing.T) { + panelJSON := `{ + "name": "Deployments", + "group": "apps", + "version": "v1", + "kind": "Deployment", + "type": "table", + "columns": [ + { + "header": "Namespace", + "path": "metadata.namespace" + }, + { + "header": "Name", + "path": "metadata.name" + }, + { + "header": "Replicas", + "path": "status.replicas" + } + ] + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + tbl, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, tbl) + assert.IsType(t, &panels.Table{}, tbl) +} + +func TestItemPanel(t *testing.T) { + panelJSON := `{ + "name": "Kube API Server", + "group": "", + "version": "v1", + "kind": "Pod", + "type": "item", + "key": { + "namespace": "kube-system", + "name": "kube-apiserver-kind-control-plane" + } + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + item, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, item) + assert.IsType(t, &panels.Item{}, item) +} + +func TestLogPanel(t *testing.T) { + panelJSON := `{ + "name": "Kube API Server Logs", + "group": "", + "version": "v1", + "kind": "Pod", + "type": "logs", + "key": { + "namespace": "kube-system", + "name": "kube-apiserver-kind-control-plane" + } + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + log, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, log) + assert.IsType(t, &panels.Logs{}, log) +} diff --git a/pkg/factories/panel/table.go b/pkg/factories/panel/table.go new file mode 100644 index 0000000..d955072 --- /dev/null +++ b/pkg/factories/panel/table.go @@ -0,0 +1,27 @@ +package panel + +import ( + "encoding/json" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + buoytypes "github.com/everettraven/buoy/pkg/types" +) + +var _ PanelFactory = &Table{} + +type Table struct { + theme styles.Theme +} + +func (t *Table) ModelForPanel(panel buoytypes.Panel) (tea.Model, error) { + tab := &buoytypes.Table{} + err := json.Unmarshal(panel.Blob, tab) + if err != nil { + return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) + } + table := panels.NewTable(panels.DefaultTableKeys, tab, t.theme) + return table, nil +} diff --git a/pkg/paneler/item.go b/pkg/paneler/item.go deleted file mode 100644 index b38db7f..0000000 --- a/pkg/paneler/item.go +++ /dev/null @@ -1,145 +0,0 @@ -package paneler - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "time" - - "github.com/alecthomas/chroma/quick" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/everettraven/buoy/pkg/charm/models/panels" - "github.com/everettraven/buoy/pkg/charm/styles" - "github.com/everettraven/buoy/pkg/types" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/dynamic/dynamicinformer" - "k8s.io/client-go/tools/cache" - "sigs.k8s.io/yaml" -) - -var _ Paneler = &Item{} - -type Item struct { - dynamicClient dynamic.Interface - discoveryClient *discovery.DiscoveryClient - restMapper meta.RESTMapper - theme *styles.Theme -} - -func NewItem(dynamicClient dynamic.Interface, discoveryClient *discovery.DiscoveryClient, restMapper meta.RESTMapper, theme *styles.Theme) *Item { - return &Item{ - dynamicClient: dynamicClient, - discoveryClient: discoveryClient, - restMapper: restMapper, - theme: theme, - } -} - -func (t *Item) Model(panel types.Panel) (tea.Model, error) { - item := types.Item{} - err := json.Unmarshal(panel.Blob, &item) - if err != nil { - return nil, fmt.Errorf("unmarshalling panel to item type: %s", err) - } - iw := t.modelWrapperForItemPanel(item) - return iw, t.runInformerForItem(item, iw) -} - -func (t *Item) modelWrapperForItemPanel(itemPanel types.Item) *panels.Item { - vp := viewport.New(100, 20) - return panels.NewItem(itemPanel.Name, vp) -} - -func (t *Item) runInformerForItem(item types.Item, panel *panels.Item) error { - theme := t.theme.SyntaxHighlightDarkTheme - if !lipgloss.HasDarkBackground() { - theme = t.theme.SyntaxHighlightLightTheme - } - // create informer and event handler - infFact := dynamicinformer.NewFilteredDynamicSharedInformerFactory(t.dynamicClient, 1*time.Minute, item.Key.Namespace, func(lo *v1.ListOptions) { - lo.FieldSelector = fmt.Sprintf("metadata.name=%s", item.Key.Name) - }) - gvk := schema.GroupVersionKind{ - Group: item.Group, - Version: item.Version, - Kind: item.Kind, - } - mapping, err := t.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - return fmt.Errorf("error creating resource mapping: %w", err) - } - - inf := infFact.ForResource(mapping.Resource) - _, err = inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - u := obj.(*unstructured.Unstructured) - itemJSON, err := u.MarshalJSON() - if err != nil { - panel.SetContent(fmt.Sprintf("error marshalling item %q", item.Key.String())) - return - } - - itemYAML, err := yaml.JSONToYAML(itemJSON) - if err != nil { - panel.SetContent(fmt.Sprintf("converting JSON to YAML for item %q", item.Key.String())) - return - } - rw := &bytes.Buffer{} - err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme) - if err != nil { - panel.SetContent(fmt.Sprintf("highlighting YAML for item %q", item.Key.String())) - return - } - highlighted, err := io.ReadAll(rw) - if err != nil { - panel.SetContent(fmt.Sprintf("reading highlighted YAML for item %q", item.Key.String())) - return - } - panel.SetContent(string(highlighted)) - }, - UpdateFunc: func(oldObj, newObj interface{}) { - u := newObj.(*unstructured.Unstructured) - itemJSON, err := u.MarshalJSON() - if err != nil { - panel.SetContent(fmt.Sprintf("error marshalling item %q", item.Key.String())) - return - } - - itemYAML, err := yaml.JSONToYAML(itemJSON) - if err != nil { - panel.SetContent(fmt.Sprintf("converting JSON to YAML for item %q", item.Key.String())) - return - } - rw := &bytes.Buffer{} - err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme) - if err != nil { - panel.SetContent(fmt.Sprintf("highlighting YAML for item %q", item.Key.String())) - return - } - highlighted, err := io.ReadAll(rw) - if err != nil { - panel.SetContent(fmt.Sprintf("reading highlighted YAML for item %q", item.Key.String())) - return - } - panel.SetContent(string(highlighted)) - }, - DeleteFunc: func(obj interface{}) { - panel.SetContent("") - }, - }) - - if err != nil { - return fmt.Errorf("adding event handler to informer: %w", err) - } - - go inf.Informer().Run(make(<-chan struct{})) - return nil -} diff --git a/pkg/paneler/logs.go b/pkg/paneler/logs.go deleted file mode 100644 index b35d6d7..0000000 --- a/pkg/paneler/logs.go +++ /dev/null @@ -1,136 +0,0 @@ -package paneler - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - - tea "github.com/charmbracelet/bubbletea" - "github.com/everettraven/buoy/pkg/charm/models/panels" - "github.com/everettraven/buoy/pkg/charm/styles" - "github.com/everettraven/buoy/pkg/types" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" -) - -var _ Paneler = &Log{} - -type Log struct { - typedClient *kubernetes.Clientset - dynamicClient dynamic.Interface - discoveryClient *discovery.DiscoveryClient - restMapper meta.RESTMapper - theme *styles.Theme -} - -func NewLog(typedClient *kubernetes.Clientset, dynamicClient dynamic.Interface, discoveryClient *discovery.DiscoveryClient, restMapper meta.RESTMapper, theme *styles.Theme) *Log { - return &Log{ - typedClient: typedClient, - dynamicClient: dynamicClient, - discoveryClient: discoveryClient, - restMapper: restMapper, - theme: theme, - } -} - -func (t *Log) Model(panel types.Panel) (tea.Model, error) { - log := &types.Logs{} - err := json.Unmarshal(panel.Blob, log) - if err != nil { - return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) - } - logPanel := panels.NewLogs(panels.DefaultLogsKeys, log.Name, t.theme) - pod, err := t.getPodForObject(log) - if err != nil { - return nil, fmt.Errorf("error getting pod for object: %w", err) - } - rc, err := logsForPod(t.typedClient, pod, log.Container) - if err != nil { - return nil, fmt.Errorf("error getting logs for pod: %w", err) - } - go streamLogs(rc, logPanel) - return logPanel, nil -} - -func (t *Log) getPodForObject(logsPanel *types.Logs) (*v1.Pod, error) { - gvk := schema.GroupVersionKind{ - Group: logsPanel.Group, - Version: logsPanel.Version, - Kind: logsPanel.Kind, - } - - if gvk == v1.SchemeGroupVersion.WithKind("Pod") { - return t.typedClient.CoreV1().Pods(logsPanel.Key.Namespace).Get(context.Background(), logsPanel.Key.Name, metav1.GetOptions{}) - } - - mapping, err := t.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - return nil, fmt.Errorf("error creating resource mapping: %w", err) - } - u, err := t.dynamicClient.Resource(mapping.Resource).Namespace(logsPanel.Key.Namespace).Get(context.Background(), logsPanel.Key.Name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("error getting object: %w", err) - } - - selector, err := getPodSelectorForUnstructured(u) - if err != nil { - return nil, fmt.Errorf("error getting pod selector for object: %w", err) - } - pods, err := t.typedClient.CoreV1().Pods(u.GetNamespace()).List(context.Background(), metav1.ListOptions{LabelSelector: selector.String()}) - if err != nil { - return nil, fmt.Errorf("error getting pods for object: %w", err) - } - if len(pods.Items) == 0 { - return nil, fmt.Errorf("no pods found for object") - } - return &pods.Items[0], nil -} - -func getPodSelectorForUnstructured(u *unstructured.Unstructured) (labels.Selector, error) { - selector, found, err := unstructured.NestedFieldCopy(u.Object, "spec", "selector") - if !found { - return nil, fmt.Errorf("no pod label selector found in object spec: %s", u.Object) - } - if err != nil { - return nil, fmt.Errorf("error getting pod label selector from object spec: %w", err) - } - sel := &metav1.LabelSelector{} - bytes, err := json.Marshal(selector) - if err != nil { - return nil, fmt.Errorf("error marshalling selector: %w", err) - } - err = json.Unmarshal(bytes, sel) - if err != nil { - return nil, fmt.Errorf("error unmarshalling selector: %w", err) - } - return metav1.LabelSelectorAsSelector(sel) -} - -func streamLogs(rc io.ReadCloser, logPanel *panels.Logs) { - scanner := bufio.NewScanner(rc) - for scanner.Scan() { - logPanel.AddContent(scanner.Text()) - } -} - -func logsForPod(kc *kubernetes.Clientset, pod *v1.Pod, container string) (io.ReadCloser, error) { - req := kc.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &v1.PodLogOptions{ - Container: container, - Follow: true, - }) - - rc, err := req.Stream(context.Background()) - if err != nil { - return nil, fmt.Errorf("fetching logs for %s/%s: %w", pod.Namespace, pod.Name, err) - } - return rc, nil -} diff --git a/pkg/paneler/paneler.go b/pkg/paneler/paneler.go deleted file mode 100644 index e438e1c..0000000 --- a/pkg/paneler/paneler.go +++ /dev/null @@ -1,60 +0,0 @@ -package paneler - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/everettraven/buoy/pkg/charm/styles" - "github.com/everettraven/buoy/pkg/types" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" -) - -type Paneler interface { - Model(types.Panel) (tea.Model, error) -} - -type paneler struct { - panelerRegistry map[string]Paneler -} - -func (p *paneler) Model(panel types.Panel) (tea.Model, error) { - if p, ok := p.panelerRegistry[panel.Type]; ok { - return p.Model(panel) - } - return nil, fmt.Errorf("panel %q has unknown panel type: %q", panel.Name, panel.Type) -} - -func NewDefaultPaneler(cfg *rest.Config, theme *styles.Theme) (Paneler, error) { - dClient, err := dynamic.NewForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("error creating dynamic client: %w", err) - } - - kubeClient, err := kubernetes.NewForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("creating kubernetes.Clientset: %w", err) - } - - di, err := discovery.NewDiscoveryClientForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("error creating discovery client: %w", err) - } - - gr, err := restmapper.GetAPIGroupResources(di) - if err != nil { - return nil, fmt.Errorf("error getting API group resources: %w", err) - } - rm := restmapper.NewDiscoveryRESTMapper(gr) - - return &paneler{ - panelerRegistry: map[string]Paneler{ - types.PanelTypeTable: NewTable(dClient, di, rm, theme), - types.PanelTypeItem: NewItem(dClient, di, rm, theme), - types.PanelTypeLogs: NewLog(kubeClient, dClient, di, rm, theme), - }, - }, nil -} diff --git a/pkg/paneler/table.go b/pkg/paneler/table.go deleted file mode 100644 index d0a6824..0000000 --- a/pkg/paneler/table.go +++ /dev/null @@ -1,115 +0,0 @@ -package paneler - -import ( - "encoding/json" - "fmt" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/everettraven/buoy/pkg/charm/models/panels" - "github.com/everettraven/buoy/pkg/charm/styles" - buoytypes "github.com/everettraven/buoy/pkg/types" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/dynamic/dynamicinformer" - "k8s.io/client-go/informers" - "k8s.io/client-go/tools/cache" -) - -var _ Paneler = &Table{} - -type Table struct { - dynamicClient dynamic.Interface - discoveryClient *discovery.DiscoveryClient - restMapper meta.RESTMapper - theme *styles.Theme -} - -func NewTable(dynamicClient dynamic.Interface, discoveryClient *discovery.DiscoveryClient, restMapper meta.RESTMapper, theme *styles.Theme) *Table { - return &Table{ - dynamicClient: dynamicClient, - discoveryClient: discoveryClient, - restMapper: restMapper, - theme: theme, - } -} - -func (t *Table) Model(panel buoytypes.Panel) (tea.Model, error) { - tab := &buoytypes.Table{} - err := json.Unmarshal(panel.Blob, tab) - if err != nil { - return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) - } - model, informer, err := t.modelForTablePanel(tab) - if err != nil { - return nil, fmt.Errorf("creating model wrapper for table panel: %w", err) - } - go informer.Informer().Run(make(chan struct{})) - return model, nil -} - -func (t *Table) modelForTablePanel(tablePanel *buoytypes.Table) (*panels.Table, informers.GenericInformer, error) { - inf, scope, err := t.informerForTable(tablePanel, nil) - if err != nil { - return nil, nil, fmt.Errorf("creating informer for table: %w", err) - } - table := panels.NewTable(panels.DefaultTableKeys, tablePanel, inf.Lister(), scope, t.theme) - _, err = setEventHandlerForTableInformer(inf, table) - if err != nil { - return nil, nil, fmt.Errorf("setting event handler for table informer: %w", err) - } - return table, inf, nil - -} - -func (t *Table) informerForTable(tablePanel *buoytypes.Table, tw *panels.Table) (informers.GenericInformer, meta.RESTScopeName, error) { - // create informer and event handler - gvk := schema.GroupVersionKind{ - Group: tablePanel.Group, - Version: tablePanel.Version, - Kind: tablePanel.Kind, - } - mapping, err := t.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - return nil, "", fmt.Errorf("error creating resource mapping: %w", err) - } - ns := tablePanel.Namespace - if mapping.Scope.Name() == meta.RESTScopeNameRoot { - ns = "" - } - infFact := dynamicinformer.NewFilteredDynamicSharedInformerFactory( - t.dynamicClient, - 1*time.Minute, - ns, - dynamicinformer.TweakListOptionsFunc(func(options *v1.ListOptions) { - ls := labels.SelectorFromSet(tablePanel.LabelSelector) - options.LabelSelector = ls.String() - }), - ) - - inf := infFact.ForResource(mapping.Resource) - - return inf, mapping.Scope.Name(), nil -} - -func setEventHandlerForTableInformer(inf informers.GenericInformer, tw *panels.Table) (cache.ResourceEventHandlerRegistration, error) { - return inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - u := obj.(*unstructured.Unstructured) - tw.AddOrUpdate(u) - }, - UpdateFunc: func(oldObj, newObj interface{}) { - u := newObj.(*unstructured.Unstructured) - tw.AddOrUpdate(u) - }, - DeleteFunc: func(obj interface{}) { - u := obj.(*unstructured.Unstructured) - tw.DeleteRow(u.GetUID()) - }, - }) -}