From e445c1ca991956ddff63b6c6b5f28ab2078ad297 Mon Sep 17 00:00:00 2001 From: "Urko." Date: Fri, 23 Aug 2024 14:11:38 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + LICENSE | 8 ++ Makefile | 47 ++++++++ README.md | 108 ++++++++++++++++++ cmd/watch_linux.go | 112 ++++++++++++++++++ cmd/windows/main.go | 129 +++++++++++++++++++++ go.mod | 19 ++++ go.sum | 33 ++++++ internal/notifier.go | 37 ++++++ internal/notifier_test.go | 27 +++++ internal/testdata/file-to-watch.txt | 4 + internal/testdata/test-script.sh | 2 + internal/watcher.go | 86 ++++++++++++++ internal/watcher_test.go | 170 ++++++++++++++++++++++++++++ main.go | 42 +++++++ 15 files changed, 827 insertions(+) create mode 100644 .gitignore create mode 100755 LICENSE create mode 100755 Makefile create mode 100755 README.md create mode 100644 cmd/watch_linux.go create mode 100644 cmd/windows/main.go create mode 100755 go.mod create mode 100755 go.sum create mode 100755 internal/notifier.go create mode 100755 internal/notifier_test.go create mode 100755 internal/testdata/file-to-watch.txt create mode 100755 internal/testdata/test-script.sh create mode 100755 internal/watcher.go create mode 100755 internal/watcher_test.go create mode 100755 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45b0e29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin +coverage +configs \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..70c58b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +---- Definitions ---- +license means right to use + + +Everybody is invited to contribute to improve this project and the main idea. +This idea which is to help the community to develop more secure code. + +By the grace of YAHWEH \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..475ae11 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +BINARY_DIR=bin +BINARY_NAME=watch-spring +APP_NAME := watch-spring +COVERAGE_DIR=coverage +MAIN := ./main.go +WIN_MAIN := ./cmd/windows/main.go + +lint: + golangci-lint run ./... +goreportcard: + goreportcard-cli -v +test: + go test ./... +test-coverage: + rm -rf ${COVERAGE_DIR} + mkdir ${COVERAGE_DIR} + go test -v -coverprofile ${COVERAGE_DIR}/cover.out ./... + go tool cover -html ${COVERAGE_DIR}/cover.out -o ${COVERAGE_DIR}/cover.html + +.PHONY: build_linux_amd64 +build_linux_amd64: + @mkdir -p $(BINARY_DIR) + GOOS=linux GOARCH=amd64 go build -o $(BINARY_DIR)/$(APP_NAME)_linux_amd64 $(MAIN) + +.PHONY: build_linux_arm64 +build_linux_arm64: + @mkdir -p $(BINARY_DIR) + GOOS=linux GOARCH=arm64 go build -o $(BINARY_DIR)/$(APP_NAME)_linux_arm64 $(MAIN) + +.PHONY: build_windows_amd64 +build_windows_amd64: + @mkdir -p $(BINARY_DIR) + GOOS=windows GOARCH=amd64 go build -o $(BINARY_DIR)/$(APP_NAME)_windows_amd64.exe $(WIN_MAIN) + +.PHONY: build_windows_386 +build_windows_386: + @mkdir -p $(BINARY_DIR) + GOOS=windows GOARCH=386 go build -o $(BINARY_DIR)/$(APP_NAME)_windows_386.exe $(WIN_MAIN) + + +.PHONY: clean +clean: + rm -rf $(BINARY_DIR) + +.PHONY: rebuild +rebuild: clean build_linux_amd64 build_linux_arm64 build_windows_amd64 build_windows_386 + diff --git a/README.md b/README.md new file mode 100755 index 0000000..b72e3df --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ + +# watch-spring + +`watch-spring` is a Windows service written in Go that monitors a specified directory for changes and executes a script when changes are detected. This project is intended for automating tasks such as running backup jobs or deploying files when changes occur in a specified directory. + +## Features + +- **Directory Monitoring**: Monitors a specified directory for file changes (specifically write operations). +- **Script Execution**: Automatically executes a specified script when changes are detected in the monitored directory. +- **Windows Service**: Runs as a Windows service, allowing the process to run in the background, even when no user is logged in. +- **Logging**: Supports logging with different levels of verbosity. + +## Installation + +1. **Build the Windows Service Executable**: + - Ensure you have Go installed on your machine. + - Clone the repository and navigate to the project directory. + - Build the service executable: + + ```sh + go build -o watch-spring.exe ./cmd + ``` + +2. **Create the Windows Service**: + - Use the built executable to create a Windows service using the `sc` command: + + ```sh + sc create "WatchSpringService" binPath= "C:\path\to\watch-spring.exe --dir C:\path\to\watch --script C:\path\to\script.bat --name BackupService" + ``` + +## Usage + +### Command-Line Flags + +| Flag | Description | Example | +|------------|----------------------------------------------------------|--------------------------------------| +| `--dir` | Directory to monitor for changes. | `--dir "C:\backup\ovh5win2oneshot"` | +| `--script` | Script to execute when a change is detected. | `--script "C:\scripts\rclone-deploy.bat"` | +| `--name` | Name of the service (used for logging and identification). | `--name "BackupService"` | +| `--log-level` | Log verbosity level (0 = Panic, 1 = Fatal, 2 = Error, 3 = Warn, 4 = Info, 5 = Debug, 6 = Trace). | `--log-level 4` | + +### Example + +To create and run a Windows service that monitors the `C:\backup\ovh5win2oneshot` directory and executes the `rclone-deploy.bat` script when changes are detected, use the following command: + +```sh +C:\path\to\watch-spring.exe --dir "C:\backup\ovh5win2oneshot" --script "C:\scripts\rclone-deploy.bat" --name "BackupService" --log-level 4 +``` + +### Running the Service + +After creating the service, it can be managed using standard Windows service commands: + +- **Start the service**: + +```sh + sc start WatchSpringService +``` + +- **Stop the service**: + +```sh +sc stop WatchSpringService +``` + +- **Delete the service**: + +```sh +sc delete WatchSpringService +``` + +## Logging + +Logging is handled using [logrus](https://github.com/sirupsen/logrus), providing different levels of verbosity. The log level can be set using the `--log-level` flag. By default, the log level is set to `Info` (level 4). + +## Development + +### Prerequisites + +- Go 1.16+ (for building the service) + +### Building the Project + +To build the project, run: + +```sh +go build -o watch-spring.exe ./cmd +``` + +### Running the Service in Interactive Mode + +While developing, you might want to run the service in interactive mode to see logs directly in the console. Simply omit the service installation steps and run the executable with the desired flags. + +```sh +watch-spring.exe --dir "C:\path\to\monitor" --script "C:\path\to\script.bat" --name "TestService" --log-level 5 +``` + +## Contributing + +Contributions are welcome! If you have any ideas, suggestions, or bug reports, feel free to open an issue or submit a pull request. + +## License + +This project is licensed under the License - see the [LICENSE](LICENSE) file for details. + +--- + +This README should give a comprehensive overview of your project, including installation, usage, and development instructions. If you need more specific details or additional sections, feel free to ask! diff --git a/cmd/watch_linux.go b/cmd/watch_linux.go new file mode 100644 index 0000000..a589403 --- /dev/null +++ b/cmd/watch_linux.go @@ -0,0 +1,112 @@ +//go:build linux +// +build linux + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "gitea.urkob.com/mcr-swiss/watch-spring/internal" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var Watch = &cobra.Command{ + Use: "watch", + Short: "", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) // or any appropriate time + defer cancel() + + // log.SetFlags(log.Ldate | log.Lmicroseconds) + + // log.SetFlags(log.Lmicroseconds) + // if cfg.LogFile { + // logFileName := fmt.Sprintf("%s.txt", time.Now().Format(strings.ReplaceAll(time.RFC1123Z, ":", "_"))) + // f, err := os.OpenFile(logFileName, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + // if err != nil { + // panic(err) + // } + + // defer f.Close() + // log.SetOutput(f) + // } + + dirToWatch, err := cmd.Flags().GetString("dir") + if err != nil { + panic(fmt.Errorf("dir %w", err)) + } + logLevel, err := cmd.Flags().GetUint("log-level") + if err != nil { + panic(fmt.Errorf("bucket %w", err)) + } + logger := logrus.New() + logger.SetLevel(logrus.Level(logLevel)) + + logger.Info("start check") + defer logger.Info("end check") + + // C:\scripts\watch-spring.exe --dir "C:\backup\ovh5win2oneshot" --script="C:\scripts\rclone-deploy.bat" + + notif := internal.NewNotifier() + w, err := internal.NewWatcher(logger, notif, internal.Execute) + if err != nil { + panic(err) + } + + defer func() { + if err := w.Close(); err != nil { + log.Fatalf("watcherIface.Close: %s\n", err) + } + }() + + if err := w.Monitor(dirToWatch); err != nil { + log.Fatalf("watcherIface.Monitor: %s\n", err) + } + + errChan := make(chan error) + go w.Listen(ctx, errChan, "", "") + + // Handle termination on ctrl+signalChan + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + for { + select { + case <-signalChan: + log.Println("shutdown") + os.Exit(1) + case err, ok := <-errChan: + if !ok { + panic(fmt.Errorf("channel has been closed")) + } + if err != nil { + log.Printf("watcherIface.Monitor: %s\n", err) + continue + } + } + } + + // if err := mailSrv.SendOK(email.EmailWithAttachments{ + // To: cfg.MailTo, + // Bucket: bucketName, + // BackupDir: dirToWatch, + // Count: map[string]int{ + // "count_ok": countOK, + // "count_local": countLocal, + // "count_b2": countB2, + // "count_not_in_local": countNotInLocal, + // "count_not_in_cloud": countNotInCloud, + // }, + // Attachments: []email.EmailAttachment{{File: bytes.NewReader([]byte(reportBuilder.String())), Title: fmt.Sprintf("%s-check-report.txt", bucketName)}}, + // }); err != nil { + // panic(fmt.Errorf("error while send email: %w", err)) + // } + }, +} diff --git a/cmd/windows/main.go b/cmd/windows/main.go new file mode 100644 index 0000000..fa6e211 --- /dev/null +++ b/cmd/windows/main.go @@ -0,0 +1,129 @@ +//go:build windows +// +build windows + +package cmd + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + + "time" + + "gitea.urkob.com/mcr-swiss/watch-spring/internal" + "github.com/sirupsen/logrus" + "golang.org/x/sys/windows/svc" +) + +type WinService struct { + logger *logrus.Logger + dirToWatch string + scriptToExecutePath string + args []string +} + +func NewWinService(logger *logrus.Logger, fileToWatchPath, scriptToExecutePath string, args ...string) *WinService { + return &WinService{ + logger: logger, + dirToWatch: fileToWatchPath, + scriptToExecutePath: scriptToExecutePath, + args: args, + } +} + +func (winsvc *WinService) Execute(args []string, req <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + notif := internal.NewNotifier() + w, err := internal.NewWatcher(winsvc.logger, notif, internal.Execute) + if err != nil { + panic(err) + } + + defer func() { + if err := w.Close(); err != nil { + log.Fatalf("watcherIface.Close: %s\n", err) + } + }() + + if err := w.Monitor(winsvc.dirToWatch); err != nil { + log.Fatalf("watcherIface.Monitor: %s\n", err) + } + + errChan := make(chan error) + go w.Listen(ctx, errChan, winsvc.scriptToExecutePath) + +loop: + for { + select { + case r := <-req: + switch r.Cmd { + case svc.Stop, svc.Shutdown: + cancel() + break loop + default: + winsvc.logger.Debugf("Received control request: %v", r.Cmd) + } + case err, ok := <-errChan: + if !ok { + cancel() + winsvc.logger.Error(fmt.Errorf("channel has been closed")) + break loop + } + if err != nil { + winsvc.logger.Error(err) + continue + } + default: + time.Sleep(2 * time.Second) + } + } + + status <- svc.Status{State: svc.StopPending} + return false, 0 +} + +func main() { + // Define command-line flags + dirFlag := flag.String("dir", "", "Directory to monitor") + scriptFlag := flag.String("script", "", "Script to execute") + nameFlag := flag.String("name", "", "Name") + logLevelFlag := flag.Uint("log-level", uint(logrus.InfoLevel), "Log Leven") + flag.Parse() + + if *dirFlag == "" || *scriptFlag == "" || *nameFlag == "" { + log.Fatal("Both --name --dir and --script flags are required") + } + + logger := logrus.New() + logger.SetLevel(logrus.Level(*logLevelFlag)) + + if err := runService(logger, fmt.Sprintf("WatchSpring-%s", *nameFlag), NewWinService( + logger, + *dirFlag, + *scriptFlag, + )); err != nil { + panic(err) + } +} + +func runService(logger *logrus.Logger, name string, svcHandler *WinService) error { + isWindowsService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("failed to determine if we are running in an interactive session: %w", err) + } + if !isWindowsService { + return errors.New("Running in interactive mode.") + } + + logger.Debug("Running as a Windows service.") + if err := svc.Run(name, svcHandler); err != nil { + return fmt.Errorf("failed to run service: %w", err) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..02f32f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module gitea.urkob.com/mcr-swiss/watch-spring + +go 1.23 + +require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.8.2 + golang.org/x/sys v0.20.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100755 index 0000000..1a14d55 --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/notifier.go b/internal/notifier.go new file mode 100755 index 0000000..728bbb3 --- /dev/null +++ b/internal/notifier.go @@ -0,0 +1,37 @@ +package internal + +import ( + "fmt" + "os" + "os/exec" + + "github.com/fsnotify/fsnotify" +) + +type NotifyIface interface { + NewWatcher() (*fsnotify.Watcher, error) +} + +type ExecFunc func(executableFilePath string, args ...string) error + +func Execute(executableFilePath string, args ...string) error { + cmd := exec.Command(executableFilePath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("cmd.Run %s", err) + } + + return nil +} + +type Notifier struct{} + +func (n *Notifier) NewWatcher() (*fsnotify.Watcher, error) { + return fsnotify.NewWatcher() +} + +func NewNotifier() *Notifier { + return &Notifier{} +} diff --git a/internal/notifier_test.go b/internal/notifier_test.go new file mode 100755 index 0000000..c474eba --- /dev/null +++ b/internal/notifier_test.go @@ -0,0 +1,27 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeploy(t *testing.T) { + err := Execute(binaryPath, scriptPath) + require.NoError(t, err) +} + +func TestDeployError(t *testing.T) { + err := Execute("", "") + require.Error(t, err) +} + +func TestExecute(t *testing.T) { + err := Execute(binaryPath, scriptPath) + require.NoError(t, err) +} + +func TestExecuteError(t *testing.T) { + err := Execute("", "") + require.Error(t, err) +} diff --git a/internal/testdata/file-to-watch.txt b/internal/testdata/file-to-watch.txt new file mode 100755 index 0000000..1674c50 --- /dev/null +++ b/internal/testdata/file-to-watch.txt @@ -0,0 +1,4 @@ +a +d + +dsdf \ No newline at end of file diff --git a/internal/testdata/test-script.sh b/internal/testdata/test-script.sh new file mode 100755 index 0000000..b4110f4 --- /dev/null +++ b/internal/testdata/test-script.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "deploy script has been called" \ No newline at end of file diff --git a/internal/watcher.go b/internal/watcher.go new file mode 100755 index 0000000..0125045 --- /dev/null +++ b/internal/watcher.go @@ -0,0 +1,86 @@ +package internal + +import ( + "context" + "errors" + "fmt" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" +) + +var ( + errEventsClosedChan = errors.New("events is closed") + errErrorsClosedChan = errors.New("errors is closed") + errNotWriteOp = errors.New("is not Write") +) + +type Watcher struct { + logger *logrus.Logger + fswatcher *fsnotify.Watcher + deploy ExecFunc +} + +func NewWatcher(logger *logrus.Logger, notifier NotifyIface, deploy ExecFunc) (*Watcher, error) { + wt, err := notifier.NewWatcher() + if err != nil { + return nil, fmt.Errorf("fsnotify.NewWatcher: %w", err) + } + return &Watcher{ + logger: logger, + fswatcher: wt, + deploy: deploy, + }, nil +} + +func (w *Watcher) Monitor(path string) error { + return w.fswatcher.Add(path) +} + +// Start listening for events. +func (w *Watcher) Listen(ctx context.Context, errChan chan<- error, scriptPath string, args ...string) { + for { + select { + case <-ctx.Done(): + errChan <- fmt.Errorf("context cancelled: %w", ctx.Err()) + return + case event, ok := <-w.fswatcher.Events: + if err := ctx.Err(); err != nil { + errChan <- fmt.Errorf("context cancelled: %w", ctx.Err()) + return + } + if !ok { + errChan <- errEventsClosedChan + return + } + + if !event.Has(fsnotify.Write) { + errChan <- fmt.Errorf("%w: %s", errNotWriteOp, event.Name) + continue + } + w.logger.Debugf("event: %s | op: %s \n", event.Name, event.Op) + + if err := w.deploy(scriptPath, args...); err != nil { + w.logger.Debugf("deploy: %s\n", err) + errChan <- err + continue + } + case err, ok := <-w.fswatcher.Errors: + if err := ctx.Err(); err != nil { + errChan <- fmt.Errorf("context cancelled: %w", ctx.Err()) + return + } + if !ok { + //log.Println(errErrorsClosedChan) + errChan <- errErrorsClosedChan + return + } + w.logger.Debugf("<-errors: %s\n", err) + errChan <- err + } + } +} + +func (w *Watcher) Close() error { + return w.fswatcher.Close() +} diff --git a/internal/watcher_test.go b/internal/watcher_test.go new file mode 100755 index 0000000..821ed8c --- /dev/null +++ b/internal/watcher_test.go @@ -0,0 +1,170 @@ +package internal + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +var logger *logrus.Logger + +type testErrorNotifier struct { + *fsnotify.Watcher +} + +func (n *testErrorNotifier) NewWatcher() (*fsnotify.Watcher, error) { + return nil, errIntentional +} + +var ( + errNotifier = &testErrorNotifier{} + okNotifier = &Notifier{} +) + +var ( + mockDeploy ExecFunc + mockErrorDeploy ExecFunc + errIntentional = errors.New("intentional error") + binaryPath = "" + scriptPath = "" + executionMaxTimeout = time.Second * 2 + events = []fsnotify.Event{ + { + Name: "test event", + Op: fsnotify.Create, + }, + { + Name: "Write", + Op: fsnotify.Write, + }, + } +) + +func TestMain(m *testing.M) { + logger = logrus.New() + logger.SetLevel(logrus.DebugLevel) + + mockDeploy = func(executableFilePath string, args ...string) error { + return nil + } + + mockErrorDeploy = func(executableFilePath string, args ...string) error { + return errIntentional + } +} + +func sendTestEvents(w *Watcher) { + for _, event := range events { + w.fswatcher.Events <- event + } +} + +func newWatcher() (*Watcher, error) { + return NewWatcher(logger, okNotifier, mockDeploy) +} + +func newWatcherWithDeployError() (*Watcher, error) { + return NewWatcher(logger, okNotifier, mockErrorDeploy) +} + +func newWatcherWithCtorError() (*Watcher, error) { + return NewWatcher(logger, errNotifier, mockDeploy) +} + +func Test_NewNotifier(t *testing.T) { + require.NotNil(t, NewNotifier()) +} + +func Test_NewWatcher(t *testing.T) { + w, err := newWatcher() + require.NoError(t, err) + require.NotNil(t, w) +} + +func Test_ErrorNewWatcher(t *testing.T) { + w, err := newWatcherWithCtorError() + require.NoError(t, err) + require.NotNil(t, w) +} + +func Test_Close(t *testing.T) { + w, err := newWatcher() + require.NoError(t, err) + require.NotNil(t, w) + require.NoError(t, w.Close()) +} + +func Test_Monitor(t *testing.T) { + w, err := newWatcher() + require.NoError(t, err) + require.NoError(t, w.Monitor("testdata/file-to-watch.txt")) +} + +func Test_ListenSuccess(t *testing.T) { + w, err := newWatcher() + require.NoError(t, err) + ctx, errors := listenWithSendEvents(w) + + for { + select { + case <-ctx.Done(): + return + case err := <-errors: + require.NoError(t, err) + return + } + } +} + +func Test_ListenError(t *testing.T) { + w, err := newWatcherWithDeployError() + require.NoError(t, err) + ctx, errors := listenWithSendEvents(w) + + for { + select { + case <-ctx.Done(): + return + case err := <-errors: + require.Error(t, err) + require.EqualError(t, err, errIntentional.Error()) + return + } + } +} + +func Test_ListenErrorChanClose(t *testing.T) { + w, err := newWatcher() + require.NoError(t, err) + ctx, errors := listenWithSendEvents(w) + close(w.fswatcher.Events) + for { + select { + case <-ctx.Done(): + return + case err := <-errors: + require.Error(t, err) + require.EqualError(t, err, errEventsClosedChan.Error()) + return + } + } +} + +func listenWithSendEvents(w *Watcher) (context.Context, chan error) { + ctx, cancel := context.WithTimeout(context.Background(), executionMaxTimeout) + errChan := make(chan error) + + go w.Listen(ctx, errChan, scriptPath) + go func(cancel context.CancelFunc) { + time.Sleep(executionMaxTimeout) + cancel() + }(cancel) + + sendTestEvents(w) + return ctx, errChan +} diff --git a/main.go b/main.go new file mode 100755 index 0000000..69d0295 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +//go:build linux +// +build linux + +package main + +import ( + "fmt" + "os" + + "gitea.urkob.com/mcr-swiss/watch-spring/cmd" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "watch-spring", + Short: "", + Long: ``, +} + +func init() { + rootCmd.AddCommand(cmd.Watch) + + // cmd.Sync.PersistentFlags().String("file", "", "absolute path of the file you want to upload to backblaze") + // cmd.Sync.PersistentFlags().String("dir", "", "absolute path of the directory you want to upload to backblaze") + // cmd.Sync.PersistentFlags().String("bucket", "", "backblaze bucket name") + + // cmd.Sync.PersistentFlags().Int("workers", runtime.NumCPU(), "The number of worker goroutines that are spawned to handle file processing in parallel. Each worker handles the upload of one file at a time, allowing for efficient use of system resources when dealing with a large number of files. The default value is the number of CPU cores available on the system, enabling the application to automatically scale its parallel processing capabilities based on the available hardware.") + + // cmd.Sync.PersistentFlags().Int("concurrent-uploads", 4, "The number of chunk uploads that can be performed simultaneously for a single file. When a file is uploaded, it might be split into multiple chunks to enable more efficient and reliable data transfer. The concurrent-uploads flag controls how many of these chunks can be uploaded in parallel during a single file upload. This is particularly useful for large files, where parallel chunk uploads can significantly speed up the overall upload time. The default value is 4.") + + // cmd.Check.PersistentFlags().String("dir", "", "Specifies the absolute path of the directory containing the backup files to be compared against the Backblaze B2 bucket. This flag is mutually exclusive with the 'file' flag.") + // cmd.Check.PersistentFlags().String("bucket", "", "Name of the Backblaze B2 bucket against which the local files or directory will be compared.") + + // cmd.Cleanup.PersistentFlags().String("bucket", "", "backblaze bucket name") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +}