diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b4ddc..95c5cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +#### **Version v0.0.8** + +##### FEATURES + + * Adding an exec option to the control set, the command is called whenever a change is made on the resource with a + condfigurable timeout (default to 60s) + -cn=secret:platform/secrets/se2:fmt=yaml,exec=tests/runme.sh,update=1s #### **Version v0.0.7** diff --git a/config.go b/config.go index b3891f5..1fdae55 100644 --- a/config.go +++ b/config.go @@ -42,6 +42,8 @@ type config struct { resources *VaultResources // the interval for producing statistics statsInterval time.Duration + // the timeout for a exec command + execTimeout time.Duration } var ( @@ -60,6 +62,7 @@ func init() { flag.BoolVar(&options.tlsVerify, "tls-skip-verify", false, "whether to check and verify the vault service certificate") flag.StringVar(&options.vaultCaFile, "ca-cert", "", "the path to the file container the CA used to verify the vault service") flag.DurationVar(&options.statsInterval, "stats", time.Duration(1)*time.Hour, "the interval to produce statistics on the accessed resources") + flag.DurationVar(&options.execTimeout, "exec-timeout", time.Duration(60) * time.Second, "the timeout applied to commands on the exec option") flag.Var(options.resources, "cn", "a resource to retrieve and monitor from vault") } diff --git a/formats.go b/formats.go new file mode 100644 index 0000000..ee172bc --- /dev/null +++ b/formats.go @@ -0,0 +1,154 @@ +/* +Copyright 2015 Home Office All rights reserved. + +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 main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "strings" + + "github.com/golang/glog" + "gopkg.in/yaml.v2" +) + +func writeIniFile(filename string, data map[string]interface{}) error { + var buf bytes.Buffer + for key, val := range data { + buf.WriteString(fmt.Sprintf("%s = %v\n", key, val)) + } + + return writeFile(filename, buf.Bytes()) +} + +func writeCSVFile(filename string, data map[string]interface{}) error { + var buf bytes.Buffer + for key, val := range data { + buf.WriteString(fmt.Sprintf("%s,%v\n", key, val)) + } + + return writeFile(filename, buf.Bytes()) +} + +func writeYAMLFile(filename string, data map[string]interface{}) error { + // marshall the content to yaml + content, err := yaml.Marshal(data) + if err != nil { + return err + } + + return writeFile(filename, content) +} + +func writeEnvFile(filename string, data map[string]interface{}) error { + var buf bytes.Buffer + for key, val := range data { + buf.WriteString(fmt.Sprintf("%s=%v\n", strings.ToUpper(key), val)) + } + + return writeFile(filename, buf.Bytes()) +} + +func writeCertificateFile(filename string, data map[string]interface{}) error { + files := map[string]string{ + "certificate": "crt", + "issuing_ca": "ca", + "private_key": "key", + } + for key, suffix := range files { + filename := fmt.Sprintf("%s.%s", filename, suffix) + content, found := data[key] + if !found { + glog.Errorf("didn't find the certification option: %s in the resource: %s", key, filename) + continue + } + + // step: write the file + if err := writeFile(filename, []byte(fmt.Sprintf("%s", content))); err != nil { + glog.Errorf("failed to write resource: %s, elemment: %s, filename: %s, error: %s", filename, suffix, filename, err) + continue + } + } + + return nil + +} + +func writeCertificateBundleFile(filename string, data map[string]interface{}) error { + certificateFile := fmt.Sprintf("%s.crt", filename) + caFile := fmt.Sprintf("%s.ca", filename) + certificate := fmt.Sprintf("%s\n\n%s", data["certificate"], data["private_key"]) + ca := fmt.Sprintf("%s", data["issuing_ca"]) + + if err := writeFile(certificateFile, []byte(certificate)); err != nil { + glog.Errorf("failed to write the bundled certificate file, error: %s", err) + return err + } + + if err := writeFile(caFile, []byte(ca)); err != nil { + glog.Errorf("failed to write the ca certificate file, errro: %s", err) + return err + } + + return nil +} + +func writeTxtFile(filename string, data map[string]interface{}) error { + keys := getKeys(data) + if len(keys) > 1 { + // step: for plain formats we need to iterate the keys and produce a file per key + for suffix, content := range data { + filename := fmt.Sprintf("%s.%s", filename, suffix) + if err := writeFile(filename, []byte(fmt.Sprintf("%v", content))); err != nil { + glog.Errorf("failed to write resource: %s, elemment: %s, filename: %s, error: %s", + filename, suffix, filename, err) + continue + } + } + return nil + } + + // step: we only have the one key, so will write plain + value, _ := data[keys[0]] + content := []byte(fmt.Sprintf("%s", value)) + + return writeFile(filename, content) +} + +func writeJSONFile(filename string, data map[string]interface{}) error { + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + return writeFile(filename, content) +} + +// writeFile ... writes the content to a file .. dah +// filename : the path to the file +// content : the content to be written +func writeFile(filename string, content []byte) error { + if options.dryRun { + glog.Infof("dry-run: filename: %s, content:", filename) + fmt.Printf("%s\n", string(content)) + return nil + } + glog.V(3).Infof("saving the file: %s", filename) + + return ioutil.WriteFile(filename, content, 0660) +} diff --git a/main.go b/main.go index 72e18f7..cfd5bc6 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( const ( Prog = "vault-sidekick" - Version = "v0.0.7" + Version = "v0.0.8" ) func main() { @@ -64,7 +64,7 @@ func main() { case evt := <-updates: glog.V(10).Infof("recieved an update from the resource: %s", evt.Resource) go func(r VaultEvent) { - if err := writeResource(evt.Resource, evt.Secret); err != nil { + if err := processResource(evt.Resource, evt.Secret); err != nil { glog.Errorf("failed to write out the update, error: %s", err) } }(evt) diff --git a/tests/runme.sh b/tests/runme.sh new file mode 100755 index 0000000..7dc453a --- /dev/null +++ b/tests/runme.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "running me: $@" >> runme.log diff --git a/utils.go b/utils.go index 157cc1d..94ac6e1 100644 --- a/utils.go +++ b/utils.go @@ -17,7 +17,6 @@ limitations under the License. package main import ( - "bytes" "encoding/json" "flag" "fmt" @@ -30,6 +29,7 @@ import ( "github.com/golang/glog" "gopkg.in/yaml.v2" + "os/exec" "path/filepath" ) @@ -155,144 +155,57 @@ func fileExists(filename string) (bool, error) { return true, nil } -// writeResourceContent is responsible for generating the specific content from the resource -// rn : a point to the vault resource +// processResource is responsible for generating the specific content from the resource +// rn : a point to the vault resource // data : a map of the related secret associated to the resource -func writeResource(rn *VaultResource, data map[string]interface{}) error { +func processResource(rn *VaultResource, data map[string]interface{}) (err error) { // step: determine the resource path - resourcePath := rn.GetFilename() - if !strings.HasPrefix(resourcePath, "/") { - resourcePath = fmt.Sprintf("%s/%s", options.outputDir, filepath.Base(resourcePath)) + filename := rn.GetFilename() + if !strings.HasPrefix(filename, "/") { + filename = fmt.Sprintf("%s/%s", options.outputDir, filepath.Base(filename)) + } + // step: format and write the file + switch rn.format { + case "yaml": + fallthrough + case "yml": + err = writeYAMLFile(filename, data) + case "json": + err = writeJSONFile(filename, data) + case "ini": + err = writeIniFile(filename, data) + case "csv": + err = writeCSVFile(filename, data) + case "env": + err = writeEnvFile(filename, data) + case "cert": + err = writeCertificateFile(filename, data) + case "txt": + err = writeTxtFile(filename, data) + case "bundle": + err = writeCertificateBundleFile(filename, data) + default: + return fmt.Errorf("unknown output format: %s", rn.format) + } + // step: check for an error + if err != nil { + return err } - glog.V(10).Infof("writing the resource: %s, format: %s", resourcePath, rn.format) - - if rn.format == "yaml" { - // marshall the content to yaml - content, err := yaml.Marshal(data) - if err != nil { - return err - } - - return writeFile(resourcePath, content) - } - - if rn.format == "ini" { - var buf bytes.Buffer - for key, val := range data { - buf.WriteString(fmt.Sprintf("%s = %v\n", key, val)) - } - - return writeFile(resourcePath, buf.Bytes()) - } - - if rn.format == "bundle" { - certificateFile := fmt.Sprintf("%s.crt", resourcePath) - caFile := fmt.Sprintf("%s.ca", resourcePath) - certificate := fmt.Sprintf("%s\n\n%s", data["certificate"], data["private_key"]) - ca := fmt.Sprintf("%s", data["issuing_ca"]) - - if err := writeFile(certificateFile, []byte(certificate)); err != nil { - glog.Errorf("failed to write the bundled certificate file, error: %s", err) - return err - } - - if err := writeFile(caFile, []byte(ca)); err != nil { - glog.Errorf("failed to write the ca certificate file, errro: %s", err) - return err - } - - return nil - } - - if rn.format == "env" { - var buf bytes.Buffer - for key, val := range data { - buf.WriteString(fmt.Sprintf("%s=%v\n", strings.ToUpper(key), val)) - } - - return writeFile(resourcePath, buf.Bytes()) - } - - if rn.format == "cert" { - files := map[string]string{ - "certificate": "crt", - "issuing_ca": "ca", - "private_key": "key", - } - for key, suffix := range files { - filename := fmt.Sprintf("%s.%s", resourcePath, suffix) - content, found := data[key] - if !found { - glog.Errorf("didn't find the certification option: %s in the resource: %s", key, rn.path) - continue + // step: check if we need to execute a command + if rn.execPath != "" { + glog.V(10).Infof("executing the command: %s for resouce: %s", rn.execPath, rn.path) + cmd := exec.Command(rn.execPath, filename) + cmd.Start() + timer := time.AfterFunc(options.execTimeout, func() { + if err := cmd.Process.Kill(); err != nil { + glog.Errorf("failed to kill the command, pid: %d, error: %s", cmd.Process.Pid, err) } - - // step: write the file - if err := writeFile(filename, []byte(fmt.Sprintf("%s", content))); err != nil { - glog.Errorf("failed to write resource: %s, elemment: %s, filename: %s, error: %s", rn, suffix, filename, err) - continue - } - } - - return nil + }) + // step: wait for the command to finish + err = cmd.Wait() + timer.Stop() } - if rn.format == "csv" { - var buf bytes.Buffer - for key, val := range data { - buf.WriteString(fmt.Sprintf("%s,%v\n", key, val)) - } - - return writeFile(resourcePath, buf.Bytes()) - } - - if rn.format == "txt" { - keys := getKeys(data) - if len(keys) > 1 { - // step: for plain formats we need to iterate the keys and produce a file per key - for suffix, content := range data { - filename := fmt.Sprintf("%s.%s", resourcePath, suffix) - if err := writeFile(filename, []byte(fmt.Sprintf("%v", content))); err != nil { - glog.Errorf("failed to write resource: %s, elemment: %s, filename: %s, error: %s", - rn, suffix, filename, err) - continue - } - } - return nil - } - - // step: we only have the one key, so will write plain - value, _ := data[keys[0]] - content := []byte(fmt.Sprintf("%s", value)) - - return writeFile(resourcePath, content) - - } - - if rn.format == "json" { - content, err := json.MarshalIndent(data, "", " ") - if err != nil { - return err - } - - return writeFile(resourcePath, content) - } - - return fmt.Errorf("unknown output format: %s", rn.format) -} - -// writeFile ... writes the content to a file .. dah -// filename : the path to the file -// content : the content to be written -func writeFile(filename string, content []byte) error { - if options.dryRun { - glog.Infof("dry-run: filename: %s, content:", filename) - fmt.Printf("%s\n", string(content)) - return nil - } - - glog.V(3).Infof("saving the file: %s", filename) - - return ioutil.WriteFile(filename, content, 0660) + return err } diff --git a/vault.go b/vault.go index a0bc301..4d0457a 100644 --- a/vault.go +++ b/vault.go @@ -325,7 +325,7 @@ func (r VaultService) get(rn *watchedResource) (err error) { // step: perform a request to vault switch rn.resource.resource { case "raw": - request := r.client.NewRequest("GET", "/v1/" + rn.resource.path) + request := r.client.NewRequest("GET", "/v1/"+rn.resource.path) for k, v := range rn.resource.options { request.Params.Add(k, v) } @@ -341,10 +341,10 @@ func (r VaultService) get(rn *watchedResource) (err error) { } // step: construct a secret from the response secret = &api.Secret{ - LeaseID: "raw", - Renewable: false, + LeaseID: "raw", + Renewable: false, Data: map[string]interface{}{ - "content" : fmt.Sprintf("%s", content), + "content": fmt.Sprintf("%s", content), }, } if rn.resource.update > 0 { diff --git a/vault_resource.go b/vault_resource.go index a768112..e22aeb2 100644 --- a/vault_resource.go +++ b/vault_resource.go @@ -37,10 +37,12 @@ const ( optionsRevokeDelay = "delay" // optionUpdate overrides the lease of the resource optionUpdate = "update" + // optionsExec executes something on a change + optionExec = "exec" ) var ( - resourceFormatRegex = regexp.MustCompile("^(yaml|json|env|ini|txt|cert|bundle|csv)$") + resourceFormatRegex = regexp.MustCompile("^(yaml|yml|json|env|ini|txt|cert|bundle|csv)$") // a map of valid resource to retrieve from vault validResources = map[string]bool{ @@ -86,6 +88,8 @@ type VaultResource struct { filename string // the template file templateFile string + // the path to an exec to run on a change + execPath string // additional options to the resource options map[string]string } diff --git a/vault_resources.go b/vault_resources.go index 34bd2df..4f0f5a1 100644 --- a/vault_resources.go +++ b/vault_resources.go @@ -96,6 +96,8 @@ func (r *VaultResources) Set(value string) error { return fmt.Errorf("the renewal option: %s is invalid, should be a boolean", value) } rn.renewable = choice + case optionExec: + rn.execPath = value case optionFilename: rn.filename = value case optionTemplatePath: