From ccc20ab2fe7e6bfb7a14e9d3175864381ae980b7 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Thu, 23 Oct 2025 16:53:36 +0200 Subject: [PATCH 1/7] refactor: renaming --- internal/cmd/context.go | 10 ++++---- internal/cmd/root.go | 2 +- .../{repository.go => file_system.go} | 20 ++++++++-------- ...repository_test.go => file_system_test.go} | 24 +++++++++---------- 4 files changed, 28 insertions(+), 28 deletions(-) rename internal/repositories/{repository.go => file_system.go} (81%) rename internal/repositories/{repository_test.go => file_system_test.go} (77%) diff --git a/internal/cmd/context.go b/internal/cmd/context.go index 7978db7..9021699 100644 --- a/internal/cmd/context.go +++ b/internal/cmd/context.go @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0a3ab6d..41f9a33 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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 } diff --git a/internal/repositories/repository.go b/internal/repositories/file_system.go similarity index 81% rename from internal/repositories/repository.go rename to internal/repositories/file_system.go index 224cad0..6e1bf3f 100644 --- a/internal/repositories/repository.go +++ b/internal/repositories/file_system.go @@ -28,7 +28,7 @@ import ( ) type FileSystemRepository struct { - path string + configFilePath string } func NewFileSystemRepository() (*FileSystemRepository, error) { @@ -45,7 +45,7 @@ func NewFileSystemRepository() (*FileSystemRepository, error) { } if exists { return &FileSystemRepository{ - path: legacyPath, + configFilePath: legacyPath, }, nil } @@ -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 } @@ -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) } diff --git a/internal/repositories/repository_test.go b/internal/repositories/file_system_test.go similarity index 77% rename from internal/repositories/repository_test.go rename to internal/repositories/file_system_test.go index c1abb5c..b3aaf7c 100644 --- a/internal/repositories/repository_test.go +++ b/internal/repositories/file_system_test.go @@ -41,19 +41,19 @@ func newSettings( func newTestFsRepository() *FileSystemRepository { return &FileSystemRepository{ - path: "/tmp/kubesafe-test-settings.yaml", + configFilePath: "/tmp/kubesafe-test-settings.yaml", } } -func TestSettingsRepository_SaveAndLoad(t *testing.T) { +func TestSettingsRepository_SaveAndLoadSettings(t *testing.T) { t.Run("Success", func(t *testing.T) { workingRepository := newTestFsRepository() settings := newSettings("context1", "context2") // Save - err := workingRepository.Save(settings) + err := workingRepository.SaveSettings(settings) assert.NoError(t, err) // Load - loadedSettings, err := workingRepository.Load() + loadedSettings, err := workingRepository.LoadSettings() assert.NoError(t, err) assert.Equal(t, settings, *loadedSettings) }) @@ -66,30 +66,30 @@ func TestSettingsRepository_SaveAndLoad(t *testing.T) { ) assert.NoError(t, err) // Save - err = workingRepository.Save(settings) + err = workingRepository.SaveSettings(settings) assert.NoError(t, err) // Load - loadedSettings, err := workingRepository.Load() + loadedSettings, err := workingRepository.LoadSettings() assert.NoError(t, err) assert.Equal(t, settings, *loadedSettings) }) t.Run("Failure", func(t *testing.T) { failingRepo := FileSystemRepository{ - path: "/unexisting", + configFilePath: "/unexisting", } settings := newSettings("context1", "context2") - err := failingRepo.Save(settings) + err := failingRepo.SaveSettings(settings) assert.Error(t, err) }) } -func TestSettingsRepository_Load(t *testing.T) { - t.Run("Paht not found should return new settings", func(t *testing.T) { +func TestSettingsRepository_LoadSettings(t *testing.T) { + t.Run("Path not found should return new settings", func(t *testing.T) { repo := FileSystemRepository{ - path: "/unexisting", + configFilePath: "/unexisting", } - loadedSettings, err := repo.Load() + loadedSettings, err := repo.LoadSettings() assert.NoError(t, err) assert.NotNil(t, loadedSettings) assert.Equal(t, core.NewSettings(), *loadedSettings) From 6c689c06c467a1cf5becce10086cd96f3e363a95 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Thu, 23 Oct 2025 17:03:08 +0200 Subject: [PATCH 2/7] fix: missing err checks --- internal/cmd/root.go | 4 ++-- internal/utils/prompts.go | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 41f9a33..43c797a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -120,14 +120,14 @@ 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 + return err } // Otherwise, ask for confirmation proceed, err := utils.Confirm( diff --git a/internal/utils/prompts.go b/internal/utils/prompts.go index d254742..25f198e 100644 --- a/internal/utils/prompts.go +++ b/internal/utils/prompts.go @@ -48,17 +48,21 @@ func SelectItem[T comparable](items []T, selectMessage string) (T, error) { return selected, err } -func PrintWarning(msg string) { +func PrintWarning(msg string) error { c := color.New(color.FgYellow) - c.Printf("%s\n", msg) + _, err := c.Printf("%s\n", msg) + return err } func Confirm(message string) (bool, error) { c := color.New(color.FgYellow) - c.Printf("%s (y/n): ", message) + _, err := c.Printf("%s (y/n): ", message) + if err != nil { + return false, err + } var input string - _, err := fmt.Scanln(&input) + _, err = fmt.Scanln(&input) if err != nil { return false, err } From 47e14e2526ec5d01b500331822bda046b8b03bdf Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Thu, 23 Oct 2025 17:07:49 +0200 Subject: [PATCH 3/7] fix: linting --- Makefile | 5 +++-- internal/core/models.go | 2 +- internal/utils/kubernetes.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 0c23ddf..b4be0ed 100644 --- a/Makefile +++ b/Makefile @@ -80,13 +80,14 @@ 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) diff --git a/internal/core/models.go b/internal/core/models.go index 7fe45d1..19613af 100644 --- a/internal/core/models.go +++ b/internal/core/models.go @@ -107,7 +107,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 diff --git a/internal/utils/kubernetes.go b/internal/utils/kubernetes.go index 38bc9b4..f3bc4de 100644 --- a/internal/utils/kubernetes.go +++ b/internal/utils/kubernetes.go @@ -77,7 +77,7 @@ func GetAvailableContexts() (map[string]string, error) { return nil, err } - var contexts map[string]string = make(map[string]string, len(config.Contexts)) + var contexts = make(map[string]string, len(config.Contexts)) for context := range config.Contexts { contexts[context] = context } From 4dd66d5eb680542ffea145433edb97ad1a0c0d02 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Thu, 23 Oct 2025 17:09:35 +0200 Subject: [PATCH 4/7] chore: remove unused target --- Makefile | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Makefile b/Makefile index b4be0ed..972e05a 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,6 @@ $(LOCALBIN): ## Tool Binaries GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint -GORELEASER ?= $(LOCALBIN)/goreleaser LICENSE_EYE ?= $(LOCALBIN)/license-eye VHS ?= $(LOCALBIN)/vhs @@ -89,11 +88,6 @@ $(GOLANGCI_LINT): $(LOCALBIN) 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) $(LICENSE_EYE): $(LOCALBIN) From f896eec6954969b46bad1cd7a03d79cba79a1d57 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Fri, 24 Oct 2025 16:56:31 +0200 Subject: [PATCH 5/7] fix: Aborted -> Canceled --- internal/cmd/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 43c797a..b2ea021 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -126,7 +126,7 @@ func NewRootCmd() *cobra.Command { namespacedContext.Context, ), ) - fmt.Println("Aborted") + fmt.Println("Canceled") return err } // Otherwise, ask for confirmation @@ -143,7 +143,7 @@ func NewRootCmd() *cobra.Command { runCmd(wrappedCmd, wrappedArgs) return nil } - fmt.Println("Aborted") + fmt.Println("Canceled") return nil }, } From e65876249307a00694d4e359d11efd4f9736acf9 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Fri, 24 Oct 2025 17:11:50 +0200 Subject: [PATCH 6/7] feat: keep track of num canceled commands --- internal/cmd/root.go | 9 ++++++-- internal/core/models.go | 20 +++++++++++------- internal/core/models_test.go | 2 +- internal/repositories/file_system_test.go | 25 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b2ea021..ccc228d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -127,7 +127,11 @@ func NewRootCmd() *cobra.Command { ), ) fmt.Println("Canceled") - return err + if err != nil { + return err + } + contextConf.Stats.CanceledCount += 1 + return repo.SaveSettings(*settings) } // Otherwise, ask for confirmation proceed, err := utils.Confirm( @@ -144,7 +148,8 @@ func NewRootCmd() *cobra.Command { return nil } fmt.Println("Canceled") - return nil + contextConf.Stats.CanceledCount += 1 + return repo.SaveSettings(*settings) }, } diff --git a/internal/core/models.go b/internal/core/models.go index 19613af..cd8d894 100644 --- a/internal/core/models.go +++ b/internal/core/models.go @@ -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 { @@ -62,6 +67,7 @@ func NewContextConf( Name: contextName, ProtectedCommands: safeActions, IsRegex: utils.IsRegex(contextName), + Stats: &ContextStats{CanceledCount: 0}, } } @@ -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 ®exConf, true } } - return conf, ok + return &conf, ok } func (s *Settings) ContainsContext(context string) bool { diff --git a/internal/core/models_test.go b/internal/core/models_test.go index 8d8f529..d2336eb 100644 --- a/internal/core/models_test.go +++ b/internal/core/models_test.go @@ -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) }) } diff --git a/internal/repositories/file_system_test.go b/internal/repositories/file_system_test.go index b3aaf7c..0997e29 100644 --- a/internal/repositories/file_system_test.go +++ b/internal/repositories/file_system_test.go @@ -45,6 +45,31 @@ func newTestFsRepository() *FileSystemRepository { } } +func TestSettingsRepository_UpdateContextStats(t *testing.T) { + t.Run("Success", func(t *testing.T) { + repo := newTestFsRepository() + settings := newSettings("context1", "context2") + // Save + err := repo.SaveSettings(settings) + assert.NoError(t, err) + // Update stats + loadedSettings, err := repo.LoadSettings() + assert.NoError(t, err) + contextConf, found := loadedSettings.GetContextConf("context1") + assert.True(t, found) + assert.Equal(t, uint(0), contextConf.Stats.CanceledCount) + contextConf.Stats.CanceledCount += 1 + err = repo.SaveSettings(*loadedSettings) + assert.NoError(t, err) + // Load and verify + updatedSettings, err := repo.LoadSettings() + assert.NoError(t, err) + updatedContextConf, found := updatedSettings.GetContextConf("context1") + assert.True(t, found) + assert.Equal(t, uint(1), updatedContextConf.Stats.CanceledCount) + }) +} + func TestSettingsRepository_SaveAndLoadSettings(t *testing.T) { t.Run("Success", func(t *testing.T) { workingRepository := newTestFsRepository() From 54cabde956b4df30fff3498c1eee51c5647913b7 Mon Sep 17 00:00:00 2001 From: Michele Zanotti Date: Fri, 24 Oct 2025 18:06:38 +0200 Subject: [PATCH 7/7] feat: show stats command --- internal/cmd/root.go | 1 + internal/cmd/stats.go | 93 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 internal/cmd/stats.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ccc228d..a8c33d2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -155,6 +155,7 @@ func NewRootCmd() *cobra.Command { // 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 diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go new file mode 100644 index 0000000..7eb7c48 --- /dev/null +++ b/internal/cmd/stats.go @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Michele Zanotti + * + * 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 +}