diff --git a/hook/hook.go b/hook/hook.go index d5f43faf..02859c6f 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -273,7 +273,7 @@ func (h *ResponseHeaders) String() string { result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value) } - return fmt.Sprint(strings.Join(result, ", ")) + return strings.Join(result, ", ") } // Set method appends new Header object from header=value notation @@ -288,6 +288,23 @@ func (h *ResponseHeaders) Set(value string) error { return nil } +// HooksFiles is a slice of String +type HooksFiles []string + +func (h *HooksFiles) String() string { + if len(*h) == 0 { + return "hooks.json" + } + + return strings.Join(*h, ", ") +} + +// Set method appends new string +func (h *HooksFiles) Set(value string) error { + *h = append(*h, value) + return nil +} + // Hook type is a structure containing details for a single hook type Hook struct { ID string `json:"id,omitempty"` @@ -427,6 +444,19 @@ func (h *Hooks) LoadFromFile(path string) error { return e } +// Append appends hooks unless the new hooks contain a hook with an ID that already exists +func (h *Hooks) Append(other *Hooks) error { + for _, hook := range *other { + if h.Match(hook.ID) != nil { + return fmt.Errorf("hook with ID %s is already defined", hook.ID) + } + + *h = append(*h, hook) + } + + return nil +} + // Match iterates through Hooks and returns first one that matches the given ID, // if no hook matches the given ID, nil is returned func (h *Hooks) Match(id string) *Hook { diff --git a/signals.go b/signals.go index e4bd4c67..d53a3f65 100644 --- a/signals.go +++ b/signals.go @@ -26,7 +26,7 @@ func watchForSignals() { if sig == syscall.SIGUSR1 { log.Println("caught USR1 signal") - reloadHooks() + reloadAllHooks() } else { log.Printf("caught unhandled signal %+v\n", sig) } diff --git a/webhook.go b/webhook.go index 7c5f62ea..8d796331 100644 --- a/webhook.go +++ b/webhook.go @@ -21,7 +21,7 @@ import ( ) const ( - version = "2.6.1" + version = "2.6.2" ) var ( @@ -30,7 +30,6 @@ var ( verbose = flag.Bool("verbose", false, "show verbose output") noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode") hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically") - hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve") hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") secure = flag.Bool("secure", false, "use HTTPS instead of HTTP") cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file") @@ -38,16 +37,35 @@ var ( justDisplayVersion = flag.Bool("version", false, "display webhook version and quit") responseHeaders hook.ResponseHeaders + hooksFiles hook.HooksFiles + + loadedHooksFromFiles = make(map[string]hook.Hooks) watcher *fsnotify.Watcher signals chan os.Signal - - hooks hook.Hooks ) -func main() { - hooks = hook.Hooks{} +func matchLoadedHook(id string) *hook.Hook { + for _, hooks := range loadedHooksFromFiles { + if hook := hooks.Match(id); hook != nil { + return hook + } + } + + return nil +} + +func lenLoadedHooks() int { + sum := 0 + for _, hooks := range loadedHooksFromFiles { + sum += len(hooks) + } + + return sum +} +func main() { + flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files") flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") flag.Parse() @@ -57,6 +75,10 @@ func main() { os.Exit(0) } + if len(hooksFiles) == 0 { + hooksFiles = append(hooksFiles, "hooks.json") + } + log.SetPrefix("[webhook] ") log.SetFlags(log.Ldate | log.Ltime) @@ -70,50 +92,63 @@ func main() { setupSignals() // load and parse hooks - log.Printf("attempting to load hooks from %s\n", *hooksFilePath) + for _, hooksFilePath := range hooksFiles { + log.Printf("attempting to load hooks from %s\n", hooksFilePath) - err := hooks.LoadFromFile(*hooksFilePath) - - if err != nil { - if !*verbose && !*noPanic { - log.SetOutput(os.Stdout) - log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err) - } + newHooks := hook.Hooks{} - log.Printf("couldn't load hooks from file! %+v\n", err) - } else { - seenHooksIds := make(map[string]bool) + err := newHooks.LoadFromFile(hooksFilePath) - log.Printf("found %d hook(s) in file\n", len(hooks)) + if err != nil { + log.Printf("couldn't load hooks from file! %+v\n", err) + } else { + log.Printf("found %d hook(s) in file\n", len(newHooks)) - for _, hook := range hooks { - if seenHooksIds[hook.ID] == true { - log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) + for _, hook := range newHooks { + if matchLoadedHook(hook.ID) != nil { + log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) + } + log.Printf("\tloaded: %s\n", hook.ID) } - seenHooksIds[hook.ID] = true - log.Printf("\tloaded: %s\n", hook.ID) + + loadedHooksFromFiles[hooksFilePath] = newHooks } } - if *hotReload { - // set up file watcher - log.Printf("setting up file watcher for %s\n", *hooksFilePath) + newHooksFiles := hooksFiles[:0] + for _, filePath := range hooksFiles { + if _, ok := loadedHooksFromFiles[filePath]; ok == true { + newHooksFiles = append(newHooksFiles, filePath) + } + } + + hooksFiles = newHooksFiles + + if !*verbose && !*noPanic && lenLoadedHooks() == 0 { + log.SetOutput(os.Stdout) + log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic") + } + if *hotReload { var err error watcher, err = fsnotify.NewWatcher() if err != nil { - log.Fatal("error creating file watcher instance", err) + log.Fatal("error creating file watcher instance\n", err) } - defer watcher.Close() - go watchForFileChange() + for _, hooksFilePath := range hooksFiles { + // set up file watcher + log.Printf("setting up file watcher for %s\n", hooksFilePath) - err = watcher.Add(*hooksFilePath) - if err != nil { - log.Fatal("error adding hooks file to the watcher", err) + err = watcher.Add(hooksFilePath) + if err != nil { + log.Fatal("error adding hooks file to the watcher\n", err) + } } + + go watchForFileChange() } l := negroni.NewLogger() @@ -159,7 +194,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { id := mux.Vars(r)["id"] - if matchedHook := hooks.Match(id); matchedHook != nil { + if matchedHook := matchLoadedHook(id); matchedHook != nil { log.Printf("%s got matched\n", id) body, err := ioutil.ReadAll(r.Body) @@ -302,32 +337,74 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b return string(out), err } -func reloadHooks() { - newHooks := hook.Hooks{} +func reloadHooks(hooksFilePath string) { + hooksInFile := hook.Hooks{} // parse and swap - log.Printf("attempting to reload hooks from %s\n", *hooksFilePath) + log.Printf("attempting to reload hooks from %s\n", hooksFilePath) - err := newHooks.LoadFromFile(*hooksFilePath) + err := hooksInFile.LoadFromFile(hooksFilePath) if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) } else { seenHooksIds := make(map[string]bool) - log.Printf("found %d hook(s) in file\n", len(newHooks)) + log.Printf("found %d hook(s) in file\n", len(hooksInFile)) - for _, hook := range newHooks { - if seenHooksIds[hook.ID] == true { + for _, hook := range hooksInFile { + wasHookIDAlreadyLoaded := false + + for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] { + if loadedHook.ID == hook.ID { + wasHookIDAlreadyLoaded = true + break + } + } + + if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] == true { log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID) log.Println("reverting hooks back to the previous configuration") return } + seenHooksIds[hook.ID] = true log.Printf("\tloaded: %s\n", hook.ID) } - hooks = newHooks + loadedHooksFromFiles[hooksFilePath] = hooksInFile + } +} + +func reloadAllHooks() { + for _, hooksFilePath := range hooksFiles { + reloadHooks(hooksFilePath) + } +} + +func removeHooks(hooksFilePath string) { + for _, hook := range loadedHooksFromFiles[hooksFilePath] { + log.Printf("\tremoving: %s\n", hook.ID) + } + + newHooksFiles := hooksFiles[:0] + for _, filePath := range hooksFiles { + if filePath != hooksFilePath { + newHooksFiles = append(newHooksFiles, filePath) + } + } + + hooksFiles = newHooksFiles + + removedHooksCount := len(loadedHooksFromFiles[hooksFilePath]) + + delete(loadedHooksFromFiles, hooksFilePath) + + log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath) + + if !*verbose && !*noPanic && lenLoadedHooks() == 0 { + log.SetOutput(os.Stdout) + log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic") } } @@ -336,9 +413,23 @@ func watchForFileChange() { select { case event := <-(*watcher).Events: if event.Op&fsnotify.Write == fsnotify.Write { - log.Println("hooks file modified") - - reloadHooks() + log.Printf("hooks file %s modified\n", event.Name) + reloadHooks(event.Name) + } else if event.Op&fsnotify.Remove == fsnotify.Remove { + log.Printf("hooks file %s removed, no longer watching this file for changes, removing hooks that were loaded from it\n", event.Name) + (*watcher).Remove(event.Name) + removeHooks(event.Name) + } else if event.Op&fsnotify.Rename == fsnotify.Rename { + if _, err := os.Stat(event.Name); os.IsNotExist(err) { + // file was removed + log.Printf("hooks file %s removed, no longer watching this file for changes, and removing hooks that were loaded from it\n", event.Name) + (*watcher).Remove(event.Name) + removeHooks(event.Name) + } else { + // file was overwritten + log.Printf("hooks file %s overwritten\n", event.Name) + reloadHooks(event.Name) + } } case err := <-(*watcher).Errors: log.Println("watcher error:", err)