diff --git a/svchost/auth/helper_program.go b/svchost/auth/helper_program.go new file mode 100644 index 000000000..d72ffe3c9 --- /dev/null +++ b/svchost/auth/helper_program.go @@ -0,0 +1,80 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + + "github.com/hashicorp/terraform/svchost" +) + +type helperProgramCredentialsSource struct { + executable string + args []string +} + +// HelperProgramCredentialsSource returns a CredentialsSource that runs the +// given program with the given arguments in order to obtain credentials. +// +// The given executable path must be an absolute path; it is the caller's +// responsibility to validate and process a relative path or other input +// provided by an end-user. If the given path is not absolute, this +// function will panic. +// +// When credentials are requested, the program will be run in a child process +// with the given arguments along with two additional arguments added to the +// end of the list: the literal string "get", followed by the requested +// hostname in ASCII compatibility form (punycode form). +func HelperProgramCredentialsSource(executable string, args ...string) CredentialsSource { + if !filepath.IsAbs(executable) { + panic("NewCredentialsSourceHelperProgram requires absolute path to executable") + } + + fullArgs := make([]string, len(args)+1) + fullArgs[0] = executable + copy(fullArgs[1:], args) + + return &helperProgramCredentialsSource{ + executable: executable, + args: fullArgs, + } +} + +func (s *helperProgramCredentialsSource) ForHost(host svchost.Hostname) (HostCredentials, error) { + args := make([]string, len(s.args), len(s.args)+2) + copy(args, s.args) + args = append(args, "get") + args = append(args, string(host)) + + outBuf := bytes.Buffer{} + errBuf := bytes.Buffer{} + + cmd := exec.Cmd{ + Path: s.executable, + Args: args, + Stdin: nil, + Stdout: &outBuf, + Stderr: &errBuf, + } + err := cmd.Run() + if _, isExitErr := err.(*exec.ExitError); isExitErr { + errText := errBuf.String() + if errText == "" { + // Shouldn't happen for a well-behaved helper program + return nil, fmt.Errorf("error in %s, but it produced no error message", s.executable) + } + return nil, fmt.Errorf("error in %s: %s", s.executable, errText) + } else if err != nil { + return nil, fmt.Errorf("failed to run %s: %s", s.executable, err) + } + + var m map[string]interface{} + err = json.Unmarshal(outBuf.Bytes(), &m) + if err != nil { + return nil, fmt.Errorf("malformed output from %s: %s", s.executable, err) + } + + return HostCredentialsFromMap(m), nil +} diff --git a/svchost/auth/helper_program_test.go b/svchost/auth/helper_program_test.go new file mode 100644 index 000000000..3fa1c3aa5 --- /dev/null +++ b/svchost/auth/helper_program_test.go @@ -0,0 +1,59 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform/svchost" +) + +func TestHelperProgramCredentialsSource(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + program := filepath.Join(wd, "test-helper/test-helper") + t.Logf("testing with helper at %s", program) + + src := HelperProgramCredentialsSource(program) + + t.Run("happy path", func(t *testing.T) { + creds, err := src.ForHost(svchost.Hostname("example.com")) + if err != nil { + t.Fatal(err) + } + if tokCreds, isTok := creds.(HostCredentialsToken); isTok { + if got, want := string(tokCreds), "example-token"; got != want { + t.Errorf("wrong token %q; want %q", got, want) + } + } else { + t.Errorf("wrong type of credentials %T", creds) + } + }) + t.Run("no credentials", func(t *testing.T) { + creds, err := src.ForHost(svchost.Hostname("nothing.example.com")) + if err != nil { + t.Fatal(err) + } + if creds != nil { + t.Errorf("got credentials; want nil") + } + }) + t.Run("unsupported credentials type", func(t *testing.T) { + creds, err := src.ForHost(svchost.Hostname("other-cred-type.example.com")) + if err != nil { + t.Fatal(err) + } + if creds != nil { + t.Errorf("got credentials; want nil") + } + }) + t.Run("lookup error", func(t *testing.T) { + _, err := src.ForHost(svchost.Hostname("fail.example.com")) + if err == nil { + t.Error("completed successfully; want error") + } + }) +} diff --git a/svchost/auth/test-helper/.gitignore b/svchost/auth/test-helper/.gitignore new file mode 100644 index 000000000..ba2906d06 --- /dev/null +++ b/svchost/auth/test-helper/.gitignore @@ -0,0 +1 @@ +main diff --git a/svchost/auth/test-helper/main.go b/svchost/auth/test-helper/main.go new file mode 100644 index 000000000..3b1b72fd5 --- /dev/null +++ b/svchost/auth/test-helper/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" +) + +// This is a simple program that implements the "helper program" protocol +// for the svchost/auth package for unit testing purposes. + +func main() { + args := os.Args + + if len(args) < 3 { + die("not enough arguments\n") + } + + if args[1] != "get" { + die("unknown subcommand %q\n", args[1]) + } + + host := args[2] + + switch host { + case "example.com": + fmt.Print(`{"token":"example-token"}`) + case "other-cred-type.example.com": + fmt.Print(`{"username":"alfred"}`) // unrecognized by main program + case "fail.example.com": + die("failing because you told me to fail\n") + default: + fmt.Print("{}") // no credentials available + } +} + +func die(f string, args ...interface{}) { + fmt.Fprintf(os.Stderr, fmt.Sprintf(f, args...)) + os.Exit(1) +} diff --git a/svchost/auth/test-helper/test-helper b/svchost/auth/test-helper/test-helper new file mode 100755 index 000000000..0ed3396c5 --- /dev/null +++ b/svchost/auth/test-helper/test-helper @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eu + +cd "$( dirname "${BASH_SOURCE[0]}" )" +[ -x main ] || go build -o main . +exec ./main "$@"