Merge pull request #4095 from hashicorp/cloudinit-provider
New resource: Cloudinit Multipart Configuration
This commit is contained in:
commit
3cdcaf5321
|
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
|
||||
"github.com/hashicorp/terraform/helper/hashcode"
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
|
||||
"github.com/sthulb/mime/multipart"
|
||||
)
|
||||
|
||||
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())))
|
||||
writer.Write([]byte("MIME-Version: 1.0\r\n"))
|
||||
|
||||
for _, part := range parts {
|
||||
header := textproto.MIMEHeader{}
|
||||
if part.ContentType == "" {
|
||||
header.Set("Content-Type", "text/plain")
|
||||
} else {
|
||||
header.Set("Content-Type", part.ContentType)
|
||||
}
|
||||
|
||||
header.Set("MIME-Version", "1.0")
|
||||
header.Set("Content-Transfer-Encoding", "7bit")
|
||||
|
||||
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,
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
r "github.com/hashicorp/terraform/helper/resource"
|
||||
)
|
||||
|
||||
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\"\nMIME-Version: 1.0\r\n--MIMEBOUNDRY\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/x-shellscript\r\nMime-Version: 1.0\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\"\nMIME-Version: 1.0\r\n--MIMEBOUNDRY\r\nContent-Disposition: attachment; filename=\"foobar.sh\"\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/x-shellscript\r\nMime-Version: 1.0\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\"\nMIME-Version: 1.0\r\n--MIMEBOUNDRY\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/x-shellscript\r\nMime-Version: 1.0\r\n\r\nbaz\r\n--MIMEBOUNDRY\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/x-shellscript\r\nMime-Version: 1.0\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\xac\xce\xc1J\x031\x10\xc6\xf1{`\xdf!\xe4>VO\u0096^\xb4=xX\x05\xa9\x82\xc7\xd9݉;\x90LB2\x85\xadOo-\x88\x8b\xe2\xadDŽ\x1f\xf3\xfd\xef\x93(\x89\xc2\xfe\x98\xa9\xb5\xf1\x10\x943\x16]E\x9ei\\\xdb>\x1dd\xc4rܸ\xee\xa1\xdb\xdd=\xbd<n\x9fߜ\xf9z\xc0+\x95\xcaIZ{su\xdd\x18\x80\x85h\xcc\xf7\xdd-ל*\xeb\x19\xa2*\x0eS<\xfd\xaf\xad\xe7@\x82\x916\x0e'\xf7\xe3\xf7\x05\xa5z*\xb0\x93!\x8d,ﭽ\xedY\x17\xe0\x1c\xaa4\xebj\x86:Q\bu(\x9cO\xa2\xe3H\xbf\xa2\x1a\xd3\xe3ǿm\x97\xde\xf2\xfe\xef\x1a@c>\x03\x00\x00\xff\xffmB\x8c\xeed\x01\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),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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{})
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
layout: "Template"
|
||||
page_title: "Template: cloudinit_multipart"
|
||||
sidebar_current: "docs-template-resource-cloudinit-config"
|
||||
description: |-
|
||||
Renders a multi-part cloud-init config from source files.
|
||||
---
|
||||
|
||||
# template\_cloudinit\_config
|
||||
|
||||
Renders a multi-part cloud-init config from source files.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
# Render a part using a `template_file`
|
||||
resource "template_file" "script" {
|
||||
template = "${file("${path.module}/init.tpl")}"
|
||||
|
||||
vars {
|
||||
consul_address = "${aws_instance.consul.private_ip}"
|
||||
}
|
||||
}
|
||||
|
||||
# Render a multi-part cloudinit config making use of the part
|
||||
# above, and other source files
|
||||
resource "template_cloudinit_config" "config" {
|
||||
gzip = true
|
||||
base64_encode = true
|
||||
|
||||
# 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}"
|
||||
}
|
||||
|
||||
part {
|
||||
content_type = "text/x-shellscript"
|
||||
content = "baz"
|
||||
}
|
||||
|
||||
part {
|
||||
content_type = "text/x-shellscript"
|
||||
content = "ffbaz"
|
||||
}
|
||||
}
|
||||
|
||||
# Start an AWS instance with the cloudinit config as user data
|
||||
resource "aws_instance" "web" {
|
||||
ami = "ami-d05e75b8"
|
||||
instance_type = "t2.micro"
|
||||
user_data = "${template_cloudinit_config.config.rendered}"
|
||||
}
|
||||
```
|
||||
|
||||
## 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 file. The order of the parts is maintained in the configuration is maintained in the rendered template.
|
||||
|
||||
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 multi-part cloudinit config.
|
Loading…
Reference in New Issue