Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"strings"

"github.com/patrickdappollonio/kubectl-slice/pkg/template"
"github.com/patrickdappollonio/kubectl-slice/slice"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -101,7 +102,7 @@ func root() *cobra.Command {
rootCommand.Flags().StringSliceVar(&opts.InputFolderExt, "extensions", []string{".yaml", ".yml"}, "the extensions to look for in the input folder")
rootCommand.Flags().BoolVarP(&opts.Recurse, "recurse", "r", false, "if true, the input folder will be read recursively (has no effect unless used with --input-folder)")
rootCommand.Flags().StringVarP(&opts.OutputDirectory, "output-dir", "o", "", "the output directory used to output the splitted files")
rootCommand.Flags().StringVarP(&opts.GoTemplate, "template", "t", slice.DefaultTemplateName, "go template used to generate the file name when creating the resource files in the output directory")
rootCommand.Flags().StringVarP(&opts.GoTemplate, "template", "t", template.DefaultTemplateName, "go template used to generate the file name when creating the resource files in the output directory")
rootCommand.Flags().BoolVar(&opts.DryRun, "dry-run", false, "if true, no files are created, but the potentially generated files will be printed as the command output")
rootCommand.Flags().BoolVar(&opts.DebugMode, "debug", false, "enable debug mode")
rootCommand.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "if true, no output is written to stdout/err")
Expand Down
74 changes: 74 additions & 0 deletions pkg/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package errors

import (
"fmt"
"strings"
)

// StrictModeSkipErr represents an error when a Kubernetes resource is skipped
// in strict mode because a required field is missing or empty
type StrictModeSkipErr struct {
FieldName string
}

func (s *StrictModeSkipErr) Error() string {
return fmt.Sprintf(
"resource does not have a Kubernetes %q field or the field is invalid or empty", s.FieldName,
)
}

// SkipErr represents an error when a Kubernetes resource is intentionally skipped
// based on user-provided include/exclude filter configuration
type SkipErr struct {
Name string
Kind string
Group string
Reason string
}

func (e *SkipErr) Error() string {
if e.Name == "" && e.Kind == "" {
if e.Group != "" {
if e.Reason != "" {
return fmt.Sprintf("resource with API group %q is skipped: %s", e.Group, e.Reason)
}
return fmt.Sprintf("resource with API group %q is configured to be skipped", e.Group)
}
return "resource is configured to be skipped"
}

if e.Reason != "" {
return fmt.Sprintf("resource %s %q is skipped: %s", e.Kind, e.Name, e.Reason)
}
return fmt.Sprintf("resource %s %q is configured to be skipped", e.Kind, e.Name)
}

// nonKubernetesMessage provides a standard error message for YAML files that don't contain
// standard Kubernetes metadata and are likely not Kubernetes resources
const nonKubernetesMessage = `the file has no Kubernetes metadata: it is most likely a non-Kubernetes YAML file, you can skip it with --skip-non-k8s`

// CantFindFieldErr represents an error when a required field is missing in a Kubernetes
// resource. It includes contextual information about the file and resource.
type CantFindFieldErr struct {
FieldName string
FileCount int
Meta interface{}
}

func (e *CantFindFieldErr) Error() string {
var sb strings.Builder

sb.WriteString(fmt.Sprintf(
"unable to find Kubernetes %q field in file %d",
e.FieldName, e.FileCount,
))

// Type assertion to check if Meta has an empty() method
if metaWithEmpty, ok := e.Meta.(interface{ empty() bool }); ok && metaWithEmpty.empty() {
sb.WriteString(": " + nonKubernetesMessage)
} else if meta, ok := e.Meta.(fmt.Stringer); ok {
sb.WriteString(fmt.Sprintf(": %s", meta.String()))
}

return sb.String()
}
174 changes: 174 additions & 0 deletions pkg/errors/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package errors

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestStrictModeSkipErr_Error(t *testing.T) {
tests := []struct {
name string
fieldName string
want string
}{
{
name: "with metadata.name field",
fieldName: "metadata.name",
want: "resource does not have a Kubernetes \"metadata.name\" field or the field is invalid or empty",
},
{
name: "with kind field",
fieldName: "kind",
want: "resource does not have a Kubernetes \"kind\" field or the field is invalid or empty",
},
{
name: "with empty field",
fieldName: "",
want: "resource does not have a Kubernetes \"\" field or the field is invalid or empty",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StrictModeSkipErr{
FieldName: tt.fieldName,
}

require.Equal(t, tt.want, s.Error())
})
}
}

func TestSkipErr_Error(t *testing.T) {
tests := []struct {
name string
err SkipErr
want string
}{
{
name: "with name and kind",
err: SkipErr{
Name: "my-pod",
Kind: "Pod",
},
want: "resource Pod \"my-pod\" is configured to be skipped",
},
{
name: "with name, kind and reason",
err: SkipErr{
Name: "my-pod",
Kind: "Pod",
Reason: "matched exclusion filter",
},
want: "resource Pod \"my-pod\" is skipped: matched exclusion filter",
},
{
name: "with group only",
err: SkipErr{
Group: "apps/v1",
},
want: "resource with API group \"apps/v1\" is configured to be skipped",
},
{
name: "with group and reason",
err: SkipErr{
Group: "apps/v1",
Reason: "matched exclusion filter",
},
want: "resource with API group \"apps/v1\" is skipped: matched exclusion filter",
},
{
name: "empty fields",
err: SkipErr{},
want: "resource is configured to be skipped",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, tt.err.Error())
})
}
}

// mockMeta implements the empty() method for testing CantFindFieldErr
type mockMeta struct {
isEmpty bool
str string
}

func (m mockMeta) empty() bool {
return m.isEmpty
}

func (m mockMeta) String() string {
return m.str
}

// mockMetaStringOnly implements just the String() method without empty()
type mockMetaStringOnly struct {
str string
}

func (m mockMetaStringOnly) String() string {
return m.str
}

func TestErrorsInterface(t *testing.T) {
require.Implementsf(t, (*error)(nil), &StrictModeSkipErr{}, "StrictModeSkipErr should implement error")
require.Implementsf(t, (*error)(nil), &SkipErr{}, "SkipErr should implement error")
require.Implementsf(t, (*error)(nil), &CantFindFieldErr{}, "CantFindFieldErr should implement error")
}

func TestCantFindFieldErr_Error(t *testing.T) {
tests := []struct {
name string
fieldName string
fileCount int
meta interface{}
want string
}{
{
name: "with empty meta",
fieldName: "metadata.name",
fileCount: 1,
meta: mockMeta{isEmpty: true},
want: "unable to find Kubernetes \"metadata.name\" field in file 1: " + nonKubernetesMessage,
},
{
name: "with non-empty meta with stringer",
fieldName: "metadata.name",
fileCount: 2,
meta: mockMeta{isEmpty: false, str: "Pod/my-pod"},
want: "unable to find Kubernetes \"metadata.name\" field in file 2: Pod/my-pod",
},
{
name: "with meta implementing only String",
fieldName: "kind",
fileCount: 3,
meta: mockMetaStringOnly{str: "Kind/Deployment"},
want: "unable to find Kubernetes \"kind\" field in file 3: Kind/Deployment",
},
{
name: "with nil meta",
fieldName: "kind",
fileCount: 4,
meta: nil,
want: "unable to find Kubernetes \"kind\" field in file 4",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &CantFindFieldErr{
FieldName: tt.fieldName,
FileCount: tt.fileCount,
Meta: tt.meta,
}
if got := e.Error(); got != tt.want {
t.Errorf("CantFindFieldErr.Error() = %q, want %q", got, tt.want)
}
})
}
}
47 changes: 23 additions & 24 deletions slice/utils.go → pkg/files/io.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
package slice
package files

import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
)

func inarray[T comparable](needle T, haystack []T) bool {
for _, v := range haystack {
if v == needle {
return true
}
}

return false
}

// loadfolder reads the folder contents recursively for `.yaml` and `.yml` files
// and returns a buffer with the contents of all files found; returns the buffer
// with all the files separated by `---` and the number of files found
func loadfolder(extensions []string, folderPath string, recurse bool) (*bytes.Buffer, int, error) {
// LoadFolder reads contents from files with matching extensions in the specified folder.
// Returns a buffer with all file contents concatenated with "---" separators between them,
// a count of files processed, and any error encountered.
func LoadFolder(extensions []string, folderPath string, recurse bool) (*bytes.Buffer, int, error) {
var buffer bytes.Buffer
var count int

Expand All @@ -39,7 +30,7 @@ func loadfolder(extensions []string, folderPath string, recurse bool) (*bytes.Bu
}

ext := strings.ToLower(filepath.Ext(path))
if inarray(ext, extensions) {
if inArray(ext, extensions) {
count++

data, err := os.ReadFile(path)
Expand Down Expand Up @@ -67,8 +58,10 @@ func loadfolder(extensions []string, folderPath string, recurse bool) (*bytes.Bu
return &buffer, count, nil
}

func loadfile(fp string) (*bytes.Buffer, error) {
f, err := openFile(fp)
// LoadFile reads a file from the filesystem and returns its contents as a buffer.
// Handles errors for file access issues.
func LoadFile(fp string) (*bytes.Buffer, error) {
f, err := OpenFile(fp)
if err != nil {
return nil, err
}
Expand All @@ -83,14 +76,13 @@ func loadfile(fp string) (*bytes.Buffer, error) {
return &buf, nil
}

func openFile(fp string) (*os.File, error) {
if fp == os.Stdin.Name() {
// On Windows, the name in Go for stdin is `/dev/stdin` which doesn't
// exist. It must use the syscall to point to the file and open it
// OpenFile opens a file for reading with special handling for stdin.
// When the filename is "-", it returns os.Stdin instead of attempting to open a file.
func OpenFile(fp string) (*os.File, error) {
if fp == os.Stdin.Name() || fp == "-" {
return os.Stdin, nil
}

// Any other file that's not stdin can be opened normally
f, err := os.Open(fp)
if err != nil {
return nil, fmt.Errorf("unable to open file %q: %s", fp, err.Error())
Expand All @@ -99,7 +91,9 @@ func openFile(fp string) (*os.File, error) {
return f, nil
}

func deleteFolderContents(location string) error {
// DeleteFolderContents removes all files and subdirectories within the specified directory.
// The directory itself is preserved.
func DeleteFolderContents(location string) error {
f, err := os.Open(location)
if err != nil {
return fmt.Errorf("unable to open folder %q: %s", location, err.Error())
Expand All @@ -119,3 +113,8 @@ func deleteFolderContents(location string) error {

return nil
}

// inArray checks if an element exists in a slice
func inArray[T comparable](needle T, haystack []T) bool {
return slices.Contains(haystack, needle)
}
Loading