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
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