Skip to content

Commit

Permalink
provider/external: data source
Browse files Browse the repository at this point in the history
A data source that executes a child process, expecting it to support a
particular gateway protocol, and exports its result. This can be used as
a straightforward way to retrieve data from sources that Terraform
doesn't natively support..
  • Loading branch information
apparentlymart committed Nov 30, 2016
1 parent 1ddb283 commit d2fb4d2
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 2 deletions.
93 changes: 93 additions & 0 deletions builtin/providers/external/data_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package external

import (
"bytes"
"encoding/json"
"fmt"
"os/exec"

"github.com/hashicorp/terraform/helper/schema"
)

func dataSource() *schema.Resource {
return &schema.Resource{
Read: dataSourceRead,

Schema: map[string]*schema.Schema{
"program": &schema.Schema{
Type: schema.TypeList,
Required: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},

"query": &schema.Schema{
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},

"result": &schema.Schema{
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
},
}
}

func dataSourceRead(d *schema.ResourceData, meta interface{}) error {

programI := d.Get("program").([]interface{})
query := d.Get("query").(map[string]interface{})

// This would be a ValidateFunc if helper/schema allowed these
// to be applied to lists.
if err := validateProgramAttr(programI); err != nil {
return err
}

program := make([]string, len(programI))
for i, vI := range programI {
program[i] = vI.(string)
}

cmd := exec.Command(program[0], program[1:]...)

queryJson, err := json.Marshal(query)
if err != nil {
// Should never happen, since we know query will always be a map
// from string to string, as guaranteed by d.Get and our schema.
return err
}

cmd.Stdin = bytes.NewReader(queryJson)

resultJson, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.Stderr != nil && len(exitErr.Stderr) > 0 {
return fmt.Errorf("failed to execute %q: %s", program[0], string(exitErr.Stderr))
}
return fmt.Errorf("command %q failed with no error message", program[0])
} else {
return fmt.Errorf("failed to execute %q: %s", program[0], err)
}
}

result := map[string]string{}
err = json.Unmarshal(resultJson, &result)
if err != nil {
return fmt.Errorf("command %q produced invalid JSON: %s", program[0], err)
}

d.Set("result", result)

d.SetId("-")
return nil
}
124 changes: 124 additions & 0 deletions builtin/providers/external/data_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package external

import (
"fmt"
"os"
"os/exec"
"path"
"regexp"
"testing"

"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/terraform"
)

const testDataSourceConfig_basic = `
data "external" "test" {
program = ["%s", "cheese"]
query = {
value = "pizza"
}
}
output "query_value" {
value = "${data.external.test.result["query_value"]}"
}
output "argument" {
value = "${data.external.test.result["argument"]}"
}
`

func TestDataSource_basic(t *testing.T) {
programPath, err := buildDataSourceTestProgram()
if err != nil {
t.Fatal(err)
return
}

resource.UnitTest(t, resource.TestCase{
Providers: testProviders,
Steps: []resource.TestStep{
resource.TestStep{
Config: fmt.Sprintf(testDataSourceConfig_basic, programPath),
Check: func(s *terraform.State) error {
_, ok := s.RootModule().Resources["data.external.test"]
if !ok {
return fmt.Errorf("missing data resource")
}

outputs := s.RootModule().Outputs

if outputs["argument"] == nil {
return fmt.Errorf("missing 'argument' output")
}
if outputs["query_value"] == nil {
return fmt.Errorf("missing 'query_value' output")
}

if outputs["argument"].Value != "cheese" {
return fmt.Errorf(
"'argument' output is %q; want 'cheese'",
outputs["argument"].Value,
)
}
if outputs["query_value"].Value != "pizza" {
return fmt.Errorf(
"'query_value' output is %q; want 'pizza'",
outputs["query_value"].Value,
)
}

return nil
},
},
},
})
}

const testDataSourceConfig_error = `
data "external" "test" {
program = ["%s"]
query = {
fail = "true"
}
}
`

func TestDataSource_error(t *testing.T) {
programPath, err := buildDataSourceTestProgram()
if err != nil {
t.Fatal(err)
return
}

resource.UnitTest(t, resource.TestCase{
Providers: testProviders,
Steps: []resource.TestStep{
resource.TestStep{
Config: fmt.Sprintf(testDataSourceConfig_error, programPath),
ExpectError: regexp.MustCompile("I was asked to fail"),
},
},
})
}

func buildDataSourceTestProgram() (string, error) {
// We have a simple Go program that we use as a stub for testing.
cmd := exec.Command(
"go", "install",
"github.com/hashicorp/terraform/builtin/providers/external/test-programs/tf-acc-external-data-source",
)
err := cmd.Run()

if err != nil {
return "", fmt.Errorf("failed to build test stub program: %s", err)
}

programPath := path.Join(
os.Getenv("GOPATH"), "bin", "tf-acc-external-data-source",
)
return programPath, nil
}
4 changes: 2 additions & 2 deletions builtin/providers/external/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
func Provider() terraform.ResourceProvider {
return &schema.Provider{
DataSourcesMap: map[string]*schema.Resource{
"external": dataSource(),
},
ResourcesMap: map[string]*schema.Resource{
},
ResourcesMap: map[string]*schema.Resource{},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
)

// This is a minimal implementation of the external data source protocol
// intended only for use in the provider acceptance tests.
//
// In practice it's likely not much harder to just write a real Terraform
// plugin if you're going to be writing your data source in Go anyway;
// this example is just in Go because we want to avoid introducing
// additional language runtimes into the test environment.
func main() {
queryBytes, err := ioutil.ReadAll(os.Stdin)
if err != nil {
panic(err)
}

var query map[string]string
err = json.Unmarshal(queryBytes, &query)
if err != nil {
panic(err)
}

if query["fail"] != "" {
fmt.Fprintf(os.Stderr, "I was asked to fail\n")
os.Exit(1)
}

var result = map[string]string{
"result": "yes",
"query_value": query["value"],
}

if len(os.Args) >= 2 {
result["argument"] = os.Args[1]
}

resultBytes, err := json.Marshal(result)
if err != nil {
panic(err)
}

os.Stdout.Write(resultBytes)
os.Exit(0)
}
35 changes: 35 additions & 0 deletions builtin/providers/external/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package external

import (
"fmt"
"os/exec"
)

// validateProgramAttr is a validation function for the "program" attribute we
// accept as input on our resources.
//
// The attribute is assumed to be specified in schema as a list of strings.
func validateProgramAttr(v interface{}) error {
args := v.([]interface{})
if len(args) < 1 {
return fmt.Errorf("'program' list must contain at least one element")
}

for i, vI := range args {
if _, ok := vI.(string); !ok {
return fmt.Errorf(
"'program' element %d is %T; a string is required",
i, vI,
)
}
}

// first element is assumed to be an executable command, possibly found
// using the PATH environment variable.
_, err := exec.LookPath(args[0].(string))
if err != nil {
return fmt.Errorf("can't find external program %q", args[0])
}

return nil
}

0 comments on commit d2fb4d2

Please sign in to comment.