diff --git a/.gitignore b/.gitignore index fc7921d..7dfaf93 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ _testmain.go .idea beethoven devconf.json +dockerconf.json temp diff --git a/Makefile b/Makefile index 57d675e..6fa9f38 100644 --- a/Makefile +++ b/Makefile @@ -8,5 +8,9 @@ compile: deps: go get +docker-dev: + GOOS=linux GOARCH=amd64 go build + docker build -t beethoven . + format: $(GO_FMT) diff --git a/beethoven.go b/beethoven.go index 7855d54..eacfd27 100644 --- a/beethoven.go +++ b/beethoven.go @@ -5,7 +5,9 @@ import ( "github.com/ContainX/beethoven/config" "github.com/ContainX/beethoven/proxy" "github.com/ContainX/depcon/pkg/logger" + "github.com/op/go-logging" "github.com/spf13/cobra" + "os" ) const ( @@ -41,7 +43,8 @@ var ( Example: Example, } // Logger - log = logger.GetLogger("beethoven") + log = logger.GetLogger("beethoven") + format = logging.MustStringFormatter("%{time:2006-01-02 15:04:05} %{level:.9s} [%{module}]: %{message}") ) func serve(cmd *cobra.Command, args []string) { @@ -56,7 +59,16 @@ func serve(cmd *cobra.Command, args []string) { } func main() { + setupLogging() rootCmd.AddCommand(serveCmd) config.AddFlags(serveCmd) rootCmd.Execute() } + +func setupLogging() { + if os.Getenv("DOCKER_ENV") != "" { + backend := logging.NewLogBackend(os.Stderr, "", 0) + backendFmt := logging.NewBackendFormatter(backend, format) + logging.SetBackend(backendFmt) + } +} diff --git a/config/config.go b/config/config.go index 7f853d8..ce37dd9 100644 --- a/config/config.go +++ b/config/config.go @@ -58,30 +58,35 @@ type Config struct { var ( FileNotFound = errors.New("Cannot find the specified config file") + dryRun = false ) // AddFlags is a hook to add additional CLI Flags func AddFlags(cmd *cobra.Command) { - cmd.Flags().String("config", "", "Path and filename of local configuration file. ex: config.yml") - cmd.Flags().String("remote", "", "URI to remote config server. ex: http://server:8888") - cmd.Flags().String("name", "beethoven", "Remote Config: The name of the app, env: CONFIG_NAME") - cmd.Flags().String("label", "master", "Remote Config: The branch to fetch the config from, env: CONFIG_LABEL") - cmd.Flags().String("profile", "default", "Remote Config: The profile to use, env: CONFIG_PROFILE") + cmd.Flags().StringP("config", "c", "", "Path and filename of local configuration file. ex: config.yml") + cmd.Flags().BoolP("remote", "r", false, "Use remote configuraion server") + cmd.Flags().StringP("server", "s", "", "Remote: URI to remote config server. ex: http://server:8888") + cmd.Flags().String("name", "beethoven", "Remote: The name of the app, env: CONFIG_NAME") + cmd.Flags().String("label", "master", "Remote: The branch to fetch the config from, env: CONFIG_LABEL") + cmd.Flags().String("profile", "default", "Remote: The profile to use, env: CONFIG_PROFILE") + cmd.Flags().Bool("dryrun", false, "Bypass NGINX validation/reload -- used for debugging logs") } func LoadConfigFromCommand(cmd *cobra.Command) (*Config, error) { - remote, _ := cmd.Flags().GetString("remote") + remote, _ := cmd.Flags().GetBool("remote") config, _ := cmd.Flags().GetString("config") + dryRun, _ = cmd.Flags().GetBool("dryrun") - if config != "" { - return loadFromFile(config) - } - - if remote != "" { + if remote { + server := os.Getenv("CONFIG_SERVER") name := os.Getenv("CONFIG_NAME") label := os.Getenv("CONFIG_LABEL") profile := os.Getenv("CONFIG_PROFILE") + if server == "" { + server, _ = cmd.Flags().GetString("server") + } + if name == "" { name, _ = cmd.Flags().GetString("name") } @@ -93,7 +98,11 @@ func LoadConfigFromCommand(cmd *cobra.Command) (*Config, error) { profile, _ = cmd.Flags().GetString("profile") } - return loadFromRemote(remote, name, label, profile) + return loadFromRemote(server, name, label, profile) + } + + if config != "" { + return loadFromFile(config) } cfg := new(Config) @@ -190,3 +199,7 @@ func (c *Config) IsFilterDefined() bool { func (c *Config) Filter() *regexp.Regexp { return c.filterRegEx } + +func (c *Config) DryRun() bool { + return dryRun +} diff --git a/generator/events.go b/generator/events.go index a9dbd09..907ed0d 100644 --- a/generator/events.go +++ b/generator/events.go @@ -7,7 +7,7 @@ import ( func (g *Generator) initSSEStream() { g.events = make(marathon.EventsChannel, 5) - filter := marathon.EventIDStatusUpdate | marathon.EventIDAPIRequest | marathon.EventIDChangedHealthCheck + filter := marathon.EventIDStatusUpdate | marathon.EventIDChangedHealthCheck err := g.marathon.CreateEventStreamListener(g.events, filter) if err != nil { @@ -48,12 +48,17 @@ func (g *Generator) shouldTriggerReload(appId string, event *marathon.Event) boo log.Warning("Event: Could not locate AppId: %s", event) return false } + + trigger := true + if g.cfg.IsFilterDefined() { match := g.cfg.Filter().MatchString(appId) log.Debug("Matching appId: %s to filter: %s -> %v", appId, g.cfg.FilterRegExStr, match) - return match + log.Debug("Event: %s", event) + trigger = match } - return true + + return trigger } // getAppID returns the application indentifier for only the evens we care to diff --git a/generator/marathon.go b/generator/marathon.go index 04662d7..2731b25 100644 --- a/generator/marathon.go +++ b/generator/marathon.go @@ -54,6 +54,7 @@ func (g *Generator) Watch(handler func(proxyConf string)) { go g.initReloadWatcher() } +// Watches the reload channel and generated a new config func (g *Generator) initReloadWatcher() { throttle := time.NewTicker(2 * time.Second) for { @@ -68,21 +69,35 @@ func (g *Generator) initReloadWatcher() { func (g *Generator) generateConfig() { if err := g.buildAppMeta(); err != nil { - log.Error("Skipping config generatin...") + log.Error("Skipping config generation...") g.tracker.SetError(err) return } - if err := g.writeConfiguration(); err != nil { + changed, err := g.writeConfiguration() + if err != nil { log.Error(err.Error()) g.tracker.SetError(err) return } + if changed { + log.Info("Reloading NGINX") + err = g.reload() + if err != nil { + log.Error(err.Error()) + g.tracker.SetError(err) + return + } + } + // No errors - clear tracker g.tracker.SetError(nil) } +// buildAppMeta Builds the app metadata used within our templates. It is responsible +// for fetching apps and tasks and remove tasks that are not healthy or the application +// all together if their are no serviceable tasks func (g *Generator) buildAppMeta() error { apps, err := g.marathon.ListApplicationsWithFilters("embed=apps.tasks") if err != nil { @@ -90,6 +105,8 @@ func (g *Generator) buildAppMeta() error { return err } + // Reset current context since the config won't be rewritten until syntax + // and validation occurs g.templateData.Apps = map[string]*App{} for _, a := range apps.Apps { @@ -101,6 +118,8 @@ func (g *Generator) buildAppMeta() error { tapp.Labels = a.Labels tapp.Tasks = []Task{} + // Iterate through the apps tasks - remove any tasks that do not match + // our criteria for being healthy for _, t := range a.Tasks { // Skip tasks with no ports if len(t.Ports) == 0 { @@ -139,6 +158,9 @@ func (g *Generator) buildAppMeta() error { return nil } +// Translate Marathon IDs using /'s to '-' since we need identifiers +// that are compat with templates. +// ex: /products/stores/someservice would be products-stores-someservice func appIdToDashes(appId string) string { parts := strings.Split(appId[1:], "/") return strings.Join(parts, "-") diff --git a/generator/nginx.go b/generator/nginx.go index d31a1ef..c402f59 100644 --- a/generator/nginx.go +++ b/generator/nginx.go @@ -1,23 +1,132 @@ package generator import ( + "bytes" "fmt" + "github.com/ContainX/beethoven/tracker" "github.com/aymerick/raymond" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "time" +) + +const ( + tempTemplateName = ".nginx.conf.tmp-" + nginxCommand = "nginx" ) // writeConfiguration writes a temporary nginx configuration based on // the specified template. It then validates the configuration for any // errors -func (g *Generator) writeConfiguration() error { +// return true if config has changed and been successfully updated +func (g *Generator) writeConfiguration() (bool, error) { tpl, err := raymond.ParseFile(g.cfg.Template) if err != nil { - return fmt.Errorf("Error loading template: %s", err.Error()) + return false, fmt.Errorf("Error loading template: %s", err.Error()) } result, err := tpl.Exec(g.templateData.Apps) if err != nil { - log.Error(err.Error()) + return false, err + } + + tplFilename, err := writeTempFile(result, filepath.Dir(g.cfg.Template), tempTemplateName) + defer g.removeTempFile(tplFilename) + + if err != nil { + return false, err + } + + g.tracker.SetLastConfigRendered(time.Now()) + log.Info("wrote config: %s, contents: \n\n%s", tplFilename, result) + + if g.cfg.DryRun() { + log.Debug("Has Changed from Config : %v", g.templateAndConfMatch(tplFilename)) + return false, nil + } + + if err = g.validateConfig(tplFilename); err != nil { + g.tracker.SetValidationError(&tracker.ValidationError{Error: err, FailedConfig: result}) + return false, err + } else { + g.tracker.ClearValidationError() + } + + // At this points if the new/old configs don't match + // issue a rename and nginx reload + log.Debug("Temp Conf and Current Config Match : %v", g.templateAndConfMatch(tplFilename)) + if g.templateAndConfMatch(tplFilename) == false { + log.Debug("Renaming %s to %s", tplFilename, g.cfg.NginxConfig) + if err := os.Rename(tplFilename, g.cfg.NginxConfig); err != nil { + return false, fmt.Errorf("Error renaming %s to %s: %s", tplFilename, g.cfg.NginxConfig, err.Error()) + } + return true, nil + } + + return false, nil +} + +func (g *Generator) removeTempFile(file string) { + os.Remove(file) +} + +// Validates the temporary configuration file using NginX +func (g *Generator) validateConfig(tplFilename string) error { + if err := g.execNginx("Validate Config:", "-c", tplFilename, "-t"); err != nil { return err } - log.Info(result) + g.tracker.SetLastConfigValid(time.Now()) + return nil } + +func (g *Generator) reload() error { + if err := g.execNginx("Reload NGINX:", "-s", "reload"); err != nil { + return err + } + g.tracker.SetLastProxyReload(time.Now()) + return nil +} + +func (g *Generator) execNginx(logPrefix string, args ...string) error { + command := exec.Command(nginxCommand, args...) + stderr := &bytes.Buffer{} + command.Stderr = stderr + + if err := command.Run(); err != nil { + return fmt.Errorf("%s, %s, output: %s", logPrefix, err.Error(), stderr.String()) + } + return nil +} + +// Determines whether there are any differences between the newly generated +// template and the existing configuration. If these are the same we bypass +// reloading NginX +// return bool - true if the two files match +func (g *Generator) templateAndConfMatch(tplFilename string) bool { + tInfo, err := os.Stat(tplFilename) + if err != nil { + log.Warning(err.Error()) + return false + } + + cInfo, err := os.Stat(g.cfg.NginxConfig) + if err != nil { + log.Warning(err.Error()) + return false + } + return (tInfo.Size() == cInfo.Size()) +} + +func writeTempFile(contents, baseDir, fileName string) (string, error) { + tmpFile, err := ioutil.TempFile(baseDir, fileName) + defer tmpFile.Close() + + if err != nil { + return tmpFile.Name(), err + } + _, err = tmpFile.WriteString(contents) + return tmpFile.Name(), err + +} diff --git a/tracker/tracker.go b/tracker/tracker.go index 5e1cdd4..32896bd 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -5,6 +5,9 @@ import ( "time" ) +// Tracker is responsible for keeping track of state and updates +// throughout Beethoven. It serves as a common information hub to +// the API type Tracker struct { cfg *config.Config status Status @@ -23,18 +26,31 @@ func (tr *Tracker) SetError(err error) { tr.status.LastError = err } +func (tr *Tracker) SetValidationError(verr *ValidationError) { + tr.status.ValidationError = verr +} + +func (tr *Tracker) ClearValidationError() { + tr.status.ValidationError = nil +} + +// SetLastSync will set the time we fetched a snapshot from Marathon func (tr *Tracker) SetLastSync(t time.Time) { tr.status.LastUpdated.LastSync = t } +// SetLastConfigRendered will set the time we rendered a temporary config func (tr *Tracker) SetLastConfigRendered(t time.Time) { tr.status.LastUpdated.LastConfigRendered = t } +// SetLastConfigValid captures the last time we had a successful rendered config validate +// via the proxy func (tr *Tracker) SetLastConfigValid(t time.Time) { tr.status.LastUpdated.LastConfigValid = t } +// SetLastProxyReload the last time we executed a reload on the proxy func (tr *Tracker) SetLastProxyReload(t time.Time) { tr.status.LastUpdated.LastProxyReload = t } diff --git a/tracker/types.go b/tracker/types.go index 1f18fa8..a9773a1 100644 --- a/tracker/types.go +++ b/tracker/types.go @@ -12,6 +12,12 @@ type Updates struct { } type Status struct { - LastUpdated Updates - LastError error + LastUpdated Updates + LastError error + ValidationError *ValidationError +} + +type ValidationError struct { + Error error + FailedConfig string }