Merge pull request #327 from jamiehannaford/sec-grp
[wip] Security Groups
diff --git a/acceptance/openstack/compute/v2/compute_test.go b/acceptance/openstack/compute/v2/compute_test.go
index 46eb9ff..33e49fe 100644
--- a/acceptance/openstack/compute/v2/compute_test.go
+++ b/acceptance/openstack/compute/v2/compute_test.go
@@ -1,4 +1,4 @@
-// +build acceptance
+// +build acceptance common
package v2
diff --git a/acceptance/openstack/compute/v2/secdefrules_test.go b/acceptance/openstack/compute/v2/secdefrules_test.go
new file mode 100644
index 0000000..78b0798
--- /dev/null
+++ b/acceptance/openstack/compute/v2/secdefrules_test.go
@@ -0,0 +1,72 @@
+// +build acceptance compute defsecrules
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ dsr "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/defsecrules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSecDefRules(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ id := createDefRule(t, client)
+
+ listDefRules(t, client)
+
+ getDefRule(t, client, id)
+
+ deleteDefRule(t, client, id)
+}
+
+func createDefRule(t *testing.T, client *gophercloud.ServiceClient) string {
+ opts := dsr.CreateOpts{
+ FromPort: tools.RandomInt(80, 89),
+ ToPort: tools.RandomInt(90, 99),
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := dsr.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created default rule %s", rule.ID)
+
+ return rule.ID
+}
+
+func listDefRules(t *testing.T, client *gophercloud.ServiceClient) {
+ err := dsr.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ drList, err := dsr.ExtractDefaultRules(page)
+ th.AssertNoErr(t, err)
+
+ for _, dr := range drList {
+ t.Logf("Listing default rule %s: Name [%s] From Port [%s] To Port [%s] Protocol [%s]",
+ dr.ID, dr.FromPort, dr.ToPort, dr.IPProtocol)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getDefRule(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ rule, err := dsr.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting rule %s: %#v", id, rule)
+}
+
+func deleteDefRule(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ err := dsr.Delete(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted rule %s", id)
+}
diff --git a/acceptance/openstack/compute/v2/secgroup_test.go b/acceptance/openstack/compute/v2/secgroup_test.go
new file mode 100644
index 0000000..4f50739
--- /dev/null
+++ b/acceptance/openstack/compute/v2/secgroup_test.go
@@ -0,0 +1,177 @@
+// +build acceptance compute secgroups
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSecGroups(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ serverID, needsDeletion := findServer(t, client)
+
+ groupID := createSecGroup(t, client)
+
+ listSecGroups(t, client)
+
+ newName := tools.RandomString("secgroup_", 5)
+ updateSecGroup(t, client, groupID, newName)
+
+ getSecGroup(t, client, groupID)
+
+ addRemoveRules(t, client, groupID)
+
+ addServerToSecGroup(t, client, serverID, newName)
+
+ removeServerFromSecGroup(t, client, serverID, newName)
+
+ if needsDeletion {
+ servers.Delete(client, serverID)
+ }
+
+ deleteSecGroup(t, client, groupID)
+}
+
+func createSecGroup(t *testing.T, client *gophercloud.ServiceClient) string {
+ opts := secgroups.CreateOpts{
+ Name: tools.RandomString("secgroup_", 5),
+ Description: "something",
+ }
+
+ group, err := secgroups.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created secgroup %s %s", group.ID, group.Name)
+
+ return group.ID
+}
+
+func listSecGroups(t *testing.T, client *gophercloud.ServiceClient) {
+ err := secgroups.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ secGrpList, err := secgroups.ExtractSecurityGroups(page)
+ th.AssertNoErr(t, err)
+
+ for _, sg := range secGrpList {
+ t.Logf("Listing secgroup %s: Name [%s] Desc [%s] TenantID [%s]", sg.ID,
+ sg.Name, sg.Description, sg.TenantID)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updateSecGroup(t *testing.T, client *gophercloud.ServiceClient, id, newName string) {
+ opts := secgroups.UpdateOpts{
+ Name: newName,
+ Description: tools.RandomString("dec_", 10),
+ }
+ group, err := secgroups.Update(client, id, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updated %s's name to %s", group.ID, group.Name)
+}
+
+func getSecGroup(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ group, err := secgroups.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting %s: %#v", id, group)
+}
+
+func addRemoveRules(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ opts := secgroups.CreateRuleOpts{
+ ParentGroupID: id,
+ FromPort: 22,
+ ToPort: 22,
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := secgroups.CreateRule(client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Adding rule %s to group %s", rule.ID, id)
+
+ err = secgroups.DeleteRule(client, rule.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted rule %s from group %s", rule.ID, id)
+}
+
+func findServer(t *testing.T, client *gophercloud.ServiceClient) (string, bool) {
+ var serverID string
+ var needsDeletion bool
+
+ err := servers.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ sList, err := servers.ExtractServers(page)
+ th.AssertNoErr(t, err)
+
+ for _, s := range sList {
+ serverID = s.ID
+ needsDeletion = false
+
+ t.Logf("Found an existing server: ID [%s]", serverID)
+ break
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ if serverID == "" {
+ t.Log("No server found, creating one")
+
+ choices, err := ComputeChoicesFromEnv()
+ th.AssertNoErr(t, err)
+
+ opts := &servers.CreateOpts{
+ Name: tools.RandomString("secgroup_test_", 5),
+ ImageRef: choices.ImageID,
+ FlavorRef: choices.FlavorID,
+ }
+
+ s, err := servers.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ serverID = s.ID
+
+ t.Logf("Created server %s, waiting for it to build", s.ID)
+ err = servers.WaitForStatus(client, serverID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+
+ needsDeletion = true
+ }
+
+ return serverID, needsDeletion
+}
+
+func addServerToSecGroup(t *testing.T, client *gophercloud.ServiceClient, serverID, groupName string) {
+ err := secgroups.AddServerToGroup(client, serverID, groupName).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Adding group %s to server %s", groupName, serverID)
+}
+
+func removeServerFromSecGroup(t *testing.T, client *gophercloud.ServiceClient, serverID, groupName string) {
+ err := secgroups.RemoveServerFromGroup(client, serverID, groupName).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Removing group %s from server %s", groupName, serverID)
+}
+
+func deleteSecGroup(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ err := secgroups.Delete(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted group %s", id)
+}
diff --git a/acceptance/tools/tools.go b/acceptance/tools/tools.go
index 61b1d7a..35679b7 100644
--- a/acceptance/tools/tools.go
+++ b/acceptance/tools/tools.go
@@ -1,10 +1,11 @@
-// +build acceptance
+// +build acceptance common
package tools
import (
"crypto/rand"
"errors"
+ mrand "math/rand"
"os"
"time"
@@ -72,6 +73,12 @@
return prefix + string(bytes)
}
+// RandomInt will return a random integer between a specified range.
+func RandomInt(min, max int) int {
+ mrand.Seed(time.Now().Unix())
+ return mrand.Intn(max-min) + min
+}
+
// Elide returns the first bit of its input string with a suffix of "..." if it's longer than
// a comfortable 40 characters.
func Elide(value string) string {
diff --git a/openstack/compute/v2/extensions/defsecrules/doc.go b/openstack/compute/v2/extensions/defsecrules/doc.go
new file mode 100644
index 0000000..2571a1a
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/doc.go
@@ -0,0 +1 @@
+package defsecrules
diff --git a/openstack/compute/v2/extensions/defsecrules/fixtures.go b/openstack/compute/v2/extensions/defsecrules/fixtures.go
new file mode 100644
index 0000000..c28e492
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/fixtures.go
@@ -0,0 +1,108 @@
+package defsecrules
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const rootPath = "/os-security-group-default-rules"
+
+func mockListRulesResponse(t *testing.T) {
+ th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group_default_rules": [
+ {
+ "from_port": 80,
+ "id": "{ruleID}",
+ "ip_protocol": "TCP",
+ "ip_range": {
+ "cidr": "10.10.10.0/24"
+ },
+ "to_port": 80
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateRuleResponse(t *testing.T) {
+ th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "security_group_default_rule": {
+ "ip_protocol": "TCP",
+ "from_port": 80,
+ "to_port": 80,
+ "cidr": "10.10.12.0/24"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group_default_rule": {
+ "from_port": 80,
+ "id": "{ruleID}",
+ "ip_protocol": "TCP",
+ "ip_range": {
+ "cidr": "10.10.12.0/24"
+ },
+ "to_port": 80
+ }
+}
+`)
+ })
+}
+
+func mockGetRuleResponse(t *testing.T, ruleID string) {
+ url := rootPath + "/" + ruleID
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group_default_rule": {
+ "id": "{ruleID}",
+ "from_port": 80,
+ "to_port": 80,
+ "ip_protocol": "TCP",
+ "ip_range": {
+ "cidr": "10.10.12.0/24"
+ }
+ }
+}
+ `)
+ })
+}
+
+func mockDeleteRuleResponse(t *testing.T, ruleID string) {
+ url := rootPath + "/" + ruleID
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/requests.go b/openstack/compute/v2/extensions/defsecrules/requests.go
new file mode 100644
index 0000000..7d19741
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests.go
@@ -0,0 +1,111 @@
+package defsecrules
+
+import (
+ "errors"
+
+ "github.com/racker/perigee"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will return a collection of default rules.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return DefaultRulePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, rootURL(client), createPage)
+}
+
+// CreateOpts represents the configuration for adding a new default rule.
+type CreateOpts struct {
+ // Required - the lower bound of the port range that will be opened.
+ FromPort int `json:"from_port"`
+
+ // Required - the upper bound of the port range that will be opened.
+ ToPort int `json:"to_port"`
+
+ // Required - the protocol type that will be allowed, e.g. TCP.
+ IPProtocol string `json:"ip_protocol"`
+
+ // ONLY required if FromGroupID is blank. This represents the IP range that
+ // will be the source of network traffic to your security group. Use
+ // 0.0.0.0/0 to allow all IP addresses.
+ CIDR string `json:"cidr,omitempty"`
+}
+
+// CreateOptsBuilder builds the create rule options into a serializable format.
+type CreateOptsBuilder interface {
+ ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// ToRuleCreateMap builds the create rule options into a serializable format.
+func (opts CreateOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+ rule := make(map[string]interface{})
+
+ if opts.FromPort == 0 {
+ return rule, errors.New("A FromPort must be set")
+ }
+ if opts.ToPort == 0 {
+ return rule, errors.New("A ToPort must be set")
+ }
+ if opts.IPProtocol == "" {
+ return rule, errors.New("A IPProtocol must be set")
+ }
+ if opts.CIDR == "" {
+ return rule, errors.New("A CIDR must be set")
+ }
+
+ rule["from_port"] = opts.FromPort
+ rule["to_port"] = opts.ToPort
+ rule["ip_protocol"] = opts.IPProtocol
+ rule["cidr"] = opts.CIDR
+
+ return map[string]interface{}{"security_group_default_rule": rule}, nil
+}
+
+// Create is the operation responsible for creating a new default rule.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var result CreateResult
+
+ reqBody, err := opts.ToRuleCreateMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = perigee.Request("POST", rootURL(client), perigee.Options{
+ Results: &result.Body,
+ ReqBody: &reqBody,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Get will return details for a particular default rule.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+
+ _, result.Err = perigee.Request("GET", resourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Delete will permanently delete a default rule from the project.
+func Delete(client *gophercloud.ServiceClient, id string) gophercloud.ErrResult {
+ var result gophercloud.ErrResult
+
+ _, result.Err = perigee.Request("DELETE", resourceURL(client, id), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+
+ return result
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/requests_test.go b/openstack/compute/v2/extensions/defsecrules/requests_test.go
new file mode 100644
index 0000000..d4ebe87
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests_test.go
@@ -0,0 +1,100 @@
+package defsecrules
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const ruleID = "{ruleID}"
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListRulesResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractDefaultRules(page)
+ th.AssertNoErr(t, err)
+
+ expected := []DefaultRule{
+ DefaultRule{
+ FromPort: 80,
+ ID: ruleID,
+ IPProtocol: "TCP",
+ IPRange: secgroups.IPRange{CIDR: "10.10.10.0/24"},
+ ToPort: 80,
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateRuleResponse(t)
+
+ opts := CreateOpts{
+ IPProtocol: "TCP",
+ FromPort: 80,
+ ToPort: 80,
+ CIDR: "10.10.12.0/24",
+ }
+
+ group, err := Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &DefaultRule{
+ ID: ruleID,
+ FromPort: 80,
+ ToPort: 80,
+ IPProtocol: "TCP",
+ IPRange: secgroups.IPRange{CIDR: "10.10.12.0/24"},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetRuleResponse(t, ruleID)
+
+ group, err := Get(client.ServiceClient(), ruleID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &DefaultRule{
+ ID: ruleID,
+ FromPort: 80,
+ ToPort: 80,
+ IPProtocol: "TCP",
+ IPRange: secgroups.IPRange{CIDR: "10.10.12.0/24"},
+ }
+
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteRuleResponse(t, ruleID)
+
+ err := Delete(client.ServiceClient(), ruleID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/results.go b/openstack/compute/v2/extensions/defsecrules/results.go
new file mode 100644
index 0000000..e588d3e
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/results.go
@@ -0,0 +1,69 @@
+package defsecrules
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// DefaultRule represents a default rule - which is identical to a
+// normal security rule.
+type DefaultRule secgroups.Rule
+
+// DefaultRulePage is a single page of a DefaultRule collection.
+type DefaultRulePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of default rules contains any results.
+func (page DefaultRulePage) IsEmpty() (bool, error) {
+ users, err := ExtractDefaultRules(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractDefaultRules returns a slice of DefaultRules contained in a single
+// page of results.
+func ExtractDefaultRules(page pagination.Page) ([]DefaultRule, error) {
+ casted := page.(DefaultRulePage).Body
+ var response struct {
+ Rules []DefaultRule `mapstructure:"security_group_default_rules"`
+ }
+
+ err := mapstructure.WeakDecode(casted, &response)
+
+ return response.Rules, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// Extract will extract a DefaultRule struct from most responses.
+func (r commonResult) Extract() (*DefaultRule, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Rule DefaultRule `mapstructure:"security_group_default_rule"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &response)
+
+ return &response.Rule, err
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/urls.go b/openstack/compute/v2/extensions/defsecrules/urls.go
new file mode 100644
index 0000000..cc928ab
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/urls.go
@@ -0,0 +1,13 @@
+package defsecrules
+
+import "github.com/rackspace/gophercloud"
+
+const rulepath = "os-security-group-default-rules"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rulepath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rulepath)
+}
diff --git a/openstack/compute/v2/extensions/secgroups/doc.go b/openstack/compute/v2/extensions/secgroups/doc.go
new file mode 100644
index 0000000..702f32c
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/doc.go
@@ -0,0 +1 @@
+package secgroups
diff --git a/openstack/compute/v2/extensions/secgroups/fixtures.go b/openstack/compute/v2/extensions/secgroups/fixtures.go
new file mode 100644
index 0000000..ca76f68
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/fixtures.go
@@ -0,0 +1,265 @@
+package secgroups
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const rootPath = "/os-security-groups"
+
+const listGroupsJSON = `
+{
+ "security_groups": [
+ {
+ "description": "default",
+ "id": "{groupID}",
+ "name": "default",
+ "rules": [],
+ "tenant_id": "openstack"
+ }
+ ]
+}
+`
+
+func mockListGroupsResponse(t *testing.T) {
+ th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, listGroupsJSON)
+ })
+}
+
+func mockListGroupsByServerResponse(t *testing.T, serverID string) {
+ url := fmt.Sprintf("%s/servers/%s%s", rootPath, serverID, rootPath)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, listGroupsJSON)
+ })
+}
+
+func mockCreateGroupResponse(t *testing.T) {
+ th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "security_group": {
+ "name": "test",
+ "description": "something"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group": {
+ "description": "something",
+ "id": "{groupID}",
+ "name": "test",
+ "rules": [],
+ "tenant_id": "openstack"
+ }
+}
+`)
+ })
+}
+
+func mockUpdateGroupResponse(t *testing.T, groupID string) {
+ url := fmt.Sprintf("%s/%s", rootPath, groupID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "security_group": {
+ "name": "new_name",
+ "description": "new_desc"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group": {
+ "description": "something",
+ "id": "{groupID}",
+ "name": "new_name",
+ "rules": [],
+ "tenant_id": "openstack"
+ }
+}
+`)
+ })
+}
+
+func mockGetGroupsResponse(t *testing.T, groupID string) {
+ url := fmt.Sprintf("%s/%s", rootPath, groupID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group": {
+ "description": "default",
+ "id": "{groupID}",
+ "name": "default",
+ "rules": [
+ {
+ "from_port": 80,
+ "group": {
+ "tenant_id": "openstack",
+ "name": "default"
+ },
+ "ip_protocol": "TCP",
+ "to_port": 85,
+ "parent_group_id": "{groupID}",
+ "ip_range": {
+ "cidr": "0.0.0.0"
+ },
+ "id": "{ruleID}"
+ }
+ ],
+ "tenant_id": "openstack"
+ }
+}
+ `)
+ })
+}
+
+func mockGetNumericIDGroupResponse(t *testing.T, groupID int) {
+ url := fmt.Sprintf("%s/%d", rootPath, groupID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group": {
+ "id": 12345
+ }
+}
+ `)
+ })
+}
+
+func mockDeleteGroupResponse(t *testing.T, groupID string) {
+ url := fmt.Sprintf("%s/%s", rootPath, groupID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockAddRuleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/os-security-group-rules", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "security_group_rule": {
+ "from_port": 22,
+ "ip_protocol": "TCP",
+ "to_port": 22,
+ "parent_group_id": "{groupID}",
+ "cidr": "0.0.0.0/0"
+ }
+} `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "security_group_rule": {
+ "from_port": 22,
+ "group": {},
+ "ip_protocol": "TCP",
+ "to_port": 22,
+ "parent_group_id": "{groupID}",
+ "ip_range": {
+ "cidr": "0.0.0.0/0"
+ },
+ "id": "{ruleID}"
+ }
+}`)
+ })
+}
+
+func mockDeleteRuleResponse(t *testing.T, ruleID string) {
+ url := fmt.Sprintf("/os-security-group-rules/%s", ruleID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockAddServerToGroupResponse(t *testing.T, serverID string) {
+ url := fmt.Sprintf("/servers/%s/action", serverID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "addSecurityGroup": {
+ "name": "test"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockRemoveServerFromGroupResponse(t *testing.T, serverID string) {
+ url := fmt.Sprintf("/servers/%s/action", serverID)
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "removeSecurityGroup": {
+ "name": "test"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/secgroups/requests.go b/openstack/compute/v2/extensions/secgroups/requests.go
new file mode 100644
index 0000000..09503d7
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests.go
@@ -0,0 +1,298 @@
+package secgroups
+
+import (
+ "errors"
+
+ "github.com/racker/perigee"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func commonList(client *gophercloud.ServiceClient, url string) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return SecurityGroupPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
+
+// List will return a collection of all the security groups for a particular
+// tenant.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return commonList(client, rootURL(client))
+}
+
+// ListByServer will return a collection of all the security groups which are
+// associated with a particular server.
+func ListByServer(client *gophercloud.ServiceClient, serverID string) pagination.Pager {
+ return commonList(client, listByServerURL(client, serverID))
+}
+
+// GroupOpts is the underlying struct responsible for creating or updating
+// security groups. It therefore represents the mutable attributes of a
+// security group.
+type GroupOpts struct {
+ // Required - the name of your security group.
+ Name string `json:"name"`
+
+ // Required - the description of your security group.
+ Description string `json:"description"`
+}
+
+// CreateOpts is the struct responsible for creating a security group.
+type CreateOpts GroupOpts
+
+// CreateOptsBuilder builds the create options into a serializable format.
+type CreateOptsBuilder interface {
+ ToSecGroupCreateMap() (map[string]interface{}, error)
+}
+
+var (
+ errName = errors.New("Name is a required field")
+ errDesc = errors.New("Description is a required field")
+)
+
+// ToSecGroupCreateMap builds the create options into a serializable format.
+func (opts CreateOpts) ToSecGroupCreateMap() (map[string]interface{}, error) {
+ sg := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return sg, errName
+ }
+ if opts.Description == "" {
+ return sg, errDesc
+ }
+
+ sg["name"] = opts.Name
+ sg["description"] = opts.Description
+
+ return map[string]interface{}{"security_group": sg}, nil
+}
+
+// Create will create a new security group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var result CreateResult
+
+ reqBody, err := opts.ToSecGroupCreateMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = perigee.Request("POST", rootURL(client), perigee.Options{
+ Results: &result.Body,
+ ReqBody: &reqBody,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// UpdateOpts is the struct responsible for updating an existing security group.
+type UpdateOpts GroupOpts
+
+// UpdateOptsBuilder builds the update options into a serializable format.
+type UpdateOptsBuilder interface {
+ ToSecGroupUpdateMap() (map[string]interface{}, error)
+}
+
+// ToSecGroupUpdateMap builds the update options into a serializable format.
+func (opts UpdateOpts) ToSecGroupUpdateMap() (map[string]interface{}, error) {
+ sg := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return sg, errName
+ }
+ if opts.Description == "" {
+ return sg, errDesc
+ }
+
+ sg["name"] = opts.Name
+ sg["description"] = opts.Description
+
+ return map[string]interface{}{"security_group": sg}, nil
+}
+
+// Update will modify the mutable properties of a security group, notably its
+// name and description.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+
+ reqBody, err := opts.ToSecGroupUpdateMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = perigee.Request("PUT", resourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ ReqBody: &reqBody,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Get will return details for a particular security group.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+
+ _, result.Err = perigee.Request("GET", resourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Delete will permanently delete a security group from the project.
+func Delete(client *gophercloud.ServiceClient, id string) gophercloud.ErrResult {
+ var result gophercloud.ErrResult
+
+ _, result.Err = perigee.Request("DELETE", resourceURL(client, id), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{202},
+ })
+
+ return result
+}
+
+// CreateRuleOpts represents the configuration for adding a new rule to an
+// existing security group.
+type CreateRuleOpts struct {
+ // Required - the ID of the group that this rule will be added to.
+ ParentGroupID string `json:"parent_group_id"`
+
+ // Required - the lower bound of the port range that will be opened.
+ FromPort int `json:"from_port"`
+
+ // Required - the upper bound of the port range that will be opened.
+ ToPort int `json:"to_port"`
+
+ // Required - the protocol type that will be allowed, e.g. TCP.
+ IPProtocol string `json:"ip_protocol"`
+
+ // ONLY required if FromGroupID is blank. This represents the IP range that
+ // will be the source of network traffic to your security group. Use
+ // 0.0.0.0/0 to allow all IP addresses.
+ CIDR string `json:"cidr,omitempty"`
+
+ // ONLY required if CIDR is blank. This value represents the ID of a group
+ // that forwards traffic to the parent group. So, instead of accepting
+ // network traffic from an entire IP range, you can instead refine the
+ // inbound source by an existing security group.
+ FromGroupID string `json:"group_id,omitempty"`
+}
+
+// CreateRuleOptsBuilder builds the create rule options into a serializable format.
+type CreateRuleOptsBuilder interface {
+ ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// ToRuleCreateMap builds the create rule options into a serializable format.
+func (opts CreateRuleOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+ rule := make(map[string]interface{})
+
+ if opts.ParentGroupID == "" {
+ return rule, errors.New("A ParentGroupID must be set")
+ }
+ if opts.FromPort == 0 {
+ return rule, errors.New("A FromPort must be set")
+ }
+ if opts.ToPort == 0 {
+ return rule, errors.New("A ToPort must be set")
+ }
+ if opts.IPProtocol == "" {
+ return rule, errors.New("A IPProtocol must be set")
+ }
+ if opts.CIDR == "" && opts.FromGroupID == "" {
+ return rule, errors.New("A CIDR or FromGroupID must be set")
+ }
+
+ rule["parent_group_id"] = opts.ParentGroupID
+ rule["from_port"] = opts.FromPort
+ rule["to_port"] = opts.ToPort
+ rule["ip_protocol"] = opts.IPProtocol
+
+ if opts.CIDR != "" {
+ rule["cidr"] = opts.CIDR
+ }
+ if opts.FromGroupID != "" {
+ rule["from_group_id"] = opts.FromGroupID
+ }
+
+ return map[string]interface{}{"security_group_rule": rule}, nil
+}
+
+// CreateRule will add a new rule to an existing security group (whose ID is
+// specified in CreateRuleOpts). You have the option of controlling inbound
+// traffic from either an IP range (CIDR) or from another security group.
+func CreateRule(client *gophercloud.ServiceClient, opts CreateRuleOptsBuilder) CreateRuleResult {
+ var result CreateRuleResult
+
+ reqBody, err := opts.ToRuleCreateMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = perigee.Request("POST", rootRuleURL(client), perigee.Options{
+ Results: &result.Body,
+ ReqBody: &reqBody,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// DeleteRule will permanently delete a rule from a security group.
+func DeleteRule(client *gophercloud.ServiceClient, id string) gophercloud.ErrResult {
+ var result gophercloud.ErrResult
+
+ _, result.Err = perigee.Request("DELETE", resourceRuleURL(client, id), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{202},
+ })
+
+ return result
+}
+
+func actionMap(prefix, groupName string) map[string]map[string]string {
+ return map[string]map[string]string{
+ prefix + "SecurityGroup": map[string]string{"name": groupName},
+ }
+}
+
+// AddServerToGroup will associate a server and a security group, enforcing the
+// rules of the group on the server.
+func AddServerToGroup(client *gophercloud.ServiceClient, serverID, groupName string) gophercloud.ErrResult {
+ var result gophercloud.ErrResult
+
+ _, result.Err = perigee.Request("POST", serverActionURL(client, serverID), perigee.Options{
+ Results: &result.Body,
+ ReqBody: actionMap("add", groupName),
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{202},
+ })
+
+ return result
+}
+
+// RemoveServerFromGroup will disassociate a server from a security group.
+func RemoveServerFromGroup(client *gophercloud.ServiceClient, serverID, groupName string) gophercloud.ErrResult {
+ var result gophercloud.ErrResult
+
+ _, result.Err = perigee.Request("POST", serverActionURL(client, serverID), perigee.Options{
+ Results: &result.Body,
+ ReqBody: actionMap("remove", groupName),
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{202},
+ })
+
+ return result
+}
diff --git a/openstack/compute/v2/extensions/secgroups/requests_test.go b/openstack/compute/v2/extensions/secgroups/requests_test.go
new file mode 100644
index 0000000..4e21d5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests_test.go
@@ -0,0 +1,248 @@
+package secgroups
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ serverID = "{serverID}"
+ groupID = "{groupID}"
+ ruleID = "{ruleID}"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListGroupsResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractSecurityGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []SecurityGroup{
+ SecurityGroup{
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ Rules: []Rule{},
+ TenantID: "openstack",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestListByServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListGroupsByServerResponse(t, serverID)
+
+ count := 0
+
+ err := ListByServer(client.ServiceClient(), serverID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractSecurityGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []SecurityGroup{
+ SecurityGroup{
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ Rules: []Rule{},
+ TenantID: "openstack",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateGroupResponse(t)
+
+ opts := CreateOpts{
+ Name: "test",
+ Description: "something",
+ }
+
+ group, err := Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SecurityGroup{
+ ID: groupID,
+ Name: "test",
+ Description: "something",
+ TenantID: "openstack",
+ Rules: []Rule{},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateGroupResponse(t, groupID)
+
+ opts := UpdateOpts{
+ Name: "new_name",
+ Description: "new_desc",
+ }
+
+ group, err := Update(client.ServiceClient(), groupID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SecurityGroup{
+ ID: groupID,
+ Name: "new_name",
+ Description: "something",
+ TenantID: "openstack",
+ Rules: []Rule{},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetGroupsResponse(t, groupID)
+
+ group, err := Get(client.ServiceClient(), groupID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SecurityGroup{
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ TenantID: "openstack",
+ Rules: []Rule{
+ Rule{
+ FromPort: 80,
+ ToPort: 85,
+ IPProtocol: "TCP",
+ IPRange: IPRange{CIDR: "0.0.0.0"},
+ Group: Group{TenantID: "openstack", Name: "default"},
+ ParentGroupID: groupID,
+ ID: ruleID,
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGetNumericID(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ numericGroupID := 12345
+
+ mockGetNumericIDGroupResponse(t, numericGroupID)
+
+ group, err := Get(client.ServiceClient(), "12345").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SecurityGroup{ID: "12345"}
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteGroupResponse(t, groupID)
+
+ err := Delete(client.ServiceClient(), groupID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAddRule(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddRuleResponse(t)
+
+ opts := CreateRuleOpts{
+ ParentGroupID: groupID,
+ FromPort: 22,
+ ToPort: 22,
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := CreateRule(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Rule{
+ FromPort: 22,
+ ToPort: 22,
+ Group: Group{},
+ IPProtocol: "TCP",
+ ParentGroupID: groupID,
+ IPRange: IPRange{CIDR: "0.0.0.0/0"},
+ ID: ruleID,
+ }
+
+ th.AssertDeepEquals(t, expected, rule)
+}
+
+func TestDeleteRule(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteRuleResponse(t, ruleID)
+
+ err := DeleteRule(client.ServiceClient(), ruleID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAddServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddServerToGroupResponse(t, serverID)
+
+ err := AddServerToGroup(client.ServiceClient(), serverID, "test").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestRemoveServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockRemoveServerFromGroupResponse(t, serverID)
+
+ err := RemoveServerFromGroup(client.ServiceClient(), serverID, "test").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/secgroups/results.go b/openstack/compute/v2/extensions/secgroups/results.go
new file mode 100644
index 0000000..478c5dc
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/results.go
@@ -0,0 +1,147 @@
+package secgroups
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// SecurityGroup represents a security group.
+type SecurityGroup struct {
+ // The unique ID of the group. If Neutron is installed, this ID will be
+ // represented as a string UUID; if Neutron is not installed, it will be a
+ // numeric ID. For the sake of consistency, we always cast it to a string.
+ ID string
+
+ // The human-readable name of the group, which needs to be unique.
+ Name string
+
+ // The human-readable description of the group.
+ Description string
+
+ // The rules which determine how this security group operates.
+ Rules []Rule
+
+ // The ID of the tenant to which this security group belongs.
+ TenantID string `mapstructure:"tenant_id"`
+}
+
+// Rule represents a security group rule, a policy which determines how a
+// security group operates and what inbound traffic it allows in.
+type Rule struct {
+ // The unique ID. If Neutron is installed, this ID will be
+ // represented as a string UUID; if Neutron is not installed, it will be a
+ // numeric ID. For the sake of consistency, we always cast it to a string.
+ ID string
+
+ // The lower bound of the port range which this security group should open up
+ FromPort int `mapstructure:"from_port"`
+
+ // The upper bound of the port range which this security group should open up
+ ToPort int `mapstructure:"to_port"`
+
+ // The IP protocol (e.g. TCP) which the security group accepts
+ IPProtocol string `mapstructure:"ip_protocol"`
+
+ // The CIDR IP range whose traffic can be received
+ IPRange IPRange `mapstructure:"ip_range"`
+
+ // The security group ID to which this rule belongs
+ ParentGroupID string `mapstructure:"parent_group_id"`
+
+ // Not documented.
+ Group Group
+}
+
+// IPRange represents the IP range whose traffic will be accepted by the
+// security group.
+type IPRange struct {
+ CIDR string
+}
+
+// Group represents a group.
+type Group struct {
+ TenantID string `mapstructure:"tenant_id"`
+ Name string
+}
+
+// SecurityGroupPage is a single page of a SecurityGroup collection.
+type SecurityGroupPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Security Groups contains any results.
+func (page SecurityGroupPage) IsEmpty() (bool, error) {
+ users, err := ExtractSecurityGroups(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractSecurityGroups returns a slice of SecurityGroups contained in a single page of results.
+func ExtractSecurityGroups(page pagination.Page) ([]SecurityGroup, error) {
+ casted := page.(SecurityGroupPage).Body
+ var response struct {
+ SecurityGroups []SecurityGroup `mapstructure:"security_groups"`
+ }
+
+ err := mapstructure.WeakDecode(casted, &response)
+
+ return response.SecurityGroups, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ commonResult
+}
+
+// Extract will extract a SecurityGroup struct from most responses.
+func (r commonResult) Extract() (*SecurityGroup, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ SecurityGroup SecurityGroup `mapstructure:"security_group"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &response)
+
+ return &response.SecurityGroup, err
+}
+
+// CreateRuleResult represents the result when adding rules to a security group.
+type CreateRuleResult struct {
+ gophercloud.Result
+}
+
+// Extract will extract a Rule struct from a CreateRuleResult.
+func (r CreateRuleResult) Extract() (*Rule, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Rule Rule `mapstructure:"security_group_rule"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &response)
+
+ return &response.Rule, err
+}
diff --git a/openstack/compute/v2/extensions/secgroups/urls.go b/openstack/compute/v2/extensions/secgroups/urls.go
new file mode 100644
index 0000000..f4760b6
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/urls.go
@@ -0,0 +1,32 @@
+package secgroups
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ secgrouppath = "os-security-groups"
+ rulepath = "os-security-group-rules"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(secgrouppath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(secgrouppath)
+}
+
+func listByServerURL(c *gophercloud.ServiceClient, serverID string) string {
+ return c.ServiceURL(secgrouppath, "servers", serverID, secgrouppath)
+}
+
+func rootRuleURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rulepath)
+}
+
+func resourceRuleURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rulepath, id)
+}
+
+func serverActionURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("servers", id, "action")
+}