From 745d83a99520f0d86cb50e8fad110297f173c564 Mon Sep 17 00:00:00 2001 From: Josh Bleecher Snyder Date: Fri, 1 May 2015 16:59:16 -0700 Subject: [PATCH 1/3] providers: add template provider Fixes #215. --- builtin/bins/provider-template/main.go | 12 ++ builtin/providers/template/provider.go | 14 +++ builtin/providers/template/provider_test.go | 13 +++ builtin/providers/template/resource.go | 119 ++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 builtin/bins/provider-template/main.go create mode 100644 builtin/providers/template/provider.go create mode 100644 builtin/providers/template/provider_test.go create mode 100644 builtin/providers/template/resource.go diff --git a/builtin/bins/provider-template/main.go b/builtin/bins/provider-template/main.go new file mode 100644 index 0000000000..3d6979e7b1 --- /dev/null +++ b/builtin/bins/provider-template/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/providers/template" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: template.Provider, + }) +} diff --git a/builtin/providers/template/provider.go b/builtin/providers/template/provider.go new file mode 100644 index 0000000000..7513341bc1 --- /dev/null +++ b/builtin/providers/template/provider.go @@ -0,0 +1,14 @@ +package template + +import ( + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "template_file": resource(), + }, + } +} diff --git a/builtin/providers/template/provider_test.go b/builtin/providers/template/provider_test.go new file mode 100644 index 0000000000..37c02bb4a4 --- /dev/null +++ b/builtin/providers/template/provider_test.go @@ -0,0 +1,13 @@ +package template + +import ( + "testing" + + "github.com/hashicorp/terraform/helper/schema" +) + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} diff --git a/builtin/providers/template/resource.go b/builtin/providers/template/resource.go new file mode 100644 index 0000000000..bbdb89c67f --- /dev/null +++ b/builtin/providers/template/resource.go @@ -0,0 +1,119 @@ +package template + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io/ioutil" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/config/lang" + "github.com/hashicorp/terraform/config/lang/ast" + "github.com/hashicorp/terraform/helper/schema" +) + +func resource() *schema.Resource { + return &schema.Resource{ + Create: Create, + Read: Read, + Update: Update, + Delete: Delete, + Exists: Exists, + + Schema: map[string]*schema.Schema{ + "filename": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "file to read template from", + }, + "vars": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + Default: make(map[string]interface{}), + Description: "variables to substitute", + }, + "rendered": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Default: nil, + Description: "rendered template", + }, + }, + } +} + +func Create(d *schema.ResourceData, meta interface{}) error { return eval(d) } +func Update(d *schema.ResourceData, meta interface{}) error { return eval(d) } +func Read(d *schema.ResourceData, meta interface{}) error { return nil } +func Delete(d *schema.ResourceData, meta interface{}) error { + d.SetId("") + return nil +} +func Exists(d *schema.ResourceData, meta interface{}) (bool, error) { + // Reload every time in case something has changed. + // This should be cheap, and cache invalidation is hard. + return false, nil +} + +func eval(d *schema.ResourceData) error { + filename := d.Get("filename").(string) + vars := d.Get("vars").(map[string]interface{}) + + buf, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + rendered, err := execute(string(buf), vars) + if err != nil { + return fmt.Errorf("failed to render %v: %v", filename, err) + } + + d.Set("rendered", rendered) + d.SetId(hash(rendered)) + return nil +} + +// execute parses and executes a template using vars. +func execute(s string, vars map[string]interface{}) (string, error) { + root, err := lang.Parse(s) + if err != nil { + return "", err + } + + varmap := make(map[string]ast.Variable) + for k, v := range vars { + // As far as I can tell, v is always a string. + // If it's not, tell the user gracefully. + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("unexpected type for variable %q: %T", k, v) + } + varmap[k] = ast.Variable{ + Value: s, + Type: ast.TypeString, + } + } + + cfg := lang.EvalConfig{ + GlobalScope: &ast.BasicScope{ + VarMap: varmap, + FuncMap: config.Funcs, + }, + } + + out, typ, err := lang.Eval(root, &cfg) + if err != nil { + return "", err + } + if typ != ast.TypeString { + return "", fmt.Errorf("unexpected output ast.Type: %v", typ) + } + + return out.(string), nil +} + +func hash(s string) string { + sha := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sha[:])[:20] +} From 76bcac303115542c1604cca90162952cb794cf05 Mon Sep 17 00:00:00 2001 From: Josh Bleecher Snyder Date: Mon, 4 May 2015 10:26:17 -0700 Subject: [PATCH 2/3] providers/template: add tests, address review comments Do directory expansion on filenames. Add basic acceptance tests. Code coverage is 72.5%. Uncovered code is uninteresting and/or impossible error cases. Note that this required adding a knob to helper/resource.TestStep to allow transient resources. --- builtin/providers/template/resource.go | 11 +++- builtin/providers/template/resource_test.go | 58 +++++++++++++++++++++ helper/resource/testing.go | 8 ++- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 builtin/providers/template/resource_test.go diff --git a/builtin/providers/template/resource.go b/builtin/providers/template/resource.go index bbdb89c67f..9e31ced1d3 100644 --- a/builtin/providers/template/resource.go +++ b/builtin/providers/template/resource.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform/config/lang" "github.com/hashicorp/terraform/config/lang/ast" "github.com/hashicorp/terraform/helper/schema" + "github.com/mitchellh/go-homedir" ) func resource() *schema.Resource { @@ -35,7 +36,6 @@ func resource() *schema.Resource { "rendered": &schema.Schema{ Type: schema.TypeString, Computed: true, - Default: nil, Description: "rendered template", }, }, @@ -55,11 +55,18 @@ func Exists(d *schema.ResourceData, meta interface{}) (bool, error) { return false, nil } +var readfile func(string) ([]byte, error) = ioutil.ReadFile // testing hook + func eval(d *schema.ResourceData) error { filename := d.Get("filename").(string) vars := d.Get("vars").(map[string]interface{}) - buf, err := ioutil.ReadFile(filename) + path, err := homedir.Expand(filename) + if err != nil { + return err + } + + buf, err := readfile(path) if err != nil { return err } diff --git a/builtin/providers/template/resource_test.go b/builtin/providers/template/resource_test.go new file mode 100644 index 0000000000..e9d3f4207c --- /dev/null +++ b/builtin/providers/template/resource_test.go @@ -0,0 +1,58 @@ +package template + +import ( + "fmt" + "testing" + + r "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +var testProviders = map[string]terraform.ResourceProvider{ + "template": Provider(), +} + +func TestTemplateRendering(t *testing.T) { + var cases = []struct { + vars string + template string + want string + }{ + {`{}`, `ABC`, `ABC`}, + {`{a="foo"}`, `${a}`, `foo`}, + {`{a="hello"}`, `${replace(a, "ello", "i")}`, `hi`}, + {`{}`, `${1+2+3}`, `6`}, + } + + for _, tt := range cases { + r.Test(t, r.TestCase{ + PreCheck: func() { + readfile = func(string) ([]byte, error) { + return []byte(tt.template), nil + } + }, + Providers: testProviders, + Steps: []r.TestStep{ + r.TestStep{ + Config: ` +resource "template_file" "t0" { + filename = "mock" + vars = ` + tt.vars + ` +} +output "rendered" { + value = "${template_file.t0.rendered}" +} +`, + Check: func(s *terraform.State) error { + got := s.RootModule().Outputs["rendered"] + if tt.want != got { + return fmt.Errorf("template:\n%s\nvars:\n%s\ngot:\n%s\nwant:\n%s\n", tt.template, tt.vars, got, tt.want) + } + return nil + }, + TransientResource: true, + }, + }, + }) + } +} diff --git a/helper/resource/testing.go b/helper/resource/testing.go index 43bee4ce49..9b97233567 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -75,6 +75,12 @@ type TestStep struct { // Destroy will create a destroy plan if set to true. Destroy bool + + // TransientResource indicates that resources created as part + // of this test step are temporary and might be recreated anew + // with every planning step. This should only be set for + // pseudo-resources, like the null resource or templates. + TransientResource bool } // Test performs an acceptance test on a resource. @@ -260,7 +266,7 @@ func testStep( if p, err := ctx.Plan(); err != nil { return state, fmt.Errorf("Error on second follow-up plan: %s", err) } else { - if p.Diff != nil && !p.Diff.Empty() { + if p.Diff != nil && !p.Diff.Empty() && !step.TransientResource { return state, fmt.Errorf( "After applying this step and refreshing, the plan was not empty:\n\n%s", p) } From 2da7c9a82343404ac5cc0e917e0d51bc9fe065eb Mon Sep 17 00:00:00 2001 From: Josh Bleecher Snyder Date: Mon, 4 May 2015 11:41:18 -0700 Subject: [PATCH 3/3] website: document templates While we're here, fix a broken link. --- .../docs/configuration/interpolation.html.md | 30 +++++++++++++++++++ .../docs/configuration/providers.html.md | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/website/source/docs/configuration/interpolation.html.md b/website/source/docs/configuration/interpolation.html.md index 45f45e2879..b24c02ce87 100644 --- a/website/source/docs/configuration/interpolation.html.md +++ b/website/source/docs/configuration/interpolation.html.md @@ -114,3 +114,33 @@ The supported built-in functions are: back into a list. This is useful for pushing lists through module outputs since they currently only support string values. Example: `split(",", module.amod.server_ids)` + +## Templates + +Long strings can be managed using templates. Templates are [resources](/docs/configuration/resources.html) defined by a filename and some variables to use during interpolation. They have a computed `rendered` attribute containing the result. + +A template resource looks like: + +``` +resource "template_file" "example" { + filename = "template.txt" + vars { + hello = "goodnight" + world = "moon" + } +} + +output "rendered" { + value = "${template_file.example.rendered}" +} +``` + +Assuming `template.txt` looks like this: + +``` +${hello} ${world}! +``` + +Then the rendered value would be `goodnight moon!`. + +You may use any of the built-in functions in your template. diff --git a/website/source/docs/configuration/providers.html.md b/website/source/docs/configuration/providers.html.md index 5c14dd845e..96b4088f75 100644 --- a/website/source/docs/configuration/providers.html.md +++ b/website/source/docs/configuration/providers.html.md @@ -9,7 +9,7 @@ description: |- # Provider Configuration Providers are responsible in Terraform for managing the lifecycle -of a [resource](/docs/configuration/resource.html): create, +of a [resource](/docs/configuration/resources.html): create, read, update, delete. Every resource in Terraform is mapped to a provider based