Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ func init() {
cobra.OnInitialize(initConfig)

rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "path to config file")
rootCmd.PersistentFlags().String("listen-addr", "", "address to listen on (default: 0.0.0.0:8080)")
rootCmd.PersistentFlags().String("app-mode", "", "application mode: production or development")
rootCmd.PersistentFlags().Bool("use-mock-data", false, "use mock data instead of real Chef server")
rootCmd.PersistentFlags().String("chef-server-url", "", "Chef server URL")
rootCmd.PersistentFlags().String("chef-username", "", "Chef server username")
rootCmd.PersistentFlags().String("chef-key-file", "", "path to Chef client key file")
rootCmd.PersistentFlags().Bool("chef-ssl-verify", true, "verify Chef server SSL certificate")
rootCmd.PersistentFlags().String("log-level", "", "log level: debug, info, warning, error, fatal")
rootCmd.PersistentFlags().String("log-format", "", "log format: json or console")
rootCmd.PersistentFlags().String("log-output", "", "log output: stdout or file path")
rootCmd.PersistentFlags().Bool("request-logging", true, "enable request logging")
rootCmd.PersistentFlags().Bool("log-health-checks", true, "log health check requests")
rootCmd.PersistentFlags().String("base-path", "", "base path for reverse proxy")
rootCmd.PersistentFlags().String("trusted-proxies", "", "comma-separated trusted proxy CIDRs")
rootCmd.PersistentFlags().Bool("enable-gzip", false, "enable gzip compression")
}

// initConfig reads in config defaults, user config files, and ENV variables if set.
Expand All @@ -51,7 +66,22 @@ func initConfig() {
v.SetConfigName("chefbrowser")
v.AddConfigPath("/etc/chefbrowser/")

// load defaults
v.BindPFlag("default.listen_addr", rootCmd.PersistentFlags().Lookup("listen-addr"))
v.BindPFlag("default.app_mode", rootCmd.PersistentFlags().Lookup("app-mode"))
v.BindPFlag("default.use_mock_data", rootCmd.PersistentFlags().Lookup("use-mock-data"))
v.BindPFlag("chef.server_url", rootCmd.PersistentFlags().Lookup("chef-server-url"))
v.BindPFlag("chef.username", rootCmd.PersistentFlags().Lookup("chef-username"))
v.BindPFlag("chef.key_file", rootCmd.PersistentFlags().Lookup("chef-key-file"))
v.BindPFlag("chef.ssl_verify", rootCmd.PersistentFlags().Lookup("chef-ssl-verify"))
v.BindPFlag("logging.level", rootCmd.PersistentFlags().Lookup("log-level"))
v.BindPFlag("logging.format", rootCmd.PersistentFlags().Lookup("log-format"))
v.BindPFlag("logging.output", rootCmd.PersistentFlags().Lookup("log-output"))
v.BindPFlag("logging.request_logging", rootCmd.PersistentFlags().Lookup("request-logging"))
v.BindPFlag("logging.log_health_checks", rootCmd.PersistentFlags().Lookup("log-health-checks"))
v.BindPFlag("server.base_path", rootCmd.PersistentFlags().Lookup("base-path"))
v.BindPFlag("server.trusted_proxies", rootCmd.PersistentFlags().Lookup("trusted-proxies"))
v.BindPFlag("server.enable_gzip", rootCmd.PersistentFlags().Lookup("enable-gzip"))

err := v.ReadConfig(bytes.NewBuffer(config.DefaultConfig))
if err != nil {
fmt.Println("failed to read default config, err:", err)
Expand Down
6 changes: 4 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
var DefaultConfig = []byte(`
app_mode = production
listen_addr = 0.0.0.0:8080
use_mock_data = false

[chef]
server_url = http://localhost/organizations/example/
Expand Down Expand Up @@ -32,8 +33,9 @@ type chefConfig struct {
}

type appConfig struct {
AppMode string `mapstructure:"app_mode"`
ListenAddr string `mapstructure:"listen_addr"`
AppMode string `mapstructure:"app_mode"`
ListenAddr string `mapstructure:"listen_addr"`
UseMockData bool `mapstructure:"use_mock_data"`
}

type loggingConfig struct {
Expand Down
3 changes: 3 additions & 0 deletions defaults.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
listen_addr = 0.0.0.0:8080
app_mode = production

# Use mock data instead of connecting to a real Chef server (for development/testing)
use_mock_data = false

[chef]
server_url = https://localhost/organizations/example/
username = example
Expand Down
4 changes: 2 additions & 2 deletions internal/app/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ var basePath = ""
type Service struct {
log *logging.Logger
config *config.Config
chef *chef.Service
chef chef.Interface
engine *echo.Echo
}

func New(config *config.Config, engine *echo.Echo, chef *chef.Service, logger *logging.Logger) *Service {
func New(config *config.Config, engine *echo.Echo, chef chef.Interface, logger *logging.Logger) *Service {
s := Service{
config: config,
chef: chef,
Expand Down
9 changes: 1 addition & 8 deletions internal/app/api/cookbooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,10 @@ func (s *Service) getCookbookVersion(c echo.Context) error {
func (s *Service) getCookbookVersions(c echo.Context) error {
name := c.Param("name")

resp, err := s.chef.GetClient().Cookbooks.GetAvailableVersions(name, "0")
versions, err := s.chef.GetCookbookVersions(c.Request().Context(), name)
if err != nil {
return c.JSON(http.StatusNotFound, ErrorResponse("failed to fetch cookbook versions"))
}

var versions []string
for _, i := range resp {
for _, j := range i.Versions {
versions = append(versions, j.Version)
}
}

return c.JSON(http.StatusOK, versions)
}
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (

type AppService struct {
Log *logging.Logger
Chef *chef.Service
Chef chef.Interface
APIService *api.Service
UIService *ui.Service
}
Expand Down
61 changes: 53 additions & 8 deletions internal/app/ui/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"

"github.com/drewhammond/chefbrowser/config"
Expand Down Expand Up @@ -35,7 +36,7 @@ func embeddedFH(config goview.Config, tmpl string) (string, error) {
type Service struct {
log *logging.Logger
config *config.Config
chef *chef.Service
chef chef.Interface
engine *echo.Echo
customLinks *CustomLinksCollection
}
Expand All @@ -53,7 +54,7 @@ type CustomLinksCollection struct {
DataBags []CustomLink // Unused, but maybe in the future
}

func New(config *config.Config, engine *echo.Echo, chef *chef.Service, logger *logging.Logger) *Service {
func New(config *config.Config, engine *echo.Echo, chef chef.Interface, logger *logging.Logger) *Service {
s := Service{
config: config,
chef: chef,
Expand Down Expand Up @@ -112,6 +113,8 @@ func (s *Service) RegisterRoutes() {
cfg.Funcs["vite_assets"] = func() template.HTML {
return template.HTML(viteTags)
}
cfg.Funcs["add"] = func(a, b int) int { return a + b }
cfg.Funcs["sub"] = func(a, b int) int { return a - b }

ev := echoview.New(cfg)
if s.config.App.AppMode == "production" {
Expand Down Expand Up @@ -257,36 +260,78 @@ func (s *Service) makeRunListURL(f string) string {
return ""
}

const maxPageSize = 10000

func (s *Service) getNodes(c echo.Context) error {
query := c.QueryParam("q")
var nodes *chef.NodeList
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(c.QueryParam("per_page"))
if perPage < 0 {
perPage = 0
}

effectivePerPage := perPage
if effectivePerPage == 0 {
effectivePerPage = maxPageSize
}

start := (page - 1) * effectivePerPage

var result *chef.NodeListResult
var err error

if query != "" {
searchQuery := query
if !strings.Contains(query, ":") {
query = fuzzifySearchStr(query)
searchQuery = fuzzifySearchStr(query)
}
nodes, err = s.chef.SearchNodes(c.Request().Context(), query)
result, err = s.chef.SearchNodesWithDetails(c.Request().Context(), searchQuery, start, effectivePerPage)
} else {
nodes, err = s.chef.GetNodes(c.Request().Context())
result, err = s.chef.GetNodesWithDetails(c.Request().Context(), start, effectivePerPage)
}

if err != nil {
s.log.Error("failed to fetch nodes", zap.Error(err))
return c.Render(http.StatusInternalServerError, "errors/500", echo.Map{
"message": "failed to fetch nodes",
})
}

totalPages := 1
if perPage > 0 {
totalPages = (result.Total + perPage - 1) / perPage
}

return c.Render(http.StatusOK, "nodes", echo.Map{
"nodes": nodes.Nodes,
"nodes": result.Nodes,
"total": result.Total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
"query": query,
"active_nav": "nodes",
"search_enabled": true,
"title": "All Nodes",
})
}

// escapeSolrSpecialChars escapes characters that have special meaning in Solr/Lucene query syntax
func escapeSolrSpecialChars(s string) string {
specialChars := []string{"\\", "+", "-", "&&", "||", "!", "(", ")", "{", "}", "[", "]", "^", "\"", "~", "?", ":", "/"}
result := s
for _, char := range specialChars {
result = strings.ReplaceAll(result, char, "\\"+char)
}
return result
}

// fuzzifySearchStr mimics the fuzzy search functionality
// provided by chef https://github.com/chef/chef/blob/main/lib/chef/search/query.rb#L109
func fuzzifySearchStr(s string) string {
escaped := escapeSolrSpecialChars(s)
format := []string{
"tags:*%v*",
"roles:*%v*",
Expand All @@ -300,7 +345,7 @@ func fuzzifySearchStr(s string) string {
if i > 0 {
b.WriteString(" OR ")
}
b.WriteString(fmt.Sprintf(f, s))
b.WriteString(fmt.Sprintf(f, escaped))
}
return b.String()
}
Expand Down
49 changes: 42 additions & 7 deletions internal/chef/chef.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,57 @@ import (
)

type Interface interface {
GetCookbook(ctx context.Context) (Cookbook, error)
GetCookbooks(ctx context.Context) ([]Cookbook, error)
// Nodes
GetNodes(ctx context.Context) (*NodeList, error)
SearchNodes(ctx context.Context, q string) (*NodeList, error)
GetNodesWithDetails(ctx context.Context, start, pageSize int) (*NodeListResult, error)
SearchNodesWithDetails(ctx context.Context, q string, start, pageSize int) (*NodeListResult, error)
GetNode(ctx context.Context, name string) (*Node, error)

// Roles
GetRoles(ctx context.Context) (*RoleList, error)
GetRole(ctx context.Context, name string) (*Role, error)

// Environments
GetEnvironments(ctx context.Context) (interface{}, error)
GetEnvironment(ctx context.Context, name string) (*chef.Environment, error)

// Cookbooks
GetCookbooks(ctx context.Context) (*CookbookListResult, error)
GetLatestCookbooks(ctx context.Context) (*CookbookListResult, error)
GetCookbook(ctx context.Context, name string) (*Cookbook, error)
GetCookbookVersion(ctx context.Context, name string, version string) (*Cookbook, error)
GetCookbookVersions(ctx context.Context, name string) ([]string, error)

// Databags
GetDatabags(ctx context.Context) (interface{}, error)
GetDatabagItems(ctx context.Context, name string) (*chef.DataBagListResult, error)
GetDatabagItemContent(ctx context.Context, databag string, item string) (chef.DataBagItem, error)

// Policies
GetPolicies(ctx context.Context) (chef.PoliciesGetResponse, error)
GetPolicy(ctx context.Context, name string) (chef.PolicyGetResponse, error)
GetPolicyRevision(ctx context.Context, name string, revision string) (chef.RevisionDetailsResponse, error)
GetPolicyGroups(ctx context.Context) (chef.PolicyGroupGetResponse, error)
GetPolicyGroup(ctx context.Context, name string) (PolicyGroup, error)

// Groups
GetGroups(ctx context.Context) (interface{}, error)
GetGroup(ctx context.Context, name string) (chef.Group, error)
}

type Service struct {
Interface
log *logging.Logger
config *config.Config
client chef.Client
}

func (s Service) GetClient() *chef.Client {
return &s.client
}
func New(config *config.Config, logger *logging.Logger) Interface {
if config.App.UseMockData {
logger.Info("Using mock data (use_mock_data = true)")
return NewMockService(logger)
}

func New(config *config.Config, logger *logging.Logger) *Service {
config.Chef.ServerURL = normalizeChefURL(config.Chef.ServerURL)
logger.Info(fmt.Sprintf("initializing chef server connection (url: %s, username: %s)",
config.Chef.ServerURL,
Expand Down
16 changes: 16 additions & 0 deletions internal/chef/cookbooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ func (s Service) GetCookbookVersion(ctx context.Context, name string, version st
return &Cookbook{cookbook}, nil
}

func (s Service) GetCookbookVersions(ctx context.Context, name string) ([]string, error) {
resp, err := s.client.Cookbooks.GetAvailableVersions(name, "0")
if err != nil {
return nil, err
}

var versions []string
for _, i := range resp {
for _, j := range i.Versions {
versions = append(versions, j.Version)
}
}

return versions, nil
}

func (s Cookbook) GetFile(ctx context.Context, client *http.Client, path string) (string, error) {
t := strings.SplitN(path, "/", 2)[0]
var loc []chef.CookbookItem
Expand Down
46 changes: 46 additions & 0 deletions internal/chef/cookbooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package chef

import (
"reflect"
"testing"
)

func TestReverseSlice(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "basic reverse",
input: []string{"1.0.0", "2.0.0", "3.0.0"},
expected: []string{"3.0.0", "2.0.0", "1.0.0"},
},
{
name: "single element",
input: []string{"1.0.0"},
expected: []string{"1.0.0"},
},
{
name: "empty slice",
input: []string{},
expected: []string{},
},
{
name: "two elements",
input: []string{"a", "b"},
expected: []string{"b", "a"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := make([]string, len(tt.input))
copy(input, tt.input)
ReverseSlice(input)
if !reflect.DeepEqual(input, tt.expected) {
t.Errorf("ReverseSlice() = %v, want %v", input, tt.expected)
}
})
}
}
Loading