lunemec/nanny

View on GitHub
api/api.go

Summary

Maintainability
A
1 hr
Test Coverage
package api

import (
    "encoding/json"
    "fmt"
    "io"
    "net"
    "net/http"
    "strconv"
    "time"

    "nanny/pkg/closer"
    "nanny/pkg/nanny"
    "nanny/pkg/notifier"
    "nanny/pkg/storage"
    "nanny/pkg/version"

    "github.com/gorilla/mux"
    log "github.com/mgutz/logxi"
    "github.com/pkg/errors"
)

// Server is Nanny API Server.
type Server struct {
    Name      string          // Name of this Nanny.
    Notifiers notifiers       // Enabled notifiers.
    Storage   storage.Storage // What to use as persistence system.

    nanny nanny.Nanny
}

// Signal represents incomming JSON-encoded data to process.
type Signal struct {
    // Name of program being monitored.
    // IP address of caller is appended to the name so it may be non-unique.
    Name     string `json:"name"`
    Notifier string `json:"notifier"` // What notifier to use.
    // After how many seconds to expect next call.
    // May contain "10s", "1h": https://golang.org/pkg/time/#ParseDuration
    NextSignal string `json:"next_signal"`
    // Activate optional all-clear notification that is sent when a call is received after an alert was sent
    AllClear bool              `json:"all_clear"`
    Meta     map[string]string `json:"meta"` // Metadata for this signal, may contain custom data.
}

// Error represents JSON error to be sent to user.
type Error struct {
    StatusCode int    `json:"status_code"`
    Message    string `json:"error"`
}

type httpError struct {
    Err        error
    StatusCode int
}

// Error implements error interface.
func (e *httpError) Error() string {
    if e.Err == nil {
        return ""
    }
    return e.Err.Error()
}

type handler func(http.ResponseWriter, *http.Request) error
type handlerWithDeps func(*nanny.Nanny, notifiers, storage.Storage, http.ResponseWriter, *http.Request) error
type notifiers map[string]notifier.Notifier

// routes contain map of URLs -> Names of routes. This hashmap is created and written to
// only once *before* the server starts. DO NOT WRITE TO IT!
var routes = map[string]string{}

// Handler returns http.Handler and error. This way we can customise
// http.Server and just pass in our handlers.
func (a *Server) Handler() (http.Handler, error) {
    if a.Notifiers == nil || len(a.Notifiers) == 0 {
        return nil, errors.New("no notifier is set, enable at least one in config")
    }
    if a.Name != "" {
        a.nanny.Name = a.Name
    }

    a.nanny.ErrorFunc = func(err error) {
        log.Error("Notify error", "err", err)
    }

    // Load persisted signals, if any.
    loadStorage(&a.nanny, a.Notifiers, a.Storage)
    return router(&a.nanny, a.Notifiers, a.Storage), nil
}

// loadStorage loads persisted signals. This function does not return error but logs
// information directly (for better error messages).
func loadStorage(n *nanny.Nanny, notifiers notifiers, store storage.Storage) {
    signals, err := store.Load()
    if err != nil {
        msg := "Unable to load persisted signals. " +
            "There may have been saved signals you will not be notified about! " +
            "Please check services using Nanny manually."
        log.Warn(msg)
        return
    }
    // create callback func using our storage.
    callbackFunc := makeCallbackFunc(store)

    // Create nanny timers from persisted signals.
    for _, signal := range signals {
        // If NextSignal would be in the past, notify user, and delete it.
        if signal.NextSignal.Before(time.Now()) {
            msg := "Found previously stored notifier that is stale. Please check " +
                "this program manually."
            log.Warn(msg, "program", signal.Name, "should_notify", signal.NextSignal.String())
            err = store.Remove(signal)
            if err != nil {
                log.Error("Unable to remove stale signal.", "err", err)
            }
            continue
        }

        notif, ok := notifiers[signal.Notifier]
        if !ok {
            msg := "Unable to find previously stored notifier. It may have been " +
                "disabled. Please check this program manually."
            log.Warn(msg, "program", signal.Name)
        }
        s := nanny.Signal{
            Name:       signal.Name,
            Notifier:   notif,
            NextSignal: time.Until(signal.NextSignal),
            AllClear:   signal.AllClear,
            Meta:       signal.Meta,

            CallbackFunc: callbackFunc,
        }

        err = n.Handle(s)
        if err != nil {
            msg := "Unable to create signal handler from previous run," +
                " please check this program manually."
            log.Warn(msg, "program", signal.Name, "err", err)
            continue
        }
        log.Info("Loaded persisted signal successful.",
            "program", signal.Name,
            "next_signal", s.NextSignal.String(),
            "all_clear", s.AllClear,
            "meta", s.Meta,
            "notifier", signal.Notifier)
    }
}

// makeCallbackFunc creates new function that can be used as nanny.Signal callback
// while injecting storage dependency. This is used to remove signal from persistent
// storage.
func makeCallbackFunc(store storage.Storage) func(*nanny.Signal) {
    return func(signal *nanny.Signal) {
        err := store.Remove(storage.Signal{Name: signal.Name})
        if err != nil {
            log.Error("Error removing signal from storage.", "err", err, "signal", signal)
        }
    }
}

func router(nanny *nanny.Nanny, notifiers notifiers, store storage.Storage) *mux.Router {
    router := mux.NewRouter()
    // Clarify this is API.
    apiRouter := router.PathPrefix("/api").Subrouter()
    apiRouter.Handle("/", panicWrap(headerWrap(errWrap(listEndpoints)))).Name("List all available API endpoints.").Methods("GET")
    apiRouter.Handle("/version", panicWrap(headerWrap(errWrap(versionHandler)))).Name("Nanny version.").Methods("GET")
    // In case of future API changes, nanny will support older versions of API.
    v1Router := apiRouter.PathPrefix("/v1").Subrouter()
    v1Router.Handle("/signals", panicWrap(headerWrap(errWrap(depWrap(nanny, notifiers, store, getSignalsHandler))))).Name("Show all registered signals.").Methods("GET")
    v1Router.Handle("/signal", panicWrap(headerWrap(errWrap(depWrap(nanny, notifiers, store, signalHandler))))).Name("Register new signal.").Methods("POST")

    err := router.Walk(saveRoutes)
    if err != nil {
        log.Error("router.Walk doesnt want to walk", "err", err)
    }

    return router
}

// listEndpoints is a handler that reads routes variable. It contains all the URL paths
// available to Nanny.
func listEndpoints(w http.ResponseWriter, req *http.Request) error {
    js, err := json.Marshal(routes)
    if err != nil {
        return errors.Wrap(err, "unable to marshal url routes to json")
    }
    w.Header().Set("Content-Type", "application/json")
    _, err = w.Write(js)
    if err != nil {
        return errors.Wrap(err, "unable to write output")
    }
    return nil
}

// versionHandler simply returns version of this nanny.
func versionHandler(w http.ResponseWriter, req *http.Request) error {
    w.WriteHeader(http.StatusOK)
    _, err := io.WriteString(w, version.VersionString)
    return errors.Wrap(err, "unable to reply with version")
}

// signalHandler handles incomming register/ping signal from a source.
func signalHandler(n *nanny.Nanny, notifiers notifiers, store storage.Storage, w http.ResponseWriter, req *http.Request) error {
    w.Header().Set("Content-Type", "application/json")
    var signal Signal

    dec := json.NewDecoder(req.Body)
    defer closer.Close(req.Body)

    err := dec.Decode(&signal)
    if err != nil {
        return &httpError{
            StatusCode: http.StatusBadRequest,
            Err:        errors.Wrap(err, "unable to decode JSON"),
        }
    }

    notif, ok := notifiers[signal.Notifier]
    if !ok {
        return &httpError{
            StatusCode: http.StatusBadRequest,
            Err:        errors.Errorf("unable to find notifier: %s", signal.Notifier),
        }
    }

    s := constructSignal(signal, notif, store, req)
    err = n.Handle(s)
    if err != nil {
        return errors.Wrap(err, "unable to handle signal")
    }

    err = store.Save(storage.Signal{
        Name:       s.Name,
        Notifier:   signal.Notifier,
        NextSignal: time.Now().Add(s.NextSignal),
        AllClear:   s.AllClear,
        Meta:       s.Meta,
    })

    // This error should not be on the API but only logged. Notifications will still
    // work.
    if err != nil {
        log.Error("Error saving signal to persistent storage", "err", err)
    }
    // When everything is OK, we should return JSON with "status_code": 200, and
    // message "status": "OK".
    // nolint: errcheck
    w.Write([]byte(`{"status_code":200, "status":"OK"}`))
    return nil
}

func getSignalsHandler(n *nanny.Nanny, notifiers notifiers, store storage.Storage, w http.ResponseWriter, req *http.Request) error {
    w.Header().Set("Content-Type", "application/json")

    signals := n.GetTimers()

    err := json.NewEncoder(w).Encode(&struct {
        NannyName string         `json:"nanny_name"`
        Programs  []*nanny.Timer `json:"signals"`
    }{
        NannyName: n.Name,
        Programs:  signals,
    })

    if err != nil {
        return &httpError{
            StatusCode: http.StatusInternalServerError,
            Err:        err,
        }
    }
    return nil
}

func constructSignal(jsonSignal Signal, notif notifier.Notifier, store storage.Storage, req *http.Request) nanny.Signal {
    s := nanny.Signal{
        Name:       constructName(jsonSignal.Name, req),
        Notifier:   notif,
        NextSignal: constructDuration(jsonSignal.NextSignal),
        AllClear:   jsonSignal.AllClear,
        Meta:       jsonSignal.Meta,

        CallbackFunc: func(s *nanny.Signal) {
            err := store.Remove(storage.Signal{Name: s.Name})
            if err != nil {
                log.Error("Error removing signal from storage.", "err", err, "signal", jsonSignal)
            }
        },
    }
    return s
}

func constructName(name string, req *http.Request) string {
    dontModifyName := req.Header.Get("X-Dont-Modify-Name")
    if dontModifyName != "" {
        return name
    }

    remoteAddr := req.Header.Get("X-Forwarded-For")
    if remoteAddr == "" {
        remoteAddr = req.RemoteAddr
    }

    // Split addr:port and add address to the name in format {programName}@{addr}.
    host, _, err := net.SplitHostPort(remoteAddr)
    if err != nil {
        log.Warn("Unable to split host from port, using whole remote address.", "addr", remoteAddr, "err", err)
        return fmt.Sprintf("%s@%s", name, remoteAddr)
    }

    return fmt.Sprintf("%s@%s", name, host)
}

func constructDuration(nextSignal string) time.Duration {
    d, err := time.ParseDuration(nextSignal)
    if err != nil {
        seconds, err := strconv.Atoi(nextSignal)
        if err != nil {
            // nextSignal string can't be converted to int, it is nonsense.
            // When we set it to 0, which will cause invalid signal and return
            // error to the user.
            return time.Duration(0)
        }
        d = time.Duration(seconds) * time.Second
    }

    return d
}

func saveRoutes(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
    path, err := route.GetPathTemplate()
    if err != nil {
        return errors.Wrap(err, "unable to save route")
    }
    routes[path] = route.GetName()
    return nil
}