Merge pull request #495 from jrperritt/optimize-object-upload
[rfr] don't copy file contents for etag
diff --git a/.travis.yml b/.travis.yml
index 0882a56..bd9e4c6 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,10 +2,10 @@
install:
- go get -v -tags 'fixtures acceptance' ./...
go:
- - 1.1
- 1.2
- 1.3
- 1.4
+ - 1.5
- tip
script: script/cibuild
after_success:
diff --git a/acceptance/openstack/orchestration/v1/stacks_test.go b/acceptance/openstack/orchestration/v1/stacks_test.go
index 01e76d6..db31cd4 100644
--- a/acceptance/openstack/orchestration/v1/stacks_test.go
+++ b/acceptance/openstack/orchestration/v1/stacks_test.go
@@ -79,3 +79,75 @@
t.Logf("Abandonded stack %+v\n", abandonedStack)
th.AssertNoErr(t, err)
}
+
+// Test using the updated interface
+func TestStacksNewTemplateFormat(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName1 := "gophercloud-test-stack-2"
+ templateOpts := new(osStacks.Template)
+ templateOpts.Bin = []byte(template)
+ createOpts := osStacks.CreateOpts{
+ Name: stackName1,
+ TemplateOpts: templateOpts,
+ Timeout: 5,
+ }
+ stack, err := stacks.Create(client, createOpts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created stack: %+v\n", stack)
+ defer func() {
+ err := stacks.Delete(client, stackName1, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName1)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ updateOpts := osStacks.UpdateOpts{
+ TemplateOpts: templateOpts,
+ Timeout: 20,
+ }
+ err = stacks.Update(client, stackName1, stack.ID, updateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "UPDATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ t.Logf("Updated stack")
+
+ err = stacks.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ stackList, err := osStacks.ExtractStacks(page)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Got stack list: %+v\n", stackList)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack: %+v\n", getStack)
+
+ abandonedStack, err := stacks.Abandon(client, stackName1, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Abandonded stack %+v\n", abandonedStack)
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/orchestration/v1/stacktemplates_test.go b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
index 14d8f44..22d5e88 100644
--- a/acceptance/openstack/orchestration/v1/stacktemplates_test.go
+++ b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
@@ -46,22 +46,21 @@
th.AssertNoErr(t, err)
t.Logf("retrieved template: %+v\n", tmpl)
- validateOpts := stacktemplates.ValidateOpts{
- Template: map[string]interface{}{
- "heat_template_version": "2013-05-23",
- "description": "Simple template to test heat commands",
- "parameters": map[string]interface{}{
- "flavor": map[string]interface{}{
+ validateOpts := osStacktemplates.ValidateOpts{
+ Template: `{"heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
"default": "m1.tiny",
"type": "string",
},
},
- "resources": map[string]interface{}{
- "hello_world": map[string]interface{}{
+ "resources": {
+ "hello_world": {
"type": "OS::Nova::Server",
- "properties": map[string]interface{}{
+ "properties": {
"key_name": "heat_key",
- "flavor": map[string]interface{}{
+ "flavor": {
"get_param": "flavor",
},
"image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
@@ -69,8 +68,7 @@
},
},
},
- },
- }
+ }`}
validatedTemplate, err := stacktemplates.Validate(client, validateOpts).Extract()
th.AssertNoErr(t, err)
t.Logf("validated template: %+v\n", validatedTemplate)
diff --git a/acceptance/rackspace/orchestration/v1/stacks_test.go b/acceptance/rackspace/orchestration/v1/stacks_test.go
index cfec4e9..61969b5 100644
--- a/acceptance/rackspace/orchestration/v1/stacks_test.go
+++ b/acceptance/rackspace/orchestration/v1/stacks_test.go
@@ -80,3 +80,75 @@
t.Logf("Abandonded stack %+v\n", abandonedStack)
th.AssertNoErr(t, err)
}
+
+// Test using the updated interface
+func TestStacksNewTemplateFormat(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName1 := "gophercloud-test-stack-2"
+ templateOpts := new(osStacks.Template)
+ templateOpts.Bin = []byte(template)
+ createOpts := osStacks.CreateOpts{
+ Name: stackName1,
+ TemplateOpts: templateOpts,
+ Timeout: 5,
+ }
+ stack, err := stacks.Create(client, createOpts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created stack: %+v\n", stack)
+ defer func() {
+ err := stacks.Delete(client, stackName1, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName1)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ updateOpts := osStacks.UpdateOpts{
+ TemplateOpts: templateOpts,
+ Timeout: 20,
+ }
+ err = stacks.Update(client, stackName1, stack.ID, updateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "UPDATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ t.Logf("Updated stack")
+
+ err = stacks.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ stackList, err := osStacks.ExtractStacks(page)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Got stack list: %+v\n", stackList)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack: %+v\n", getStack)
+
+ abandonedStack, err := stacks.Abandon(client, stackName1, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Abandonded stack %+v\n", abandonedStack)
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/rackspace/orchestration/v1/stacktemplates_test.go b/acceptance/rackspace/orchestration/v1/stacktemplates_test.go
index 1f7b217..e4ccd9e 100644
--- a/acceptance/rackspace/orchestration/v1/stacktemplates_test.go
+++ b/acceptance/rackspace/orchestration/v1/stacktemplates_test.go
@@ -49,21 +49,20 @@
t.Logf("retrieved template: %+v\n", tmpl)
validateOpts := osStacktemplates.ValidateOpts{
- Template: map[string]interface{}{
- "heat_template_version": "2013-05-23",
+ Template: `{"heat_template_version": "2013-05-23",
"description": "Simple template to test heat commands",
- "parameters": map[string]interface{}{
- "flavor": map[string]interface{}{
+ "parameters": {
+ "flavor": {
"default": "m1.tiny",
"type": "string",
},
},
- "resources": map[string]interface{}{
- "hello_world": map[string]interface{}{
+ "resources": {
+ "hello_world": {
"type": "OS::Nova::Server",
- "properties": map[string]interface{}{
+ "properties": {
"key_name": "heat_key",
- "flavor": map[string]interface{}{
+ "flavor": {
"get_param": "flavor",
},
"image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
@@ -71,8 +70,7 @@
},
},
},
- },
- }
+ }`}
validatedTemplate, err := stacktemplates.Validate(client, validateOpts).Extract()
th.AssertNoErr(t, err)
t.Logf("validated template: %+v\n", validatedTemplate)
diff --git a/openstack/orchestration/v1/stackresources/fixtures.go b/openstack/orchestration/v1/stackresources/fixtures.go
index c3c3d3f..952dc54 100644
--- a/openstack/orchestration/v1/stackresources/fixtures.go
+++ b/openstack/orchestration/v1/stackresources/fixtures.go
@@ -28,10 +28,13 @@
LogicalID: "hello_world",
StatusReason: "state changed",
UpdatedTime: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ CreationTime: time.Date(2015, 2, 5, 21, 33, 10, 0, time.UTC),
RequiredBy: []interface{}{},
Status: "CREATE_IN_PROGRESS",
PhysicalID: "49181cd6-169a-4130-9455-31185bbfc5bf",
Type: "OS::Nova::Server",
+ Attributes: map[string]interface{}{"SXSW": "atx"},
+ Description: "Some resource",
},
}
@@ -40,6 +43,8 @@
{
"resources": [
{
+ "description": "Some resource",
+ "attributes": {"SXSW": "atx"},
"resource_name": "hello_world",
"links": [
{
@@ -54,6 +59,7 @@
"logical_resource_id": "hello_world",
"resource_status_reason": "state changed",
"updated_time": "2015-02-05T21:33:11",
+ "creation_time": "2015-02-05T21:33:10",
"required_by": [],
"resource_status": "CREATE_IN_PROGRESS",
"physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
@@ -93,10 +99,13 @@
LogicalID: "hello_world",
StatusReason: "state changed",
UpdatedTime: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ CreationTime: time.Date(2015, 2, 5, 21, 33, 10, 0, time.UTC),
RequiredBy: []interface{}{},
Status: "CREATE_IN_PROGRESS",
PhysicalID: "49181cd6-169a-4130-9455-31185bbfc5bf",
Type: "OS::Nova::Server",
+ Attributes: map[string]interface{}{"SXSW": "atx"},
+ Description: "Some resource",
},
}
@@ -121,7 +130,10 @@
"required_by": [],
"resource_status": "CREATE_IN_PROGRESS",
"physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
- "resource_type": "OS::Nova::Server"
+ "creation_time": "2015-02-05T21:33:10",
+ "resource_type": "OS::Nova::Server",
+ "attributes": {"SXSW": "atx"},
+ "description": "Some resource"
}
]
}`
@@ -162,6 +174,7 @@
},
},
LogicalID: "wordpress_instance",
+ Attributes: map[string]interface{}{"SXSW": "atx"},
StatusReason: "state changed",
UpdatedTime: time.Date(2014, 12, 10, 18, 34, 35, 0, time.UTC),
RequiredBy: []interface{}{},
@@ -174,6 +187,8 @@
const GetOutput = `
{
"resource": {
+ "description": "Some resource",
+ "attributes": {"SXSW": "atx"},
"resource_name": "wordpress_instance",
"description": "",
"links": [
@@ -240,7 +255,7 @@
}
// ListTypesExpected represents the expected object from a ListTypes request.
-var ListTypesExpected = []string{
+var ListTypesExpected = ResourceTypes{
"OS::Nova::Server",
"OS::Heat::RandomString",
"OS::Swift::Container",
@@ -251,6 +266,18 @@
"OS::Nova::KeyPair",
}
+// same as above, but sorted
+var SortedListTypesExpected = ResourceTypes{
+ "OS::Cinder::VolumeAttachment",
+ "OS::Heat::RandomString",
+ "OS::Nova::FloatingIP",
+ "OS::Nova::FloatingIPAssociation",
+ "OS::Nova::KeyPair",
+ "OS::Nova::Server",
+ "OS::Swift::Container",
+ "OS::Trove::Instance",
+}
+
// ListTypesOutput represents the response body from a ListTypes request.
const ListTypesOutput = `
{
@@ -296,6 +323,11 @@
},
},
ResourceType: "OS::Heat::AResourceName",
+ SupportStatus: map[string]interface{}{
+ "message": "A status message",
+ "status": "SUPPORTED",
+ "version": "2014.1",
+ },
}
// GetSchemaOutput represents the response body from a Schema request.
@@ -314,7 +346,12 @@
"description": "A resource description."
}
},
- "resource_type": "OS::Heat::AResourceName"
+ "resource_type": "OS::Heat::AResourceName",
+ "support_status": {
+ "message": "A status message",
+ "status": "SUPPORTED",
+ "version": "2014.1"
+ }
}`
// HandleGetSchemaSuccessfully creates an HTTP handler at `/resource_types/OS::Heat::AResourceName`
@@ -332,56 +369,7 @@
}
// GetTemplateExpected represents the expected object from a Template request.
-var GetTemplateExpected = &TypeTemplate{
- HeatTemplateFormatVersion: "2012-12-12",
- Outputs: map[string]interface{}{
- "private_key": map[string]interface{}{
- "Description": "The private key if it has been saved.",
- "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"private_key\"]}",
- },
- "public_key": map[string]interface{}{
- "Description": "The public key.",
- "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"public_key\"]}",
- },
- },
- Parameters: map[string]interface{}{
- "name": map[string]interface{}{
- "Description": "The name of the key pair.",
- "Type": "String",
- },
- "public_key": map[string]interface{}{
- "Description": "The optional public key. This allows users to supply the public key from a pre-existing key pair. If not supplied, a new key pair will be generated.",
- "Type": "String",
- },
- "save_private_key": map[string]interface{}{
- "AllowedValues": []string{
- "True",
- "true",
- "False",
- "false",
- },
- "Default": false,
- "Description": "True if the system should remember a generated private key; False otherwise.",
- "Type": "String",
- },
- },
- Resources: map[string]interface{}{
- "KeyPair": map[string]interface{}{
- "Properties": map[string]interface{}{
- "name": map[string]interface{}{
- "Ref": "name",
- },
- "public_key": map[string]interface{}{
- "Ref": "public_key",
- },
- "save_private_key": map[string]interface{}{
- "Ref": "save_private_key",
- },
- },
- "Type": "OS::Nova::KeyPair",
- },
- },
-}
+var GetTemplateExpected = "{\n \"HeatTemplateFormatVersion\": \"2012-12-12\",\n \"Outputs\": {\n \"private_key\": {\n \"Description\": \"The private key if it has been saved.\",\n \"Value\": \"{\\\"Fn::GetAtt\\\": [\\\"KeyPair\\\", \\\"private_key\\\"]}\"\n },\n \"public_key\": {\n \"Description\": \"The public key.\",\n \"Value\": \"{\\\"Fn::GetAtt\\\": [\\\"KeyPair\\\", \\\"public_key\\\"]}\"\n }\n },\n \"Parameters\": {\n \"name\": {\n \"Description\": \"The name of the key pair.\",\n \"Type\": \"String\"\n },\n \"public_key\": {\n \"Description\": \"The optional public key. This allows users to supply the public key from a pre-existing key pair. If not supplied, a new key pair will be generated.\",\n \"Type\": \"String\"\n },\n \"save_private_key\": {\n \"AllowedValues\": [\n \"True\",\n \"true\",\n \"False\",\n \"false\"\n ],\n \"Default\": false,\n \"Description\": \"True if the system should remember a generated private key; False otherwise.\",\n \"Type\": \"String\"\n }\n },\n \"Resources\": {\n \"KeyPair\": {\n \"Properties\": {\n \"name\": {\n \"Ref\": \"name\"\n },\n \"public_key\": {\n \"Ref\": \"public_key\"\n },\n \"save_private_key\": {\n \"Ref\": \"save_private_key\"\n }\n },\n \"Type\": \"OS::Nova::KeyPair\"\n }\n }\n}"
// GetTemplateOutput represents the response body from a Template request.
const GetTemplateOutput = `
diff --git a/openstack/orchestration/v1/stackresources/requests_test.go b/openstack/orchestration/v1/stackresources/requests_test.go
index f137878..e5045a7 100644
--- a/openstack/orchestration/v1/stackresources/requests_test.go
+++ b/openstack/orchestration/v1/stackresources/requests_test.go
@@ -1,6 +1,7 @@
package stackresources
import (
+ "sort"
"testing"
"github.com/rackspace/gophercloud/pagination"
@@ -75,6 +76,9 @@
th.AssertNoErr(t, err)
th.CheckDeepEquals(t, ListTypesExpected, actual)
+ // test if sorting works
+ sort.Sort(actual)
+ th.CheckDeepEquals(t, SortedListTypesExpected, actual)
return true, nil
})
@@ -103,5 +107,5 @@
th.AssertNoErr(t, err)
expected := GetTemplateExpected
- th.AssertDeepEquals(t, expected, actual)
+ th.AssertDeepEquals(t, expected, string(actual))
}
diff --git a/openstack/orchestration/v1/stackresources/results.go b/openstack/orchestration/v1/stackresources/results.go
index df79d58..6ddc766 100644
--- a/openstack/orchestration/v1/stackresources/results.go
+++ b/openstack/orchestration/v1/stackresources/results.go
@@ -1,6 +1,7 @@
package stackresources
import (
+ "encoding/json"
"fmt"
"reflect"
"time"
@@ -12,15 +13,18 @@
// Resource represents a stack resource.
type Resource struct {
- Links []gophercloud.Link `mapstructure:"links"`
- LogicalID string `mapstructure:"logical_resource_id"`
- Name string `mapstructure:"resource_name"`
- PhysicalID string `mapstructure:"physical_resource_id"`
- RequiredBy []interface{} `mapstructure:"required_by"`
- Status string `mapstructure:"resource_status"`
- StatusReason string `mapstructure:"resource_status_reason"`
- Type string `mapstructure:"resource_type"`
- UpdatedTime time.Time `mapstructure:"-"`
+ Attributes map[string]interface{} `mapstructure:"attributes"`
+ CreationTime time.Time `mapstructure:"-"`
+ Description string `mapstructure:"description"`
+ Links []gophercloud.Link `mapstructure:"links"`
+ LogicalID string `mapstructure:"logical_resource_id"`
+ Name string `mapstructure:"resource_name"`
+ PhysicalID string `mapstructure:"physical_resource_id"`
+ RequiredBy []interface{} `mapstructure:"required_by"`
+ Status string `mapstructure:"resource_status"`
+ StatusReason string `mapstructure:"resource_status_reason"`
+ Type string `mapstructure:"resource_type"`
+ UpdatedTime time.Time `mapstructure:"-"`
}
// FindResult represents the result of a Find operation.
@@ -54,6 +58,13 @@
}
res.Res[i].UpdatedTime = t
}
+ if date, ok := resource["creation_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Res[i].CreationTime = t
+ }
}
return res.Res, nil
@@ -75,18 +86,6 @@
return len(resources) == 0, nil
}
-// LastMarker returns the last container name in a ListResult.
-func (r ResourcePage) LastMarker() (string, error) {
- resources, err := ExtractResources(r)
- if err != nil {
- return "", err
- }
- if len(resources) == 0 {
- return "", nil
- }
- return resources[len(resources)-1].PhysicalID, nil
-}
-
// ExtractResources interprets the results of a single page from a List() call, producing a slice of Resource entities.
func ExtractResources(page pagination.Page) ([]Resource, error) {
casted := page.(ResourcePage).Body
@@ -94,8 +93,9 @@
var response struct {
Resources []Resource `mapstructure:"resources"`
}
- err := mapstructure.Decode(casted, &response)
-
+ if err := mapstructure.Decode(casted, &response); err != nil {
+ return nil, err
+ }
var resources []interface{}
switch casted.(type) {
case map[string]interface{}:
@@ -115,9 +115,16 @@
}
response.Resources[i].UpdatedTime = t
}
+ if date, ok := resource["creation_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ response.Resources[i].CreationTime = t
+ }
}
- return response.Resources, err
+ return response.Resources, nil
}
// GetResult represents the result of a Get operation.
@@ -149,6 +156,13 @@
}
res.Res.UpdatedTime = t
}
+ if date, ok := resource["creation_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Res.CreationTime = t
+ }
return res.Res, nil
}
@@ -192,21 +206,42 @@
return len(rts) == 0, nil
}
+// ResourceTypes represents the type that holds the result of ExtractResourceTypes.
+// We define methods on this type to sort it before output
+type ResourceTypes []string
+
+func (r ResourceTypes) Len() int {
+ return len(r)
+}
+
+func (r ResourceTypes) Swap(i, j int) {
+ r[i], r[j] = r[j], r[i]
+}
+
+func (r ResourceTypes) Less(i, j int) bool {
+ return r[i] < r[j]
+}
+
// ExtractResourceTypes extracts and returns resource types.
-func ExtractResourceTypes(page pagination.Page) ([]string, error) {
+func ExtractResourceTypes(page pagination.Page) (ResourceTypes, error) {
+ casted := page.(ResourceTypePage).Body
+
var response struct {
- ResourceTypes []string `mapstructure:"resource_types"`
+ ResourceTypes ResourceTypes `mapstructure:"resource_types"`
}
- err := mapstructure.Decode(page.(ResourceTypePage).Body, &response)
- return response.ResourceTypes, err
+ if err := mapstructure.Decode(casted, &response); err != nil {
+ return nil, err
+ }
+ return response.ResourceTypes, nil
}
// TypeSchema represents a stack resource schema.
type TypeSchema struct {
- Attributes map[string]interface{} `mapstructure:"attributes"`
- Properties map[string]interface{} `mapstrucutre:"properties"`
- ResourceType string `mapstructure:"resource_type"`
+ Attributes map[string]interface{} `mapstructure:"attributes"`
+ Properties map[string]interface{} `mapstrucutre:"properties"`
+ ResourceType string `mapstructure:"resource_type"`
+ SupportStatus map[string]interface{} `mapstructure:"support_status"`
}
// SchemaResult represents the result of a Schema operation.
@@ -230,31 +265,20 @@
return &res, nil
}
-// TypeTemplate represents a stack resource template.
-type TypeTemplate struct {
- HeatTemplateFormatVersion string
- Outputs map[string]interface{}
- Parameters map[string]interface{}
- Resources map[string]interface{}
-}
-
// TemplateResult represents the result of a Template operation.
type TemplateResult struct {
gophercloud.Result
}
-// Extract returns a pointer to a TypeTemplate object and is called after a
+// Extract returns the template and is called after a
// Template operation.
-func (r TemplateResult) Extract() (*TypeTemplate, error) {
+func (r TemplateResult) Extract() ([]byte, error) {
if r.Err != nil {
return nil, r.Err
}
-
- var res TypeTemplate
-
- if err := mapstructure.Decode(r.Body, &res); err != nil {
+ template, err := json.MarshalIndent(r.Body, "", " ")
+ if err != nil {
return nil, err
}
-
- return &res, nil
+ return template, nil
}
diff --git a/openstack/orchestration/v1/stacks/environment.go b/openstack/orchestration/v1/stacks/environment.go
new file mode 100644
index 0000000..abaff20
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment.go
@@ -0,0 +1,137 @@
+package stacks
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Environment is a structure that represents stack environments
+type Environment struct {
+ TE
+}
+
+// EnvironmentSections is a map containing allowed sections in a stack environment file
+var EnvironmentSections = map[string]bool{
+ "parameters": true,
+ "parameter_defaults": true,
+ "resource_registry": true,
+}
+
+// Validate validates the contents of the Environment
+func (e *Environment) Validate() error {
+ if e.Parsed == nil {
+ if err := e.Parse(); err != nil {
+ return err
+ }
+ }
+ for key := range e.Parsed {
+ if _, ok := EnvironmentSections[key]; !ok {
+ return fmt.Errorf("Environment has wrong section: %s", key)
+ }
+ }
+ return nil
+}
+
+// Parse environment file to resolve the URL's of the resources. This is done by
+// reading from the `Resource Registry` section, which is why the function is
+// named GetRRFileContents.
+func (e *Environment) getRRFileContents(ignoreIf igFunc) error {
+ // initialize environment if empty
+ if e.Files == nil {
+ e.Files = make(map[string]string)
+ }
+ if e.fileMaps == nil {
+ e.fileMaps = make(map[string]string)
+ }
+
+ // get the resource registry
+ rr := e.Parsed["resource_registry"]
+
+ // search the resource registry for URLs
+ switch rr.(type) {
+ // process further only if the resource registry is a map
+ case map[string]interface{}, map[interface{}]interface{}:
+ rrMap, err := toStringKeys(rr)
+ if err != nil {
+ return err
+ }
+ // the resource registry might contain a base URL for the resource. If
+ // such a field is present, use it. Otherwise, use the default base URL.
+ var baseURL string
+ if val, ok := rrMap["base_url"]; ok {
+ baseURL = val.(string)
+ } else {
+ baseURL = e.baseURL
+ }
+
+ // The contents of the resource may be located in a remote file, which
+ // will be a template. Instantiate a temporary template to manage the
+ // contents.
+ tempTemplate := new(Template)
+ tempTemplate.baseURL = baseURL
+ tempTemplate.client = e.client
+
+ // Fetch the contents of remote resource URL's
+ if err = tempTemplate.getFileContents(rr, ignoreIf, false); err != nil {
+ return err
+ }
+ // check the `resources` section (if it exists) for more URL's. Note that
+ // the previous call to GetFileContents was (deliberately) not recursive
+ // as we want more control over where to look for URL's
+ if val, ok := rrMap["resources"]; ok {
+ switch val.(type) {
+ // process further only if the contents are a map
+ case map[string]interface{}, map[interface{}]interface{}:
+ resourcesMap, err := toStringKeys(val)
+ if err != nil {
+ return err
+ }
+ for _, v := range resourcesMap {
+ switch v.(type) {
+ case map[string]interface{}, map[interface{}]interface{}:
+ resourceMap, err := toStringKeys(v)
+ if err != nil {
+ return err
+ }
+ var resourceBaseURL string
+ // if base_url for the resource type is defined, use it
+ if val, ok := resourceMap["base_url"]; ok {
+ resourceBaseURL = val.(string)
+ } else {
+ resourceBaseURL = baseURL
+ }
+ tempTemplate.baseURL = resourceBaseURL
+ if err := tempTemplate.getFileContents(v, ignoreIf, false); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ // if the resource registry contained any URL's, store them. This can
+ // then be passed as parameter to api calls to Heat api.
+ e.Files = tempTemplate.Files
+ return nil
+ default:
+ return nil
+ }
+}
+
+// function to choose keys whose values are other environment files
+func ignoreIfEnvironment(key string, value interface{}) bool {
+ // base_url and hooks refer to components which cannot have urls
+ if key == "base_url" || key == "hooks" {
+ return true
+ }
+ // if value is not string, it cannot be a URL
+ valueString, ok := value.(string)
+ if !ok {
+ return true
+ }
+ // if value contains `::`, it must be a reference to another resource type
+ // e.g. OS::Nova::Server : Rackspace::Cloud::Server
+ if strings.Contains(valueString, "::") {
+ return true
+ }
+ return false
+}
diff --git a/openstack/orchestration/v1/stacks/environment_test.go b/openstack/orchestration/v1/stacks/environment_test.go
new file mode 100644
index 0000000..3a3c2b9
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment_test.go
@@ -0,0 +1,184 @@
+package stacks
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestEnvironmentValidation(t *testing.T) {
+ environmentJSON := new(Environment)
+ environmentJSON.Bin = []byte(ValidJSONEnvironment)
+ err := environmentJSON.Validate()
+ th.AssertNoErr(t, err)
+
+ environmentYAML := new(Environment)
+ environmentYAML.Bin = []byte(ValidYAMLEnvironment)
+ err = environmentYAML.Validate()
+ th.AssertNoErr(t, err)
+
+ environmentInvalid := new(Environment)
+ environmentInvalid.Bin = []byte(InvalidEnvironment)
+ if err = environmentInvalid.Validate(); err == nil {
+ t.Error("environment validation did not catch invalid environment")
+ }
+}
+
+func TestEnvironmentParsing(t *testing.T) {
+ environmentJSON := new(Environment)
+ environmentJSON.Bin = []byte(ValidJSONEnvironment)
+ err := environmentJSON.Parse()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, ValidJSONEnvironmentParsed, environmentJSON.Parsed)
+
+ environmentYAML := new(Environment)
+ environmentYAML.Bin = []byte(ValidJSONEnvironment)
+ err = environmentYAML.Parse()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, ValidJSONEnvironmentParsed, environmentYAML.Parsed)
+
+ environmentInvalid := new(Environment)
+ environmentInvalid.Bin = []byte("Keep Austin Weird")
+ err = environmentInvalid.Parse()
+ if err == nil {
+ t.Error("environment parsing did not catch invalid environment")
+ }
+}
+
+func TestIgnoreIfEnvironment(t *testing.T) {
+ var keyValueTests = []struct {
+ key string
+ value interface{}
+ out bool
+ }{
+ {"base_url", "afksdf", true},
+ {"not_type", "hooks", false},
+ {"get_file", "::", true},
+ {"hooks", "dfsdfsd", true},
+ {"type", "sdfubsduf.yaml", false},
+ {"type", "sdfsdufs.environment", false},
+ {"type", "sdfsdf.file", false},
+ {"type", map[string]string{"key": "value"}, true},
+ }
+ var result bool
+ for _, kv := range keyValueTests {
+ result = ignoreIfEnvironment(kv.key, kv.value)
+ if result != kv.out {
+ t.Errorf("key: %v, value: %v expected: %v, actual: %v", kv.key, kv.value, kv.out, result)
+ }
+ }
+}
+
+func TestGetRRFileContents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ environmentContent := `
+heat_template_version: 2013-05-23
+
+description:
+ Heat WordPress template to support F18, using only Heat OpenStack-native
+ resource types, and without the requirement for heat-cfntools in the image.
+ WordPress is web software you can use to create a beautiful website or blog.
+ This template installs a single-instance WordPress deployment using a local
+ MySQL database to store the data.
+
+parameters:
+
+ key_name:
+ type: string
+ description : Name of a KeyPair to enable SSH access to the instance
+
+resources:
+ wordpress_instance:
+ type: OS::Nova::Server
+ properties:
+ image: { get_param: image_id }
+ flavor: { get_param: instance_type }
+ key_name: { get_param: key_name }`
+
+ dbContent := `
+heat_template_version: 2014-10-16
+
+description:
+ Test template for Trove resource capabilities
+
+parameters:
+ db_pass:
+ type: string
+ hidden: true
+ description: Database access password
+ default: secrete
+
+resources:
+
+service_db:
+ type: OS::Trove::Instance
+ properties:
+ name: trove_test_db
+ datastore_type: mariadb
+ flavor: 1GB Instance
+ size: 10
+ databases:
+ - name: test_data
+ users:
+ - name: kitchen_sink
+ password: { get_param: db_pass }
+ databases: [ test_data ]`
+ baseurl, err := getBasePath()
+ th.AssertNoErr(t, err)
+
+ fakeEnvURL := strings.Join([]string{baseurl, "my_env.yaml"}, "/")
+ urlparsed, err := url.Parse(fakeEnvURL)
+ th.AssertNoErr(t, err)
+ // handler for my_env.yaml
+ th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ w.Header().Set("Content-Type", "application/jason")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, environmentContent)
+ })
+
+ fakeDBURL := strings.Join([]string{baseurl, "my_db.yaml"}, "/")
+ urlparsed, err = url.Parse(fakeDBURL)
+ th.AssertNoErr(t, err)
+
+ // handler for my_db.yaml
+ th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ w.Header().Set("Content-Type", "application/jason")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, dbContent)
+ })
+
+ client := fakeClient{BaseClient: getHTTPClient()}
+ env := new(Environment)
+ env.Bin = []byte(`{"resource_registry": {"My::WP::Server": "my_env.yaml", "resources": {"my_db_server": {"OS::DBInstance": "my_db.yaml"}}}}`)
+ env.client = client
+
+ err = env.Parse()
+ th.AssertNoErr(t, err)
+ err = env.getRRFileContents(ignoreIfEnvironment)
+ th.AssertNoErr(t, err)
+ expectedEnvFilesContent := "\nheat_template_version: 2013-05-23\n\ndescription:\n Heat WordPress template to support F18, using only Heat OpenStack-native\n resource types, and without the requirement for heat-cfntools in the image.\n WordPress is web software you can use to create a beautiful website or blog.\n This template installs a single-instance WordPress deployment using a local\n MySQL database to store the data.\n\nparameters:\n\n key_name:\n type: string\n description : Name of a KeyPair to enable SSH access to the instance\n\nresources:\n wordpress_instance:\n type: OS::Nova::Server\n properties:\n image: { get_param: image_id }\n flavor: { get_param: instance_type }\n key_name: { get_param: key_name }"
+ expectedDBFilesContent := "\nheat_template_version: 2014-10-16\n\ndescription:\n Test template for Trove resource capabilities\n\nparameters:\n db_pass:\n type: string\n hidden: true\n description: Database access password\n default: secrete\n\nresources:\n\nservice_db:\n type: OS::Trove::Instance\n properties:\n name: trove_test_db\n datastore_type: mariadb\n flavor: 1GB Instance\n size: 10\n databases:\n - name: test_data\n users:\n - name: kitchen_sink\n password: { get_param: db_pass }\n databases: [ test_data ]"
+
+ th.AssertEquals(t, expectedEnvFilesContent, env.Files[fakeEnvURL])
+ th.AssertEquals(t, expectedDBFilesContent, env.Files[fakeDBURL])
+
+ env.fixFileRefs()
+ expectedParsed := map[string]interface{}{
+ "resource_registry": "2015-04-30",
+ "My::WP::Server": fakeEnvURL,
+ "resources": map[string]interface{}{
+ "my_db_server": map[string]interface{}{
+ "OS::DBInstance": fakeDBURL,
+ },
+ },
+ }
+ env.Parse()
+ th.AssertDeepEquals(t, expectedParsed, env.Parsed)
+}
diff --git a/openstack/orchestration/v1/stacks/fixtures.go b/openstack/orchestration/v1/stacks/fixtures.go
index 3a621da..83f5dec 100644
--- a/openstack/orchestration/v1/stacks/fixtures.go
+++ b/openstack/orchestration/v1/stacks/fixtures.go
@@ -63,6 +63,7 @@
CreationTime: time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC),
Status: "CREATE_COMPLETE",
ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Tags: []string{"rackspace", "atx"},
},
ListedStack{
Description: "Simple template to test heat commands",
@@ -78,6 +79,7 @@
UpdatedTime: time.Date(2014, 12, 11, 17, 40, 37, 0, time.UTC),
Status: "UPDATE_COMPLETE",
ID: "db6977b2-27aa-4775-9ae7-6213212d4ada",
+ Tags: []string{"sfo", "satx"},
},
}
@@ -98,7 +100,8 @@
"creation_time": "2015-02-03T20:07:39",
"updated_time": null,
"stack_status": "CREATE_COMPLETE",
- "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87"
+ "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "tags": ["rackspace", "atx"]
},
{
"description": "Simple template to test heat commands",
@@ -113,7 +116,8 @@
"creation_time": "2014-12-11T17:39:16",
"updated_time": "2014-12-11T17:40:37",
"stack_status": "UPDATE_COMPLETE",
- "id": "db6977b2-27aa-4775-9ae7-6213212d4ada"
+ "id": "db6977b2-27aa-4775-9ae7-6213212d4ada",
+ "tags": ["sfo", "satx"]
}
]
}
@@ -165,6 +169,7 @@
Status: "CREATE_COMPLETE",
ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
TemplateDescription: "Simple template to test heat commands",
+ Tags: []string{"rackspace", "atx"},
}
// GetOutput represents the response body from a Get request.
@@ -194,7 +199,8 @@
"stack_status": "CREATE_COMPLETE",
"updated_time": null,
"id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
- "template_description": "Simple template to test heat commands"
+ "template_description": "Simple template to test heat commands",
+ "tags": ["rackspace", "atx"]
}
}
`
@@ -248,7 +254,6 @@
"OS::stack_name": "postman_stack",
"OS::stack_id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
},
- StatusReason: "Stack CREATE completed successfully",
Name: "postman_stack",
CreationTime: time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC),
Links: []gophercloud.Link{
@@ -259,7 +264,6 @@
},
Capabilities: []interface{}{},
NotificationTopics: []interface{}{},
- Status: "CREATE_COMPLETE",
ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
TemplateDescription: "Simple template to test heat commands",
}
@@ -316,6 +320,20 @@
"type": "OS::Nova::Server",
},
},
+ Files: map[string]string{
+ "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "heat_template_version: 2014-10-16\nparameters:\n flavor:\n type: string\n description: Flavor for the server to be created\n default: 4353\n hidden: true\nresources:\n test_server:\n type: \"OS::Nova::Server\"\n properties:\n name: test-server\n flavor: 2 GB General Purpose v1\n image: Debian 7 (Wheezy) (PVHVM)\n",
+ },
+ StackUserProjectID: "897686",
+ ProjectID: "897686",
+ Environment: map[string]interface{}{
+ "encrypted_param_names": make([]map[string]interface{}, 0),
+ "parameter_defaults": make(map[string]interface{}),
+ "parameters": make(map[string]interface{}),
+ "resource_registry": map[string]interface{}{
+ "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml",
+ "resources": make(map[string]interface{}),
+ },
+ },
}
// AbandonOutput represents the response body from an Abandon request.
@@ -354,21 +372,233 @@
"name": "hello_world",
"resource_id": "8a310d36-46fc-436f-8be4-37a696b8ac63",
"action": "CREATE",
- "type": "OS::Nova::Server",
+ "type": "OS::Nova::Server"
}
- }
+ },
+ "files": {
+ "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "heat_template_version: 2014-10-16\nparameters:\n flavor:\n type: string\n description: Flavor for the server to be created\n default: 4353\n hidden: true\nresources:\n test_server:\n type: \"OS::Nova::Server\"\n properties:\n name: test-server\n flavor: 2 GB General Purpose v1\n image: Debian 7 (Wheezy) (PVHVM)\n"
+},
+ "environment": {
+ "encrypted_param_names": [],
+ "parameter_defaults": {},
+ "parameters": {},
+ "resource_registry": {
+ "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml",
+ "resources": {}
+ }
+ },
+ "stack_user_project_id": "897686",
+ "project_id": "897686"
}`
// HandleAbandonSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/abandon`
// on the test handler mux that responds with an `Abandon` response.
-func HandleAbandonSuccessfully(t *testing.T) {
- th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/abandon", func(w http.ResponseWriter, r *http.Request) {
+func HandleAbandonSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c8/abandon", func(w http.ResponseWriter, r *http.Request) {
th.TestMethod(t, r, "DELETE")
th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
th.TestHeader(t, r, "Accept", "application/json")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- fmt.Fprintf(w, AbandonOutput)
+ fmt.Fprintf(w, output)
})
}
+
+// ValidJSONTemplate is a valid OpenStack Heat template in JSON format
+const ValidJSONTemplate = `
+{
+ "heat_template_version": "2014-10-16",
+ "parameters": {
+ "flavor": {
+ "default": 4353,
+ "description": "Flavor for the server to be created",
+ "hidden": true,
+ "type": "string"
+ }
+ },
+ "resources": {
+ "test_server": {
+ "properties": {
+ "flavor": "2 GB General Purpose v1",
+ "image": "Debian 7 (Wheezy) (PVHVM)",
+ "name": "test-server"
+ },
+ "type": "OS::Nova::Server"
+ }
+ }
+}
+`
+
+// ValidJSONTemplateParsed is the expected parsed version of ValidJSONTemplate
+var ValidJSONTemplateParsed = map[string]interface{}{
+ "heat_template_version": "2014-10-16",
+ "parameters": map[string]interface{}{
+ "flavor": map[string]interface{}{
+ "default": 4353,
+ "description": "Flavor for the server to be created",
+ "hidden": true,
+ "type": "string",
+ },
+ },
+ "resources": map[string]interface{}{
+ "test_server": map[string]interface{}{
+ "properties": map[string]interface{}{
+ "flavor": "2 GB General Purpose v1",
+ "image": "Debian 7 (Wheezy) (PVHVM)",
+ "name": "test-server",
+ },
+ "type": "OS::Nova::Server",
+ },
+ },
+}
+
+// ValidYAMLTemplate is a valid OpenStack Heat template in YAML format
+const ValidYAMLTemplate = `
+heat_template_version: 2014-10-16
+parameters:
+ flavor:
+ type: string
+ description: Flavor for the server to be created
+ default: 4353
+ hidden: true
+resources:
+ test_server:
+ type: "OS::Nova::Server"
+ properties:
+ name: test-server
+ flavor: 2 GB General Purpose v1
+ image: Debian 7 (Wheezy) (PVHVM)
+`
+
+// InvalidTemplateNoVersion is an invalid template as it has no `version` section
+const InvalidTemplateNoVersion = `
+parameters:
+ flavor:
+ type: string
+ description: Flavor for the server to be created
+ default: 4353
+ hidden: true
+resources:
+ test_server:
+ type: "OS::Nova::Server"
+ properties:
+ name: test-server
+ flavor: 2 GB General Purpose v1
+ image: Debian 7 (Wheezy) (PVHVM)
+`
+
+// ValidJSONEnvironment is a valid environment for a stack in JSON format
+const ValidJSONEnvironment = `
+{
+ "parameters": {
+ "user_key": "userkey"
+ },
+ "resource_registry": {
+ "My::WP::Server": "file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml",
+ "OS::Quantum*": "OS::Neutron*",
+ "AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml",
+ "OS::Metering::Alarm": "OS::Ceilometer::Alarm",
+ "AWS::RDS::DBInstance": "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml",
+ "resources": {
+ "my_db_server": {
+ "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml"
+ },
+ "my_server": {
+ "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+ "hooks": "pre-create"
+ },
+ "nested_stack": {
+ "nested_resource": {
+ "hooks": "pre-update"
+ },
+ "another_resource": {
+ "hooks": [
+ "pre-create",
+ "pre-update"
+ ]
+ }
+ }
+ }
+ }
+}
+`
+
+// ValidJSONEnvironmentParsed is the expected parsed version of ValidJSONEnvironment
+var ValidJSONEnvironmentParsed = map[string]interface{}{
+ "parameters": map[string]interface{}{
+ "user_key": "userkey",
+ },
+ "resource_registry": map[string]interface{}{
+ "My::WP::Server": "file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml",
+ "OS::Quantum*": "OS::Neutron*",
+ "AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml",
+ "OS::Metering::Alarm": "OS::Ceilometer::Alarm",
+ "AWS::RDS::DBInstance": "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml",
+ "resources": map[string]interface{}{
+ "my_db_server": map[string]interface{}{
+ "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+ },
+ "my_server": map[string]interface{}{
+ "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+ "hooks": "pre-create",
+ },
+ "nested_stack": map[string]interface{}{
+ "nested_resource": map[string]interface{}{
+ "hooks": "pre-update",
+ },
+ "another_resource": map[string]interface{}{
+ "hooks": []interface{}{
+ "pre-create",
+ "pre-update",
+ },
+ },
+ },
+ },
+ },
+}
+
+// ValidYAMLEnvironment is a valid environment for a stack in YAML format
+const ValidYAMLEnvironment = `
+parameters:
+ user_key: userkey
+resource_registry:
+ My::WP::Server: file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml
+ # allow older templates with Quantum in them.
+ "OS::Quantum*": "OS::Neutron*"
+ # Choose your implementation of AWS::CloudWatch::Alarm
+ "AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml"
+ #"AWS::CloudWatch::Alarm": "OS::Heat::CWLiteAlarm"
+ "OS::Metering::Alarm": "OS::Ceilometer::Alarm"
+ "AWS::RDS::DBInstance": "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml"
+ resources:
+ my_db_server:
+ "OS::DBInstance": file:///home/mine/all_my_cool_templates/db.yaml
+ my_server:
+ "OS::DBInstance": file:///home/mine/all_my_cool_templates/db.yaml
+ hooks: pre-create
+ nested_stack:
+ nested_resource:
+ hooks: pre-update
+ another_resource:
+ hooks: [pre-create, pre-update]
+`
+
+// InvalidEnvironment is an invalid environment as it has an extra section called `resources`
+const InvalidEnvironment = `
+parameters:
+ flavor:
+ type: string
+ description: Flavor for the server to be created
+ default: 4353
+ hidden: true
+resources:
+ test_server:
+ type: "OS::Nova::Server"
+ properties:
+ name: test-server
+ flavor: 2 GB General Purpose v1
+ image: Debian 7 (Wheezy) (PVHVM)
+parameter_defaults:
+ KeyName: heat_key
+`
diff --git a/openstack/orchestration/v1/stacks/requests.go b/openstack/orchestration/v1/stacks/requests.go
index 0dd6af2..8616644 100644
--- a/openstack/orchestration/v1/stacks/requests.go
+++ b/openstack/orchestration/v1/stacks/requests.go
@@ -2,6 +2,7 @@
import (
"errors"
+ "strings"
"github.com/rackspace/gophercloud"
"github.com/rackspace/gophercloud/pagination"
@@ -32,9 +33,16 @@
type CreateOpts struct {
// (REQUIRED) The name of the stack. It must start with an alphabetic character.
Name string
+ // (REQUIRED) A structure that contains either the template file or url. Call the
+ // associated methods to extract the information relevant to send in a create request.
+ TemplateOpts *Template
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, TemplateURL will be ignored
// (OPTIONAL; REQUIRED IF Template IS EMPTY) The URL of the template to instantiate.
// This value is ignored if Template is supplied inline.
TemplateURL string
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, Template will be ignored
// (OPTIONAL; REQUIRED IF TemplateURL IS EMPTY) A template to instantiate. The value
// is a stringified version of the JSON/YAML template. Since the template will likely
// be located in a file, one way to set this variable is by using ioutil.ReadFile:
@@ -50,8 +58,14 @@
// creation fails. Default is true, meaning all resources are not deleted when
// stack creation fails.
DisableRollback Rollback
+ // (OPTIONAL) A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment
+ // (DEPRECATED): Please use EnvironmentOpts to provide Environment data
// (OPTIONAL) A stringified JSON environment for the stack.
Environment string
+ // (DEPRECATED): Files is automatically determined
+ // by parsing the template and environment passed as TemplateOpts and
+ // EnvironmentOpts respectively.
// (OPTIONAL) A map that maps file names to file contents. It can also be used
// to pass provider template contents. Example:
// Files: `{"myfile": "#!/bin/bash\necho 'Hello world' > /root/testfile.txt"}`
@@ -60,6 +74,8 @@
Parameters map[string]string
// (OPTIONAL) The timeout for stack creation in minutes.
Timeout int
+ // (OPTIONAL) A list of tags to assosciate with the Stack
+ Tags []string
}
// ToStackCreateMap casts a CreateOpts struct to a map.
@@ -70,25 +86,60 @@
return s, errors.New("Required field 'Name' not provided.")
}
s["stack_name"] = opts.Name
-
- if opts.Template != "" {
- s["template"] = opts.Template
- } else if opts.TemplateURL != "" {
- s["template_url"] = opts.TemplateURL
+ Files := make(map[string]string)
+ if opts.TemplateOpts == nil {
+ if opts.Template != "" {
+ s["template"] = opts.Template
+ } else if opts.TemplateURL != "" {
+ s["template_url"] = opts.TemplateURL
+ } else {
+ return s, errors.New("Either Template or TemplateURL must be provided.")
+ }
} else {
- return s, errors.New("Either Template or TemplateURL must be provided.")
+ if err := opts.TemplateOpts.Parse(); err != nil {
+ return nil, err
+ }
+
+ if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+ return nil, err
+ }
+ opts.TemplateOpts.fixFileRefs()
+ s["template"] = string(opts.TemplateOpts.Bin)
+
+ for k, v := range opts.TemplateOpts.Files {
+ Files[k] = v
+ }
+ }
+ if opts.DisableRollback != nil {
+ s["disable_rollback"] = &opts.DisableRollback
+ }
+
+ if opts.EnvironmentOpts != nil {
+ if err := opts.EnvironmentOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+ return nil, err
+ }
+ opts.EnvironmentOpts.fixFileRefs()
+ for k, v := range opts.EnvironmentOpts.Files {
+ Files[k] = v
+ }
+ s["environment"] = string(opts.EnvironmentOpts.Bin)
+ } else if opts.Environment != "" {
+ s["environment"] = opts.Environment
+ }
+
+ if opts.Files != nil {
+ s["files"] = opts.Files
+ } else {
+ s["files"] = Files
}
if opts.DisableRollback != nil {
s["disable_rollback"] = &opts.DisableRollback
}
- if opts.Environment != "" {
- s["environment"] = opts.Environment
- }
- if opts.Files != nil {
- s["files"] = opts.Files
- }
if opts.Parameters != nil {
s["parameters"] = opts.Parameters
}
@@ -97,6 +148,9 @@
s["timeout_mins"] = opts.Timeout
}
+ if opts.Tags != nil {
+ s["tags"] = strings.Join(opts.Tags, ",")
+ }
return s, nil
}
@@ -133,9 +187,16 @@
Name string
// (REQUIRED) The timeout for stack creation in minutes.
Timeout int
+ // (REQUIRED) A structure that contains either the template file or url. Call the
+ // associated methods to extract the information relevant to send in a create request.
+ TemplateOpts *Template
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, TemplateURL will be ignored
// (OPTIONAL; REQUIRED IF Template IS EMPTY) The URL of the template to instantiate.
// This value is ignored if Template is supplied inline.
TemplateURL string
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, Template will be ignored
// (OPTIONAL; REQUIRED IF TemplateURL IS EMPTY) A template to instantiate. The value
// is a stringified version of the JSON/YAML template. Since the template will likely
// be located in a file, one way to set this variable is by using ioutil.ReadFile:
@@ -151,8 +212,14 @@
// creation fails. Default is true, meaning all resources are not deleted when
// stack creation fails.
DisableRollback Rollback
+ // (OPTIONAL) A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment
+ // (DEPRECATED): Please use EnvironmentOpts to provide Environment data
// (OPTIONAL) A stringified JSON environment for the stack.
Environment string
+ // (DEPRECATED): Files is automatically determined
+ // by parsing the template and environment passed as TemplateOpts and
+ // EnvironmentOpts respectively.
// (OPTIONAL) A map that maps file names to file contents. It can also be used
// to pass provider template contents. Example:
// Files: `{"myfile": "#!/bin/bash\necho 'Hello world' > /root/testfile.txt"}`
@@ -169,15 +236,30 @@
return s, errors.New("Required field 'Name' not provided.")
}
s["stack_name"] = opts.Name
-
- if opts.Template != "" {
- s["template"] = opts.Template
- } else if opts.TemplateURL != "" {
- s["template_url"] = opts.TemplateURL
+ Files := make(map[string]string)
+ if opts.TemplateOpts == nil {
+ if opts.Template != "" {
+ s["template"] = opts.Template
+ } else if opts.TemplateURL != "" {
+ s["template_url"] = opts.TemplateURL
+ } else {
+ return s, errors.New("Either Template or TemplateURL must be provided.")
+ }
} else {
- return s, errors.New("Either Template or TemplateURL must be provided.")
- }
+ if err := opts.TemplateOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+ return nil, err
+ }
+ opts.TemplateOpts.fixFileRefs()
+ s["template"] = string(opts.TemplateOpts.Bin)
+
+ for k, v := range opts.TemplateOpts.Files {
+ Files[k] = v
+ }
+ }
if opts.AdoptStackData == "" {
return s, errors.New("Required field 'AdoptStackData' not provided.")
}
@@ -187,22 +269,38 @@
s["disable_rollback"] = &opts.DisableRollback
}
- if opts.Environment != "" {
+ if opts.EnvironmentOpts != nil {
+ if err := opts.EnvironmentOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+ return nil, err
+ }
+ opts.EnvironmentOpts.fixFileRefs()
+ for k, v := range opts.EnvironmentOpts.Files {
+ Files[k] = v
+ }
+ s["environment"] = string(opts.EnvironmentOpts.Bin)
+ } else if opts.Environment != "" {
s["environment"] = opts.Environment
}
+
if opts.Files != nil {
s["files"] = opts.Files
+ } else {
+ s["files"] = Files
}
+
if opts.Parameters != nil {
s["parameters"] = opts.Parameters
}
- if opts.Timeout == 0 {
- return nil, errors.New("Required field 'Timeout' not provided.")
+ if opts.Timeout != 0 {
+ s["timeout"] = opts.Timeout
}
s["timeout_mins"] = opts.Timeout
- return map[string]interface{}{"stack": s}, nil
+ return s, nil
}
// Adopt accepts an AdoptOpts struct and creates a new stack using the resources
@@ -305,9 +403,16 @@
// UpdateOpts contains the common options struct used in this package's Update
// operation.
type UpdateOpts struct {
+ // (REQUIRED) A structure that contains either the template file or url. Call the
+ // associated methods to extract the information relevant to send in a create request.
+ TemplateOpts *Template
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, TemplateURL will be ignored
// (OPTIONAL; REQUIRED IF Template IS EMPTY) The URL of the template to instantiate.
// This value is ignored if Template is supplied inline.
TemplateURL string
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, Template will be ignored
// (OPTIONAL; REQUIRED IF TemplateURL IS EMPTY) A template to instantiate. The value
// is a stringified version of the JSON/YAML template. Since the template will likely
// be located in a file, one way to set this variable is by using ioutil.ReadFile:
@@ -319,8 +424,14 @@
// }
// opts.Template = string(b)
Template string
+ // (OPTIONAL) A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment
+ // (DEPRECATED): Please use EnvironmentOpts to provide Environment data
// (OPTIONAL) A stringified JSON environment for the stack.
Environment string
+ // (DEPRECATED): Files is automatically determined
+ // by parsing the template and environment passed as TemplateOpts and
+ // EnvironmentOpts respectively.
// (OPTIONAL) A map that maps file names to file contents. It can also be used
// to pass provider template contents. Example:
// Files: `{"myfile": "#!/bin/bash\necho 'Hello world' > /root/testfile.txt"}`
@@ -329,26 +440,58 @@
Parameters map[string]string
// (OPTIONAL) The timeout for stack creation in minutes.
Timeout int
+ // (OPTIONAL) A list of tags to assosciate with the Stack
+ Tags []string
}
// ToStackUpdateMap casts a CreateOpts struct to a map.
func (opts UpdateOpts) ToStackUpdateMap() (map[string]interface{}, error) {
s := make(map[string]interface{})
-
- if opts.Template != "" {
- s["template"] = opts.Template
- } else if opts.TemplateURL != "" {
- s["template_url"] = opts.TemplateURL
+ Files := make(map[string]string)
+ if opts.TemplateOpts == nil {
+ if opts.Template != "" {
+ s["template"] = opts.Template
+ } else if opts.TemplateURL != "" {
+ s["template_url"] = opts.TemplateURL
+ } else {
+ return s, errors.New("Either Template or TemplateURL must be provided.")
+ }
} else {
- return s, errors.New("Either Template or TemplateURL must be provided.")
+ if err := opts.TemplateOpts.Parse(); err != nil {
+ return nil, err
+ }
+
+ if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+ return nil, err
+ }
+ opts.TemplateOpts.fixFileRefs()
+ s["template"] = string(opts.TemplateOpts.Bin)
+
+ for k, v := range opts.TemplateOpts.Files {
+ Files[k] = v
+ }
}
- if opts.Environment != "" {
+ if opts.EnvironmentOpts != nil {
+ if err := opts.EnvironmentOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+ return nil, err
+ }
+ opts.EnvironmentOpts.fixFileRefs()
+ for k, v := range opts.EnvironmentOpts.Files {
+ Files[k] = v
+ }
+ s["environment"] = string(opts.EnvironmentOpts.Bin)
+ } else if opts.Environment != "" {
s["environment"] = opts.Environment
}
if opts.Files != nil {
s["files"] = opts.Files
+ } else {
+ s["files"] = Files
}
if opts.Parameters != nil {
@@ -359,6 +502,10 @@
s["timeout_mins"] = opts.Timeout
}
+ if opts.Tags != nil {
+ s["tags"] = strings.Join(opts.Tags, ",")
+ }
+
return s, nil
}
@@ -397,9 +544,16 @@
Name string
// (REQUIRED) The timeout for stack creation in minutes.
Timeout int
+ // (REQUIRED) A structure that contains either the template file or url. Call the
+ // associated methods to extract the information relevant to send in a create request.
+ TemplateOpts *Template
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, TemplateURL will be ignored
// (OPTIONAL; REQUIRED IF Template IS EMPTY) The URL of the template to instantiate.
// This value is ignored if Template is supplied inline.
TemplateURL string
+ // (DEPRECATED): Please use TemplateOpts for providing the template. If
+ // TemplateOpts is provided, Template will be ignored
// (OPTIONAL; REQUIRED IF TemplateURL IS EMPTY) A template to instantiate. The value
// is a stringified version of the JSON/YAML template. Since the template will likely
// be located in a file, one way to set this variable is by using ioutil.ReadFile:
@@ -415,8 +569,14 @@
// creation fails. Default is true, meaning all resources are not deleted when
// stack creation fails.
DisableRollback Rollback
+ // (OPTIONAL) A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment
+ // (DEPRECATED): Please use EnvironmentOpts to provide Environment data
// (OPTIONAL) A stringified JSON environment for the stack.
Environment string
+ // (DEPRECATED): Files is automatically determined
+ // by parsing the template and environment passed as TemplateOpts and
+ // EnvironmentOpts respectively.
// (OPTIONAL) A map that maps file names to file contents. It can also be used
// to pass provider template contents. Example:
// Files: `{"myfile": "#!/bin/bash\necho 'Hello world' > /root/testfile.txt"}`
@@ -433,25 +593,56 @@
return s, errors.New("Required field 'Name' not provided.")
}
s["stack_name"] = opts.Name
-
- if opts.Template != "" {
- s["template"] = opts.Template
- } else if opts.TemplateURL != "" {
- s["template_url"] = opts.TemplateURL
+ Files := make(map[string]string)
+ if opts.TemplateOpts == nil {
+ if opts.Template != "" {
+ s["template"] = opts.Template
+ } else if opts.TemplateURL != "" {
+ s["template_url"] = opts.TemplateURL
+ } else {
+ return s, errors.New("Either Template or TemplateURL must be provided.")
+ }
} else {
- return s, errors.New("Either Template or TemplateURL must be provided.")
- }
+ if err := opts.TemplateOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+ return nil, err
+ }
+ opts.TemplateOpts.fixFileRefs()
+ s["template"] = string(opts.TemplateOpts.Bin)
+
+ for k, v := range opts.TemplateOpts.Files {
+ Files[k] = v
+ }
+ }
if opts.DisableRollback != nil {
s["disable_rollback"] = &opts.DisableRollback
}
- if opts.Environment != "" {
+ if opts.EnvironmentOpts != nil {
+ if err := opts.EnvironmentOpts.Parse(); err != nil {
+ return nil, err
+ }
+ if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+ return nil, err
+ }
+ opts.EnvironmentOpts.fixFileRefs()
+ for k, v := range opts.EnvironmentOpts.Files {
+ Files[k] = v
+ }
+ s["environment"] = string(opts.EnvironmentOpts.Bin)
+ } else if opts.Environment != "" {
s["environment"] = opts.Environment
}
+
if opts.Files != nil {
s["files"] = opts.Files
+ } else {
+ s["files"] = Files
}
+
if opts.Parameters != nil {
s["parameters"] = opts.Parameters
}
diff --git a/openstack/orchestration/v1/stacks/requests_test.go b/openstack/orchestration/v1/stacks/requests_test.go
index 1e32ca2..0fde44b 100644
--- a/openstack/orchestration/v1/stacks/requests_test.go
+++ b/openstack/orchestration/v1/stacks/requests_test.go
@@ -52,6 +52,35 @@
th.AssertDeepEquals(t, expected, actual)
}
+func TestCreateStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t, CreateOutput)
+ template := new(Template)
+ template.Bin = []byte(`
+ {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ }
+ }`)
+ createOpts := CreateOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: Disable,
+ }
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
func TestAdoptStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -97,6 +126,52 @@
th.AssertDeepEquals(t, expected, actual)
}
+func TestAdoptStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t, CreateOutput)
+ template := new(Template)
+ template.Bin = []byte(`
+{
+ "stack_name": "postman_stack",
+ "template": {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ },
+ "resources": {
+ "hello_world": {
+ "type":"OS::Nova::Server",
+ "properties": {
+ "key_name": "heat_key",
+ "flavor": {
+ "get_param": "flavor"
+ },
+ "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+ }
+}`)
+ adoptOpts := AdoptOpts{
+ AdoptStackData: `{environment{parameters{}}}`,
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: Disable,
+ }
+ actual, err := Adopt(fake.ServiceClient(), adoptOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
func TestListStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -163,6 +238,30 @@
th.AssertNoErr(t, err)
}
+func TestUpdateStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateSuccessfully(t)
+
+ template := new(Template)
+ template.Bin = []byte(`
+ {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ }
+ }`)
+ updateOpts := UpdateOpts{
+ TemplateOpts: template,
+ }
+ err := Update(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada", updateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
func TestDeleteStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -215,3 +314,45 @@
expected := PreviewExpected
th.AssertDeepEquals(t, expected, actual)
}
+
+func TestPreviewStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePreviewSuccessfully(t, GetOutput)
+
+ template := new(Template)
+ template.Bin = []byte(`
+ {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ }
+ }`)
+ previewOpts := PreviewOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: Disable,
+ }
+ actual, err := Preview(fake.ServiceClient(), previewOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := PreviewExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestAbandonStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAbandonSuccessfully(t, AbandonOutput)
+
+ actual, err := Abandon(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c8").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := AbandonExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stacks/results.go b/openstack/orchestration/v1/stacks/results.go
index dca06e4..432bc8e 100644
--- a/openstack/orchestration/v1/stacks/results.go
+++ b/openstack/orchestration/v1/stacks/results.go
@@ -69,6 +69,7 @@
Name string `mapstructure:"stack_name"`
Status string `mapstructure:"stack_status"`
StatusReason string `mapstructure:"stack_status_reason"`
+ Tags []string `mapstructure:"tags"`
UpdatedTime time.Time `mapstructure:"-"`
}
@@ -81,7 +82,7 @@
Stacks []ListedStack `mapstructure:"stacks"`
}
- err := mapstructure.Decode(page.(StackPage).Body, &res)
+ err := mapstructure.Decode(casted, &res)
if err != nil {
return nil, err
}
@@ -133,6 +134,7 @@
Name string `mapstructure:"stack_name"`
Status string `mapstructure:"stack_status"`
StatusReason string `mapstructure:"stack_status_reason"`
+ Tags []string `mapstructure:"tags"`
TemplateDescription string `mapstructure:"template_description"`
Timeout int `mapstructure:"timeout_mins"`
UpdatedTime time.Time `mapstructure:"-"`
@@ -200,21 +202,19 @@
// PreviewedStack represents the result of a Preview operation.
type PreviewedStack struct {
- Capabilities []interface{} `mapstructure:"capabilities"`
- CreationTime time.Time `mapstructure:"-"`
- Description string `mapstructure:"description"`
- DisableRollback bool `mapstructure:"disable_rollback"`
- ID string `mapstructure:"id"`
- Links []gophercloud.Link `mapstructure:"links"`
- Name string `mapstructure:"stack_name"`
- NotificationTopics []interface{} `mapstructure:"notification_topics"`
- Parameters map[string]string `mapstructure:"parameters"`
- Resources []map[string]interface{} `mapstructure:"resources"`
- Status string `mapstructure:"stack_status"`
- StatusReason string `mapstructure:"stack_status_reason"`
- TemplateDescription string `mapstructure:"template_description"`
- Timeout int `mapstructure:"timeout_mins"`
- UpdatedTime time.Time `mapstructure:"-"`
+ Capabilities []interface{} `mapstructure:"capabilities"`
+ CreationTime time.Time `mapstructure:"-"`
+ Description string `mapstructure:"description"`
+ DisableRollback bool `mapstructure:"disable_rollback"`
+ ID string `mapstructure:"id"`
+ Links []gophercloud.Link `mapstructure:"links"`
+ Name string `mapstructure:"stack_name"`
+ NotificationTopics []interface{} `mapstructure:"notification_topics"`
+ Parameters map[string]string `mapstructure:"parameters"`
+ Resources []interface{} `mapstructure:"resources"`
+ TemplateDescription string `mapstructure:"template_description"`
+ Timeout int `mapstructure:"timeout_mins"`
+ UpdatedTime time.Time `mapstructure:"-"`
}
// PreviewResult represents the result of a Preview operation.
@@ -269,12 +269,16 @@
// AbandonedStack represents the result of an Abandon operation.
type AbandonedStack struct {
- Status string `mapstructure:"status"`
- Name string `mapstructure:"name"`
- Template map[string]interface{} `mapstructure:"template"`
- Action string `mapstructure:"action"`
- ID string `mapstructure:"id"`
- Resources map[string]interface{} `mapstructure:"resources"`
+ Status string `mapstructure:"status"`
+ Name string `mapstructure:"name"`
+ Template map[string]interface{} `mapstructure:"template"`
+ Action string `mapstructure:"action"`
+ ID string `mapstructure:"id"`
+ Resources map[string]interface{} `mapstructure:"resources"`
+ Files map[string]string `mapstructure:"files"`
+ StackUserProjectID string `mapstructure:"stack_user_project_id"`
+ ProjectID string `mapstructure:"project_id"`
+ Environment map[string]interface{} `mapstructure:"environment"`
}
// AbandonResult represents the result of an Abandon operation.
diff --git a/openstack/orchestration/v1/stacks/template.go b/openstack/orchestration/v1/stacks/template.go
new file mode 100644
index 0000000..234ce49
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/template.go
@@ -0,0 +1,139 @@
+package stacks
+
+import (
+ "fmt"
+ "github.com/rackspace/gophercloud"
+ "reflect"
+ "strings"
+)
+
+// Template is a structure that represents OpenStack Heat templates
+type Template struct {
+ TE
+}
+
+// TemplateFormatVersions is a map containing allowed variations of the template format version
+// Note that this contains the permitted variations of the _keys_ not the values.
+var TemplateFormatVersions = map[string]bool{
+ "HeatTemplateFormatVersion": true,
+ "heat_template_version": true,
+ "AWSTemplateFormatVersion": true,
+}
+
+// Validate validates the contents of the Template
+func (t *Template) Validate() error {
+ if t.Parsed == nil {
+ if err := t.Parse(); err != nil {
+ return err
+ }
+ }
+ for key := range t.Parsed {
+ if _, ok := TemplateFormatVersions[key]; ok {
+ return nil
+ }
+ }
+ return fmt.Errorf("Template format version not found.")
+}
+
+// GetFileContents recursively parses a template to search for urls. These urls
+// are assumed to point to other templates (known in OpenStack Heat as child
+// templates). The contents of these urls are fetched and stored in the `Files`
+// parameter of the template structure. This is the only way that a user can
+// use child templates that are located in their filesystem; urls located on the
+// web (e.g. on github or swift) can be fetched directly by Heat engine.
+func (t *Template) getFileContents(te interface{}, ignoreIf igFunc, recurse bool) error {
+ // initialize template if empty
+ if t.Files == nil {
+ t.Files = make(map[string]string)
+ }
+ if t.fileMaps == nil {
+ t.fileMaps = make(map[string]string)
+ }
+ switch te.(type) {
+ // if te is a map
+ case map[string]interface{}, map[interface{}]interface{}:
+ teMap, err := toStringKeys(te)
+ if err != nil {
+ return err
+ }
+ for k, v := range teMap {
+ value, ok := v.(string)
+ if !ok {
+ // if the value is not a string, recursively parse that value
+ if err := t.getFileContents(v, ignoreIf, recurse); err != nil {
+ return err
+ }
+ } else if !ignoreIf(k, value) {
+ // at this point, the k, v pair has a reference to an external template.
+ // The assumption of heatclient is that value v is a reference
+ // to a file in the users environment
+
+ // create a new child template
+ childTemplate := new(Template)
+
+ // initialize child template
+
+ // get the base location of the child template
+ baseURL, err := gophercloud.NormalizePathURL(t.baseURL, value)
+ if err != nil {
+ return err
+ }
+ childTemplate.baseURL = baseURL
+ childTemplate.client = t.client
+
+ // fetch the contents of the child template
+ if err := childTemplate.Parse(); err != nil {
+ return err
+ }
+
+ // process child template recursively if required. This is
+ // required if the child template itself contains references to
+ // other templates
+ if recurse {
+ if err := childTemplate.getFileContents(childTemplate.Parsed, ignoreIf, recurse); err != nil {
+ return err
+ }
+ }
+ // update parent template with current child templates' content.
+ // At this point, the child template has been parsed recursively.
+ t.fileMaps[value] = childTemplate.URL
+ t.Files[childTemplate.URL] = string(childTemplate.Bin)
+
+ }
+ }
+ return nil
+ // if te is a slice, call the function on each element of the slice.
+ case []interface{}:
+ teSlice := te.([]interface{})
+ for i := range teSlice {
+ if err := t.getFileContents(teSlice[i], ignoreIf, recurse); err != nil {
+ return err
+ }
+ }
+ // if te is anything else, return
+ case string, bool, float64, nil, int:
+ return nil
+ default:
+ return fmt.Errorf("%v: Unrecognized type", reflect.TypeOf(te))
+
+ }
+ return nil
+}
+
+// function to choose keys whose values are other template files
+func ignoreIfTemplate(key string, value interface{}) bool {
+ // key must be either `get_file` or `type` for value to be a URL
+ if key != "get_file" && key != "type" {
+ return true
+ }
+ // value must be a string
+ valueString, ok := value.(string)
+ if !ok {
+ return true
+ }
+ // `.template` and `.yaml` are allowed suffixes for template URLs when referred to by `type`
+ if key == "type" && !(strings.HasSuffix(valueString, ".template") || strings.HasSuffix(valueString, ".yaml")) {
+ return true
+ }
+ return false
+}
diff --git a/openstack/orchestration/v1/stacks/template_test.go b/openstack/orchestration/v1/stacks/template_test.go
new file mode 100644
index 0000000..6884db8
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/template_test.go
@@ -0,0 +1,148 @@
+package stacks
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTemplateValidation(t *testing.T) {
+ templateJSON := new(Template)
+ templateJSON.Bin = []byte(ValidJSONTemplate)
+ err := templateJSON.Validate()
+ th.AssertNoErr(t, err)
+
+ templateYAML := new(Template)
+ templateYAML.Bin = []byte(ValidYAMLTemplate)
+ err = templateYAML.Validate()
+ th.AssertNoErr(t, err)
+
+ templateInvalid := new(Template)
+ templateInvalid.Bin = []byte(InvalidTemplateNoVersion)
+ if err = templateInvalid.Validate(); err == nil {
+ t.Error("Template validation did not catch invalid template")
+ }
+}
+
+func TestTemplateParsing(t *testing.T) {
+ templateJSON := new(Template)
+ templateJSON.Bin = []byte(ValidJSONTemplate)
+ err := templateJSON.Parse()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, ValidJSONTemplateParsed, templateJSON.Parsed)
+
+ templateYAML := new(Template)
+ templateYAML.Bin = []byte(ValidJSONTemplate)
+ err = templateYAML.Parse()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, ValidJSONTemplateParsed, templateYAML.Parsed)
+
+ templateInvalid := new(Template)
+ templateInvalid.Bin = []byte("Keep Austin Weird")
+ err = templateInvalid.Parse()
+ if err == nil {
+ t.Error("Template parsing did not catch invalid template")
+ }
+}
+
+func TestIgnoreIfTemplate(t *testing.T) {
+ var keyValueTests = []struct {
+ key string
+ value interface{}
+ out bool
+ }{
+ {"not_get_file", "afksdf", true},
+ {"not_type", "sdfd", true},
+ {"get_file", "shdfuisd", false},
+ {"type", "dfsdfsd", true},
+ {"type", "sdfubsduf.yaml", false},
+ {"type", "sdfsdufs.template", false},
+ {"type", "sdfsdf.file", true},
+ {"type", map[string]string{"key": "value"}, true},
+ }
+ var result bool
+ for _, kv := range keyValueTests {
+ result = ignoreIfTemplate(kv.key, kv.value)
+ if result != kv.out {
+ t.Errorf("key: %v, value: %v expected: %v, actual: %v", kv.key, kv.value, result, kv.out)
+ }
+ }
+}
+
+func TestGetFileContents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ baseurl, err := getBasePath()
+ th.AssertNoErr(t, err)
+ fakeURL := strings.Join([]string{baseurl, "my_nova.yaml"}, "/")
+ urlparsed, err := url.Parse(fakeURL)
+ th.AssertNoErr(t, err)
+ myNovaContent := `heat_template_version: 2014-10-16
+parameters:
+ flavor:
+ type: string
+ description: Flavor for the server to be created
+ default: 4353
+ hidden: true
+resources:
+ test_server:
+ type: "OS::Nova::Server"
+ properties:
+ name: test-server
+ flavor: 2 GB General Purpose v1
+ image: Debian 7 (Wheezy) (PVHVM)
+ networks:
+ - {uuid: 11111111-1111-1111-1111-111111111111}`
+ th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ w.Header().Set("Content-Type", "application/jason")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, myNovaContent)
+ })
+
+ client := fakeClient{BaseClient: getHTTPClient()}
+ te := new(Template)
+ te.Bin = []byte(`heat_template_version: 2015-04-30
+resources:
+ my_server:
+ type: my_nova.yaml`)
+ te.client = client
+
+ err = te.Parse()
+ th.AssertNoErr(t, err)
+ err = te.getFileContents(te.Parsed, ignoreIfTemplate, true)
+ th.AssertNoErr(t, err)
+ expectedFiles := map[string]string{
+ "my_nova.yaml": `heat_template_version: 2014-10-16
+parameters:
+ flavor:
+ type: string
+ description: Flavor for the server to be created
+ default: 4353
+ hidden: true
+resources:
+ test_server:
+ type: "OS::Nova::Server"
+ properties:
+ name: test-server
+ flavor: 2 GB General Purpose v1
+ image: Debian 7 (Wheezy) (PVHVM)
+ networks:
+ - {uuid: 11111111-1111-1111-1111-111111111111}`}
+ th.AssertEquals(t, expectedFiles["my_nova.yaml"], te.Files[fakeURL])
+ te.fixFileRefs()
+ expectedParsed := map[string]interface{}{
+ "heat_template_version": "2015-04-30",
+ "resources": map[string]interface{}{
+ "my_server": map[string]interface{}{
+ "type": fakeURL,
+ },
+ },
+ }
+ te.Parse()
+ th.AssertDeepEquals(t, expectedParsed, te.Parsed)
+}
diff --git a/openstack/orchestration/v1/stacks/utils.go b/openstack/orchestration/v1/stacks/utils.go
new file mode 100644
index 0000000..7b476a9
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/utils.go
@@ -0,0 +1,161 @@
+package stacks
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "path/filepath"
+ "reflect"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+ "gopkg.in/yaml.v2"
+)
+
+// Client is an interface that expects a Get method similar to http.Get. This
+// is needed for unit testing, since we can mock an http client. Thus, the
+// client will usually be an http.Client EXCEPT in unit tests.
+type Client interface {
+ Get(string) (*http.Response, error)
+}
+
+// TE is a base structure for both Template and Environment
+type TE struct {
+ // Bin stores the contents of the template or environment.
+ Bin []byte
+ // URL stores the URL of the template. This is allowed to be a 'file://'
+ // for local files.
+ URL string
+ // Parsed contains a parsed version of Bin. Since there are 2 different
+ // fields referring to the same value, you must be careful when accessing
+ // this filed.
+ Parsed map[string]interface{}
+ // Files contains a mapping between the urls in templates to their contents.
+ Files map[string]string
+ // fileMaps is a map used internally when determining Files.
+ fileMaps map[string]string
+ // baseURL represents the location of the template or environment file.
+ baseURL string
+ // client is an interface which allows TE to fetch contents from URLS
+ client Client
+}
+
+// Fetch fetches the contents of a TE from its URL. Once a TE structure has a
+// URL, call the fetch method to fetch the contents.
+func (t *TE) Fetch() error {
+ // if the baseURL is not provided, use the current directors as the base URL
+ if t.baseURL == "" {
+ u, err := getBasePath()
+ if err != nil {
+ return err
+ }
+ t.baseURL = u
+ }
+
+ // if the contents are already present, do nothing.
+ if t.Bin != nil {
+ return nil
+ }
+
+ // get a fqdn from the URL using the baseURL of the TE. For local files,
+ // the URL's will have the `file` scheme.
+ u, err := gophercloud.NormalizePathURL(t.baseURL, t.URL)
+ if err != nil {
+ return err
+ }
+ t.URL = u
+
+ // get an HTTP client if none present
+ if t.client == nil {
+ t.client = getHTTPClient()
+ }
+
+ // use the client to fetch the contents of the TE
+ resp, err := t.client.Get(t.URL)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ t.Bin = body
+ return nil
+}
+
+// get the basepath of the TE
+func getBasePath() (string, error) {
+ basePath, err := filepath.Abs(".")
+ if err != nil {
+ return "", err
+ }
+ u, err := gophercloud.NormalizePathURL("", basePath)
+ if err != nil {
+ return "", err
+ }
+ return u, nil
+}
+
+// get a an HTTP client to retrieve URL's. This client allows the use of `file`
+// scheme since we may need to fetch files from users filesystem
+func getHTTPClient() Client {
+ transport := &http.Transport{}
+ transport.RegisterProtocol("file", http.NewFileTransport(http.Dir("/")))
+ return &http.Client{Transport: transport}
+}
+
+// Parse will parse the contents and then validate. The contents MUST be either JSON or YAML.
+func (t *TE) Parse() error {
+ if err := t.Fetch(); err != nil {
+ return err
+ }
+ if jerr := json.Unmarshal(t.Bin, &t.Parsed); jerr != nil {
+ if yerr := yaml.Unmarshal(t.Bin, &t.Parsed); yerr != nil {
+ return fmt.Errorf("Data in neither json nor yaml format.")
+ }
+ }
+ return t.Validate()
+}
+
+// Validate validates the contents of TE
+func (t *TE) Validate() error {
+ return nil
+}
+
+// igfunc is a parameter used by GetFileContents and GetRRFileContents to check
+// for valid URL's.
+type igFunc func(string, interface{}) bool
+
+// convert map[interface{}]interface{} to map[string]interface{}
+func toStringKeys(m interface{}) (map[string]interface{}, error) {
+ switch m.(type) {
+ case map[string]interface{}, map[interface{}]interface{}:
+ typedMap := make(map[string]interface{})
+ if _, ok := m.(map[interface{}]interface{}); ok {
+ for k, v := range m.(map[interface{}]interface{}) {
+ typedMap[k.(string)] = v
+ }
+ } else {
+ typedMap = m.(map[string]interface{})
+ }
+ return typedMap, nil
+ default:
+ return nil, fmt.Errorf("Expected a map of type map[string]interface{} or map[interface{}]interface{}, actual type: %v", reflect.TypeOf(m))
+
+ }
+}
+
+// fix the reference to files by replacing relative URL's by absolute
+// URL's
+func (t *TE) fixFileRefs() {
+ tStr := string(t.Bin)
+ if t.fileMaps == nil {
+ return
+ }
+ for k, v := range t.fileMaps {
+ tStr = strings.Replace(tStr, k, v, -1)
+ }
+ t.Bin = []byte(tStr)
+}
diff --git a/openstack/orchestration/v1/stacks/utils_test.go b/openstack/orchestration/v1/stacks/utils_test.go
new file mode 100644
index 0000000..2536e03
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/utils_test.go
@@ -0,0 +1,94 @@
+package stacks
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTEFixFileRefs(t *testing.T) {
+ te := TE{
+ Bin: []byte(`string_to_replace: my fair lady`),
+ fileMaps: map[string]string{
+ "string_to_replace": "london bridge is falling down",
+ },
+ }
+ te.fixFileRefs()
+ th.AssertEquals(t, string(te.Bin), `london bridge is falling down: my fair lady`)
+}
+
+func TesttoStringKeys(t *testing.T) {
+ var test1 interface{} = map[interface{}]interface{}{
+ "Adam": "Smith",
+ "Isaac": "Newton",
+ }
+ result1, err := toStringKeys(test1)
+ th.AssertNoErr(t, err)
+
+ expected := map[string]interface{}{
+ "Adam": "Smith",
+ "Isaac": "Newton",
+ }
+ th.AssertDeepEquals(t, result1, expected)
+}
+
+func TestGetBasePath(t *testing.T) {
+ _, err := getBasePath()
+ th.AssertNoErr(t, err)
+}
+
+// test if HTTP client can read file type URLS. Read the URL of this file
+// because if this test is running, it means this file _must_ exist
+func TestGetHTTPClient(t *testing.T) {
+ client := getHTTPClient()
+ baseurl, err := getBasePath()
+ th.AssertNoErr(t, err)
+ resp, err := client.Get(baseurl)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, resp.StatusCode, 200)
+}
+
+// Implement a fakeclient that can be used to mock out HTTP requests
+type fakeClient struct {
+ BaseClient Client
+}
+
+// this client's Get method first changes the URL given to point to
+// testhelper's (th) endpoints. This is done because the http Mux does not seem
+// to work for fqdns with the `file` scheme
+func (c fakeClient) Get(url string) (*http.Response, error) {
+ newurl := strings.Replace(url, "file://", th.Endpoint(), 1)
+ return c.BaseClient.Get(newurl)
+}
+
+// test the fetch function
+func TestFetch(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ baseurl, err := getBasePath()
+ th.AssertNoErr(t, err)
+ fakeURL := strings.Join([]string{baseurl, "file.yaml"}, "/")
+ urlparsed, err := url.Parse(fakeURL)
+ th.AssertNoErr(t, err)
+
+ th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ w.Header().Set("Content-Type", "application/jason")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, "Fee-fi-fo-fum")
+ })
+
+ client := fakeClient{BaseClient: getHTTPClient()}
+ te := TE{
+ URL: "file.yaml",
+ client: client,
+ }
+ err = te.Fetch()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, fakeURL, te.URL)
+ th.AssertEquals(t, "Fee-fi-fo-fum", string(te.Bin))
+}
diff --git a/openstack/orchestration/v1/stacktemplates/fixtures.go b/openstack/orchestration/v1/stacktemplates/fixtures.go
index 71fa808..fa9b301 100644
--- a/openstack/orchestration/v1/stacktemplates/fixtures.go
+++ b/openstack/orchestration/v1/stacktemplates/fixtures.go
@@ -10,29 +10,7 @@
)
// GetExpected represents the expected object from a Get request.
-var GetExpected = &Template{
- Description: "Simple template to test heat commands",
- HeatTemplateVersion: "2013-05-23",
- Parameters: map[string]interface{}{
- "flavor": map[string]interface{}{
- "default": "m1.tiny",
- "type": "string",
- },
- },
- Resources: map[string]interface{}{
- "hello_world": map[string]interface{}{
- "type": "OS::Nova::Server",
- "properties": map[string]interface{}{
- "key_name": "heat_key",
- "flavor": map[string]interface{}{
- "get_param": "flavor",
- },
- "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
- "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n",
- },
- },
- },
-}
+var GetExpected = "{\n \"description\": \"Simple template to test heat commands\",\n \"heat_template_version\": \"2013-05-23\",\n \"parameters\": {\n \"flavor\": {\n \"default\": \"m1.tiny\",\n \"type\": \"string\"\n }\n },\n \"resources\": {\n \"hello_world\": {\n \"properties\": {\n \"flavor\": {\n \"get_param\": \"flavor\"\n },\n \"image\": \"ad091b52-742f-469e-8f3c-fd81cadf0743\",\n \"key_name\": \"heat_key\"\n },\n \"type\": \"OS::Nova::Server\"\n }\n }\n}"
// GetOutput represents the response body from a Get request.
const GetOutput = `
@@ -53,8 +31,7 @@
"flavor": {
"get_param": "flavor"
},
- "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
- "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ "image": "ad091b52-742f-469e-8f3c-fd81cadf0743"
}
}
}
diff --git a/openstack/orchestration/v1/stacktemplates/requests.go b/openstack/orchestration/v1/stacktemplates/requests.go
index ad1e468..c0cea35 100644
--- a/openstack/orchestration/v1/stacktemplates/requests.go
+++ b/openstack/orchestration/v1/stacktemplates/requests.go
@@ -23,14 +23,14 @@
// ValidateOpts specifies the template validation parameters.
type ValidateOpts struct {
- Template map[string]interface{}
+ Template string
TemplateURL string
}
// ToStackTemplateValidateMap assembles a request body based on the contents of a ValidateOpts.
func (opts ValidateOpts) ToStackTemplateValidateMap() (map[string]interface{}, error) {
vo := make(map[string]interface{})
- if opts.Template != nil {
+ if opts.Template != "" {
vo["template"] = opts.Template
return vo, nil
}
diff --git a/openstack/orchestration/v1/stacktemplates/requests_test.go b/openstack/orchestration/v1/stacktemplates/requests_test.go
index d31c4ac..42667c9 100644
--- a/openstack/orchestration/v1/stacktemplates/requests_test.go
+++ b/openstack/orchestration/v1/stacktemplates/requests_test.go
@@ -16,7 +16,7 @@
th.AssertNoErr(t, err)
expected := GetExpected
- th.AssertDeepEquals(t, expected, actual)
+ th.AssertDeepEquals(t, expected, string(actual))
}
func TestValidateTemplate(t *testing.T) {
@@ -25,29 +25,29 @@
HandleValidateSuccessfully(t, ValidateOutput)
opts := ValidateOpts{
- Template: map[string]interface{}{
- "heat_template_version": "2013-05-23",
- "description": "Simple template to test heat commands",
- "parameters": map[string]interface{}{
- "flavor": map[string]interface{}{
- "default": "m1.tiny",
- "type": "string",
- },
- },
- "resources": map[string]interface{}{
- "hello_world": map[string]interface{}{
- "type": "OS::Nova::Server",
- "properties": map[string]interface{}{
- "key_name": "heat_key",
- "flavor": map[string]interface{}{
- "get_param": "flavor",
- },
- "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
- "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n",
- },
- },
- },
- },
+ Template: `{
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ },
+ "resources": {
+ "hello_world": {
+ "type": "OS::Nova::Server",
+ "properties": {
+ "key_name": "heat_key",
+ "flavor": {
+ "get_param": "flavor"
+ },
+ "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+ }`,
}
actual, err := Validate(fake.ServiceClient(), opts).Extract()
th.AssertNoErr(t, err)
diff --git a/openstack/orchestration/v1/stacktemplates/results.go b/openstack/orchestration/v1/stacktemplates/results.go
index ac2f24b..4e9ba5a 100644
--- a/openstack/orchestration/v1/stacktemplates/results.go
+++ b/openstack/orchestration/v1/stacktemplates/results.go
@@ -1,42 +1,33 @@
package stacktemplates
import (
+ "encoding/json"
"github.com/mitchellh/mapstructure"
"github.com/rackspace/gophercloud"
)
-// Template represents a stack template.
-type Template struct {
- Description string `mapstructure:"description"`
- HeatTemplateVersion string `mapstructure:"heat_template_version"`
- Parameters map[string]interface{} `mapstructure:"parameters"`
- Resources map[string]interface{} `mapstructure:"resources"`
-}
-
// GetResult represents the result of a Get operation.
type GetResult struct {
gophercloud.Result
}
-// Extract returns a pointer to a Template object and is called after a
-// Get operation.
-func (r GetResult) Extract() (*Template, error) {
+// Extract returns the JSON template and is called after a Get operation.
+func (r GetResult) Extract() ([]byte, error) {
if r.Err != nil {
return nil, r.Err
}
-
- var res Template
- if err := mapstructure.Decode(r.Body, &res); err != nil {
+ template, err := json.MarshalIndent(r.Body, "", " ")
+ if err != nil {
return nil, err
}
-
- return &res, nil
+ return template, nil
}
// ValidatedTemplate represents the parsed object returned from a Validate request.
type ValidatedTemplate struct {
- Description string
- Parameters map[string]interface{}
+ Description string `mapstructure:"Description"`
+ Parameters map[string]interface{} `mapstructure:"Parameters"`
+ ParameterGroups map[string]interface{} `mapstructure:"ParameterGroups"`
}
// ValidateResult represents the result of a Validate operation.
diff --git a/rackspace/orchestration/v1/stackresources/delegate_test.go b/rackspace/orchestration/v1/stackresources/delegate_test.go
index 18e9614..116e44c 100644
--- a/rackspace/orchestration/v1/stackresources/delegate_test.go
+++ b/rackspace/orchestration/v1/stackresources/delegate_test.go
@@ -104,5 +104,5 @@
th.AssertNoErr(t, err)
expected := os.GetTemplateExpected
- th.AssertDeepEquals(t, expected, actual)
+ th.AssertDeepEquals(t, expected, string(actual))
}
diff --git a/rackspace/orchestration/v1/stacks/delegate_test.go b/rackspace/orchestration/v1/stacks/delegate_test.go
index a1fb393..553ae94 100644
--- a/rackspace/orchestration/v1/stacks/delegate_test.go
+++ b/rackspace/orchestration/v1/stacks/delegate_test.go
@@ -172,6 +172,170 @@
th.AssertDeepEquals(t, expected, actual)
}
+func TestCreateStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreateSuccessfully(t, CreateOutput)
+
+ createOpts := os.CreateOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: new(os.Template),
+ DisableRollback: os.Disable,
+ }
+ createOpts.TemplateOpts.Bin = []byte(`{
+ "outputs": {
+ "db_host": {
+ "value": {
+ "get_attr": [
+ "db",
+ "hostname"
+ ]
+ }
+ }
+ },
+ "heat_template_version": "2014-10-16",
+ "description": "HEAT template for creating a Cloud Database.\n",
+ "parameters": {
+ "db_name": {
+ "default": "wordpress",
+ "type": "string",
+ "description": "the name for the database",
+ "constraints": [
+ {
+ "length": {
+ "max": 64,
+ "min": 1
+ },
+ "description": "must be between 1 and 64 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z][a-zA-Z0-9]*",
+ "description": "must begin with a letter and contain only alphanumeric characters."
+ }
+ ]
+ },
+ "db_instance_name": {
+ "default": "Cloud_DB",
+ "type": "string",
+ "description": "the database instance name"
+ },
+ "db_username": {
+ "default": "admin",
+ "hidden": true,
+ "type": "string",
+ "description": "database admin account username",
+ "constraints": [
+ {
+ "length": {
+ "max": 16,
+ "min": 1
+ },
+ "description": "must be between 1 and 16 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z][a-zA-Z0-9]*",
+ "description": "must begin with a letter and contain only alphanumeric characters."
+ }
+ ]
+ },
+ "db_volume_size": {
+ "default": 30,
+ "type": "number",
+ "description": "database volume size (in GB)",
+ "constraints": [
+ {
+ "range": {
+ "max": 1024,
+ "min": 1
+ },
+ "description": "must be between 1 and 1024 GB"
+ }
+ ]
+ },
+ "db_flavor": {
+ "default": "1GB Instance",
+ "type": "string",
+ "description": "database instance size",
+ "constraints": [
+ {
+ "description": "must be a valid cloud database flavor",
+ "allowed_values": [
+ "1GB Instance",
+ "2GB Instance",
+ "4GB Instance",
+ "8GB Instance",
+ "16GB Instance"
+ ]
+ }
+ ]
+ },
+ "db_password": {
+ "default": "admin",
+ "hidden": true,
+ "type": "string",
+ "description": "database admin account password",
+ "constraints": [
+ {
+ "length": {
+ "max": 41,
+ "min": 1
+ },
+ "description": "must be between 1 and 14 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z0-9]*",
+ "description": "must contain only alphanumeric characters."
+ }
+ ]
+ }
+ },
+ "resources": {
+ "db": {
+ "type": "OS::Trove::Instance",
+ "properties": {
+ "flavor": {
+ "get_param": "db_flavor"
+ },
+ "size": {
+ "get_param": "db_volume_size"
+ },
+ "users": [
+ {
+ "password": {
+ "get_param": "db_password"
+ },
+ "name": {
+ "get_param": "db_username"
+ },
+ "databases": [
+ {
+ "get_param": "db_name"
+ }
+ ]
+ }
+ ],
+ "name": {
+ "get_param": "db_instance_name"
+ },
+ "databases": [
+ {
+ "name": {
+ "get_param": "db_name"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }`)
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
func TestAdoptStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -336,6 +500,172 @@
th.AssertDeepEquals(t, expected, actual)
}
+func TestAdoptStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreateSuccessfully(t, CreateOutput)
+ template := new(os.Template)
+ template.Bin = []byte(`{
+ "outputs": {
+ "db_host": {
+ "value": {
+ "get_attr": [
+ "db",
+ "hostname"
+ ]
+ }
+ }
+ },
+ "heat_template_version": "2014-10-16",
+ "description": "HEAT template for creating a Cloud Database.\n",
+ "parameters": {
+ "db_name": {
+ "default": "wordpress",
+ "type": "string",
+ "description": "the name for the database",
+ "constraints": [
+ {
+ "length": {
+ "max": 64,
+ "min": 1
+ },
+ "description": "must be between 1 and 64 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z][a-zA-Z0-9]*",
+ "description": "must begin with a letter and contain only alphanumeric characters."
+ }
+ ]
+ },
+ "db_instance_name": {
+ "default": "Cloud_DB",
+ "type": "string",
+ "description": "the database instance name"
+ },
+ "db_username": {
+ "default": "admin",
+ "hidden": true,
+ "type": "string",
+ "description": "database admin account username",
+ "constraints": [
+ {
+ "length": {
+ "max": 16,
+ "min": 1
+ },
+ "description": "must be between 1 and 16 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z][a-zA-Z0-9]*",
+ "description": "must begin with a letter and contain only alphanumeric characters."
+ }
+ ]
+ },
+ "db_volume_size": {
+ "default": 30,
+ "type": "number",
+ "description": "database volume size (in GB)",
+ "constraints": [
+ {
+ "range": {
+ "max": 1024,
+ "min": 1
+ },
+ "description": "must be between 1 and 1024 GB"
+ }
+ ]
+ },
+ "db_flavor": {
+ "default": "1GB Instance",
+ "type": "string",
+ "description": "database instance size",
+ "constraints": [
+ {
+ "description": "must be a valid cloud database flavor",
+ "allowed_values": [
+ "1GB Instance",
+ "2GB Instance",
+ "4GB Instance",
+ "8GB Instance",
+ "16GB Instance"
+ ]
+ }
+ ]
+ },
+ "db_password": {
+ "default": "admin",
+ "hidden": true,
+ "type": "string",
+ "description": "database admin account password",
+ "constraints": [
+ {
+ "length": {
+ "max": 41,
+ "min": 1
+ },
+ "description": "must be between 1 and 14 characters"
+ },
+ {
+ "allowed_pattern": "[a-zA-Z0-9]*",
+ "description": "must contain only alphanumeric characters."
+ }
+ ]
+ }
+ },
+ "resources": {
+ "db": {
+ "type": "OS::Trove::Instance",
+ "properties": {
+ "flavor": {
+ "get_param": "db_flavor"
+ },
+ "size": {
+ "get_param": "db_volume_size"
+ },
+ "users": [
+ {
+ "password": {
+ "get_param": "db_password"
+ },
+ "name": {
+ "get_param": "db_username"
+ },
+ "databases": [
+ {
+ "get_param": "db_name"
+ }
+ ]
+ }
+ ],
+ "name": {
+ "get_param": "db_instance_name"
+ },
+ "databases": [
+ {
+ "name": {
+ "get_param": "db_name"
+ }
+ }
+ ]
+ }
+ }
+ }
+}`)
+
+ adoptOpts := os.AdoptOpts{
+ AdoptStackData: `{\"environment\":{\"parameters\":{}}, \"status\":\"COMPLETE\",\"name\": \"trovestack\",\n \"template\": {\n \"outputs\": {\n \"db_host\": {\n \"value\": {\n \"get_attr\": [\n \"db\",\n \"hostname\"\n ]\n }\n }\n },\n \"heat_template_version\": \"2014-10-16\",\n \"description\": \"HEAT template for creating a Cloud Database.\\n\",\n \"parameters\": {\n \"db_instance_name\": {\n \"default\": \"Cloud_DB\",\n \"type\": \"string\",\n \"description\": \"the database instance name\"\n },\n \"db_flavor\": {\n \"default\": \"1GB Instance\",\n \"type\": \"string\",\n \"description\": \"database instance size\",\n \"constraints\": [\n {\n \"description\": \"must be a valid cloud database flavor\",\n \"allowed_values\": [\n \"1GB Instance\",\n \"2GB Instance\",\n \"4GB Instance\",\n \"8GB Instance\",\n \"16GB Instance\"\n ]\n }\n ]\n },\n \"db_password\": {\n \"default\": \"admin\",\n \"hidden\": true,\n \"type\": \"string\",\n \"description\": \"database admin account password\",\n \"constraints\": [\n {\n \"length\": {\n \"max\": 41,\n \"min\": 1\n },\n \"description\": \"must be between 1 and 14 characters\"\n },\n {\n \"allowed_pattern\": \"[a-zA-Z0-9]*\",\n \"description\": \"must contain only alphanumeric characters.\"\n }\n ]\n },\n \"db_name\": {\n \"default\": \"wordpress\",\n \"type\": \"string\",\n \"description\": \"the name for the database\",\n \"constraints\": [\n {\n \"length\": {\n \"max\": 64,\n \"min\": 1\n },\n \"description\": \"must be between 1 and 64 characters\"\n },\n {\n \"allowed_pattern\": \"[a-zA-Z][a-zA-Z0-9]*\",\n \"description\": \"must begin with a letter and contain only alphanumeric characters.\"\n }\n ]\n },\n \"db_username\": {\n \"default\": \"admin\",\n \"hidden\": true,\n \"type\": \"string\",\n \"description\": \"database admin account username\",\n \"constraints\": [\n {\n \"length\": {\n \"max\": 16,\n \"min\": 1\n },\n \"description\": \"must be between 1 and 16 characters\"\n },\n {\n \"allowed_pattern\": \"[a-zA-Z][a-zA-Z0-9]*\",\n \"description\": \"must begin with a letter and contain only alphanumeric characters.\"\n }\n ]\n },\n \"db_volume_size\": {\n \"default\": 30,\n \"type\": \"number\",\n \"description\": \"database volume size (in GB)\",\n \"constraints\": [\n {\n \"range\": {\n \"max\": 1024,\n \"min\": 1\n },\n \"description\": \"must be between 1 and 1024 GB\"\n }\n ]\n }\n },\n \"resources\": {\n \"db\": {\n \"type\": \"OS::Trove::Instance\",\n \"properties\": {\n \"flavor\": {\n \"get_param\": \"db_flavor\"\n },\n \"databases\": [\n {\n \"name\": {\n \"get_param\": \"db_name\"\n }\n }\n ],\n \"users\": [\n {\n \"password\": {\n \"get_param\": \"db_password\"\n },\n \"name\": {\n \"get_param\": \"db_username\"\n },\n \"databases\": [\n {\n \"get_param\": \"db_name\"\n }\n ]\n }\n ],\n \"name\": {\n \"get_param\": \"db_instance_name\"\n },\n \"size\": {\n \"get_param\": \"db_volume_size\"\n }\n }\n }\n }\n },\n \"action\": \"CREATE\",\n \"id\": \"exxxxd-7xx5-4xxb-bxx2-cxxxxxx5\",\n \"resources\": {\n \"db\": {\n \"status\": \"COMPLETE\",\n \"name\": \"db\",\n \"resource_data\": {},\n \"resource_id\": \"exxxx2-9xx0-4xxxb-bxx2-dxxxxxx4\",\n \"action\": \"CREATE\",\n \"type\": \"OS::Trove::Instance\",\n \"metadata\": {}\n }\n }\n},`,
+ Name: "stackadopted",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: os.Disable,
+ }
+ actual, err := Adopt(fake.ServiceClient(), adoptOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
func TestListStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -390,6 +720,45 @@
th.AssertNoErr(t, err)
}
+func TestUpdateStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleUpdateSuccessfully(t)
+
+ updateOpts := os.UpdateOpts{
+ TemplateOpts: new(os.Template),
+ }
+ updateOpts.TemplateOpts.Bin = []byte(`
+ {
+ "stack_name": "postman_stack",
+ "template": {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ },
+ "resources": {
+ "hello_world": {
+ "type": "OS::Nova::Server",
+ "properties": {
+ "key_name": "heat_key",
+ "flavor": {
+ "get_param": "flavor"
+ },
+ "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+ }
+ }`)
+ err := Update(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada", updateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
func TestDeleteStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
@@ -443,19 +812,59 @@
th.AssertDeepEquals(t, expected, actual)
}
-/*
+func TestPreviewStackNewTemplateFormat(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandlePreviewSuccessfully(t, os.GetOutput)
+
+ previewOpts := os.PreviewOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: new(os.Template),
+ DisableRollback: os.Disable,
+ }
+ previewOpts.TemplateOpts.Bin = []byte(`
+ {
+ "stack_name": "postman_stack",
+ "template": {
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": {
+ "default": "m1.tiny",
+ "type": "string"
+ }
+ },
+ "resources": {
+ "hello_world": {
+ "type": "OS::Nova::Server",
+ "properties": {
+ "key_name": "heat_key",
+ "flavor": {
+ "get_param": "flavor"
+ },
+ "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+ }
+ }`)
+ actual, err := Preview(fake.ServiceClient(), previewOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.PreviewExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
func TestAbandonStack(t *testing.T) {
th.SetupHTTP()
defer th.TeardownHTTP()
- os.HandleAbandonSuccessfully(t)
+ os.HandleAbandonSuccessfully(t, os.AbandonOutput)
- //actual, err := Abandon(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
- //th.AssertNoErr(t, err)
- res := Abandon(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87") //.Extract()
- th.AssertNoErr(t, res.Err)
- t.Logf("actual: %+v", res)
+ actual, err := Abandon(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c8").Extract()
+ th.AssertNoErr(t, err)
- //expected := os.AbandonExpected
- //th.AssertDeepEquals(t, expected, actual)
+ expected := os.AbandonExpected
+ th.AssertDeepEquals(t, expected, actual)
}
-*/
diff --git a/rackspace/orchestration/v1/stacktemplates/delegate_test.go b/rackspace/orchestration/v1/stacktemplates/delegate_test.go
index d4006c4..d4d0f8f 100644
--- a/rackspace/orchestration/v1/stacktemplates/delegate_test.go
+++ b/rackspace/orchestration/v1/stacktemplates/delegate_test.go
@@ -17,7 +17,7 @@
th.AssertNoErr(t, err)
expected := os.GetExpected
- th.AssertDeepEquals(t, expected, actual)
+ th.AssertDeepEquals(t, expected, string(actual))
}
func TestValidateTemplate(t *testing.T) {
@@ -26,29 +26,18 @@
os.HandleValidateSuccessfully(t, os.ValidateOutput)
opts := os.ValidateOpts{
- Template: map[string]interface{}{
- "heat_template_version": "2013-05-23",
- "description": "Simple template to test heat commands",
- "parameters": map[string]interface{}{
- "flavor": map[string]interface{}{
- "default": "m1.tiny",
- "type": "string",
- },
- },
- "resources": map[string]interface{}{
- "hello_world": map[string]interface{}{
- "type": "OS::Nova::Server",
- "properties": map[string]interface{}{
- "key_name": "heat_key",
- "flavor": map[string]interface{}{
- "get_param": "flavor",
- },
- "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
- "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n",
- },
- },
- },
- },
+ Template: `{
+ "Description": "Simple template to test heat commands",
+ "Parameters": {
+ "flavor": {
+ "Default": "m1.tiny",
+ "Type": "String",
+ "NoEcho": "false",
+ "Description": "",
+ "Label": "flavor"
+ }
+ }
+ }`,
}
actual, err := Validate(fake.ServiceClient(), opts).Extract()
th.AssertNoErr(t, err)
diff --git a/util.go b/util.go
index fbd9fe9..3d6a4e4 100644
--- a/util.go
+++ b/util.go
@@ -2,6 +2,8 @@
import (
"errors"
+ "net/url"
+ "path/filepath"
"strings"
"time"
)
@@ -42,3 +44,39 @@
}
return url
}
+
+// NormalizePathURL is used to convert rawPath to a fqdn, using basePath as
+// a reference in the filesystem, if necessary. basePath is assumed to contain
+// either '.' when first used, or the file:// type fqdn of the parent resource.
+// e.g. myFavScript.yaml => file://opt/lib/myFavScript.yaml
+func NormalizePathURL(basePath, rawPath string) (string, error) {
+ u, err := url.Parse(rawPath)
+ if err != nil {
+ return "", err
+ }
+ // if a scheme is defined, it must be a fqdn already
+ if u.Scheme != "" {
+ return u.String(), nil
+ }
+ // if basePath is a url, then child resources are assumed to be relative to it
+ bu, err := url.Parse(basePath)
+ if err != nil {
+ return "", err
+ }
+ var basePathSys, absPathSys string
+ if bu.Scheme != "" {
+ basePathSys = filepath.FromSlash(bu.Path)
+ absPathSys = filepath.Join(basePathSys, rawPath)
+ bu.Path = filepath.ToSlash(absPathSys)
+ return bu.String(), nil
+ }
+
+ absPathSys = filepath.Join(basePath, rawPath)
+ u.Path = filepath.ToSlash(absPathSys)
+ if err != nil {
+ return "", err
+ }
+ u.Scheme = "file"
+ return u.String(), nil
+
+}
diff --git a/util_test.go b/util_test.go
index 5a15a00..dcec77f 100644
--- a/util_test.go
+++ b/util_test.go
@@ -1,6 +1,9 @@
package gophercloud
import (
+ "os"
+ "path/filepath"
+ "strings"
"testing"
th "github.com/rackspace/gophercloud/testhelper"
@@ -12,3 +15,71 @@
})
th.CheckNoErr(t, err)
}
+
+func TestNormalizeURL(t *testing.T) {
+ urls := []string{
+ "NoSlashAtEnd",
+ "SlashAtEnd/",
+ }
+ expected := []string{
+ "NoSlashAtEnd/",
+ "SlashAtEnd/",
+ }
+ for i := 0; i < len(expected); i++ {
+ th.CheckEquals(t, expected[i], NormalizeURL(urls[i]))
+ }
+
+}
+
+func TestNormalizePathURL(t *testing.T) {
+ baseDir, _ := os.Getwd()
+
+ rawPath := "template.yaml"
+ basePath, _ := filepath.Abs(".")
+ result, _ := NormalizePathURL(basePath, rawPath)
+ expected := strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "template.yaml"}, "/")
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "http://www.google.com"
+ basePath, _ = filepath.Abs(".")
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = "http://www.google.com"
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml"
+ basePath, _ = filepath.Abs(".")
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "very/nested/file.yaml"}, "/")
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml"
+ basePath = "http://www.google.com"
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = "http://www.google.com/very/nested/file.yaml"
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml/"
+ basePath = "http://www.google.com/"
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = "http://www.google.com/very/nested/file.yaml"
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml"
+ basePath = "http://www.google.com/even/more"
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = "http://www.google.com/even/more/very/nested/file.yaml"
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml"
+ basePath = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more"}, "/")
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more/very/nested/file.yaml"}, "/")
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml/"
+ basePath = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more"}, "/")
+ result, _ = NormalizePathURL(basePath, rawPath)
+ expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more/very/nested/file.yaml"}, "/")
+ th.CheckEquals(t, expected, result)
+
+}