diff --git a/go.mod b/go.mod index 24dfd6c..ae4280c 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/camptocamp/helm-sops go 1.20 -require go.mozilla.org/sops/v3 v3.7.3 +require ( + go.mozilla.org/sops/v3 v3.7.3 + gopkg.in/yaml.v3 v3.0.1 +) require ( cloud.google.com/go/compute v1.5.0 // indirect @@ -82,7 +85,6 @@ require ( gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/urfave/cli.v1 v1.20.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace go.mozilla.org/sops/v3 v3.7.3 => github.com/camptocamp/sops/v3 v3.7.4-0.20230517081230-891507a64d12 diff --git a/helm_wrapper.go b/helm_wrapper.go new file mode 100644 index 0000000..8bae607 --- /dev/null +++ b/helm_wrapper.go @@ -0,0 +1,203 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "regexp" + "sync" + "syscall" + + "go.mozilla.org/sops/v3/decrypt" +) + +type HelmWrapper struct { + Errors []error + errMutex sync.Mutex + + ExitCode int + + helmBinPath string + pipeWriterWaitGroup sync.WaitGroup + valuesArgRegexp *regexp.Regexp + temporaryDirectory string +} + +func NewHelmWrapper() (*HelmWrapper, error) { + c := HelmWrapper{} + + c.Errors = []error{} + c.pipeWriterWaitGroup = sync.WaitGroup{} + c.valuesArgRegexp = regexp.MustCompile("^(-f|--values)(?:=(.+))?$") + + // Determine the name of the helm binary by examining our binary name + helmBinName := "helm" + ourBinName := path.Base(os.Args[0]) + if ourBinName == "helm" || ourBinName == "helm2" || ourBinName == "helm3" { + helmBinName = fmt.Sprintf("_%s", ourBinName) + } + + var err error + c.helmBinPath, err = exec.LookPath(helmBinName) + if err != nil { + return nil, fmt.Errorf("failed to find Helm binary '%s': %s", helmBinName, err) + } + + return &c, nil +} + +func (c *HelmWrapper) errorf(msg string, a ...interface{}) error { + e := fmt.Errorf(msg, a...) + c.errMutex.Lock() + c.Errors = append(c.Errors, e) + c.errMutex.Unlock() + return e +} + +func (c *HelmWrapper) pipeWriter(outPipeName string, data []byte) { + c.pipeWriterWaitGroup.Add(1) + defer c.pipeWriterWaitGroup.Done() + + cleartextSecretFile, err := os.OpenFile(outPipeName, os.O_WRONLY, 0) + if err != nil { + c.errorf("failed to open cleartext secret pipe '%s' in pipe writer: %s", outPipeName, err) + return + } + defer func() { + err := cleartextSecretFile.Close() + if err != nil { + c.errorf("failed to close cleartext secret pipe '%s' in pipe writer: %s", outPipeName, err) + } + }() + + _, err = cleartextSecretFile.Write(data) + if err != nil { + c.errorf("failed to write cleartext secret to pipe '%s': %s", outPipeName, err) + } +} + +func (c *HelmWrapper) valuesArg(args []string) (string, string, error) { + valuesArgRegexpMatches := c.valuesArgRegexp.FindStringSubmatch(args[0]) + if valuesArgRegexpMatches == nil { + return "", "", nil + } + + var filename string + if len(valuesArgRegexpMatches[2]) > 0 { + // current arg is in the format --values=filename + filename = valuesArgRegexpMatches[2] + } else if len(args) > 1 { + // arg is in the format "-f filename" + filename = args[1] + } else { + return "", "", c.errorf("missing filename after -f or --values") + } + + cleartextSecretFilename := fmt.Sprintf("%s/%x", c.temporaryDirectory, sha256.Sum256([]byte(filename))) + + return filename, cleartextSecretFilename, nil +} + +func (c *HelmWrapper) replaceValueFileArg(args []string, cleartextSecretFilename string) { + valuesArgRegexpMatches := c.valuesArgRegexp.FindStringSubmatch(args[0]) + + // replace the filename with our pipe + if len(valuesArgRegexpMatches[2]) > 0 { + args[0] = fmt.Sprintf("%s=%s", valuesArgRegexpMatches[1], cleartextSecretFilename) + } else { + args[1] = cleartextSecretFilename + } +} + +func (c *HelmWrapper) mkTmpDir() (func(), error) { + var err error + c.temporaryDirectory, err = ioutil.TempDir("", fmt.Sprintf("%s.", path.Base(os.Args[0]))) + if err != nil { + return nil, c.errorf("failed to create temporary directory: %s", err) + } + return func() { + err := os.RemoveAll(c.temporaryDirectory) + if err != nil { + c.errorf("failed to remove temporary directory '%s': %s", c.temporaryDirectory, err) + } + }, nil +} + +func (c *HelmWrapper) mkPipe(cleartextSecretFilename string) (func(), error) { + err := syscall.Mkfifo(cleartextSecretFilename, 0600) + if err != nil { + return nil, c.errorf("failed to create cleartext secret pipe '%s': %s", cleartextSecretFilename, err) + } + return func() { + err := os.Remove(cleartextSecretFilename) + if err != nil { + c.errorf("failed to remove cleartext secret pipe '%s': %s", cleartextSecretFilename, err) + } + }, nil +} + +func (c *HelmWrapper) RunHelm() { + var err error + // Setup temporary directory and defer cleanup + cleanFn, err := c.mkTmpDir() + if err != nil { + return + } + defer cleanFn() + + // Loop through arguments looking for --values or -f. + // If we find a values argument, check if file has a sops section indicating it is encrypted. + // Setup a named pipe and write the decrypted data into that for helm. + for i := range os.Args { + args := os.Args[i:] + + filename, cleartextSecretFilename, err := c.valuesArg(args) + if err != nil { + return + } + if filename == "" { + continue + } + + encrypted, err := DetectSopsYaml(filename) + if err != nil { + c.errorf("error checking if file is encrypted: %s", err) + return + } + if !encrypted { + continue + } + + c.replaceValueFileArg(args, cleartextSecretFilename) + + cleartextSecrets, err := decrypt.File(filename, "yaml") + if err != nil { + c.errorf("failed to decrypt secret file '%s': %s", filename, err) + return + } + + cleanFn, err := c.mkPipe(cleartextSecretFilename) + if err != nil { + return + } + defer cleanFn() + + go c.pipeWriter(cleartextSecretFilename, cleartextSecrets) + } + defer c.pipeWriterWaitGroup.Wait() + + cmd := exec.Command(c.helmBinPath, os.Args[1:]...) + cmd.Env = os.Environ() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + c.ExitCode = cmd.ProcessState.ExitCode() + c.errorf("failed to run Helm: %s", err) + } +} diff --git a/helm_wrapper_test.go b/helm_wrapper_test.go new file mode 100644 index 0000000..d9226c5 --- /dev/null +++ b/helm_wrapper_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "os" + "testing" +) + +var g_hw *HelmWrapper + +func init() { + g_hw, _ = NewHelmWrapper() +} + +func TestNewHelmWrapper(t *testing.T) { + // TODO +} + +func TestErrorf(t *testing.T) { + g_hw.Errors = []error{} + err := g_hw.errorf("test %s %d %t", "a", 1, true) + if g_hw.Errors[0] != err { + t.Errorf("errorf(test %%s %%d %%t, a, 1, true) = %s; want %s", g_hw.Errors[0], err) + } +} + +func TestPipeWriter(t *testing.T) { + // TODO +} + +func TestValuesArg(t *testing.T) { + res, _, err := g_hw.valuesArg([]string{"-f", "cat.yaml"}) + if res != "cat.yaml" || err != nil { + t.Errorf("valuesArg([]string{\"-f\", \"cat.yaml\"}) = %s, %s; want cat.yaml, ", res, "cat.yaml") + } + + res, _, err = g_hw.valuesArg([]string{"--values", "cat.yaml"}) + if res != "cat.yaml" || err != nil { + t.Errorf("valuesArg([]string{\"--valuse\", \"cat.yaml\"}) = %s, %s; want cat.yaml, ", res, "cat.yaml") + } + + res, _, err = g_hw.valuesArg([]string{"--values=cat.yaml"}) + if res != "cat.yaml" || err != nil { + t.Errorf("valuesArg([]string{\"--values=cat.yaml\"}) = %s, %s; want cat.yaml, ", res, "cat.yaml") + } +} + +func TestReplaceValueFileArg(t *testing.T) { + args := []string{"-f", "cat.yaml"} + g_hw.replaceValueFileArg(args, "dog.yaml") + if args[1] != "dog.yaml" { + t.Errorf("args[1] = %s; want dog.yaml", args[1]) + } + + args = []string{"--values", "cat.yaml"} + g_hw.replaceValueFileArg(args, "dog.yaml") + if args[1] != "dog.yaml" { + t.Errorf("args[1] = %s; want dog.yaml", args[1]) + } + + args = []string{"--values=cat.yaml"} + g_hw.replaceValueFileArg(args, "dog.yaml") + if args[0] != "--values=dog.yaml" { + t.Errorf("args[1] = %s; want --values=dog.yaml", args[1]) + } +} + +func TestMkTmpDir(t *testing.T) { + // ensure no errors + cleanFn, err := g_hw.mkTmpDir() + if err != nil { + t.Errorf("mkTmpDir error: %s", err) + } + + // dir exists + if _, err = os.Stat(g_hw.temporaryDirectory); err != nil { + t.Errorf("mkTmpDir stat error: %s", err) + } + + // ensure dir is deleted + cleanFn() + if _, err = os.Stat(g_hw.temporaryDirectory); err == nil { + t.Errorf("mkTmpDir cleanup func did not work") + } else if !os.IsNotExist(err) { + t.Errorf("mkTmpDir cleanup something went wrong: %s", err) + } +} + +func TestMkPipe(t *testing.T) { + // ensure no errors + cleanFn, err := g_hw.mkPipe("cat.yaml") + if err != nil { + t.Errorf("mkPipe error: %s", err) + } + + // file exists + if _, err = os.Stat("cat.yaml"); err != nil { + t.Errorf("mkPipe stat error: %s", err) + } + + // ensure file is deleted + cleanFn() + if _, err = os.Stat("cat.yaml"); err == nil { + t.Errorf("mkPipe cleanup func did not work") + } else if !os.IsNotExist(err) { + t.Errorf("mkPipe cleanup something went wrong: %s", err) + } +} + +func TestRunHelm(t *testing.T) { + // TODO +} diff --git a/main.go b/main.go index 31b0594..b0489f5 100644 --- a/main.go +++ b/main.go @@ -20,227 +20,22 @@ along with Helm Sops. If not, see . package main import ( - "crypto/sha256" "fmt" - "io/ioutil" "os" - "os/exec" - "path" - "regexp" - "sync" - "syscall" - - "go.mozilla.org/sops/v3/decrypt" -) - -var ( - valuesArgRegexp *regexp.Regexp - secretFilenameRegexp *regexp.Regexp ) -func init() { - valuesArgRegexp = regexp.MustCompile("^(-f|--values)(?:=(.+))?$") - secretFilenameRegexp = regexp.MustCompile("^((?:.*/)?secrets(?:(?:-|\\.|_).+)?.yaml)$") -} - -func runHelm() (errs []error) { - var helmPath string - var err error - - switch executableName := path.Base(os.Args[0]); executableName { - case "helm", "helm2", "helm3": - executableName = fmt.Sprintf("_%s", executableName) - - helmPath, err = exec.LookPath(executableName) - - if err != nil { - return append(errs, fmt.Errorf("failed to find Helm binary '%s'", executableName)) - } - default: - helmPath, err = exec.LookPath("helm") - - if err != nil { - return append(errs, fmt.Errorf("failed to find Helm binary 'helm'")) - } - } - - temporaryDirectory, err := ioutil.TempDir("", fmt.Sprintf("%s.", path.Base(os.Args[0]))) - - if err != nil { - return append(errs, fmt.Errorf("failed to create temporary directory: %s", err)) - } - - defer func() { - err := os.RemoveAll(temporaryDirectory) - - if err != nil { - errs = append(errs, fmt.Errorf("failed to remove temporary directory '%s': %s", temporaryDirectory, err)) - - return - } - }() - -loop: - for args := os.Args[1:]; len(args) > 0; args = args[1:] { - arg := args[0] - - if valuesArgRegexpMatches := valuesArgRegexp.FindStringSubmatch(arg); valuesArgRegexpMatches != nil { - var filename string - - switch { - case len(valuesArgRegexpMatches[2]) > 0: - filename = valuesArgRegexpMatches[2] - case len(args) > 1: - filename = args[1] - default: - break loop - } - - if secretFilenameRegexpMatches := secretFilenameRegexp.FindStringSubmatch(filename); secretFilenameRegexpMatches != nil { - secretFilename := secretFilenameRegexpMatches[0] - cleartextSecretFilename := fmt.Sprintf("%s/%x", temporaryDirectory, sha256.Sum256([]byte(secretFilename))) - - cleartextSecrets, err := decrypt.File(secretFilename, "yaml") - - if err != nil { - return append(errs, fmt.Errorf("failed to decrypt secret file '%s': %s", secretFilename, err)) - } - - err = syscall.Mkfifo(cleartextSecretFilename, 0600) - - if err != nil { - return append(errs, fmt.Errorf("failed to create cleartext secret pipe '%s': %s", cleartextSecretFilename, err)) - } - - defer func(cleartextSecretFilename string) { - err := os.Remove(cleartextSecretFilename) - - if err != nil { - errs = append(errs, fmt.Errorf("failed to remove cleartext secret pipe '%s': %s", cleartextSecretFilename, err)) - - return - } - }(cleartextSecretFilename) - - var errs1 []error - var errs2 []error - - pipeWriterWaitGroup := sync.WaitGroup{} - pipeCloseChannel := make(chan struct{}) - - defer func(errs1 *[]error, errs2 *[]error, pipeWriterWaitGroup *sync.WaitGroup, pipeCloseChannel chan struct{}) { - close(pipeCloseChannel) - - pipeWriterWaitGroup.Wait() - - errs = append(errs, *errs1...) - errs = append(errs, *errs2...) - }(&errs1, &errs2, &pipeWriterWaitGroup, pipeCloseChannel) - - pipeWriterWaitGroup.Add(2) - - pipeWriterUnlockedChannel := make(chan struct{}, 1) - - go func(cleartextSecretFilename string, cleartextSecrets []byte, errs *[]error, pipeWriterUnlockedChannel chan struct{}, pipeWriterWaitGroup *sync.WaitGroup) { - defer pipeWriterWaitGroup.Done() - - cleartextSecretFile, err := os.OpenFile(cleartextSecretFilename, os.O_WRONLY, 0) - - pipeWriterUnlockedChannel <- struct{}{} - - if err != nil { - *errs = append(*errs, fmt.Errorf("failed to open cleartext secret pipe '%s' in pipe writer: %s", cleartextSecretFilename, err)) - - return - } - - defer func() { - err := cleartextSecretFile.Close() - - if err != nil { - *errs = append(*errs, fmt.Errorf("failed to close cleartext secret pipe '%s' in pipe writer: %s", cleartextSecretFilename, err)) - - return - } - }() - - _, err = cleartextSecretFile.Write(cleartextSecrets) - - if err != nil { - *errs = append(*errs, fmt.Errorf("failed to write cleartext secret to pipe '%s': %s", cleartextSecretFilename, err)) - - return - } - }(cleartextSecretFilename, cleartextSecrets, &errs1, pipeWriterUnlockedChannel, &pipeWriterWaitGroup) - - go func(cleartextSecretFilename string, errs *[]error, pipeCloseChannel chan struct{}, pipeWriterUnlockedChannel chan struct{}, pipeWriterWaitGroup *sync.WaitGroup) { - defer pipeWriterWaitGroup.Done() - - <-pipeCloseChannel - - select { - case <-pipeWriterUnlockedChannel: - return - default: - } - - cleartextSecretFile, err := os.OpenFile(cleartextSecretFilename, os.O_RDWR, 0) - - if err != nil { - *errs = append(*errs, fmt.Errorf("failed to open cleartext secret pipe '%s' in pipe closer: %s", cleartextSecretFilename, err)) - - return - } - - <-pipeWriterUnlockedChannel - - defer func() { - err := cleartextSecretFile.Close() - - if err != nil { - *errs = append(*errs, fmt.Errorf("failed to close cleartext secret pipe '%s' in pipe closer: %s", cleartextSecretFilename, err)) - - return - } - }() - }(cleartextSecretFilename, &errs2, pipeCloseChannel, pipeWriterUnlockedChannel, &pipeWriterWaitGroup) - - if len(valuesArgRegexpMatches[2]) > 0 { - args[0] = fmt.Sprintf("%s=%s", valuesArgRegexpMatches[1], cleartextSecretFilename) - } else { - args[1] = cleartextSecretFilename - args = args[1:] - } - } - } - } - - cmd := exec.Command(helmPath, os.Args[1:]...) - - cmd.Env = os.Environ() - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() - +func main() { + w, err := NewHelmWrapper() if err != nil { - return append(errs, fmt.Errorf("failed to run Helm: %s", err)) + fmt.Fprintf(os.Stderr, "[helm-sops] Error: %s\n", err) + os.Exit(1) } - return -} - -func main() { - errs := runHelm() - - exitCode := 0 + w.RunHelm() - for _, err := range errs { + for _, err := range w.Errors { fmt.Fprintf(os.Stderr, "[helm-sops] Error: %s\n", err) - - exitCode = 1 } - os.Exit(exitCode) + os.Exit(w.ExitCode) } diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..f8012b6 --- /dev/null +++ b/utils.go @@ -0,0 +1,24 @@ +package main + +import ( + "io/ioutil" + "gopkg.in/yaml.v3" +) + +func DetectSopsYaml(filename string) (bool, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return false, err + } + + var sf map[string]interface{} + err = yaml.Unmarshal(data, &sf) + if err != nil { + return false, err + } + + if _, ok := sf["sops"]; ok { + return true, nil + } + return false, nil +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..aef3a5a --- /dev/null +++ b/utils_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "io/ioutil" + "os" +) + +func TestDetectSopsFile(t *testing.T) { + + tmp, err := ioutil.TempFile("", "utils_test.*.yaml") + if err != nil { + t.Errorf("Error creating temp file") + } + defer os.Remove(tmp.Name()) + + // Test negative case + tmp.WriteString(`--- +secret: hello +`) + res, err := DetectSopsYaml(tmp.Name()) + if res != false || err != nil { + t.Errorf("DetectSopsYaml(tmp) = %t, %v; want false, ", res, err) + } + + // Test positive case + tmp.Seek(0, 0) + tmp.WriteString(`--- +secret: ENC[AES256_GCM,...] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + lastmodified: '2020-11-03T01:45:48Z' + pgp: [] + version: 3.6.1 +`) + res, err = DetectSopsYaml(tmp.Name()) + if res != true || err != nil { + t.Errorf("DetectSopsYaml(tmp) = %t, %v; want true, ", res, err) + } +}