Skip to content
Merged
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
9 changes: 2 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,18 @@ $(LOCALBIN):

## Tool Binaries
GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint
GORELEASER ?= $(LOCALBIN)/goreleaser
LICENSE_EYE ?= $(LOCALBIN)/license-eye
VHS ?= $(LOCALBIN)/vhs

## Tool Versions
GOLANGCI_LINT_VERSION ?= 1.64.8
GOLANGCI_LINT_VERSION ?= 2.5.0
GORELEASER_VERSION ?= 1.26.1

.PHONY: golangci-lint ## Download golanci-lint if necessary
golangci-lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT): $(LOCALBIN)
test -s $(LOCALBIN)/golanci-lint || GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION}
test -s $(LOCALBIN)/golanci-lint || GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION}

.PHONY: goreleaser ## Download goreleaser if necessary
goreleaser: $(GORELEASER)
$(GORELEASER): $(LOCALBIN)
test -s $(LOCALBIN)/goreleaser || GOBIN=$(LOCALBIN) go install github.com/goreleaser/goreleaser@v${GORELEASER_VERSION}

.PHONY: license-eye ## Download license-eye if necessary
license-eye: $(LICENSE_EYE)
Expand Down
10 changes: 5 additions & 5 deletions internal/cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func newAddContextCmd() *cobra.Command {
if err != nil {
return err
}
settings, err := repo.Load()
settings, err := repo.LoadSettings()
if err != nil {
return err
}
Expand All @@ -91,7 +91,7 @@ func newAddContextCmd() *cobra.Command {
return err
}
// Save settings
err = repo.Save(*settings)
err = repo.SaveSettings(*settings)
if err != nil {
return err
}
Expand All @@ -116,7 +116,7 @@ func newListContextsCmd() *cobra.Command {
if err != nil {
return err
}
settings, err := repo.Load()
settings, err := repo.LoadSettings()
if err != nil {
return err
}
Expand Down Expand Up @@ -148,7 +148,7 @@ func newRemoveContextCmd() *cobra.Command {
if err != nil {
return err
}
settings, err := repo.Load()
settings, err := repo.LoadSettings()
if err != nil {
return err
}
Expand All @@ -173,7 +173,7 @@ func newRemoveContextCmd() *cobra.Command {
if err != nil {
return err
}
err = repo.Save(*settings)
err = repo.SaveSettings(*settings)
if err != nil {
return err
}
Expand Down
18 changes: 12 additions & 6 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func NewRootCmd() *cobra.Command {
if err != nil {
return err
}
settings, err := repo.Load()
settings, err := repo.LoadSettings()
if err != nil {
return err
}
Expand All @@ -120,14 +120,18 @@ func NewRootCmd() *cobra.Command {
// If no-interactive mode, just abort
noInteractive, _ := cmd.Flags().GetBool(FLAG_NO_INTERACTIVE)
if noInteractive {
utils.PrintWarning(
err = utils.PrintWarning(
fmt.Sprintf(
"[WARNING] Running a protected command on safe context %q.",
namespacedContext.Context,
),
)
fmt.Println("Aborted")
return nil
fmt.Println("Canceled")
if err != nil {
return err
}
contextConf.Stats.CanceledCount += 1
return repo.SaveSettings(*settings)
}
// Otherwise, ask for confirmation
proceed, err := utils.Confirm(
Expand All @@ -143,13 +147,15 @@ func NewRootCmd() *cobra.Command {
runCmd(wrappedCmd, wrappedArgs)
return nil
}
fmt.Println("Aborted")
return nil
fmt.Println("Canceled")
contextConf.Stats.CanceledCount += 1
return repo.SaveSettings(*settings)
},
}

// Add sub commands
rootCmd.AddCommand(NewContextCmd())
rootCmd.AddCommand(NewStatsCmd())
rootCmd.Flags().
Bool(FLAG_NO_INTERACTIVE, false, "If set, kubesafe will directly prevent the execution on protected contexts without asking for confirmation")
return rootCmd
Expand Down
93 changes: 93 additions & 0 deletions internal/cmd/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2025 Michele Zanotti <m.zanotti019@gmail.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package cmd

import (
"fmt"
"sort"
"strings"

"github.com/spf13/cobra"
"github.com/telemaco019/kubesafe/internal/core"
"github.com/telemaco019/kubesafe/internal/repositories"
)

func printStats(contexts []core.ContextConf) {
if len(contexts) == 0 {
fmt.Println("No contexts found.")
return
}

// Find the longest context name
maxNameLen := len("Context")
for _, c := range contexts {
if l := len(c.Name); l > maxNameLen {
maxNameLen = l
}
}

// Calculate padding and separator length
padding := 4
firstColumnWidth := maxNameLen + padding
separatorLength := firstColumnWidth + len("Canceled Commands")

fmt.Printf("%-*s%s\n", firstColumnWidth, "Context", "Canceled Commands")
fmt.Println(strings.Repeat("-", separatorLength))

for _, c := range contexts {
fmt.Printf("%-*s%d\n", firstColumnWidth, c.Name, c.Stats.CanceledCount)
}

fmt.Println(strings.Repeat("-", separatorLength))
}
func NewStatsCmd() *cobra.Command {
statsCommand := &cobra.Command{
Use: "stats",
Short: "Show Kubesafe statistics",
Args: cobra.NoArgs,
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
repo, err := repositories.NewFileSystemRepository()
if err != nil {
return err
}

settings, err := repo.LoadSettings()
if err != nil {
return err
}

if len(settings.Contexts) == 0 {
fmt.Println("No contexts found.")
return nil
}

fmt.Println("\nKubesafe Context Statistics")
fmt.Println()

// Sort contexts by canceled count descending
sort.Slice(settings.Contexts, func(i, j int) bool {
return settings.Contexts[i].Stats.CanceledCount > settings.Contexts[j].Stats.CanceledCount
})
printStats(settings.Contexts)

return nil
},
}

return statsCommand
}
22 changes: 14 additions & 8 deletions internal/core/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ var DEFAULT_KUBECTL_PROTECTED_COMMANDS = []string{
"uninstall",
}

type ContextStats struct {
// CanceledCount is the number of times the execution of a command was canceled by the user.
CanceledCount uint `yaml:"canceledCount"`
}
type ContextConf struct {
Name string `yaml:"name"`
IsRegex bool `yaml:"isRegex"`
ProtectedCommands []string `yaml:"commands"`
Name string `yaml:"name"`
IsRegex bool `yaml:"isRegex"`
ProtectedCommands []string `yaml:"commands"`
Stats *ContextStats `yaml:"stats"`
}

func (c *ContextConf) IsProtected(command string) bool {
Expand All @@ -62,6 +67,7 @@ func NewContextConf(
Name: contextName,
ProtectedCommands: safeActions,
IsRegex: utils.IsRegex(contextName),
Stats: &ContextStats{CanceledCount: 0},
}
}

Expand Down Expand Up @@ -107,7 +113,7 @@ func (s *Settings) RemoveContext(context string) error {
if !s.ContainsContext(context) {
return fmt.Errorf("context %q not found", context)
}
var newContexts []ContextConf = make([]ContextConf, 0)
var newContexts = make([]ContextConf, 0)
for _, c := range s.Contexts {
if c.Name == context {
continue
Expand All @@ -119,19 +125,19 @@ func (s *Settings) RemoveContext(context string) error {
return nil
}

func (s *Settings) GetContextConf(context string) (ContextConf, bool) {
func (s *Settings) GetContextConf(context string) (*ContextConf, bool) {
// First check the lookup map
conf, ok := s.contextLookup[context]
if ok {
return conf, ok
return &conf, ok
}
// If the context is not found in the lookup map, check the regexes
for _, regexConf := range s.contextRegexes {
if utils.RegexMatches(regexConf.Name, context) {
return regexConf, true
return &regexConf, true
}
}
return conf, ok
return &conf, ok
}

func (s *Settings) ContainsContext(context string) bool {
Expand Down
2 changes: 1 addition & 1 deletion internal/core/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func TestGetContextConf(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
context, ok := tc.settings.GetContextConf(tc.contextName)
assert.DeepEqual(t, context, tc.wantContext)
assert.DeepEqual(t, *context, tc.wantContext)
assert.Equal(t, ok, tc.wantOk)
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
)

type FileSystemRepository struct {
path string
configFilePath string
}

func NewFileSystemRepository() (*FileSystemRepository, error) {
Expand All @@ -45,7 +45,7 @@ func NewFileSystemRepository() (*FileSystemRepository, error) {
}
if exists {
return &FileSystemRepository{
path: legacyPath,
configFilePath: legacyPath,
}, nil
}

Expand All @@ -66,27 +66,27 @@ func NewFileSystemRepository() (*FileSystemRepository, error) {
}
}
return &FileSystemRepository{
path: path.Join(kubesafeDir, "config.yaml"),
configFilePath: path.Join(kubesafeDir, "config.yaml"),
}, nil
}

func (r *FileSystemRepository) Save(settings core.Settings) error {
slog.Debug("Saving settings", "path", r.path)
func (r *FileSystemRepository) SaveSettings(settings core.Settings) error {
slog.Debug("Saving settings", "path", r.configFilePath)
settingsFile, err := yaml.Marshal(settings)
if err != nil {
return fmt.Errorf("error marshalling settings: %w", err)
}
err = os.WriteFile(r.path, settingsFile, 0644)
err = os.WriteFile(r.configFilePath, settingsFile, 0644)
if err != nil {
return fmt.Errorf("error writing settings file: %w", err)
}
return nil
}

func (r *FileSystemRepository) Load() (*core.Settings, error) {
slog.Debug("Loading settings", "path", r.path)
func (r *FileSystemRepository) LoadSettings() (*core.Settings, error) {
slog.Debug("Loading settings", "path", r.configFilePath)
// If file does not exist, return a new Settings
exists, err := utils.FileExists(r.path)
exists, err := utils.FileExists(r.configFilePath)
if err != nil {
return nil, err
}
Expand All @@ -95,7 +95,7 @@ func (r *FileSystemRepository) Load() (*core.Settings, error) {
return &settings, nil
}
// Otherwise, read it from file
settingsFile, err := os.ReadFile(r.path)
settingsFile, err := os.ReadFile(r.configFilePath)
if err != nil {
return nil, fmt.Errorf("error reading settings file: %w", err)
}
Expand Down
Loading