Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"external" data source, for integrating with external programs #8768

Merged
merged 5 commits into from
Dec 5, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions builtin/bins/provider-external/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package main

import (
"github.com/hashicorp/terraform/builtin/providers/external"
"github.com/hashicorp/terraform/plugin"
)

func main() {
plugin.Serve(&plugin.ServeOpts{
ProviderFunc: external.Provider,
})
}
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
}
15 changes: 15 additions & 0 deletions builtin/providers/external/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package external

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

func Provider() terraform.ResourceProvider {
return &schema.Provider{
DataSourcesMap: map[string]*schema.Resource{
"external": dataSource(),
},
ResourcesMap: map[string]*schema.Resource{},
}
}
18 changes: 18 additions & 0 deletions builtin/providers/external/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package external

import (
"testing"

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

func TestProvider(t *testing.T) {
if err := Provider().(*schema.Provider).InternalValidate(); err != nil {
t.Fatalf("err: %s", err)
}
}

var testProviders = map[string]terraform.ResourceProvider{
"external": Provider(),
}
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
}
2 changes: 2 additions & 0 deletions command/internal_plugin_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
dnsimpleprovider "github.com/hashicorp/terraform/builtin/providers/dnsimple"
dockerprovider "github.com/hashicorp/terraform/builtin/providers/docker"
dynprovider "github.com/hashicorp/terraform/builtin/providers/dyn"
externalprovider "github.com/hashicorp/terraform/builtin/providers/external"
fastlyprovider "github.com/hashicorp/terraform/builtin/providers/fastly"
githubprovider "github.com/hashicorp/terraform/builtin/providers/github"
googleprovider "github.com/hashicorp/terraform/builtin/providers/google"
Expand Down Expand Up @@ -84,6 +85,7 @@ var InternalProviders = map[string]plugin.ProviderFunc{
"dnsimple": dnsimpleprovider.Provider,
"docker": dockerprovider.Provider,
"dyn": dynprovider.Provider,
"external": externalprovider.Provider,
"fastly": fastlyprovider.Provider,
"github": githubprovider.Provider,
"google": googleprovider.Provider,
Expand Down
5 changes: 4 additions & 1 deletion terraform/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ func resourceProvider(t, alias string) string {

idx := strings.IndexRune(t, '_')
if idx == -1 {
return ""
// If no underscores, the resource name is assumed to be
// also the provider name, e.g. if the provider exposes
// only a single resource of each type.
return t
}

return t[:idx]
Expand Down
Loading