diff --git a/cli/parser.go b/cli/parser.go index f4d9127..3514ed3 100644 --- a/cli/parser.go +++ b/cli/parser.go @@ -12,12 +12,14 @@ import ( type ArgumentsReceived struct { KubeconfigFile, SourceClusterContext, TargetClusterContext, NamespaceName, FiltersForObject, Include, Exclude *string VerboseDiffs *int + FileOutput *string Err error } type ArgumentsReceivedValidated struct { KubeconfigFile, SourceClusterContext, TargetClusterContext, NamespaceName, FiltersForObject string Include, Exclude []string VerboseDiffs int + FileOutput string Err error } @@ -46,6 +48,7 @@ func PaserReader() ArgumentsReceivedValidated { Excludek8sObjects := parser.String("e", "exclude", &argparse.Options{Help: "List of kubernetes objects to include, this should be an element or a comma separated list."}) namespaceName := parser.String("n", "namespace", &argparse.Options{Help: "Namespace that needs to be copied. defaults to 'default' namespace. The option also accepts wilcard matching of namespace. E.G.: '*-pci' would match any namespace that ends with -pci. Notice that the '' might be required in some consoles like iterm"}) filtersForObject := parser.String("f", "filter", &argparse.Options{Help: "Filter what parts of the object I want to compare. must be used together with -i option to apply to that type of objects"}) + fileOutput := parser.String("l", "file", &argparse.Options{Required: false, Help: "Save the output to a file. If not provided, the output will be printed to the console."}) err := parser.Parse(os.Args) if err != nil { // In case of error print error and print usage @@ -60,6 +63,7 @@ func PaserReader() ArgumentsReceivedValidated { Include: []string{""}, Exclude: []string{""}, VerboseDiffs: *verboseDiffs, + FileOutput: "", Err: err} } TheArgs := ArgumentsReceived{ @@ -71,6 +75,7 @@ func PaserReader() ArgumentsReceivedValidated { Exclude: Excludek8sObjects, FiltersForObject: filtersForObject, VerboseDiffs: verboseDiffs, + FileOutput: fileOutput, Err: err} ArgumentsReceivedValidated := ValidateParametersFromParserArgs(TheArgs) return ArgumentsReceivedValidated @@ -134,6 +139,28 @@ func ValidateParametersFromParserArgs(TheArgs ArgumentsReceived) ArgumentsReceiv fmt.Println("The program will try to execute anyway, but the output might not be what you expect.") fmt.Println("The -f is to be used with one and only one -i include object type at the time.") } + file := *TheArgs.FileOutput + if file != "" { + valid, filePath, err := tools.IsValidPath(file) + if err == nil { + if valid { + fmt.Printf("The output will be saved to the file: %s\n", filePath) + } + } else { + fmt.Println(err) + } + return ArgumentsReceivedValidated{ + KubeconfigFile: configFile, + SourceClusterContext: strSourceClusterContext, + TargetClusterContext: strTargetClusterContext, + NamespaceName: strNamespaceName, + FiltersForObject: *TheArgs.FiltersForObject, + Include: includeStr, + Exclude: excludeStr, + VerboseDiffs: *TheArgs.VerboseDiffs, + FileOutput: filePath, + Err: nil} + } return ArgumentsReceivedValidated{ KubeconfigFile: configFile, SourceClusterContext: strSourceClusterContext, @@ -143,6 +170,7 @@ func ValidateParametersFromParserArgs(TheArgs ArgumentsReceived) ArgumentsReceiv Include: includeStr, Exclude: excludeStr, VerboseDiffs: *TheArgs.VerboseDiffs, + FileOutput: "", Err: nil} } diff --git a/cli/parser_test.go b/cli/parser_test.go index 1b01176..9972806 100644 --- a/cli/parser_test.go +++ b/cli/parser_test.go @@ -57,6 +57,7 @@ func TestValidateParametersFromParserArgs(t *testing.T) { Include: stringPtr("deployment"), Exclude: stringPtr("service"), VerboseDiffs: intPtr(1), + FileOutput: stringPtr("output.txt"), Err: nil, }, expectedArgsValidated: ArgumentsReceivedValidated{ @@ -68,6 +69,7 @@ func TestValidateParametersFromParserArgs(t *testing.T) { Include: []string{"deployment"}, Exclude: []string{"service"}, VerboseDiffs: 1, + FileOutput: "output.txt", Err: nil, }, }, diff --git a/main.go b/main.go index 43c15ad..a7ad7e8 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,38 @@ func main() { err := fmt.Errorf("error parsing arguments: %v", args.Err) panic(err) } + if args.FileOutput != "" { + defer tools.LogOutput(args.FileOutput)() + } + // var file *os.File + // if args.FileOutput != "" { + // _, fileString, _ := tools.IsValidPath(args.FileOutput) + // var err error + // file, err = os.Create(fileString) + // if err != nil { + // fmt.Println("Error opening file:", err) + // os.Exit(1) + // } + // defer file.Close() + // } + // // Create a multi-writer to write to both stdout and the file (if specified) + // var writers []io.Writer + // writers = append(writers, os.Stdout) + // if file != nil { + // writers = append(writers, file) + // } + // multiWriter := io.MultiWriter(writers...) + // // Use a goroutine to copy output to both stdout and the file + // go func(out io.Writer) { + // for { + // // Copy output to both stdout and the file + // _, err := io.Copy(out, os.Stdin) + // if err != nil { + // fmt.Println("Error copying output:", err) + // return + // } + // } + // }(multiWriter) // Connect to source cluster clientsetToSource, err := connect.ConnectToSource(args.SourceClusterContext, &args.KubeconfigFile) diff --git a/tools/file-works.go b/tools/file-works.go new file mode 100644 index 0000000..a3c97b1 --- /dev/null +++ b/tools/file-works.go @@ -0,0 +1,109 @@ +package tools + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" +) + +// isValidPath checks if the given path is valid for the current operating system. +func IsValidPath(path string) (isValid bool, absPath string, err error) { + // Check if the path contains invalid characters for the current OS. + var invalidChars string + switch runtime.GOOS { + case "windows": + invalidChars = `<>|?*` + default: + invalidChars = `<>:"\|?*` + } + + for _, char := range invalidChars { + if strings.ContainsRune(path, char) { + return false, "", fmt.Errorf("invalid character '%c' found in the path", char) + } + } + + // Check if the path is empty or consists of only whitespace characters. + if len(strings.TrimSpace(path)) == 0 { + return false, "", fmt.Errorf("empty path") + } + + // Convert the path to absolute path if it's relative. + if !filepath.IsAbs(path) { + // Get the current working directory. + cwd, err := os.Getwd() + if err != nil { + // Failed to get the current working directory. + return false, "", err + } + // Join the current working directory with the relative path to get absolute path. + absPath = filepath.Join(cwd, path) + } else { + absPath = path + } + + // Try to create or open the file. + file, err := os.OpenFile(absPath, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + // Check if the error is due to a missing directory in the path. + if os.IsNotExist(err) { + return false, "", fmt.Errorf("the file or one or more directories in the path do not exist or can not be created: %v", err) + } + // Check if the error is due to permission issues. + if os.IsPermission(err) { + return false, "", fmt.Errorf("no permission to write on path: %s", absPath) + } + // For other errors, return the error as is. + return false, "", err + } + // Defer the closure of the file and its removal. + defer func() { + file.Close() + os.Remove(absPath) + }() + + return true, absPath, nil +} + +func LogOutput(logfile string) func() { + // open file read/write | create if not exist | clear file at open if exists + f, _ := os.OpenFile(logfile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) + + // save existing stdout | MultiWriter writes to saved stdout and file + out := os.Stdout + mw := io.MultiWriter(out, f) + + // get pipe reader and writer | writes to pipe writer come out pipe reader + r, w, _ := os.Pipe() + + // replace stdout,stderr with pipe writer | all writes to stdout, stderr will go through pipe instead (fmt.print, log) + os.Stdout = w + os.Stderr = w + + // writes with log.Print should also write to mw + log.SetOutput(mw) + + //create channel to control exit | will block until all copies are finished + exit := make(chan bool) + + go func() { + // copy all reads from pipe to multiwriter, which writes to stdout and file + _, _ = io.Copy(mw, r) + // when r or w is closed copy will finish and true will be sent to channel + exit <- true + }() + + // function to be deferred in main until program exits + return func() { + // close writer then block on exit channel | this will let mw finish writing before the program exits + _ = w.Close() + <-exit + // close file after all writes have finished + _ = f.Close() + } + +} diff --git a/tools/file-works_test.go b/tools/file-works_test.go new file mode 100644 index 0000000..363640a --- /dev/null +++ b/tools/file-works_test.go @@ -0,0 +1,122 @@ +package tools + +import ( + "log" + "os" + "strings" + "testing" +) + +func TestIsValidPath(t *testing.T) { + // Example usage: + paths := map[string]struct { + expectedValid bool + expectedError string + }{ + "/path/to/valid/file.txt": { + expectedValid: false, + expectedError: "the file or one or more directories in the path do not exist or can not be created", + }, + "invalid*file.txt": { + expectedValid: false, + expectedError: "invalid character '*' found in the path", + }, + "": { + expectedValid: false, + expectedError: "empty path", + }, + " ": { + expectedValid: false, + expectedError: "empty path", + }, + "/path/with/invalid|character": { + expectedValid: false, + expectedError: "invalid character '|' found in the path", + }, + "C:\\Program Files\\Example\\file.txt": { + expectedValid: false, + expectedError: "invalid character ':' found in the path", + }, + "D:/Documents/Report.docx": { + expectedValid: false, + expectedError: "invalid character ':' found in the path", + }, + "/home/user/pictures/photo.jpg": { + expectedValid: false, + expectedError: "the file or one or more directories in the path do not exist or can not be created", + }, + "file.txt": { + expectedValid: true, + expectedError: "", + }, + "folder1/file.txt": { + expectedValid: false, + expectedError: "the file or one or more directories in the path do not exist or can not be created", + }, + "../parent/file.txt": { + expectedValid: false, + expectedError: "the file or one or more directories in the path do not exist or can not be created", + }, + "..\\parent\\file.txt": { + expectedValid: false, + expectedError: "invalid character '\\' found in the path", + }, + } + + for path, expected := range paths { + valid, _, err := IsValidPath(path) + if err != nil { + if !strings.Contains(err.Error(), expected.expectedError) { + t.Errorf("Unexpected error for path %s. Expected: %s, Got: %s", path, expected.expectedError, err.Error()) + } + } else { + if valid != expected.expectedValid { + t.Errorf("Path %s validation failed. Expected valid: %t but got: %t", path, expected.expectedValid, valid) + } + } + } +} + +func TestLogOutput(t *testing.T) { + // Create a temporary file for logging + tmpfile, err := os.CreateTemp("", "testlog-") + if err != nil { + t.Fatalf("Error creating temporary file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + // Create a channel to signal when the goroutine has completed + done := make(chan struct{}) + + // Call LogOutput with the temporary file + go func() { + defer close(done) + LogOutput(tmpfile.Name()) // Assuming LogOutput returns immediately after starting the goroutine + }() + + // Write a log message + log.Print("Test log message") + + // Wait for the goroutine to finish + <-done + + // Read and verify the contents of the log file + // (You should implement this verification logic) +} + +func TestLogOutputWithInvalidPath(t *testing.T) { + // Call LogOutput with an invalid path + err := LogOutput("invalid*file.txt") + if err == nil { + t.Errorf("Expected error for invalid path") + } + defer os.Remove("invalid*file.txt") +} + +func TestLogOutputWithEmptyPath(t *testing.T) { + // Call LogOutput with an empty path + err := LogOutput("") + if err == nil { + t.Errorf("Expected error for empty path") + } +}