diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/environment/environment.go | 173 | ||||
-rw-r--r-- | internal/environment/environment_test.go | 61 | ||||
-rw-r--r-- | internal/environment/flags.go | 57 | ||||
-rw-r--r-- | internal/event/event.go | 100 | ||||
-rw-r--r-- | internal/event/event_test.go | 72 | ||||
-rw-r--r-- | internal/handlers/common.go | 75 | ||||
-rw-r--r-- | internal/handlers/events.go | 36 | ||||
-rw-r--r-- | internal/handlers/ipxemenu.go | 64 | ||||
-rw-r--r-- | internal/handlers/middleware.go | 110 | ||||
-rw-r--r-- | internal/handlers/polling.go | 161 | ||||
-rw-r--r-- | internal/handlers/static.go | 127 | ||||
-rw-r--r-- | internal/handlers/templates.go | 83 | ||||
-rw-r--r-- | internal/ipxe/ipxescript.go | 87 | ||||
-rw-r--r-- | internal/log/log.go | 61 | ||||
-rw-r--r-- | internal/mappings/mappings.go | 80 | ||||
-rw-r--r-- | internal/mappings/mappings_test.go | 100 | ||||
-rw-r--r-- | internal/mappings/parse.go | 78 | ||||
-rw-r--r-- | internal/polling/polling.go | 257 | ||||
-rw-r--r-- | internal/router/router.go | 63 | ||||
-rw-r--r-- | internal/server/server.go | 123 | ||||
-rw-r--r-- | internal/templates/templates.go | 233 | ||||
-rw-r--r-- | internal/utils/util_test.go | 39 | ||||
-rw-r--r-- | internal/utils/utils.go | 105 |
23 files changed, 2345 insertions, 0 deletions
diff --git a/internal/environment/environment.go b/internal/environment/environment.go new file mode 100644 index 0000000..eac0430 --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,173 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "fmt" + "html/template" + "io/ioutil" + "net" + "os" + "path" + "path/filepath" + "regexp" + "sync" + + "github.com/thousandeyes/shoelaces/internal/event" + "github.com/thousandeyes/shoelaces/internal/log" + "github.com/thousandeyes/shoelaces/internal/mappings" + "github.com/thousandeyes/shoelaces/internal/server" + "github.com/thousandeyes/shoelaces/internal/templates" +) + +// Environment struct holds the shoelaces instance global data. +type Environment struct { + ConfigFile string + BaseURL string + HostnameMaps []mappings.HostnameMap + NetworkMaps []mappings.NetworkMap + ServerStates *server.States + EventLog *event.Log + ParamsBlacklist []string + Templates *templates.ShoelacesTemplates // Dynamic slc templates + StaticTemplates *template.Template // Static Templates + Environments []string // Valid config environments + Logger log.Logger + + Port int + Domain string + DataDir string + StaticDir string + EnvDir string + TemplateExtension string + MappingsFile string + Debug bool +} + +// New returns an initialized environment structure +func New() *Environment { + env := defaultEnvironment() + env.setFlags() + env.validateFlags() + + if env.Debug { + env.Logger = log.AllowDebug(env.Logger) + } + + env.BaseURL = fmt.Sprintf("%s:%d", env.Domain, env.Port) + env.Environments = env.initEnvOverrides() + + env.EventLog = &event.Log{} + + env.Logger.Info("component", "environment", "msg", "Override found", "environment", env.Environments) + + mappingsPath := path.Join(env.DataDir, env.MappingsFile) + if err := env.initMappings(mappingsPath); err != nil { + panic(err) + } + + env.initStaticTemplates() + env.Templates.ParseTemplates(env.Logger, env.DataDir, env.EnvDir, env.Environments, env.TemplateExtension) + server.StartStateCleaner(env.Logger, env.ServerStates) + + return env +} + +func defaultEnvironment() *Environment { + env := &Environment{} + env.NetworkMaps = make([]mappings.NetworkMap, 0) + env.HostnameMaps = make([]mappings.HostnameMap, 0) + env.ServerStates = &server.States{sync.RWMutex{}, make(map[string]*server.State)} + env.ParamsBlacklist = []string{"baseURL"} + env.Templates = templates.New() + env.Environments = make([]string, 0) + env.Logger = log.MakeLogger(os.Stdout) + + return env +} + +func (env *Environment) initStaticTemplates() { + staticTemplates := []string{ + path.Join(env.StaticDir, "templates/html/header.html"), + path.Join(env.StaticDir, "templates/html/index.html"), + path.Join(env.StaticDir, "templates/html/events.html"), + path.Join(env.StaticDir, "templates/html/mappings.html"), + path.Join(env.StaticDir, "templates/html/footer.html"), + } + + fmt.Println(env.StaticDir) + + for _, t := range staticTemplates { + if _, err := os.Stat(t); err != nil { + env.Logger.Error("component", "environment", "msg", "Template does not exists!", "environment", t) + os.Exit(1) + } + } + + env.StaticTemplates = template.Must(template.ParseFiles(staticTemplates...)) +} + +func (env *Environment) initEnvOverrides() []string { + var environments = make([]string, 0) + envPath := filepath.Join(env.DataDir, env.EnvDir) + files, err := ioutil.ReadDir(envPath) + if err == nil { + for _, f := range files { + if f.IsDir() { + environments = append(environments, f.Name()) + } + } + } + return environments +} + +func (env *Environment) initMappings(mappingsPath string) error { + configMappings := mappings.ParseYamlMappings(env.Logger, mappingsPath) + + for _, configNetMap := range configMappings.NetworkMaps { + _, ipnet, err := net.ParseCIDR(configNetMap.Network) + if err != nil { + return err + } + + netMap := mappings.NetworkMap{Network: ipnet, Script: initScript(configNetMap.Script)} + env.NetworkMaps = append(env.NetworkMaps, netMap) + } + + for _, configHostMap := range configMappings.HostnameMaps { + regex, err := regexp.Compile(configHostMap.Hostname) + if err != nil { + return err + } + + hostMap := mappings.HostnameMap{Hostname: regex, Script: initScript(configHostMap.Script)} + env.HostnameMaps = append(env.HostnameMaps, hostMap) + } + + return nil +} + +func initScript(configScript mappings.YamlScript) *mappings.Script { + mappingScript := &mappings.Script{ + Name: configScript.Name, + Environment: configScript.Environment, + Params: make(map[string]interface{}), + } + for key := range configScript.Params { + mappingScript.Params[key] = configScript.Params[key] + } + + return mappingScript +} diff --git a/internal/environment/environment_test.go b/internal/environment/environment_test.go new file mode 100644 index 0000000..8ffe88d --- /dev/null +++ b/internal/environment/environment_test.go @@ -0,0 +1,61 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "testing" + + "github.com/thousandeyes/shoelaces/internal/mappings" +) + +func TestDefaultEnvironment(t *testing.T) { + env := defaultEnvironment() + if env.BaseURL != "" { + t.Error("BaseURL should be empty string if instantiated directly.") + } + if len(env.HostnameMaps) != 0 { + t.Error("Hostname mappings should be empty") + } + if len(env.NetworkMaps) != 0 { + t.Error("Network mappings should be empty") + } + if len(env.ParamsBlacklist) != 1 && + env.ParamsBlacklist[0] != "baseURL" { + t.Error("ParamsBlacklist should have only baseURL") + } +} + +func TestInitScript(t *testing.T) { + params := make(map[string]string) + params["one"] = "one_value" + configScript := mappings.YamlScript{Name: "testscript", Params: params} + mappingScript := initScript(configScript) + if mappingScript.Name != "testscript" { + t.Errorf("Expected: %s\nGot: %s\n", "testscript", mappingScript.Name) + } + val, ok := mappingScript.Params["one"] + if !ok { + t.Error("Missing param") + } else { + v, ok := val.(string) + if !ok { + t.Error("Bad value type") + } else { + if v != "one_value" { + t.Error("Bad value") + } + } + } +} diff --git a/internal/environment/flags.go b/internal/environment/flags.go new file mode 100644 index 0000000..8250690 --- /dev/null +++ b/internal/environment/flags.go @@ -0,0 +1,57 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "fmt" + "os" + + "github.com/namsral/flag" +) + +func (env *Environment) setFlags() { + flag.StringVar(&env.ConfigFile, "config", "", "My config file") + flag.IntVar(&env.Port, "port", 8080, "The port where I'm going to listen") + flag.StringVar(&env.Domain, "domain", "localhost", "The address where I'm going to listen") + flag.StringVar(&env.DataDir, "data-dir", "", "Directory with mappings, configs, templates, etc.") + flag.StringVar(&env.StaticDir, "static-dir", "web", "A custom web directory with static files") + flag.StringVar(&env.EnvDir, "env-dir", "env_overrides", "Directory with overrides") + flag.StringVar(&env.TemplateExtension, "template-extension", ".slc", "Shoelaces template extension") + flag.StringVar(&env.MappingsFile, "mappings-file", "mappings.yaml", "My mappings YAML file") + flag.BoolVar(&env.Debug, "debug", false, "Debug mode") + + flag.Parse() +} + +func (env *Environment) validateFlags() { + error := false + + if env.DataDir == "" { + fmt.Println("[*] You must specify the data-dir parameter") + error = true + } + + if env.StaticDir == "" { + fmt.Println("[*] You must specify the data-dir parameter") + error = true + } + + if error { + fmt.Println("\nAvailable parameters:") + flag.PrintDefaults() + fmt.Println("\nParameters can be specified as environment variables, arguments or in a config file.") + os.Exit(1) + } +} diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000..52db432 --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,100 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "encoding/json" + "time" + + "github.com/thousandeyes/shoelaces/internal/server" +) + +// Type holds the different typs of events +type Type int + +const ( + // HostPoll is the event generated when a host poll Shoelaces for a script + HostPoll Type = 0 + // UserSelection is the event generated when a user selects a script and hits "Boot!" + UserSelection Type = 1 + // HostBoot is the event generated when a host finally boots + HostBoot Type = 2 + // HostTimeout is the event generated when a host polls and after some + // minutes without activity, timeouts. + HostTimeout Type = 3 + + // PtrMatchBoot is triggered when a PTR is matched to an IP + PtrMatchBoot = "DNS Match" + // SubnetMatchBoot is triggered when an IP matches a subnet mapping + SubnetMatchBoot = "Subnet Match" + // ManualBoot is triggered when the user selects manual boot + ManualBoot = "Manual" +) + +// Event holds information related to the interactions of hosts when they boot. +// It's used exclusively in the Shoelaces web frontend. +type Event struct { + Type Type `json:"eventType"` + Date time.Time `json:"date"` + Server server.Server `json:"server"` + BootType string `json:"bootType"` + Script string `json:"script"` + Message string `json:"message"` + Params map[string]interface{} `json:"params"` +} + +// Log holds the events log +type Log struct { + Events map[string][]Event +} + +// New creates a new Event object +func New(eventType Type, srv server.Server, bootType, script string, params map[string]interface{}) Event { + var event Event + + event.Type = eventType + event.Date = time.Now() + event.Server = srv + event.BootType = bootType + event.Script = script + event.Params = params + + event.setMessage() + + return event +} + +func (e *Event) setMessage() { + switch e.Type { + case HostPoll: + e.Message = "Host " + e.Server.Hostname + " polled for a script." + case UserSelection: + e.Message = "A user selected " + e.Script + " for the host " + e.Server.Hostname + "." + case HostBoot: + params, _ := json.Marshal(e.Params) + e.Message = "Host " + e.Server.Hostname + " booted using " + e.BootType + " method with the following parameters: " + string(params) + case HostTimeout: + e.Message = "Host " + e.Server.Hostname + " timed out." + } +} + +// AddEvent adds an Event into the event log +func (el *Log) AddEvent(eventType Type, srv server.Server, bootType string, script string, params map[string]interface{}) { + if el.Events == nil { + el.Events = make(map[string][]Event) + } + + el.Events[srv.Mac] = append(el.Events[srv.Mac], New(eventType, srv, bootType, script, params)) +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000..a2eb341 --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,72 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "encoding/json" + "testing" + "time" + + "github.com/thousandeyes/shoelaces/internal/server" +) + +const expectedEvent = `{"eventType":0,"date":"1970-01-01T00:00:00Z","server":{"Mac":"","IP":"","Hostname":"test_host"},"bootType":"Manual","script":"freebsd.ipxe","message":"","params":{"baseURL":"localhost:8080","cloudconfig":"virtual","hostname":"","version":"12345"}}` + +func TestNew(t *testing.T) { + event := New(HostPoll, server.Server{Mac: "", IP: "", Hostname: "test_host"}, PtrMatchBoot, "msdos.ipxe", map[string]interface{}{"test": "testParam"}) + if event.Type != HostPoll { + t.Errorf("Expected: \"%d\"\nGot: \"%d\"", HostPoll, event.Type) + } + if event.Server.Hostname != "test_host" { + t.Errorf("Expected: \"test_host\"\nGot: \"%s\"", event.Server.Hostname) + } + if event.BootType != PtrMatchBoot { + t.Errorf("Expected: \"%s\"\nGot: \"%s\"", PtrMatchBoot, event.Server.Hostname) + } + if event.Script != "msdos.ipxe" { + t.Errorf("Expected: \"msdos.ipxe\"\nGot: \"%s\"", event.Server.Hostname) + } + if len(event.Params) != 1 { + t.Error("Expected one parameter") + } + if event.Params["test"] != "testParam" { + t.Error("Expected parameter test: testParam") + } + now := time.Now() + if event.Date.After(now) { + t.Errorf("Expected %s to be after %s", event.Date, now) + } +} + +func TestEventMarshalJSON(t *testing.T) { + event := Event{ + Type: HostPoll, + Date: time.Unix(0, 0).UTC(), + Server: server.Server{Mac: "", IP: "", Hostname: "test_host"}, + BootType: ManualBoot, + Script: "freebsd.ipxe", + Message: "", + Params: map[string]interface{}{ + "baseURL": "localhost:8080", + "cloudconfig": "virtual", + "hostname": "", + "version": "12345", + }, + } + marshaled, _ := json.Marshal(event) + if string(marshaled) != expectedEvent { + t.Errorf("Expected %s\nGot: %s\n", expectedEvent, marshaled) + } +} diff --git a/internal/handlers/common.go b/internal/handlers/common.go new file mode 100644 index 0000000..99511e2 --- /dev/null +++ b/internal/handlers/common.go @@ -0,0 +1,75 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "html/template" + "net/http" + + "github.com/thousandeyes/shoelaces/internal/environment" + "github.com/thousandeyes/shoelaces/internal/ipxe" + "github.com/thousandeyes/shoelaces/internal/mappings" +) + +// DefaultTemplateRenderer holds information for rendering a template based +// on its name. It implements the http.Handler interface. +type DefaultTemplateRenderer struct { + templateName string +} + +// RenderDefaultTemplate renders a template by the given name +func RenderDefaultTemplate(name string) *DefaultTemplateRenderer { + return &DefaultTemplateRenderer{templateName: name} +} + +func (t *DefaultTemplateRenderer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + tpl := env.StaticTemplates + // XXX: Probably not ideal as it's doing the directory listing on every request + ipxeScripts := ipxe.ScriptList(env) + tplVars := struct { + BaseURL string + HostnameMaps *[]mappings.HostnameMap + NetworkMaps *[]mappings.NetworkMap + Scripts *[]ipxe.Script + }{ + env.BaseURL, + &env.HostnameMaps, + &env.NetworkMaps, + &ipxeScripts, + } + renderTemplate(w, tpl, "header", tplVars) + renderTemplate(w, tpl, t.templateName, tplVars) + renderTemplate(w, tpl, "footer", tplVars) +} + +func renderTemplate(w http.ResponseWriter, tpl *template.Template, tmpl string, d interface{}) { + err := tpl.ExecuteTemplate(w, tmpl, d) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func envFromRequest(r *http.Request) *environment.Environment { + return r.Context().Value(ShoelacesEnvCtxID).(*environment.Environment) +} + +func envNameFromRequest(r *http.Request) string { + e := r.Context().Value(ShoelacesEnvNameCtxID) + if e != nil { + return e.(string) + } + return "" +} diff --git a/internal/handlers/events.go b/internal/handlers/events.go new file mode 100644 index 0000000..f794343 --- /dev/null +++ b/internal/handlers/events.go @@ -0,0 +1,36 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "encoding/json" + "net/http" + "os" +) + +// ListEvents returns a JSON list of the logged events. +func ListEvents(w http.ResponseWriter, r *http.Request) { + // Get Environment and convert the EventLog to JSON + env := envFromRequest(r) + eventList, err := json.Marshal(env.EventLog.Events) + if err != nil { + env.Logger.Error("component", "handler", "err", err) + os.Exit(1) + } + + //Write the EventLog and send the HTTP response + w.Header().Set("Content-Type", "application/json") + w.Write(eventList) +} diff --git a/internal/handlers/ipxemenu.go b/internal/handlers/ipxemenu.go new file mode 100644 index 0000000..0ce8a5a --- /dev/null +++ b/internal/handlers/ipxemenu.go @@ -0,0 +1,64 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "bytes" + "fmt" + "net/http" + + "github.com/thousandeyes/shoelaces/internal/ipxe" +) + +const menuHeader = "#!ipxe\n" + + "chain /poll/1/${netX/mac:hexhyp}\n" + + "menu Choose target to boot\n" + +const menuFooter = "\n" + + "choose target\n" + + "echo -n Enter hostname or none:\n" + + "read hostname\n" + + "set baseurl %s\n" + + "# Boot it as intended.\n" + + "chain ${target}\n" + +// IPXEMenu serves the ipxe menu with list of all available scripts +func IPXEMenu(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + scripts := ipxe.ScriptList(env) + if len(scripts) == 0 { + http.Error(w, "No Scripts Found", http.StatusInternalServerError) + return + } + + var bootItemsBuffer bytes.Buffer + //Creates the top portion of the iPXE menu + bootItemsBuffer.WriteString(menuHeader) + for _, s := range scripts { + //Formats the bootable scripts separated by newlines into a single string + var desc string + if len(s.Env) > 0 { + desc = fmt.Sprintf("%s [%s]", s.Name, s.Env) + } else { + desc = string(s.Name) + } + bootItem := fmt.Sprintf("item %s%s %s\n", s.Path, s.Name, desc) + bootItemsBuffer.WriteString(bootItem) + } + //Creates the bottom portion of the iPXE menu + bootItemsBuffer.WriteString(fmt.Sprintf(menuFooter, env.BaseURL)) + w.Write(bootItemsBuffer.Bytes()) +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..9525efb --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,110 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "context" + "github.com/justinas/alice" + "net/http" + "regexp" + + "github.com/thousandeyes/shoelaces/internal/environment" +) + +// ShoelacesCtxID Shoelaces Specific Request Context ID. +type ShoelacesCtxID int + +// ShoelacesEnvCtxID is the context id key for the shoelaces.Environment. +const ShoelacesEnvCtxID ShoelacesCtxID = 0 + +// ShoelacesEnvNameCtxID is the context ID key for the chosen environment. +const ShoelacesEnvNameCtxID ShoelacesCtxID = 1 + +var envRe = regexp.MustCompile(`^(:?/env\/([a-zA-Z0-9_-]+))?(\/.*)`) + +// environmentMiddleware Rewrites the URL in case it was an environment +// specific and sets the environment in the context. +func environmentMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqEnv string + m := envRe.FindStringSubmatch(r.URL.Path) + if len(m) > 0 && m[2] != "" { + r.URL.Path = m[3] + reqEnv = m[2] + } + ctx := context.WithValue(r.Context(), ShoelacesEnvNameCtxID, reqEnv) + h.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// loggingMiddleware adds an entry to the logger each time the HTTP service +// receives a request. +func loggingMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := envFromRequest(r).Logger + + logger.Info("component", "http", "type", "request", "src", r.RemoteAddr, "method", r.Method, "url", r.URL) + h.ServeHTTP(w, r) + }) +} + +// SecureHeaders adds secure headers to the responses +func secureHeadersMiddleware(h http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add X-XSS-Protection header + w.Header().Add("X-XSS-Protection", "1; mode=block") + + // Add X-Content-Type-Options header + w.Header().Add("X-Content-Type-Options", "nosniff") + + // Prevent page from being displayed in an iframe + w.Header().Add("X-Frame-Options", "DENY") + + // Prevent page from being displayed in an iframe + w.Header().Add("Content-Security-Policy", "script-src 'self'") + + h.ServeHTTP(w, r) + }) +} + +// disableCacheMiddleware sets a header for disabling HTTP caching +func disableCacheMiddleware(h http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "no-cache, no-store, must-revalidate") + + h.ServeHTTP(w, r) + }) +} + +// MiddlewareChain receives a Shoelaces environment and returns a chains of +// middlewares to apply to every request. +func MiddlewareChain(env *environment.Environment) alice.Chain { + // contextMiddleware sets the environment key in the request Context. + contextMiddleware := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ShoelacesEnvCtxID, env) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + } + + return alice.New( + secureHeadersMiddleware, + disableCacheMiddleware, + environmentMiddleware, + contextMiddleware, + loggingMiddleware) +} diff --git a/internal/handlers/polling.go b/internal/handlers/polling.go new file mode 100644 index 0000000..12f36e2 --- /dev/null +++ b/internal/handlers/polling.go @@ -0,0 +1,161 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/thousandeyes/shoelaces/internal/log" + "github.com/thousandeyes/shoelaces/internal/polling" + "github.com/thousandeyes/shoelaces/internal/server" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +// PollHandler is called by iPXE boot agents. It returns the boot script +// specified on the configuration or, if the host is unknown, it makes it +// retry for a while until the user specifies alternative IPXE boot script. +func PollHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + // iPXE MAC addresses come with dashes instead of colons + mac := utils.MacDashToColon(vars["mac"]) + host := r.FormValue("host") + + err = validateMACAndIP(env.Logger, mac, ip) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if host == "" { + host = resolveHostname(env.Logger, ip) + } + + server := server.New(mac, ip, host) + script, err := polling.Poll( + env.Logger, env.ServerStates, env.HostnameMaps, env.NetworkMaps, + env.EventLog, env.Templates, env.BaseURL, server) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte(script)) +} + +// ServerListHandler provides a list of the servers that tried to boot +// but did not match the hostname regex or network mappings. +func ServerListHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + servers, err := json.Marshal(polling.ListServers(env.ServerStates)) + if err != nil { + env.Logger.Error("component", "handler", "err", err) + os.Exit(1) + } + + w.Header().Set("Content-Type", "application/json") + w.Write(servers) +} + +// UpdateTargetHandler is a POST endpoint that receives parameters for +// booting manually. +func UpdateTargetHandler(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mac, scriptName, environment, params := parsePostForm(r.PostForm) + if mac == "" || scriptName == "" { + http.Error(w, "MAC address and target must not be empty", http.StatusBadRequest) + return + } + + server := server.New(mac, ip, "") + inputErr, err := polling.UpdateTarget( + env.Logger, env.ServerStates, env.Templates, env.EventLog, env.BaseURL, server, + scriptName, environment, params) + + if err != nil { + if inputErr { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + http.Redirect(w, r, "/", http.StatusFound) +} + +func parsePostForm(form map[string][]string) (mac, scriptName, environment string, params map[string]interface{}) { + params = make(map[string]interface{}) + for k, v := range form { + if k == "mac" { + mac = utils.MacDashToColon(v[0]) + } else if k == "target" { + scriptName = v[0] + } else if k == "environment" { + environment = v[0] + } else { + params[k] = v[0] + } + } + return +} + +func validateMACAndIP(logger log.Logger, mac string, ip string) (err error) { + if !utils.IsValidMAC(mac) { + logger.Error("component", "polling", "msg", "Invalid MAC", "mac", mac) + return fmt.Errorf("%s", "Invalid MAC") + } + + if !utils.IsValidIP(ip) { + logger.Error("component", "polling", "msg", "Invalid IP", "ip", ip) + return fmt.Errorf("%s", "Invalid IP") + } + + logger.Debug("component", "polling", "msg", "MAC and IP validated", "mac", mac, "ip", ip) + + return nil +} + +func resolveHostname(logger log.Logger, ip string) string { + host := utils.ResolveHostname(ip) + if host == "" { + logger.Info("component", "polling", "msg", "Can't resolve IP", "ip", ip) + } + + return host +} diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..b27fa1a --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,127 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "fmt" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + "sort" +) + +// StaticConfigFileHandler handles static config files +type StaticConfigFileHandler struct{} + +func (s *StaticConfigFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + env := envFromRequest(r) + envName := envNameFromRequest(r) + basePath := path.Join(env.DataDir, "static") + if envName == "" { + http.FileServer(http.Dir(basePath)).ServeHTTP(w, r) + return + } + envPath := filepath.Join(env.DataDir, env.EnvDir, envName, "static") + OverlayFileServer(envPath, basePath).ServeHTTP(w, r) +} + +// StaticConfigFileServer returns a StaticConfigFileHandler instance implementing http.Handler +func StaticConfigFileServer() *StaticConfigFileHandler { + return &StaticConfigFileHandler{} +} + +// OverlayFileServerHandler handles request for overlayer directories +type OverlayFileServerHandler struct { + upper string + lower string +} + +// OverlayFileServer serves static content from two overlayed directories +func OverlayFileServer(upper, lower string) *OverlayFileServerHandler { + return &OverlayFileServerHandler{ + upper: upper, + lower: lower, + } +} + +func (o *OverlayFileServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + fp := filepath.Clean(r.URL.Path) + upper := filepath.Clean(path.Join(o.upper, fp)) + lower := filepath.Clean(path.Join(o.lower, fp)) + + // TODO: try to avoid stat()-ing both if not necessary + infoUpper, errUpper := os.Stat(upper) + infoLower, errLower := os.Stat(lower) + + // If both upper and lower files/dirs do not exist, return 404 + if errUpper != nil && os.IsNotExist(errUpper) && + errLower != nil && os.IsNotExist(errLower) { + http.NotFound(w, r) + return + } + + isDir := false + fileList := make(map[string]os.FileInfo) + + if errUpper == nil && infoUpper.IsDir() { + files, _ := ioutil.ReadDir(upper) + for _, f := range files { + fileList[f.Name()] = f + } + isDir = true + } + if errLower == nil && infoLower.IsDir() { + files, _ := ioutil.ReadDir(lower) + for _, f := range files { + if _, ok := fileList[f.Name()]; !ok { + fileList[f.Name()] = f + } + } + isDir = true + } + + // Generate HTML directory index + if isDir { + fileListIndex := []string{} + for i := range fileList { + fileListIndex = append(fileListIndex, i) + } + sort.Strings(fileListIndex) + w.Write([]byte("<pre>\n")) + for _, i := range fileListIndex { + f := fileList[i] + name := f.Name() + if f.IsDir() { + name = name + "/" + } + l := fmt.Sprintf("<a href=\"%s\">%s</a>\n", name, name) + w.Write([]byte(l)) + } + w.Write([]byte("</pre>\n")) + return + } + + // Serve the file from the upper layer if it exists. + if errUpper == nil { + http.ServeFile(w, r, upper) + // If not serve it from the lower + } else if errLower == nil { + http.ServeFile(w, r, lower) + } + http.NotFound(w, r) +} diff --git a/internal/handlers/templates.go b/internal/handlers/templates.go new file mode 100644 index 0000000..df1bc58 --- /dev/null +++ b/internal/handlers/templates.go @@ -0,0 +1,83 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/gorilla/mux" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +// TemplateHandler is the dynamic configuration provider endpoint. It +// receives a key and maybe an environment. +func TemplateHandler(w http.ResponseWriter, r *http.Request) { + variablesMap := map[string]interface{}{} + configName := mux.Vars(r)["key"] + + if configName == "" { + http.Error(w, "No template name provided", http.StatusNotFound) + return + } + + for key, val := range r.URL.Query() { + variablesMap[key] = val[0] + } + + env := envFromRequest(r) + envName := envNameFromRequest(r) + variablesMap["baseURL"] = utils.BaseURLforEnvName(env.BaseURL, envName) + + configString, err := env.Templates.RenderTemplate(env.Logger, configName, variablesMap, envName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + io.WriteString(w, configString) + } +} + +// GetTemplateParams receives a script name and returns the parameters +// required for completing that template. +func GetTemplateParams(w http.ResponseWriter, r *http.Request) { + var vars []string + env := envFromRequest(r) + + filterBlacklist := func(s string) bool { + return !utils.StringInSlice(s, env.ParamsBlacklist) + } + + script := r.URL.Query().Get("script") + if script == "" { + http.Error(w, "Required script parameter", http.StatusInternalServerError) + return + } + + envName := r.URL.Query().Get("environment") + if envName == "" { + envName = "default" + } + + vars = utils.Filter(env.Templates.ListVariables(script, envName), filterBlacklist) + + marshaled, err := json.Marshal(vars) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(marshaled) +} diff --git a/internal/ipxe/ipxescript.go b/internal/ipxe/ipxescript.go new file mode 100644 index 0000000..7253195 --- /dev/null +++ b/internal/ipxe/ipxescript.go @@ -0,0 +1,87 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ipxe + +import ( + "io/ioutil" + "path/filepath" + "strings" + + "github.com/thousandeyes/shoelaces/internal/environment" + "github.com/thousandeyes/shoelaces/internal/log" +) + +// ScriptName keeps the name of a script +type ScriptName string + +// EnvName holds the name of an environment +type EnvName string + +// ScriptPath holds the path of a script +type ScriptPath string + +// Script holds information regarding an IPXE script. +type Script struct { + Name ScriptName + Env EnvName + Path ScriptPath +} + +// ScriptList receives the global environment and return a list of IPXE +// scripts. +func ScriptList(env *environment.Environment) []Script { + ipxeScripts := make([]Script, 0) + // Collect scripts from the main config dir. + ipxeScripts = appendScriptsFromDir(env.Logger, ipxeScripts, env.TemplateExtension, + filepath.Join(env.DataDir, "ipxe"), "", "/configs/") + + // Collect scripts from the config environments if any + if len(env.Environments) > 0 { + for _, e := range env.Environments { + ep := filepath.Join(env.DataDir, env.EnvDir, e, "ipxe") + ipxeScripts = appendScriptsFromDir(env.Logger, ipxeScripts, env.TemplateExtension, ep, + EnvName(e), ScriptPath("/env/"+e+"/configs/")) + } + } + return ipxeScripts +} + +func appendScriptsFromDir(logger log.Logger, scripts []Script, templateExtension string, dir string, e EnvName, p ScriptPath) []Script { + for _, s := range scriptDirList(logger, templateExtension, dir) { + scripts = append(scripts, Script{Name: s, Env: e, Path: p}) + } + return scripts +} + +// scriptDirList returns the names of all available ipxe script templates +func scriptDirList(logger log.Logger, templateExtension string, datadir string) []ScriptName { + files, err := ioutil.ReadDir(datadir) + if err != nil { + logger.Info("component=ipxescript action=dir-list dir=%s err=\"%v\"", datadir, err.Error()) + return nil + } + + ipxeSuffix := ".ipxe" + suffix := ipxeSuffix + templateExtension + var pxeFiles []ScriptName + for _, f := range files { + // Skip over directories and non-template files. + if f.IsDir() || !strings.HasSuffix(f.Name(), suffix) { + continue + } + pxeFiles = append(pxeFiles, ScriptName(strings.TrimSuffix(f.Name(), templateExtension))) + } + return pxeFiles +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..071a816 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,61 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "io" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" +) + +// Logger struct holds a log.Logger plus functions required for logging +// with different levels. They functions are syntactic sugar to avoid +// having to import "github.com/go-kit/kit/log/level" in every package that +// has to cast a log. +type Logger struct { + Raw log.Logger + Info func(...interface{}) error + Debug func(...interface{}) error + Error func(...interface{}) error +} + +const callerLevel int = 6 + +// MakeLogger receives a io.Writer and return a Logger struct. +func MakeLogger(w io.Writer) Logger { + raw := log.NewLogfmtLogger(log.NewSyncWriter(w)) + raw = log.With(raw, "ts", log.DefaultTimestampUTC, "caller", log.Caller(callerLevel)) + filtered := level.NewFilter(raw, level.AllowInfo()) + + return Logger{ + Raw: raw, + Info: level.Info(filtered).Log, + Debug: level.Debug(filtered).Log, + Error: level.Error(filtered).Log, + } +} + +// AllowDebug receives a Logger and enables the debug logging level. +func AllowDebug(l Logger) Logger { + filtered := level.NewFilter(l.Raw, level.AllowDebug()) + + return Logger{ + Raw: l.Raw, + Info: level.Info(filtered).Log, + Debug: level.Debug(filtered).Log, + Error: level.Error(filtered).Log, + } +} diff --git a/internal/mappings/mappings.go b/internal/mappings/mappings.go new file mode 100644 index 0000000..fba7201 --- /dev/null +++ b/internal/mappings/mappings.go @@ -0,0 +1,80 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mappings + +import ( + "net" + "regexp" + "strings" +) + +// Script holds information related to a booting script. +type Script struct { + Name string + Environment string + Params map[string]interface{} +} + +// NetworkMap struct contains an association between a CIDR network and a +// Script. +type NetworkMap struct { + Network *net.IPNet + Script *Script +} + +// HostnameMap struct contains an association between a hostname regular +// expression and a Script. +type HostnameMap struct { + Hostname *regexp.Regexp + Script *Script +} + +// FindScriptForHostname receives a HostnameMap and a string (that can be a +// regular expression), and tries to find a match in that map. If it finds +// a match, it returns the associated script. +func FindScriptForHostname(maps []HostnameMap, hostname string) (script *Script, ok bool) { + for _, m := range maps { + if m.Hostname.MatchString(hostname) { + return m.Script, true + } + } + return nil, false +} + +// FindScriptForNetwork receives a NetworkMap and an IP and tries to see if +// that IP belongs to any of the configured networks. If it finds a match, +// it returns the associated script. +func FindScriptForNetwork(maps []NetworkMap, ip string) (script *Script, ok bool) { + for _, m := range maps { + if m.Network.Contains(net.ParseIP(ip)) { + return m.Script, true + } + } + return nil, false +} + +func (s Script) String() string { + var result = s.Name + " : { " + elems := []string{} + if s.Environment != "" { + elems = append(elems, "environment: "+s.Environment) + } + for key, value := range s.Params { + elems = append(elems, key+": "+value.(string)) + } + result += strings.Join(elems, ", ") + " }" + + return result +} diff --git a/internal/mappings/mappings_test.go b/internal/mappings/mappings_test.go new file mode 100644 index 0000000..ed45114 --- /dev/null +++ b/internal/mappings/mappings_test.go @@ -0,0 +1,100 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mappings + +import ( + "net" + "regexp" + "testing" +) + +var ( + mockScriptParams1 = map[string]interface{}{ + "param11": "param1_value1", + "param21": "param2_value1", + } + mockScriptParams2 = map[string]interface{}{ + "param12": "param1_value2", + "param22": "param2_value2", + } + mockScript1 = Script{Name: "mock_script1", Params: mockScriptParams1} + mockScript2 = Script{Name: "mock_script2", Params: mockScriptParams2} + + mockRegex1, _ = regexp.Compile("mock_host1") + mockRegex2, _ = regexp.Compile("mock_host2") + + mockHostNameMap1 = HostnameMap{ + Hostname: mockRegex1, + Script: &mockScript1, + } + + mockHostNameMap2 = HostnameMap{ + Hostname: mockRegex2, + Script: &mockScript2, + } + + _, mockNetwork1, _ = net.ParseCIDR("10.0.0.0/8") + _, mockNetwork2, _ = net.ParseCIDR("192.168.0.0/16") + + mockNetworkMap1 = NetworkMap{ + Network: mockNetwork1, + Script: &mockScript1, + } + mockNetworkMap2 = NetworkMap{ + Network: mockNetwork2, + Script: &mockScript2, + } +) + +func TestScript(t *testing.T) { + expected1 := "mock_script1 : { param11: param1_value1, param21: param2_value1 }" + expected2 := "mock_script1 : { param21: param2_value1, param11: param1_value1 }" + mockScriptString := mockScript1.String() + if mockScriptString != expected1 && mockScriptString != expected2 { + t.Errorf("Expected: %s or %s\nGot: %s\n", expected1, expected2, mockScriptString) + } +} + +func TestFindScriptForHostname(t *testing.T) { + maps := []HostnameMap{mockHostNameMap1, mockHostNameMap2} + script, success := FindScriptForHostname(maps, "mock_host1") + if !(script.Name == "mock_script1" && success) { + t.Error("Hostname should have matched") + } + script, success = FindScriptForHostname(maps, "mock_host2") + if !(script.Name == "mock_script2" && success) { + t.Error("Hostname should have matched") + } + script, success = FindScriptForHostname(maps, "mock_host_bad") + if !(script == nil && !success) { + t.Error("Hostname should have not matched") + } +} + +func TestScriptForNetwork(t *testing.T) { + maps := []NetworkMap{mockNetworkMap1, mockNetworkMap2} + script, success := FindScriptForNetwork(maps, "10.0.0.1") + if !(script.Name == "mock_script1" && success) { + t.Error("IP should have matched the network map") + } + script, success = FindScriptForNetwork(maps, "192.168.0.1") + if !(script.Name == "mock_script2" && success) { + t.Error("IP should have matched the network map") + } + script, success = FindScriptForNetwork(maps, "8.8.8.8") + if !(script == nil && !success) { + t.Error("IP shouildn't have matched the network map") + } +} diff --git a/internal/mappings/parse.go b/internal/mappings/parse.go new file mode 100644 index 0000000..64de5bb --- /dev/null +++ b/internal/mappings/parse.go @@ -0,0 +1,78 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mappings + +import ( + "io/ioutil" + "os" + + "gopkg.in/yaml.v2" + + "github.com/thousandeyes/shoelaces/internal/log" +) + +// Mappings struct contains YamlNetworkMaps and YamlHostnameMaps. +type Mappings struct { + NetworkMaps []YamlNetworkMap `yaml:"networkMaps"` + HostnameMaps []YamlHostnameMap `yaml:"hostnameMaps"` +} + +// YamlNetworkMap struct contains an association between a CIDR network and a +// Script. It's different than mapping.NetworkMap in the sense that this +// struct can be used to parse the JSON mapping file. +type YamlNetworkMap struct { + Network string + Script YamlScript +} + +// YamlHostnameMap struct contains an association between a hostname regular +// expression and a Script. It's different than mapping.HostnameMap in the +// sense that this struct can be used to parse the JSON mapping file. +type YamlHostnameMap struct { + Hostname string + Script YamlScript +} + +// YamlScript holds information regarding a script. Its name, its environment +// and its parameters. +type YamlScript struct { + Name string + Environment string + Params map[string]string +} + +// ParseYamlMappings parses the mappings yaml file into a Mappings struct. +func ParseYamlMappings(logger log.Logger, mappingsFile string) *Mappings { + var mappings Mappings + + logger.Info("component", "config", "msg", "Reading mappings", "source", mappingsFile) + yamlFile, err := ioutil.ReadFile(mappingsFile) + + if err != nil { + logger.Error(err) + os.Exit(1) + } + + mappings.NetworkMaps = make([]YamlNetworkMap, 0) + mappings.HostnameMaps = make([]YamlHostnameMap, 0) + + err = yaml.Unmarshal(yamlFile, &mappings) + if err != nil { + logger.Error(err) + os.Exit(1) + } + + return &mappings +} diff --git a/internal/polling/polling.go b/internal/polling/polling.go new file mode 100644 index 0000000..853a751 --- /dev/null +++ b/internal/polling/polling.go @@ -0,0 +1,257 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package polling + +import ( + "bytes" + "errors" + "fmt" + "sort" + "text/template" + "time" + + "github.com/thousandeyes/shoelaces/internal/event" + "github.com/thousandeyes/shoelaces/internal/log" + "github.com/thousandeyes/shoelaces/internal/mappings" + "github.com/thousandeyes/shoelaces/internal/server" + "github.com/thousandeyes/shoelaces/internal/templates" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +// ManualAction represent an action taken when no automatic boot is available. +type ManualAction int + +const ( + maxRetry = 10 + + retryScript = "#!ipxe\n" + + "prompt --key 0x02 --timeout 10000 shoelaces: Press Ctrl-B for manual override... && " + + "chain -ar http://{{.baseURL}}/ipxemenu || " + + "chain -ar http://{{.baseURL}}/poll/1/{{.macAddress}}\n" + + timeoutScript = "#!ipxe\n" + + "exit\n" + + // BootAction is used when a user selects a script for the polling + // server. The server polls once again, so it gets the selected script + // as answer. + BootAction ManualAction = 0 + // RetryAction is used when a server polling does not yet have a script + // selected by the user, hence it has to retry. + RetryAction ManualAction = 1 + // TimeoutAction is used when a server polling is timing out. + TimeoutAction ManualAction = 2 +) + +// ListServers provides a list of the servers that tried to boot +// but did not match the hostname regex or network mappings. +func ListServers(serverStates *server.States) server.Servers { + ret := make([]server.Server, 0) + + serverStates.RLock() + for _, s := range serverStates.Servers { + if s.Target == server.InitTarget { + ret = append(ret, s.Server) + } + } + defer serverStates.RUnlock() + sort.Sort(server.Servers(ret)) + + return ret +} + +// UpdateTarget receives parameters for booting manually. When a host +// didn't match any of the automatic methods for booting, it's going to be +// put on hold. This method is called when something is finally chosen for +// that host. +func UpdateTarget(logger log.Logger, serverStates *server.States, + templateRenderer *templates.ShoelacesTemplates, eventLog *event.Log, baseURL string, srv server.Server, + scriptName string, envName string, params map[string]interface{}) (inputErr bool, err error) { + + if !utils.IsValidMAC(srv.Mac) { + return true, errors.New("Invalid MAC") + } + // Test the template with user inputs + setHostName(params, srv.Mac) + + params["baseURL"] = utils.BaseURLforEnvName(baseURL, envName) + _, err = templateRenderer.RenderTemplate(logger, scriptName, params, envName) + if err != nil { + inputErr = true + return + } + + serverStates.Lock() + defer serverStates.Unlock() + servers := serverStates.Servers + if servers[srv.Mac] == nil { + return true, errors.New("MAC is not in the booting state") + } + + hostname := servers[srv.Mac].Server.Hostname + logger.Debug("component", "polling", "msg", "Setting server override", "server", srv.Mac, "target", scriptName, "environment", envName, "hostname", hostname, "params", params) + eventLog.AddEvent(event.UserSelection, srv, "", scriptName, nil) + servers[srv.Mac].Target = scriptName + servers[srv.Mac].Environment = envName + servers[srv.Mac].Params = params + return false, nil +} + +// Poll contains the main logic of Shoelaces. It uses several heuristics to find +// the right script to return, as network maps, hostname maps and manual +// selection. +func Poll(logger log.Logger, serverStates *server.States, + hostnameMaps []mappings.HostnameMap, networkMaps []mappings.NetworkMap, + eventLog *event.Log, templateRenderer *templates.ShoelacesTemplates, + baseURL string, srv server.Server) (scriptText string, err error) { + + script, found := attemptAutomaticBoot(logger, hostnameMaps, networkMaps, templateRenderer, eventLog, baseURL, srv) + if found { + return script, nil + } + + return manualAction(logger, serverStates, templateRenderer, eventLog, baseURL, srv) +} + +func attemptAutomaticBoot(logger log.Logger, hostnameMaps []mappings.HostnameMap, networkMaps []mappings.NetworkMap, + templateRenderer *templates.ShoelacesTemplates, eventLog *event.Log, + baseURL string, srv server.Server) (scriptText string, found bool) { + + // Find with reverse hostname matched with the hostname regexps + if script, found := mappings.FindScriptForHostname(hostnameMaps, srv.Hostname); found { + logger.Debug("component", "polling", "msg", "Host found", "where", "hostname-mapping", "host", srv.Hostname) + eventLog.AddEvent(event.HostBoot, srv, event.PtrMatchBoot, script.Name, script.Params) + script.Params["hostname"] = srv.Hostname + + return genBootScript(logger, templateRenderer, baseURL, script), found + } + logger.Debug("component", "polling", "msg", "Host not found", "where", "hostname-mapping", "host", srv.Hostname) + + // Find with IP belonging to a configured subnet + if script, found := mappings.FindScriptForNetwork(networkMaps, srv.IP); found { + logger.Debug("component", "polling", "msg", "Host found", "where", "network-mapping", "ip", srv.IP) + setHostName(script.Params, srv.Mac) + srv.Hostname = script.Params["hostname"].(string) + eventLog.AddEvent(event.HostBoot, srv, event.SubnetMatchBoot, script.Name, script.Params) + + return genBootScript(logger, templateRenderer, baseURL, script), found + } + logger.Debug("component", "polling", "msg", "Host not found", "where", "network-mapping", "ip", srv.IP) + + return "", false +} + +func manualAction(logger log.Logger, serverStates *server.States, templateRenderer *templates.ShoelacesTemplates, + eventLog *event.Log, baseURL string, srv server.Server) (scriptText string, err error) { + + script, action := chooseManualAction(logger, serverStates, eventLog, srv) + logger.Debug("component", "polling", "target-script-name", script, "action", action) + + switch action { + case BootAction: + setHostName(script.Params, srv.Mac) + srv.Hostname = script.Params["hostname"].(string) + eventLog.AddEvent(event.HostBoot, srv, event.ManualBoot, script.Name, script.Params) + return genBootScript(logger, templateRenderer, baseURL, script), nil + + case RetryAction: + return genRetryScript(logger, baseURL, srv.Mac), nil + + case TimeoutAction: + return timeoutScript, nil + + default: + logger.Info("component", "polling", "msg", "Unknown action") + return "", fmt.Errorf("%s", "Unknown action") + } +} + +func chooseManualAction(logger log.Logger, serverStates *server.States, + eventLog *event.Log, srv server.Server) (*mappings.Script, ManualAction) { + + serverStates.Lock() + defer serverStates.Unlock() + + if m := serverStates.Servers[srv.Mac]; m != nil { + if m.Target != server.InitTarget { + serverStates.DeleteServer(srv.Mac) + logger.Debug("component", "polling", "msg", "Server boot", "mac", srv.Mac) + return &mappings.Script{ + Name: m.Target, + Environment: m.Environment, + Params: m.Params}, BootAction + } else if m.Retry <= maxRetry { + m.Retry++ + m.LastAccess = int(time.Now().UTC().Unix()) + logger.Debug("component", "polling", "msg", "Retrying reboot", "mac", srv.Mac) + return nil, RetryAction + } else { + serverStates.DeleteServer(srv.Mac) + logger.Debug("component", "polling", "msg", "Timing out server", "mac", srv.Mac) + return nil, TimeoutAction + } + } + + serverStates.AddServer(srv) + logger.Debug("component", "polling", "msg", "New server", "mac", srv.Mac) + eventLog.AddEvent(event.HostPoll, srv, "", "", nil) + + return nil, RetryAction +} + +func setHostName(params map[string]interface{}, mac string) { + if _, ok := params["hostname"]; !ok { + hostname := utils.MacColonToDash(mac) + if hnPrefix, ok := params["hostnamePrefix"]; ok { + hnPrefixStr, isString := hnPrefix.(string) + if !isString { + hnPrefixStr = "" + } + params["hostname"] = hnPrefixStr + hostname + } else { + params["hostname"] = hostname + } + } +} + +func genBootScript(logger log.Logger, templateRenderer *templates.ShoelacesTemplates, baseURL string, script *mappings.Script) string { + script.Params["baseURL"] = utils.BaseURLforEnvName(baseURL, script.Environment) + text, err := templateRenderer.RenderTemplate(logger, script.Name, script.Params, script.Environment) + if err != nil { + panic(err) + } + return text +} + +func genRetryScript(logger log.Logger, baseURL string, mac string) string { + variablesMap := map[string]interface{}{} + parsedTemplate := &bytes.Buffer{} + + tmpl, err := template.New("retry").Parse(retryScript) + if err != nil { + logger.Info("component", "polling", "msg", "Error parsing retry template", "mac", mac) + panic(err) + } + + variablesMap["baseURL"] = baseURL + variablesMap["macAddress"] = utils.MacColonToDash(mac) + err = tmpl.Execute(parsedTemplate, variablesMap) + if err != nil { + logger.Info("component", "polling", "msg", "Error executing retry template", "mac", mac) + panic(err) + } + + return parsedTemplate.String() +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..abe4f8b --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,63 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/thousandeyes/shoelaces/internal/environment" + "github.com/thousandeyes/shoelaces/internal/handlers" +) + +// ShoelacesRouter sets up all routes and handlers for shoelaces +func ShoelacesRouter(env *environment.Environment) http.Handler { + r := mux.NewRouter() + + // Main UI page + r.Handle("/", handlers.RenderDefaultTemplate("index")).Methods("GET") + // Event Log History page + r.Handle("/events", handlers.RenderDefaultTemplate("events")).Methods("GET") + // Currently configured mappings page + r.Handle("/mappings", handlers.RenderDefaultTemplate("mappings")).Methods("GET") + // Static files used by the UI + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", + http.FileServer(http.Dir(env.StaticDir)))) + // Manual boot parameters POST endpoint + r.HandleFunc("/update/target", handlers.UpdateTargetHandler).Methods("POST") + // Provides a list of the servers that tried to boot but did not match + // the hostname regex or network mappings + r.HandleFunc("/ajax/servers", handlers.ServerListHandler).Methods("GET") + // Event Log History JSON endpoint + r.HandleFunc("/ajax/events", handlers.ListEvents).Methods("GET") + // Provides the list of possible parameters for a given template + r.HandleFunc("/ajax/script/params", handlers.GetTemplateParams) + + // Static configuration files endpoint + r.PathPrefix("/configs/static/").Handler(http.StripPrefix("/configs/static/", + handlers.StaticConfigFileServer())) + // Dynamic configuration endpoint + r.HandleFunc("/configs/{key}", handlers.TemplateHandler).Methods("GET") + + // Called by iPXE boot agents, returns boot script specified on the configuration + // or if the host is unknown makes it retry for a while until the user specifies + // alternative ipxe boot script + r.HandleFunc("/poll/1/{mac}", handlers.PollHandler).Methods("GET") + // Serves a generated iPXE boot script providing a selection + // of all of the boot scripts available on the filesystem for that environment. + r.HandleFunc("/ipxemenu", handlers.IPXEMenu).Methods("GET") + + return r +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1bc7e88 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,123 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "sync" + "time" + + "github.com/thousandeyes/shoelaces/internal/log" +) + +const ( + // InitTarget is an initial dummy target assigned to the servers + InitTarget = "NOTARGET" +) + +// Server holds data that uniquely identifies a server +type Server struct { + Mac string + IP string + Hostname string +} + +// Servers is an array of Server +type Servers []Server + +// Len implementation for the sort Interface +func (s Servers) Len() int { + return len(s) +} + +// Swap implementation for the sort interface +func (s Servers) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less implementation for the Sort interface +func (s Servers) Less(i, j int) bool { + return s[i].Mac < s[j].Mac +} + +// State holds information regarding a host that is attempting to boot. +type State struct { + Server + Target string + Environment string + Params map[string]interface{} + Retry int + LastAccess int +} + +// States holds a map between MAC addresses and +// States. It provides a mutex for thread-safety. +type States struct { + sync.RWMutex + Servers map[string]*State +} + +// New returns a Server with is values initialized +func New(mac string, ip string, hostname string) Server { + return Server{ + Mac: mac, + IP: ip, + Hostname: hostname, + } +} + +// AddServer adds a server to the States struct +func (m *States) AddServer(server Server) { + m.Servers[server.Mac] = &State{ + Server: server, + Target: InitTarget, + Retry: 1, + LastAccess: int(time.Now().UTC().Unix()), + } +} + +// DeleteServer deletes a server from the States struct +func (m *States) DeleteServer(mac string) { + delete(m.Servers, mac) +} + +// StartStateCleaner spawns a goroutine that cleans MAC addresses that +// have been inactive in Shoelaces for more than 3 minutes. +func StartStateCleaner(logger log.Logger, serverStates *States) { + const ( + // 3 minutes + expireAfterSec = 3 * 60 + cleanInterval = time.Minute + ) + // Clean up the server states. Expire after 3 minutes + go func() { + for { + time.Sleep(cleanInterval) + + servers := serverStates.Servers + expire := int(time.Now().UTC().Unix()) - expireAfterSec + + logger.Debug("component", "polling", "msg", "Cleaning", "before", time.Unix(int64(expire), 0)) + + serverStates.Lock() + for mac, state := range servers { + if state.LastAccess <= expire { + delete(servers, mac) + logger.Debug("component", "polling", "msg", "Mac cleaned", "mac", mac) + } + } + serverStates.Unlock() + } + }() +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..0df384a --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,233 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package templates + +import ( + "bufio" + "bytes" + "errors" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/thousandeyes/shoelaces/internal/log" + "github.com/thousandeyes/shoelaces/internal/utils" +) + +const defaultEnvironment = "default" + +var varRegex = regexp.MustCompile(`{{\.(.*?)}}`) +var configNameRegex = regexp.MustCompile(`{{define\s+"(.*?)".*}}`) + +// ShoelacesTemplates holds the core attributes for handling the dyanmic configurations +// in Shoelaces. +type ShoelacesTemplates struct { + envTemplates map[string]shoelacesTemplateEnvironment + dataDir string + envDir string + tplExt string +} + +type shoelacesTemplateEnvironment struct { + templateObj *template.Template + templateVars map[string][]string +} + +type shoelacesTemplateInfo struct { + name string + variables []string +} + +// New creates and initializes a new ShoelacesTemplates instance a returns a pointer to +// it. +func New() *ShoelacesTemplates { + e := make(map[string]shoelacesTemplateEnvironment) + e[defaultEnvironment] = shoelacesTemplateEnvironment{ + templateObj: template.New(""), + templateVars: make(map[string][]string), + } + return &ShoelacesTemplates{envTemplates: e} +} + +func (s *ShoelacesTemplates) parseTemplateInfo(logger log.Logger, path string) shoelacesTemplateInfo { + fh, err := os.Open(path) + if err != nil { + logger.Error("component", "template", "err", err.Error()) + os.Exit(1) + } + + defer fh.Close() + + templateVars := make([]string, 0) + scanner := bufio.NewScanner(fh) + templateName := "" + i := 0 + for scanner.Scan() { + // find variables + result := varRegex.FindAllStringSubmatch(scanner.Text(), -1) + if varRegex.MatchString(scanner.Text()) { + for _, v := range result { + // we only want the actual match, being second in the group + if !utils.StringInSlice(v[1], templateVars) { + templateVars = append(templateVars, v[1]) + } + } + } + // if first line get name of template + if i == 0 { + nameResult := configNameRegex.FindAllStringSubmatch(scanner.Text(), -1) + templateName = nameResult[0][1] + } + i++ + } + + return shoelacesTemplateInfo{name: templateName, variables: templateVars} +} + +func (s *ShoelacesTemplates) checkAddEnvironment(logger log.Logger, environment string) { + if _, ok := s.envTemplates[environment]; !ok { + c, e := s.envTemplates[defaultEnvironment].templateObj.Clone() + if e != nil { + logger.Error("component", "template", "msg", "Template for environment already executed", "environment", environment) + os.Exit(1) + } + s.envTemplates[environment] = shoelacesTemplateEnvironment{ + templateObj: c, + templateVars: make(map[string][]string), + } + } +} + +func (s *ShoelacesTemplates) addTemplate(logger log.Logger, path string, environment string) error { + s.checkAddEnvironment(logger, environment) + i := s.parseTemplateInfo(logger, path) + _, err := s.envTemplates[environment].templateObj.ParseFiles(path) + if err != nil { + return err + } + s.envTemplates[environment].templateVars[i.name] = i.variables + return nil +} + +func (s *ShoelacesTemplates) getEnvFromPath(path string) string { + envPath := filepath.Join(s.dataDir, s.envDir) + if strings.HasPrefix(path, envPath) { + return strings.Split(strings.TrimPrefix(path, envPath), "/")[1] + } + return defaultEnvironment +} + +// ParseTemplates travels the dataDir and loads in an internal structure +// all the templates found. +func (s *ShoelacesTemplates) ParseTemplates(logger log.Logger, dataDir string, envDir string, envs []string, tplExt string) { + s.dataDir = dataDir + s.envDir = envDir + s.tplExt = tplExt + + logger.Debug("component", "template", "msg", "Template parsing started", "dir", dataDir) + + tplScannerDefault := func(p string, info os.FileInfo, err error) error { + if strings.HasPrefix(p, path.Join(dataDir, envDir)) { + return err + } + if strings.HasSuffix(p, tplExt) { + logger.Info("component", "template", "msg", "Parsing file", "file", p) + if err := s.addTemplate(logger, p, defaultEnvironment); err != nil { + logger.Error("component", "template", "err", err.Error()) + os.Exit(1) + } + } + return err + } + + tplScannerOverride := func(p string, info os.FileInfo, err error) error { + if strings.HasSuffix(p, tplExt) { + env := s.getEnvFromPath(p) + logger.Info("component", "template", "msg", "Parsing ovveride", "environment", env, "file", p) + + if err := s.addTemplate(logger, p, env); err != nil { + logger.Error("component", "template", "err", err.Error()) + os.Exit(1) + } + } + return err + } + + if err := filepath.Walk(dataDir, tplScannerDefault); err != nil { + panic(err) + } + logger.Info("component", "template", "msg", "Parsing override files", "dir", path.Join(dataDir, envDir)) + if err := filepath.Walk(path.Join(dataDir, envDir), tplScannerOverride); err != nil { + logger.Info("component", "template", "msg", "No overrides found") + } + logger.Debug("component", "template", "msg", "Parsing ended") +} + +// RenderTemplate receives a name and a map of parameters, among other +// arguments, and returns the rendered template. It's aware of the +// environment, in case of any. +func (s *ShoelacesTemplates) RenderTemplate(logger log.Logger, configName string, paramMap map[string]interface{}, envName string) (string, error) { + if envName == "" { + envName = defaultEnvironment + } + logger.Info("component", "template", "action", "template-request", "template", configName, "env", envName, "parameters", utils.MapToString(paramMap)) + + requiredVariables := s.envTemplates[envName].templateVars[configName] + + var b bytes.Buffer + err := s.envTemplates[envName].templateObj.ExecuteTemplate(&b, configName, paramMap) + // Fall back to default template in case this is non default environment + // XXX: this is temporary and will be simplified to reduce the code duplication + if err != nil && envName != defaultEnvironment { + requiredVariables = s.envTemplates[defaultEnvironment].templateVars[configName] + err = s.envTemplates[defaultEnvironment].templateObj.ExecuteTemplate(&b, configName, paramMap) + } + if err != nil { + logger.Info("component", "template", "action", "render-template", "err", err.Error()) + return "", err + } + r := b.String() + if strings.Contains(r, "<no value>") { + missingVariables := "" + for _, requiredVariable := range requiredVariables { + if !utils.KeyInMap(requiredVariable, paramMap) { + if len(missingVariables) > 0 { + missingVariables += ", " + } + missingVariables += requiredVariable + } + } + logger.Info("component", "template", "msg", "Missing variables in request", "variables", missingVariables) + return "", errors.New("Missing variables in request: " + missingVariables) + } + + return r, nil +} + +// ListVariables receives a template name and return the list of variables +// that belong to it. It's mainly used by the web frontend to provide a +// list of dynamic fields to complete before rendering a template. +func (s *ShoelacesTemplates) ListVariables(templateName, envName string) []string { + if e, ok := s.envTemplates[envName]; ok { + if v, ok := e.templateVars[templateName]; ok { + return v + } + } + var empty []string + return empty +} diff --git a/internal/utils/util_test.go b/internal/utils/util_test.go new file mode 100644 index 0000000..f9daee8 --- /dev/null +++ b/internal/utils/util_test.go @@ -0,0 +1,39 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "testing" +) + +func TestMacColonToDash(t *testing.T) { + testNormMac := func(givenMac, expectedMac string) { + if MacColonToDash(givenMac) != expectedMac { + t.Errorf("Expected: %s\nGot: %s", expectedMac, givenMac) + } + } + testNormMac("ff:ff:ff:ff:ff:ff", "ff-ff-ff-ff-ff-ff") + testNormMac("ff-ff-ff-ff-ff-ff", "ff-ff-ff-ff-ff-ff") +} + +func TestMacDashToColon(t *testing.T) { + testNormMac := func(givenMac, expectedMac string) { + if MacDashToColon(givenMac) != expectedMac { + t.Errorf("Expected: %s\nGot: %s", expectedMac, givenMac) + } + } + testNormMac("ff-ff-ff-ff-ff-ff", "ff:ff:ff:ff:ff:ff") + testNormMac("ff.ff.ff.ff.ff.ff", "ff.ff.ff.ff.ff.ff") +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..b7ee256 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,105 @@ +// Copyright 2018 ThousandEyes Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "fmt" + "net" + "path/filepath" + "strings" +) + +// Filter receives a slide of strings and a function that receives a string +// and returns a bool, and returns a slide that has only the strings that +// returned true when they were applied the received function. +func Filter(files []string, fn func(string) bool) []string { + var ret []string + for _, f := range files { + if fn(f) { + ret = append(ret, f) + } + } + + return ret +} + +// StringInSlice receives a string and a slice of strings and returns true if it exists +// there. +func StringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +// KeyInMap checks wheter the received key exists in the received map. +func KeyInMap(key string, mapInput map[string]interface{}) bool { + _, found := mapInput[key] + return found +} + +// MapToString provides a string representation of a map of strings. +func MapToString(mapInput map[string]interface{}) string { + result := "" + for k, v := range mapInput { + if len(result) > 0 { + result += ", " + } + result += fmt.Sprintf("%s:%v", k, v) + } + return result +} + +// BaseURLforEnvName provides an environment-sensitive method for returning +// the BaseURL of the application. +func BaseURLforEnvName(baseURL, environment string) string { + if environment != "" { + return filepath.Join(baseURL, "env", environment) + } + return baseURL +} + +// ResolveHostname receives an IP and returns the resolved PTR. It returns an +// empty string in case the DNS lookup fails. +func ResolveHostname(ip string) (host string) { + hosts, err := net.LookupAddr(ip) + if err != nil { + return "" + } + return hosts[0] +} + +// IsValidIP returns whether or not an IP is well-formed. +func IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +// IsValidMAC returns whether or not a MAC address is well-formed. +func IsValidMAC(mac string) bool { + _, err := net.ParseMAC(mac) + return err == nil +} + +// MacColonToDash receives a mac address and replace its colons by dashes +func MacColonToDash(mac string) string { + return strings.Replace(mac, ":", "-", -1) +} + +// MacDashToColon receives a mac address and replace its dashes by colons +func MacDashToColon(mac string) string { + return strings.Replace(mac, "-", ":", -1) +} |