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.
This commit is contained in:
James Nugent 2015-10-05 19:08:33 -04:00
parent 5fb6cc2811
commit a926fa6fdd
5 changed files with 397 additions and 13 deletions

View File

@ -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(),
},
}
}

View File

@ -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{})

View File

@ -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,
}

View File

@ -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),
),
},
},
})
}
}

View File

@ -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.