From a926fa6fdd633fc43127bd18719099e92b1f4377 Mon Sep 17 00:00:00 2001 From: James Nugent Date: Mon, 5 Oct 2015 19:08:33 -0400 Subject: [PATCH] Adds template_cloudinit_config resource to template This adds a new resource to template to generate multipart cloudinit configurations to be used with other providers/resources. The resource has the ability gzip and base64 encode the parts. --- builtin/providers/template/provider.go | 3 +- builtin/providers/template/resource.go | 24 +- .../template/resource_cloudinit_config.go | 223 ++++++++++++++++++ .../resource_cloudinit_config_test.go | 92 ++++++++ .../template/r/cloudinit_config.html.markdown | 68 ++++++ 5 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 builtin/providers/template/resource_cloudinit_config.go create mode 100644 builtin/providers/template/resource_cloudinit_config_test.go create mode 100644 website/source/docs/providers/template/r/cloudinit_config.html.markdown diff --git a/builtin/providers/template/provider.go b/builtin/providers/template/provider.go index 7513341bc..1ebf3ae22 100644 --- a/builtin/providers/template/provider.go +++ b/builtin/providers/template/provider.go @@ -8,7 +8,8 @@ import ( func Provider() terraform.ResourceProvider { return &schema.Provider{ ResourcesMap: map[string]*schema.Resource{ - "template_file": resource(), + "template_file": resourceFile(), + "template_cloudinit_config": resourceCloudinitConfig(), }, } } diff --git a/builtin/providers/template/resource.go b/builtin/providers/template/resource.go index 8022c064b..fd0808828 100644 --- a/builtin/providers/template/resource.go +++ b/builtin/providers/template/resource.go @@ -15,12 +15,12 @@ import ( "github.com/hashicorp/terraform/helper/schema" ) -func resource() *schema.Resource { +func resourceFile() *schema.Resource { return &schema.Resource{ - Create: Create, - Delete: Delete, - Exists: Exists, - Read: Read, + Create: resourceFileCreate, + Delete: resourceFileDelete, + Exists: resourceFileExists, + Read: resourceFileRead, Schema: map[string]*schema.Schema{ "template": &schema.Schema{ @@ -69,8 +69,8 @@ func resource() *schema.Resource { } } -func Create(d *schema.ResourceData, meta interface{}) error { - rendered, err := render(d) +func resourceFileCreate(d *schema.ResourceData, meta interface{}) error { + rendered, err := renderFile(d) if err != nil { return err } @@ -79,13 +79,13 @@ func Create(d *schema.ResourceData, meta interface{}) error { return nil } -func Delete(d *schema.ResourceData, meta interface{}) error { +func resourceFileDelete(d *schema.ResourceData, meta interface{}) error { d.SetId("") return nil } -func Exists(d *schema.ResourceData, meta interface{}) (bool, error) { - rendered, err := render(d) +func resourceFileExists(d *schema.ResourceData, meta interface{}) (bool, error) { + rendered, err := renderFile(d) if err != nil { if _, ok := err.(templateRenderError); ok { log.Printf("[DEBUG] Got error while rendering in Exists: %s", err) @@ -98,7 +98,7 @@ func Exists(d *schema.ResourceData, meta interface{}) (bool, error) { return hash(rendered) == d.Id(), nil } -func Read(d *schema.ResourceData, meta interface{}) error { +func resourceFileRead(d *schema.ResourceData, meta interface{}) error { // Logic is handled in Exists, which only returns true if the rendered // contents haven't changed. That means if we get here there's nothing to // do. @@ -107,7 +107,7 @@ func Read(d *schema.ResourceData, meta interface{}) error { type templateRenderError error -func render(d *schema.ResourceData) (string, error) { +func renderFile(d *schema.ResourceData) (string, error) { template := d.Get("template").(string) filename := d.Get("filename").(string) vars := d.Get("vars").(map[string]interface{}) diff --git a/builtin/providers/template/resource_cloudinit_config.go b/builtin/providers/template/resource_cloudinit_config.go new file mode 100644 index 000000000..88af9bef2 --- /dev/null +++ b/builtin/providers/template/resource_cloudinit_config.go @@ -0,0 +1,223 @@ +package template + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "io" + "mime/multipart" + "net/textproto" + "strconv" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceCloudinitConfig() *schema.Resource { + return &schema.Resource{ + Create: resourceCloudinitConfigCreate, + Delete: resourceCloudinitConfigDelete, + Exists: resourceCloudinitConfigExists, + Read: resourceCloudinitConfigRead, + + Schema: map[string]*schema.Schema{ + "part": &schema.Schema{ + Type: schema.TypeList, + Required: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "content_type": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: func(v interface{}, k string) (ws []string, errors []error) { + value := v.(string) + + if _, supported := supportedContentTypes[value]; !supported { + errors = append(errors, fmt.Errorf("Part has an unsupported content type: %s", v)) + } + + return + }, + }, + "content": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "filename": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "merge_type": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "gzip": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: true, + ForceNew: true, + }, + "base64_encode": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: true, + ForceNew: true, + }, + "rendered": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Description: "rendered cloudinit configuration", + }, + }, + } +} + +func resourceCloudinitConfigCreate(d *schema.ResourceData, meta interface{}) error { + rendered, err := renderCloudinitConfig(d) + if err != nil { + return err + } + + d.Set("rendered", rendered) + d.SetId(strconv.Itoa(hashcode.String(rendered))) + return nil +} + +func resourceCloudinitConfigDelete(d *schema.ResourceData, meta interface{}) error { + d.SetId("") + return nil +} + +func resourceCloudinitConfigExists(d *schema.ResourceData, meta interface{}) (bool, error) { + rendered, err := renderCloudinitConfig(d) + if err != nil { + return false, err + } + + return strconv.Itoa(hashcode.String(rendered)) == d.Id(), nil +} + +func resourceCloudinitConfigRead(d *schema.ResourceData, meta interface{}) error { + return nil +} + +func renderCloudinitConfig(d *schema.ResourceData) (string, error) { + gzipOutput := d.Get("gzip").(bool) + base64Output := d.Get("base64_encode").(bool) + + partsValue, hasParts := d.GetOk("part") + if !hasParts { + return "", fmt.Errorf("No parts found in the cloudinit resource declaration") + } + + cloudInitParts := make(cloudInitParts, len(partsValue.([]interface{}))) + for i, v := range partsValue.([]interface{}) { + p := v.(map[string]interface{}) + + part := cloudInitPart{} + if p, ok := p["content_type"]; ok { + part.ContentType = p.(string) + } + if p, ok := p["content"]; ok { + part.Content = p.(string) + } + if p, ok := p["merge_type"]; ok { + part.MergeType = p.(string) + } + if p, ok := p["filename"]; ok { + part.Filename = p.(string) + } + cloudInitParts[i] = part + } + + var buffer bytes.Buffer + + var err error + if gzipOutput { + gzipWriter := gzip.NewWriter(&buffer) + err = renderPartsToWriter(cloudInitParts, gzipWriter) + gzipWriter.Close() + } else { + err = renderPartsToWriter(cloudInitParts, &buffer) + } + if err != nil { + return "", err + } + + output := "" + if base64Output { + output = base64.StdEncoding.EncodeToString(buffer.Bytes()) + } else { + output = buffer.String() + } + + return output, nil +} + +func renderPartsToWriter(parts cloudInitParts, writer io.Writer) error { + mimeWriter := multipart.NewWriter(writer) + defer mimeWriter.Close() + + // we need to set the boundary explictly, otherwise the boundary is random + // and this causes terraform to complain about the resource being different + if err := mimeWriter.SetBoundary("MIMEBOUNDRY"); err != nil { + return err + } + + writer.Write([]byte(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\n", mimeWriter.Boundary()))) + + for _, part := range parts { + header := textproto.MIMEHeader{} + if part.ContentType == "" { + header.Set("Content-Type", "text/plain") + } else { + header.Set("Content-Type", part.ContentType) + } + + if part.Filename != "" { + header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, part.Filename)) + } + + if part.MergeType != "" { + header.Set("X-Merge-Type", part.MergeType) + } + + partWriter, err := mimeWriter.CreatePart(header) + if err != nil { + return err + } + + _, err = partWriter.Write([]byte(part.Content)) + if err != nil { + return err + } + } + + return nil +} + +type cloudInitPart struct { + ContentType string + MergeType string + Filename string + Content string +} + +type cloudInitParts []cloudInitPart + +// Support content types as specified by http://cloudinit.readthedocs.org/en/latest/topics/format.html +var supportedContentTypes = map[string]bool{ + "text/x-include-once-url": true, + "text/x-include-url": true, + "text/cloud-config-archive": true, + "text/upstart-job": true, + "text/cloud-config": true, + "text/part-handler": true, + "text/x-shellscript": true, + "text/cloud-boothook": true, +} diff --git a/builtin/providers/template/resource_cloudinit_config_test.go b/builtin/providers/template/resource_cloudinit_config_test.go new file mode 100644 index 000000000..83d0a6152 --- /dev/null +++ b/builtin/providers/template/resource_cloudinit_config_test.go @@ -0,0 +1,92 @@ +package template + +import ( + "testing" + + r "github.com/hashicorp/terraform/helper/resource" + // "github.com/hashicorp/terraform/terraform" +) + +// var testProviders = map[string]terraform.ResourceProvider{ +// "template": Provider(), +// } + +func TestRender(t *testing.T) { + testCases := []struct { + ResourceBlock string + Expected string + }{ + { + `resource "template_cloudinit_config" "foo" { + gzip = false + base64_encode = false + + part { + content_type = "text/x-shellscript" + content = "baz" + } + }`, + "Content-Type: multipart/mixed; boundary=\"MIMEBOUNDRY\"\n--MIMEBOUNDRY\r\nContent-Type: text/x-shellscript\r\n\r\nbaz\r\n--MIMEBOUNDRY--\r\n", + }, + { + `resource "template_cloudinit_config" "foo" { + gzip = false + base64_encode = false + + part { + content_type = "text/x-shellscript" + content = "baz" + filename = "foobar.sh" + } + }`, + "Content-Type: multipart/mixed; boundary=\"MIMEBOUNDRY\"\n--MIMEBOUNDRY\r\nContent-Type: text/x-shellscript\r\nContent-Disposition: attachment; filename=\"foobar.sh\"\r\n\r\nbaz\r\n--MIMEBOUNDRY--\r\n", + }, + { + `resource "template_cloudinit_config" "foo" { + gzip = false + base64_encode = false + + part { + content_type = "text/x-shellscript" + content = "baz" + } + part { + content_type = "text/x-shellscript" + content = "ffbaz" + } + }`, + "Content-Type: multipart/mixed; boundary=\"MIMEBOUNDRY\"\n--MIMEBOUNDRY\r\nContent-Type: text/x-shellscript\r\n\r\nbaz\r\n--MIMEBOUNDRY\r\nContent-Type: text/x-shellscript\r\n\r\nffbaz\r\n--MIMEBOUNDRY--\r\n", + }, + { + `resource "template_cloudinit_config" "foo" { + gzip = true + base64_encode = false + + part { + content_type = "text/x-shellscript" + content = "baz" + filename = "ah" + } + part { + content_type = "text/x-shellscript" + content = "ffbaz" + } + }`, + "\x1f\x8b\b\x00\x00\tn\x88\x00\xff\x94\x8d\xbd\n\xc2@\x10\x84\xfb\x83{\x87\xe3\xfa%}B\x1a\x8d\x85E\x14D\v\xcbM\xb2!\v\xf7Gn\x03\x89O\xaf\x9d\x8a\x95\xe5\f3߷\x8fA(\b\\\xb7D\xa5\xf1\x8b\x13N8K\xe1y\xa5\xa12]\\\u0080\xf3V\xdb\xf6\xd8\x1ev\xe7۩\xb9ܭ\x02\xf8\x88Z}C\x84V)V\xc8\x139\x97\xfb\x99\x93\xbc\x17\r\xe7\x143\v\xc7P\x1a\x14\xc1~\xf2\xaf\xbe2#;\n詶8Y\xad\xb4\xea\xf0\xa1\xff\xf7h5\x8e\xbfO\x00\xad\x9e\x01\x00\x00\xff\xff\xecM\xd3\x1e\xe9\x00\x00\x00", + }, + } + + for _, tt := range testCases { + r.Test(t, r.TestCase{ + Providers: testProviders, + Steps: []r.TestStep{ + r.TestStep{ + Config: tt.ResourceBlock, + Check: r.ComposeTestCheckFunc( + r.TestCheckResourceAttr("template_cloudinit_config.foo", "rendered", tt.Expected), + ), + }, + }, + }) + } +} diff --git a/website/source/docs/providers/template/r/cloudinit_config.html.markdown b/website/source/docs/providers/template/r/cloudinit_config.html.markdown new file mode 100644 index 000000000..84416e2d5 --- /dev/null +++ b/website/source/docs/providers/template/r/cloudinit_config.html.markdown @@ -0,0 +1,68 @@ +--- +layout: "Template" +page_title: "Template: cloudinit_multipart" +sidebar_current: "docs-template-resource-cloudinit-config" +description: |- + Renders a cloud-init config. +--- + +# template\_cloudinit\_config + +Renders a template from a file. + +## Example Usage + +``` +resource "template_file" "script" { + template = "${file("${path.module}/init.tpl")}" + + vars { + consul_address = "${aws_instance.consul.private_ip}" + } +} + +resource "template_cloudinit_config" "config" { + # Setup hello world script to be called by the cloud-config + part { + filename = "init.cfg" + content_type = "text/part-handler" + content = "${template_file.script.rendered}" + } + + # Setup cloud-config yaml + part { + content_type = "text/cloud-config" + content = "${file(\"config.yaml\")" + } +} + + + +``` + +## Argument Reference + +The following arguments are supported: + +* `gzip` - (Optional) Specify whether or not to gzip the rendered output. + +* `base64_encode` - (Optional) Base64 encoding of the rendered output. + +* `part` - (Required) One may specify this many times, this creates a fragment of the rendered cloud-init config. + +The `part` block supports: + +* `filename` - (Optional) Filename to save part as. + +* `content_type` - (Optional) Content type to send file as. + +* `content` - (Required) Body for the part. + +* `merge_type` - (Optional) Gives the ability to merge multiple blocks of cloud-config together. + + +## Attributes Reference + +The following attributes are exported: + +* `rendered` - The final rendered template.