rackspace/gophercloud repo
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..325b90a
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,16 @@
+language: go
+install:
+ - go get -v -tags 'fixtures acceptance' ./...
+go:
+ - 1.2
+ - 1.3
+ - 1.4
+ - 1.5
+script: script/cibuild
+after_success:
+ - go get golang.org/x/tools/cmd/cover
+ - go get github.com/axw/gocov/gocov
+ - go get github.com/mattn/goveralls
+ - export PATH=$PATH:$HOME/gopath/bin/
+ - goveralls 2k7PTU3xa474Hymwgdj6XjqenNfGTNkO8
+sudo: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6ba5beb
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,274 @@
+# Contributing to gophercloud
+
+- [Getting started](#getting-started)
+- [Tests](#tests)
+- [Style guide](#basic-style-guide)
+- [5 ways to get involved](#5-ways-to-get-involved)
+
+## Setting up your git workspace
+
+As a contributor you will need to setup your workspace in a slightly different
+way than just downloading it. Here are the basic installation instructions:
+
+1. Configure your `$GOPATH` and run `go get` as described in the main
+[README](/README.md#how-to-install).
+
+2. Move into the directory that houses your local repository:
+
+ ```bash
+ cd ${GOPATH}/src/github.com/rackspace/gophercloud
+ ```
+
+3. Fork the `rackspace/gophercloud` repository and update your remote refs. You
+will need to rename the `origin` remote branch to `upstream`, and add your
+fork as `origin` instead:
+
+ ```bash
+ git remote rename origin upstream
+ git remote add origin git@github.com/<my_username>/gophercloud
+ ```
+
+4. Checkout the latest development branch:
+
+ ```bash
+ git checkout master
+ ```
+
+5. If you're working on something (discussed more in detail below), you will
+need to checkout a new feature branch:
+
+ ```bash
+ git checkout -b my-new-feature
+ ```
+
+Another thing to bear in mind is that you will need to add a few extra
+environment variables for acceptance tests - this is documented in our
+[acceptance tests readme](/acceptance).
+
+## Tests
+
+When working on a new or existing feature, testing will be the backbone of your
+work since it helps uncover and prevent regressions in the codebase. There are
+two types of test we use in gophercloud: unit tests and acceptance tests, which
+are both described below.
+
+### Unit tests
+
+Unit tests are the fine-grained tests that establish and ensure the behaviour
+of individual units of functionality. We usually test on an
+operation-by-operation basis (an operation typically being an API action) with
+the use of mocking to set up explicit expectations. Each operation will set up
+its HTTP response expectation, and then test how the system responds when fed
+this controlled, pre-determined input.
+
+To make life easier, we've introduced a bunch of test helpers to simplify the
+process of testing expectations with assertions:
+
+```go
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSomething(t *testing.T) {
+ result, err := Operation()
+
+ testhelper.AssertEquals(t, "foo", result.Bar)
+ testhelper.AssertNoErr(t, err)
+}
+
+func TestSomethingElse(t *testing.T) {
+ testhelper.CheckEquals(t, "expected", "actual")
+}
+```
+
+`AssertEquals` and `AssertNoErr` will throw a fatal error if a value does not
+match an expected value or if an error has been declared, respectively. You can
+also use `CheckEquals` and `CheckNoErr` for the same purpose; the only difference
+being that `t.Errorf` is raised rather than `t.Fatalf`.
+
+Here is a truncated example of mocked HTTP responses:
+
+```go
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGet(t *testing.T) {
+ // Setup the HTTP request multiplexer and server
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+ // Test we're using the correct HTTP method
+ th.TestMethod(t, r, "GET")
+
+ // Test we're setting the auth token
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ // Set the appropriate headers for our mocked response
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ // Set the HTTP body
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+
+ // Call our API operation
+ network, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+
+ // Assert no errors and equality
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Status, "ACTIVE")
+}
+```
+
+### Acceptance tests
+
+As we've already mentioned, unit tests have a very narrow and confined focus -
+they test small units of behaviour. Acceptance tests on the other hand have a
+far larger scope: they are fully functional tests that test the entire API of a
+service in one fell swoop. They don't care about unit isolation or mocking
+expectations, they instead do a full run-through and consequently test how the
+entire system _integrates_ together. When an API satisfies expectations, it
+proves by default that the requirements for a contract have been met.
+
+Please be aware that acceptance tests will hit a live API - and may incur
+service charges from your provider. Although most tests handle their own
+teardown procedures, it is always worth manually checking that resources are
+deleted after the test suite finishes.
+
+### Running tests
+
+To run all tests:
+
+```bash
+go test ./...
+```
+
+To run all tests with verbose output:
+
+```bash
+go test -v ./...
+```
+
+To run tests that match certain [build tags]():
+
+```bash
+go test -tags "foo bar" ./...
+```
+
+To run tests for a particular sub-package:
+
+```bash
+cd ./path/to/package && go test .
+```
+
+## Basic style guide
+
+We follow the standard formatting recommendations and language idioms set out
+in the [Effective Go](https://golang.org/doc/effective_go.html) guide. It's
+definitely worth reading - but the relevant sections are
+[formatting](https://golang.org/doc/effective_go.html#formatting)
+and [names](https://golang.org/doc/effective_go.html#names).
+
+## 5 ways to get involved
+
+There are five main ways you can get involved in our open-source project, and
+each is described briefly below. Once you've made up your mind and decided on
+your fix, you will need to follow the same basic steps that all submissions are
+required to adhere to:
+
+1. [fork](https://help.github.com/articles/fork-a-repo/) the `rackspace/gophercloud` repository
+2. checkout a [new branch](https://github.com/Kunena/Kunena-Forum/wiki/Create-a-new-branch-with-git-and-manage-branches)
+3. submit your branch as a [pull request](https://help.github.com/articles/creating-a-pull-request/)
+
+### 1. Providing feedback
+
+On of the easiest ways to get readily involved in our project is to let us know
+about your experiences using our SDK. Feedback like this is incredibly useful
+to us, because it allows us to refine and change features based on what our
+users want and expect of us. There are a bunch of ways to get in contact! You
+can [ping us](https://developer.rackspace.com/support/) via e-mail, talk to us on irc
+(#rackspace-dev on freenode), [tweet us](https://twitter.com/rackspace), or
+submit an issue on our [bug tracker](/issues). Things you might like to tell us
+are:
+
+* how easy was it to start using our SDK?
+* did it meet your expectations? If not, why not?
+* did our documentation help or hinder you?
+* what could we improve in general?
+
+### 2. Fixing bugs
+
+If you want to start fixing open bugs, we'd really appreciate that! Bug fixing
+is central to any project. The best way to get started is by heading to our
+[bug tracker](https://github.com/rackspace/gophercloud/issues) and finding open
+bugs that you think nobody is working on. It might be useful to comment on the
+thread to see the current state of the issue and if anybody has made any
+breakthroughs on it so far.
+
+### 3. Improving documentation
+
+We have three forms of documentation:
+
+* short README documents that briefly introduce a topic
+* reference documentation on [godoc.org](http://godoc.org) that is automatically
+generated from source code comments
+* user documentation on our [homepage](http://gophercloud.io) that includes
+getting started guides, installation guides and code samples
+
+If you feel that a certain section could be improved - whether it's to clarify
+ambiguity, correct a technical mistake, or to fix a grammatical error - please
+feel entitled to do so! We welcome doc pull requests with the same childlike
+enthusiasm as any other contribution!
+
+### 4. Optimizing existing features
+
+If you would like to improve or optimize an existing feature, please be aware
+that we adhere to [semantic versioning](http://semver.org) - which means that
+we cannot introduce breaking changes to the API without a major version change
+(v1.x -> v2.x). Making that leap is a big step, so we encourage contributors to
+refactor rather than rewrite. Running tests will prevent regression and avoid
+the possibility of breaking somebody's current implementation.
+
+Another tip is to keep the focus of your work as small as possible - try not to
+introduce a change that affects lots and lots of files because it introduces
+added risk and increases the cognitive load on the reviewers checking your
+work. Change-sets which are easily understood and will not negatively impact
+users are more likely to be integrated quickly.
+
+Lastly, if you're seeking to optimize a particular operation, you should try to
+demonstrate a negative performance impact - perhaps using go's inbuilt
+[benchmark capabilities](http://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go).
+
+### 5. Working on a new feature
+
+If you've found something we've left out, definitely feel free to start work on
+introducing that feature. It's always useful to open an issue or submit a pull
+request early on to indicate your intent to a core contributor - this enables
+quick/early feedback and can help steer you in the right direction by avoiding
+known issues. It might also help you avoid losing time implementing something
+that might not ever work. One tip is to prefix your Pull Request issue title
+with [wip] - then people know it's a work in progress.
+
+You must ensure that all of your work is well tested - both in terms of unit
+and acceptance tests. Untested code will not be merged because it introduces
+too much of a risk to end-users.
+
+Happy hacking!
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 0000000..63beb30
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,13 @@
+Contributors
+============
+
+| Name | Email |
+| ---- | ----- |
+| Samuel A. Falvo II | <sam.falvo@rackspace.com>
+| Glen Campbell | <glen.campbell@rackspace.com>
+| Jesse Noller | <jesse.noller@rackspace.com>
+| Jon Perritt | <jon.perritt@rackspace.com>
+| Ash Wilson | <ash.wilson@rackspace.com>
+| Jamie Hannaford | <jamie.hannaford@rackspace.com>
+| Don Schenck | don.schenck@rackspace.com>
+| Joe Topjian | <joe@topjian.net>
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fbbbc9e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,191 @@
+Copyright 2012-2013 Rackspace, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+
+------
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
index a3e0070..0a0da59 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,160 @@
-# Gophercloud
-A Go SDK for interacting with OpenStack
+# Gophercloud: an OpenStack SDK for Go
+[](https://travis-ci.org/rackspace/gophercloud)
+
+Gophercloud is a flexible SDK that allows you to consume and work with OpenStack
+clouds in a simple and idiomatic way using golang. Many services are supported,
+including Compute, Block Storage, Object Storage, Networking, and Identity.
+Each service API is backed with getting started guides, code samples, reference
+documentation, unit tests and acceptance tests.
+
+## Useful links
+
+* [Gophercloud homepage](http://gophercloud.io)
+* [Reference documentation](http://godoc.org/github.com/rackspace/gophercloud)
+* [Getting started guides](http://gophercloud.io/docs)
+* [Effective Go](https://golang.org/doc/effective_go.html)
+
+## How to install
+
+Before installing, you need to ensure that your [GOPATH environment variable](https://golang.org/doc/code.html#GOPATH)
+is pointing to an appropriate directory where you want to install Gophercloud:
+
+```bash
+mkdir $HOME/go
+export GOPATH=$HOME/go
+```
+
+To protect yourself against changes in your dependencies, we highly recommend choosing a
+[dependency management solution](https://github.com/golang/go/wiki/PackageManagementTools) for
+your projects, such as [godep](https://github.com/tools/godep). Once this is set up, you can install
+Gophercloud as a dependency like so:
+
+```bash
+go get github.com/rackspace/gophercloud
+
+# Edit your code to import relevant packages from "github.com/rackspace/gophercloud"
+
+godep save ./...
+```
+
+This will install all the source files you need into a `Godeps/_workspace` directory, which is
+referenceable from your own source files when you use the `godep go` command.
+
+## Getting started
+
+### Credentials
+
+Because you'll be hitting an API, you will need to retrieve your OpenStack
+credentials and either store them as environment variables or in your local Go
+files. The first method is recommended because it decouples credential
+information from source code, allowing you to push the latter to your version
+control system without any security risk.
+
+You will need to retrieve the following:
+
+* username
+* password
+* tenant name or tenant ID
+* a valid Keystone identity URL
+
+For users that have the OpenStack dashboard installed, there's a shortcut. If
+you visit the `project/access_and_security` path in Horizon and click on the
+"Download OpenStack RC File" button at the top right hand corner, you will
+download a bash file that exports all of your access details to environment
+variables. To execute the file, run `source admin-openrc.sh` and you will be
+prompted for your password.
+
+### Authentication
+
+Once you have access to your credentials, you can begin plugging them into
+Gophercloud. The next step is authentication, and this is handled by a base
+"Provider" struct. To get one, you can either pass in your credentials
+explicitly, or tell Gophercloud to use environment variables:
+
+```go
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+// Option 1: Pass in the values yourself
+opts := gophercloud.AuthOptions{
+ IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+ Username: "{username}",
+ Password: "{password}",
+ TenantID: "{tenant_id}",
+}
+
+// Option 2: Use a utility function to retrieve all your environment variables
+opts, err := openstack.AuthOptionsFromEnv()
+```
+
+Once you have the `opts` variable, you can pass it in and get back a
+`ProviderClient` struct:
+
+```go
+provider, err := openstack.AuthenticatedClient(opts)
+```
+
+The `ProviderClient` is the top-level client that all of your OpenStack services
+derive from. The provider contains all of the authentication details that allow
+your Go code to access the API - such as the base URL and token ID.
+
+### Provision a server
+
+Once we have a base Provider, we inject it as a dependency into each OpenStack
+service. In order to work with the Compute API, we need a Compute service
+client; which can be created like so:
+
+```go
+client, err := openstack.NewComputeV2(provider, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+})
+```
+
+We then use this `client` for any Compute API operation we want. In our case,
+we want to provision a new server - so we invoke the `Create` method and pass
+in the flavor ID (hardware specification) and image ID (operating system) we're
+interested in:
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+server, err := servers.Create(client, servers.CreateOpts{
+ Name: "My new server!",
+ FlavorRef: "flavor_id",
+ ImageRef: "image_id",
+}).Extract()
+```
+
+If you are unsure about what images and flavors are, you can read our [Compute
+Getting Started guide](http://gophercloud.io/docs/compute). The above code
+sample creates a new server with the parameters, and embodies the new resource
+in the `server` variable (a
+[`servers.Server`](http://godoc.org/github.com/rackspace/gophercloud) struct).
+
+### Next steps
+
+Cool! You've handled authentication, got your `ProviderClient` and provisioned
+a new server. You're now ready to use more OpenStack services.
+
+* [Getting started with Compute](http://gophercloud.io/docs/compute)
+* [Getting started with Object Storage](http://gophercloud.io/docs/object-storage)
+* [Getting started with Networking](http://gophercloud.io/docs/networking)
+* [Getting started with Block Storage](http://gophercloud.io/docs/block-storage)
+* [Getting started with Identity](http://gophercloud.io/docs/identity)
+
+## Contributing
+
+Engaging the community and lowering barriers for contributors is something we
+care a lot about. For this reason, we've taken the time to write a [contributing
+guide](./CONTRIBUTING.md) for folks interested in getting involved in our project.
+If you're not sure how you can get involved, feel free to submit an issue or
+[contact us](https://developer.rackspace.com/support/). You don't need to be a
+Go expert - all members of the community are welcome!
+
+## Help and feedback
+
+If you're struggling with something or have spotted a potential bug, feel free
+to submit an issue to our [bug tracker](/issues) or [contact us directly](https://developer.rackspace.com/support/).
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 0000000..76a94d5
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,338 @@
+# Upgrading to v1.0.0
+
+With the arrival of this new major version increment, the unfortunate news is
+that breaking changes have been introduced to existing services. The API
+has been completely rewritten from the ground up to make the library more
+extensible, maintainable and easy-to-use.
+
+Below we've compiled upgrade instructions for the various services that
+existed before. If you have a specific issue that is not addressed below,
+please [submit an issue](/issues/new) or
+[e-mail our support team](https://developer.rackspace.com/support/).
+
+* [Authentication](#authentication)
+* [Servers](#servers)
+ * [List servers](#list-servers)
+ * [Get server details](#get-server-details)
+ * [Create server](#create-server)
+ * [Resize server](#resize-server)
+ * [Reboot server](#reboot-server)
+ * [Update server](#update-server)
+ * [Rebuild server](#rebuild-server)
+ * [Change admin password](#change-admin-password)
+ * [Delete server](#delete-server)
+ * [Rescue server](#rescue-server)
+* [Images and flavors](#images-and-flavors)
+ * [List images](#list-images)
+ * [List flavors](#list-flavors)
+ * [Create/delete image](#createdelete-image)
+* [Other](#other)
+ * [List keypairs](#list-keypairs)
+ * [Create/delete keypair](#createdelete-keypair)
+ * [List IP addresses](#list-ip-addresses)
+
+# Authentication
+
+One of the major differences that this release introduces is the level of
+sub-packaging to differentiate between services and providers. You now have
+the option of authenticating with OpenStack and other providers (like Rackspace).
+
+To authenticate with a vanilla OpenStack installation, you can either specify
+your credentials like this:
+
+```go
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+)
+
+opts := gophercloud.AuthOptions{
+ IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+ Username: "{username}",
+ Password: "{password}",
+ TenantID: "{tenant_id}",
+}
+```
+
+Or have them pulled in through environment variables, like this:
+
+```go
+opts, err := openstack.AuthOptionsFromEnv()
+```
+
+Once you have your `AuthOptions` struct, you pass it in to get back a `Provider`,
+like so:
+
+```go
+provider, err := openstack.AuthenticatedClient(opts)
+```
+
+This provider is the top-level structure that all services are created from.
+
+# Servers
+
+Before you can interact with the Compute API, you need to retrieve a
+`gophercloud.ServiceClient`. To do this:
+
+```go
+// Define your region, etc.
+opts := gophercloud.EndpointOpts{Region: "RegionOne"}
+
+client, err := openstack.NewComputeV2(provider, opts)
+```
+
+## List servers
+
+All operations that involve API collections (servers, flavors, images) now use
+the `pagination.Pager` interface. This interface represents paginated entities
+that can be iterated over.
+
+Once you have a Pager, you can then pass a callback function into its `EachPage`
+method, and this will allow you to traverse over the collection and execute
+arbitrary functionality. So, an example with list servers:
+
+```go
+import (
+ "fmt"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// We have the option of filtering the server list. If we want the full
+// collection, leave it as an empty struct or nil
+opts := servers.ListOpts{Name: "server_1"}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := servers.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ serverList, err := servers.ExtractServers(page)
+
+ // `s' will be a servers.Server struct
+ for _, s := range serverList {
+ fmt.Printf("We have a server. ID=%s, Name=%s", s.ID, s.Name)
+ }
+})
+```
+
+## Get server details
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// Get the HTTP result
+response := servers.Get(client, "server_id")
+
+// Extract a Server struct from the response
+server, err := response.Extract()
+```
+
+## Create server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// Define our options
+opts := servers.CreateOpts{
+ Name: "new_server",
+ FlavorRef: "flavorID",
+ ImageRef: "imageID",
+}
+
+// Get our response
+response := servers.Create(client, opts)
+
+// Extract
+server, err := response.Extract()
+```
+
+## Change admin password
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+result := servers.ChangeAdminPassword(client, "server_id", "newPassword_&123")
+```
+
+## Resize server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+result := servers.Resize(client, "server_id", "new_flavor_id")
+```
+
+## Reboot server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// You have a choice of two reboot methods: servers.SoftReboot or servers.HardReboot
+result := servers.Reboot(client, "server_id", servers.SoftReboot)
+```
+
+## Update server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+opts := servers.UpdateOpts{Name: "new_name"}
+
+server, err := servers.Update(client, "server_id", opts).Extract()
+```
+
+## Rebuild server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// You have the option of specifying additional options
+opts := RebuildOpts{
+ Name: "new_name",
+ AdminPass: "admin_password",
+ ImageID: "image_id",
+ Metadata: map[string]string{"owner": "me"},
+}
+
+result := servers.Rebuild(client, "server_id", opts)
+
+// You can extract a servers.Server struct from the HTTP response
+server, err := result.Extract()
+```
+
+## Delete server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+response := servers.Delete(client, "server_id")
+```
+
+## Rescue server
+
+The server rescue extension for Compute is not currently supported.
+
+# Images and flavors
+
+## List images
+
+As with listing servers (see above), you first retrieve a Pager, and then pass
+in a callback over each page:
+
+```go
+import (
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+)
+
+// We have the option of filtering the image list. If we want the full
+// collection, leave it as an empty struct
+opts := images.ListOpts{ChangesSince: "2014-01-01T01:02:03Z", Name: "Ubuntu 12.04"}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := images.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ imageList, err := images.ExtractImages(page)
+
+ for _, i := range imageList {
+ // "i" will be an images.Image
+ }
+})
+```
+
+## List flavors
+
+```go
+import (
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+)
+
+// We have the option of filtering the flavor list. If we want the full
+// collection, leave it as an empty struct
+opts := flavors.ListOpts{ChangesSince: "2014-01-01T01:02:03Z", MinRAM: 4}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := flavors.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ flavorList, err := networks.ExtractFlavors(page)
+
+ for _, f := range flavorList {
+ // "f" will be a flavors.Flavor
+ }
+})
+```
+
+## Create/delete image
+
+Image management has been shifted to Glance, but unfortunately this service is
+not supported as of yet. You can, however, list Compute images like so:
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := images.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ imageList, err := images.ExtractImages(page)
+
+ for _, i := range imageList {
+ // "i" will be an images.Image
+ }
+})
+```
+
+# Other
+
+## List keypairs
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := keypairs.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ keyList, err := keypairs.ExtractKeyPairs(page)
+
+ for _, k := range keyList {
+ // "k" will be a keypairs.KeyPair
+ }
+})
+```
+
+## Create/delete keypairs
+
+To create a new keypair, you need to specify its name and, optionally, a
+pregenerated OpenSSH-formatted public key.
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+
+opts := keypairs.CreateOpts{
+ Name: "new_key",
+ PublicKey: "...",
+}
+
+response := keypairs.Create(client, opts)
+
+key, err := response.Extract()
+```
+
+To delete an existing keypair:
+
+```go
+response := keypairs.Delete(client, "keypair_id")
+```
+
+## List IP addresses
+
+This operation is not currently supported.
diff --git a/acceptance/README.md b/acceptance/README.md
new file mode 100644
index 0000000..3199837
--- /dev/null
+++ b/acceptance/README.md
@@ -0,0 +1,57 @@
+# Gophercloud Acceptance tests
+
+The purpose of these acceptance tests is to validate that SDK features meet
+the requirements of a contract - to consumers, other parts of the library, and
+to a remote API.
+
+> **Note:** Because every test will be run against a real API endpoint, you
+> may incur bandwidth and service charges for all the resource usage. These
+> tests *should* remove their remote products automatically. However, there may
+> be certain cases where this does not happen; always double-check to make sure
+> you have no stragglers left behind.
+
+### Step 1. Set environment variables
+
+A lot of tests rely on environment variables for configuration - so you will need
+to set them before running the suite. If you're testing against pure OpenStack APIs,
+you can download a file that contains all of these variables for you: just visit
+the `project/access_and_security` page in your control panel and click the "Download
+OpenStack RC File" button at the top right. For all other providers, you will need
+to set them manually.
+
+#### Authentication
+
+|Name|Description|
+|---|---|
+|`OS_USERNAME`|Your API username|
+|`OS_PASSWORD`|Your API password|
+|`OS_AUTH_URL`|The identity URL you need to authenticate|
+|`OS_TENANT_NAME`|Your API tenant name|
+|`OS_TENANT_ID`|Your API tenant ID|
+|`RS_USERNAME`|Your Rackspace username|
+|`RS_API_KEY`|Your Rackspace API key|
+
+#### General
+
+|Name|Description|
+|---|---|
+|`OS_REGION_NAME`|The region you want your resources to reside in|
+|`RS_REGION`|Rackspace region you want your resource to reside in|
+
+#### Compute
+
+|Name|Description|
+|---|---|
+|`OS_IMAGE_ID`|The ID of the image your want your server to be based on|
+|`OS_FLAVOR_ID`|The ID of the flavor you want your server to be based on|
+|`OS_FLAVOR_ID_RESIZE`|The ID of the flavor you want your server to be resized to|
+|`RS_IMAGE_ID`|The ID of the image you want servers to be created with|
+|`RS_FLAVOR_ID`|The ID of the flavor you want your server to be created with|
+
+### 2. Run the test suite
+
+From the root directory, run:
+
+```
+./script/acceptancetest
+```
diff --git a/acceptance/openstack/blockstorage/v1/snapshots_test.go b/acceptance/openstack/blockstorage/v1/snapshots_test.go
new file mode 100644
index 0000000..7741aa9
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/snapshots_test.go
@@ -0,0 +1,70 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/snapshots"
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSnapshots(t *testing.T) {
+
+ client, err := newClient(t)
+ th.AssertNoErr(t, err)
+
+ v, err := volumes.Create(client, &volumes.CreateOpts{
+ Name: "gophercloud-test-volume",
+ Size: 1,
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ err = volumes.WaitForStatus(client, v.ID, "available", 120)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created volume: %v\n", v)
+
+ ss, err := snapshots.Create(client, &snapshots.CreateOpts{
+ Name: "gophercloud-test-snapshot",
+ VolumeID: v.ID,
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ err = snapshots.WaitForStatus(client, ss.ID, "available", 120)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created snapshot: %+v\n", ss)
+
+ err = snapshots.Delete(client, ss.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ err = gophercloud.WaitFor(120, func() (bool, error) {
+ _, err := snapshots.Get(client, ss.ID).Extract()
+ if err != nil {
+ return true, nil
+ }
+
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ t.Log("Deleted snapshot\n")
+
+ err = volumes.Delete(client, v.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ err = gophercloud.WaitFor(120, func() (bool, error) {
+ _, err := volumes.Get(client, v.ID).Extract()
+ if err != nil {
+ return true, nil
+ }
+
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ t.Log("Deleted volume\n")
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumes_test.go b/acceptance/openstack/blockstorage/v1/volumes_test.go
new file mode 100644
index 0000000..7760427
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumes_test.go
@@ -0,0 +1,63 @@
+// +build acceptance blockstorage
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) (*gophercloud.ServiceClient, error) {
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := openstack.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ return openstack.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+func TestVolumes(t *testing.T) {
+ client, err := newClient(t)
+ th.AssertNoErr(t, err)
+
+ cv, err := volumes.Create(client, &volumes.CreateOpts{
+ Size: 1,
+ Name: "gophercloud-test-volume",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ err = volumes.WaitForStatus(client, cv.ID, "available", 60)
+ th.AssertNoErr(t, err)
+ err = volumes.Delete(client, cv.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ }()
+
+ _, err = volumes.Update(client, cv.ID, &volumes.UpdateOpts{
+ Name: "gophercloud-updated-volume",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ v, err := volumes.Get(client, cv.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got volume: %+v\n", v)
+
+ if v.Name != "gophercloud-updated-volume" {
+ t.Errorf("Unable to update volume: Expected name: gophercloud-updated-volume\nActual name: %s", v.Name)
+ }
+
+ err = volumes.List(client, &volumes.ListOpts{Name: "gophercloud-updated-volume"}).EachPage(func(page pagination.Page) (bool, error) {
+ vols, err := volumes.ExtractVolumes(page)
+ th.CheckEquals(t, 1, len(vols))
+ return true, err
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumetypes_test.go b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
new file mode 100644
index 0000000..000bc01
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
@@ -0,0 +1,49 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumetypes"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestVolumeTypes(t *testing.T) {
+ client, err := newClient(t)
+ th.AssertNoErr(t, err)
+
+ vt, err := volumetypes.Create(client, &volumetypes.CreateOpts{
+ ExtraSpecs: map[string]interface{}{
+ "capabilities": "gpu",
+ "priority": 3,
+ },
+ Name: "gophercloud-test-volumeType",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ time.Sleep(10000 * time.Millisecond)
+ err = volumetypes.Delete(client, vt.ID).ExtractErr()
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ }()
+ t.Logf("Created volume type: %+v\n", vt)
+
+ vt, err = volumetypes.Get(client, vt.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got volume type: %+v\n", vt)
+
+ err = volumetypes.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ volTypes, err := volumetypes.ExtractVolumeTypes(page)
+ if len(volTypes) != 1 {
+ t.Errorf("Expected 1 volume type, got %d", len(volTypes))
+ }
+ t.Logf("Listing volume types: %+v\n", volTypes)
+ return true, err
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/client_test.go b/acceptance/openstack/client_test.go
new file mode 100644
index 0000000..6e88819
--- /dev/null
+++ b/acceptance/openstack/client_test.go
@@ -0,0 +1,40 @@
+// +build acceptance
+
+package openstack
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+)
+
+func TestAuthenticatedClient(t *testing.T) {
+ // Obtain credentials from the environment.
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to acquire credentials: %v", err)
+ }
+
+ client, err := openstack.AuthenticatedClient(ao)
+ if err != nil {
+ t.Fatalf("Unable to authenticate: %v", err)
+ }
+
+ if client.TokenID == "" {
+ t.Errorf("No token ID assigned to the client")
+ }
+
+ t.Logf("Client successfully acquired a token: %v", client.TokenID)
+
+ // Find the storage service in the service catalog.
+ storage, err := openstack.NewObjectStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+ if err != nil {
+ t.Errorf("Unable to locate a storage service: %v", err)
+ } else {
+ t.Logf("Located a storage service at endpoint: [%s]", storage.Endpoint)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go
new file mode 100644
index 0000000..add0e5f
--- /dev/null
+++ b/acceptance/openstack/compute/v2/bootfromvolume_test.go
@@ -0,0 +1,55 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBootFromVolume(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ name := tools.RandomString("Gophercloud-", 8)
+ t.Logf("Creating server [%s].", name)
+
+ bd := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ UUID: choices.ImageID,
+ SourceType: bootfromvolume.Image,
+ VolumeSize: 10,
+ },
+ }
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ }
+ server, err := bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{
+ serverCreateOpts,
+ bd,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Created server: %+v\n", server)
+ defer servers.Delete(client, server.ID)
+ t.Logf("Deleting server [%s]...", name)
+}
diff --git a/acceptance/openstack/compute/v2/compute_test.go b/acceptance/openstack/compute/v2/compute_test.go
new file mode 100644
index 0000000..c1bbf79
--- /dev/null
+++ b/acceptance/openstack/compute/v2/compute_test.go
@@ -0,0 +1,104 @@
+// +build acceptance common
+
+package v2
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+func newClient() (*gophercloud.ServiceClient, error) {
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ client, err := openstack.AuthenticatedClient(ao)
+ if err != nil {
+ return nil, err
+ }
+
+ return openstack.NewComputeV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+func waitForStatus(client *gophercloud.ServiceClient, server *servers.Server, status string) error {
+ return tools.WaitFor(func() (bool, error) {
+ latest, err := servers.Get(client, server.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if latest.Status == status {
+ // Success!
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
+
+// ComputeChoices contains image and flavor selections for use by the acceptance tests.
+type ComputeChoices struct {
+ // ImageID contains the ID of a valid image.
+ ImageID string
+
+ // FlavorID contains the ID of a valid flavor.
+ FlavorID string
+
+ // FlavorIDResize contains the ID of a different flavor available on the same OpenStack installation, that is distinct
+ // from FlavorID.
+ FlavorIDResize string
+
+ // NetworkName is the name of a network to launch the instance on.
+ NetworkName string
+}
+
+// ComputeChoicesFromEnv populates a ComputeChoices struct from environment variables.
+// If any required state is missing, an `error` will be returned that enumerates the missing properties.
+func ComputeChoicesFromEnv() (*ComputeChoices, error) {
+ imageID := os.Getenv("OS_IMAGE_ID")
+ flavorID := os.Getenv("OS_FLAVOR_ID")
+ flavorIDResize := os.Getenv("OS_FLAVOR_ID_RESIZE")
+ networkName := os.Getenv("OS_NETWORK_NAME")
+
+ missing := make([]string, 0, 3)
+ if imageID == "" {
+ missing = append(missing, "OS_IMAGE_ID")
+ }
+ if flavorID == "" {
+ missing = append(missing, "OS_FLAVOR_ID")
+ }
+ if flavorIDResize == "" {
+ missing = append(missing, "OS_FLAVOR_ID_RESIZE")
+ }
+ if networkName == "" {
+ networkName = "public"
+ }
+
+ notDistinct := ""
+ if flavorID == flavorIDResize {
+ notDistinct = "OS_FLAVOR_ID and OS_FLAVOR_ID_RESIZE must be distinct."
+ }
+
+ if len(missing) > 0 || notDistinct != "" {
+ text := "You're missing some important setup:\n"
+ if len(missing) > 0 {
+ text += " * These environment variables must be provided: " + strings.Join(missing, ", ") + "\n"
+ }
+ if notDistinct != "" {
+ text += " * " + notDistinct + "\n"
+ }
+
+ return nil, fmt.Errorf(text)
+ }
+
+ return &ComputeChoices{ImageID: imageID, FlavorID: flavorID, FlavorIDResize: flavorIDResize, NetworkName: networkName}, nil
+}
diff --git a/acceptance/openstack/compute/v2/extension_test.go b/acceptance/openstack/compute/v2/extension_test.go
new file mode 100644
index 0000000..1356ffa
--- /dev/null
+++ b/acceptance/openstack/compute/v2/extension_test.go
@@ -0,0 +1,47 @@
+// +build acceptance compute extensionss
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListExtensions(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ err = extensions.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ exts, err := extensions.ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+
+ for i, ext := range exts {
+ t.Logf("[%02d] name=[%s]\n", i, ext.Name)
+ t.Logf(" alias=[%s]\n", ext.Alias)
+ t.Logf(" description=[%s]\n", ext.Description)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func TestGetExtension(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ ext, err := extensions.Get(client, "os-admin-actions").Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Extension details:")
+ t.Logf(" name=[%s]\n", ext.Name)
+ t.Logf(" namespace=[%s]\n", ext.Namespace)
+ t.Logf(" alias=[%s]\n", ext.Alias)
+ t.Logf(" description=[%s]\n", ext.Description)
+ t.Logf(" updated=[%s]\n", ext.Updated)
+}
diff --git a/acceptance/openstack/compute/v2/flavors_test.go b/acceptance/openstack/compute/v2/flavors_test.go
new file mode 100644
index 0000000..9f51b12
--- /dev/null
+++ b/acceptance/openstack/compute/v2/flavors_test.go
@@ -0,0 +1,57 @@
+// +build acceptance compute flavors
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func TestListFlavors(t *testing.T) {
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ t.Logf("ID\tRegion\tName\tStatus\tCreated")
+
+ pager := flavors.ListDetail(client, nil)
+ count, pages := 0, 0
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("---")
+ pages++
+ flavors, err := flavors.ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, f := range flavors {
+ t.Logf("%s\t%s\t%d\t%d\t%d", f.ID, f.Name, f.RAM, f.Disk, f.VCPUs)
+ }
+
+ return true, nil
+ })
+
+ t.Logf("--------\n%d flavors listed on %d pages.", count, pages)
+}
+
+func TestGetFlavor(t *testing.T) {
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ flavor, err := flavors.Get(client, choices.FlavorID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get flavor information: %v", err)
+ }
+
+ t.Logf("Flavor: %#v", flavor)
+}
diff --git a/acceptance/openstack/compute/v2/floatingip_test.go b/acceptance/openstack/compute/v2/floatingip_test.go
new file mode 100644
index 0000000..de6efc9
--- /dev/null
+++ b/acceptance/openstack/compute/v2/floatingip_test.go
@@ -0,0 +1,168 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/floatingip"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func createFIPServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err := servers.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func createFloatingIP(t *testing.T, client *gophercloud.ServiceClient) (*floatingip.FloatingIP, error) {
+ pool := os.Getenv("OS_POOL_NAME")
+ fip, err := floatingip.Create(client, &floatingip.CreateOpts{
+ Pool: pool,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Obtained Floating IP: %v", fip.IP)
+
+ return fip, err
+}
+
+func associateFloatingIPDeprecated(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+ // This form works, but is considered deprecated.
+ // See associateFloatingIP or associateFloatingIPFixed
+ err := floatingip.Associate(client, serverId, fip.IP).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Associated floating IP %v from instance %v", fip.IP, serverId)
+ defer func() {
+ err = floatingip.Disassociate(client, serverId, fip.IP).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disassociated floating IP %v from instance %v", fip.IP, serverId)
+ }()
+ floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func associateFloatingIP(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+ associateOpts := floatingip.AssociateOpts{
+ ServerID: serverId,
+ FloatingIP: fip.IP,
+ }
+
+ err := floatingip.AssociateInstance(client, associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Associated floating IP %v from instance %v", fip.IP, serverId)
+ defer func() {
+ err = floatingip.DisassociateInstance(client, associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disassociated floating IP %v from instance %v", fip.IP, serverId)
+ }()
+ floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func associateFloatingIPFixed(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+
+ network := os.Getenv("OS_NETWORK_NAME")
+ server, err := servers.Get(client, serverId).Extract()
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+
+ var fixedIP string
+ for _, networkAddresses := range server.Addresses[network].([]interface{}) {
+ address := networkAddresses.(map[string]interface{})
+ if address["OS-EXT-IPS:type"] == "fixed" {
+ if address["version"].(float64) == 4 {
+ fixedIP = address["addr"].(string)
+ }
+ }
+ }
+
+ associateOpts := floatingip.AssociateOpts{
+ ServerID: serverId,
+ FloatingIP: fip.IP,
+ FixedIP: fixedIP,
+ }
+
+ err = floatingip.AssociateInstance(client, associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Associated floating IP %v from instance %v with Fixed IP %v", fip.IP, serverId, fixedIP)
+ defer func() {
+ err = floatingip.DisassociateInstance(client, associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disassociated floating IP %v from instance %v with Fixed IP %v", fip.IP, serverId, fixedIP)
+ }()
+ floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, floatingIp.FixedIP, fixedIP)
+ t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func TestFloatingIP(t *testing.T) {
+ pool := os.Getenv("OS_POOL_NAME")
+ if pool == "" {
+ t.Fatalf("OS_POOL_NAME must be set")
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := createFIPServer(t, client, choices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(client, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ fip, err := createFloatingIP(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create floating IP: %v", err)
+ }
+ defer func() {
+ err = floatingip.Delete(client, fip.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Floating IP deleted.")
+ }()
+
+ associateFloatingIPDeprecated(t, client, server.ID, fip)
+ associateFloatingIP(t, client, server.ID, fip)
+ associateFloatingIPFixed(t, client, server.ID, fip)
+
+}
diff --git a/acceptance/openstack/compute/v2/images_test.go b/acceptance/openstack/compute/v2/images_test.go
new file mode 100644
index 0000000..ceab22f
--- /dev/null
+++ b/acceptance/openstack/compute/v2/images_test.go
@@ -0,0 +1,37 @@
+// +build acceptance compute images
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func TestListImages(t *testing.T) {
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute: client: %v", err)
+ }
+
+ t.Logf("ID\tRegion\tName\tStatus\tCreated")
+
+ pager := images.ListDetail(client, nil)
+ count, pages := 0, 0
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ images, err := images.ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range images {
+ t.Logf("%s\t%s\t%s\t%s", i.ID, i.Name, i.Status, i.Created)
+ }
+
+ return true, nil
+ })
+
+ t.Logf("--------\n%d images listed on %d pages.", count, pages)
+}
diff --git a/acceptance/openstack/compute/v2/keypairs_test.go b/acceptance/openstack/compute/v2/keypairs_test.go
new file mode 100644
index 0000000..a4fe8db
--- /dev/null
+++ b/acceptance/openstack/compute/v2/keypairs_test.go
@@ -0,0 +1,74 @@
+// +build acceptance
+
+package v2
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+
+ "golang.org/x/crypto/ssh"
+)
+
+const keyName = "gophercloud_test_key_pair"
+
+func TestCreateServerWithKeyPair(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ publicKey := privateKey.PublicKey
+ pub, err := ssh.NewPublicKey(&publicKey)
+ th.AssertNoErr(t, err)
+ pubBytes := ssh.MarshalAuthorizedKey(pub)
+ pk := string(pubBytes)
+
+ kp, err := keypairs.Create(client, keypairs.CreateOpts{
+ Name: keyName,
+ PublicKey: pk,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created key pair: %s\n", kp)
+
+ choices, err := ComputeChoicesFromEnv()
+ th.AssertNoErr(t, err)
+
+ name := tools.RandomString("Gophercloud-", 8)
+ t.Logf("Creating server [%s] with key pair.", name)
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ }
+
+ server, err := servers.Create(client, keypairs.CreateOptsExt{
+ serverCreateOpts,
+ keyName,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer servers.Delete(client, server.ID)
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ server, err = servers.Get(client, server.ID).Extract()
+ t.Logf("Created server: %+v\n", server)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, server.KeyName, keyName)
+
+ t.Logf("Deleting key pair [%s]...", kp.Name)
+ err = keypairs.Delete(client, keyName).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleting server [%s]...", name)
+}
diff --git a/acceptance/openstack/compute/v2/network_test.go b/acceptance/openstack/compute/v2/network_test.go
new file mode 100644
index 0000000..7ebe7ec
--- /dev/null
+++ b/acceptance/openstack/compute/v2/network_test.go
@@ -0,0 +1,78 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/networks"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func getNetworkIDFromNetworkExtension(t *testing.T, client *gophercloud.ServiceClient, networkName string) (string, error) {
+ allPages, err := networks.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ networkList, err := networks.ExtractNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ networkID := ""
+ for _, network := range networkList {
+ t.Logf("Network: %v", network)
+ if network.Label == networkName {
+ networkID = network.ID
+ }
+ }
+
+ t.Logf("Found network ID for %s: %s\n", networkName, networkID)
+
+ return networkID, nil
+}
+
+func TestNetworks(t *testing.T) {
+ networkName := os.Getenv("OS_NETWORK_NAME")
+ if networkName == "" {
+ t.Fatalf("OS_NETWORK_NAME must be set")
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ networkID, err := getNetworkIDFromNetworkExtension(t, client, networkName)
+ if err != nil {
+ t.Fatalf("Unable to get network ID: %v", err)
+ }
+
+ // createNetworkServer is defined in tenantnetworks_test.go
+ server, err := createNetworkServer(t, client, choices, networkID)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(client, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ allPages, err := networks.List(client).AllPages()
+ allNetworks, err := networks.ExtractNetworks(allPages)
+ th.AssertNoErr(t, err)
+ t.Logf("Retrieved all %d networks: %+v", len(allNetworks), allNetworks)
+}
diff --git a/acceptance/openstack/compute/v2/pkg.go b/acceptance/openstack/compute/v2/pkg.go
new file mode 100644
index 0000000..bb158c3
--- /dev/null
+++ b/acceptance/openstack/compute/v2/pkg.go
@@ -0,0 +1,3 @@
+// The v2 package contains acceptance tests for the Openstack Compute V2 service.
+
+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/openstack/compute/v2/servergroup_test.go b/acceptance/openstack/compute/v2/servergroup_test.go
new file mode 100644
index 0000000..945854e
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servergroup_test.go
@@ -0,0 +1,143 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/schedulerhints"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/servergroups"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func createServerGroup(t *testing.T, computeClient *gophercloud.ServiceClient) (*servergroups.ServerGroup, error) {
+ sg, err := servergroups.Create(computeClient, &servergroups.CreateOpts{
+ Name: "test",
+ Policies: []string{"affinity"},
+ }).Extract()
+
+ if err != nil {
+ t.Fatalf("Unable to create server group: %v", err)
+ }
+
+ t.Logf("Created server group: %v", sg.ID)
+ t.Logf("It has policies: %v", sg.Policies)
+
+ return sg, nil
+}
+
+func getServerGroup(t *testing.T, computeClient *gophercloud.ServiceClient, sgID string) error {
+ sg, err := servergroups.Get(computeClient, sgID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get server group: %v", err)
+ }
+
+ t.Logf("Got server group: %v", sg.Name)
+
+ return nil
+}
+
+func createServerInGroup(t *testing.T, computeClient *gophercloud.ServiceClient, choices *ComputeChoices, serverGroup *servergroups.ServerGroup) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ }
+ server, err := servers.Create(computeClient, schedulerhints.CreateOptsExt{
+ serverCreateOpts,
+ schedulerhints.SchedulerHints{
+ Group: serverGroup.ID,
+ },
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func verifySchedulerWorked(t *testing.T, firstServer, secondServer *servers.Server) error {
+ t.Logf("First server hostID: %v", firstServer.HostID)
+ t.Logf("Second server hostID: %v", secondServer.HostID)
+ if firstServer.HostID == secondServer.HostID {
+ return nil
+ }
+
+ return fmt.Errorf("%s and %s were not scheduled on the same host.", firstServer.ID, secondServer.ID)
+}
+
+func TestServerGroups(t *testing.T) {
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ computeClient, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ sg, err := createServerGroup(t, computeClient)
+ if err != nil {
+ t.Fatalf("Unable to create server group: %v", err)
+ }
+ defer func() {
+ servergroups.Delete(computeClient, sg.ID)
+ t.Logf("Server Group deleted.")
+ }()
+
+ err = getServerGroup(t, computeClient, sg.ID)
+ if err != nil {
+ t.Fatalf("Unable to get server group: %v", err)
+ }
+
+ firstServer, err := createServerInGroup(t, computeClient, choices, sg)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(computeClient, firstServer.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(computeClient, firstServer, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ firstServer, err = servers.Get(computeClient, firstServer.ID).Extract()
+
+ secondServer, err := createServerInGroup(t, computeClient, choices, sg)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(computeClient, secondServer.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(computeClient, secondServer, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ secondServer, err = servers.Get(computeClient, secondServer.ID).Extract()
+
+ if err = verifySchedulerWorked(t, firstServer, secondServer); err != nil {
+ t.Fatalf("Scheduling did not work: %v", err)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/servers_test.go b/acceptance/openstack/compute/v2/servers_test.go
new file mode 100644
index 0000000..f6c7c05
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servers_test.go
@@ -0,0 +1,484 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListServers(t *testing.T) {
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ t.Logf("ID\tRegion\tName\tStatus\tIPv4\tIPv6")
+
+ pager := servers.List(client, servers.ListOpts{})
+ count, pages := 0, 0
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ t.Logf("---")
+
+ servers, err := servers.ExtractServers(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range servers {
+ t.Logf("%s\t%s\t%s\t%s\t%s\t\n", s.ID, s.Name, s.Status, s.AccessIPv4, s.AccessIPv6)
+ count++
+ }
+
+ return true, nil
+ })
+
+ t.Logf("--------\n%d servers listed on %d pages.\n", count, pages)
+}
+
+func networkingClient() (*gophercloud.ServiceClient, error) {
+ opts, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ provider, err := openstack.AuthenticatedClient(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+func createServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var network networks.Network
+
+ networkingClient, err := networkingClient()
+ if err != nil {
+ t.Fatalf("Unable to create a networking client: %v", err)
+ }
+
+ pager := networks.List(networkingClient, networks.ListOpts{
+ Name: choices.NetworkName,
+ Limit: 1,
+ })
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ networks, err := networks.ExtractNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ if len(networks) == 0 {
+ t.Fatalf("No networks to attach to server")
+ return false, err
+ }
+
+ network = networks[0]
+
+ return false, nil
+ })
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err := servers.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ Networks: []servers.Network{
+ servers.Network{UUID: network.ID},
+ },
+ AdminPass: pwd,
+ Personality: servers.Personality{
+ &servers.File{
+ Path: "/etc/test",
+ Contents: []byte("hello world"),
+ },
+ },
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func TestCreateDestroyServer(t *testing.T) {
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(client, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ pager := servers.ListAddresses(client, server.ID)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ networks, err := servers.ExtractAddresses(page)
+ if err != nil {
+ return false, err
+ }
+
+ for n, a := range networks {
+ t.Logf("%s: %+v\n", n, a)
+ }
+ return true, nil
+ })
+
+ pager = servers.ListAddressesByNetwork(client, server.ID, choices.NetworkName)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ addresses, err := servers.ExtractNetworkAddresses(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, a := range addresses {
+ t.Logf("%+v\n", a)
+ }
+ return true, nil
+ })
+}
+
+func TestUpdateServer(t *testing.T) {
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ alternateName := tools.RandomString("ACPTTEST", 16)
+ for alternateName == server.Name {
+ alternateName = tools.RandomString("ACPTTEST", 16)
+ }
+
+ t.Logf("Attempting to rename the server to %s.", alternateName)
+
+ updated, err := servers.Update(client, server.ID, servers.UpdateOpts{Name: alternateName}).Extract()
+ if err != nil {
+ t.Fatalf("Unable to rename server: %v", err)
+ }
+
+ if updated.ID != server.ID {
+ t.Errorf("Updated server ID [%s] didn't match original server ID [%s]!", updated.ID, server.ID)
+ }
+
+ err = tools.WaitFor(func() (bool, error) {
+ latest, err := servers.Get(client, updated.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ return latest.Name == alternateName, nil
+ })
+}
+
+func TestActionChangeAdminPassword(t *testing.T) {
+ t.Parallel()
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ randomPassword := tools.MakeNewPassword(server.AdminPass)
+ res := servers.ChangeAdminPassword(client, server.ID, randomPassword)
+ if res.Err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, server, "PASSWORD"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestActionReboot(t *testing.T) {
+ t.Parallel()
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ res := servers.Reboot(client, server.ID, "aldhjflaskhjf")
+ if res.Err == nil {
+ t.Fatal("Expected the SDK to provide an ArgumentError here")
+ }
+
+ t.Logf("Attempting reboot of server %s", server.ID)
+ res = servers.Reboot(client, server.ID, servers.OSReboot)
+ if res.Err != nil {
+ t.Fatalf("Unable to reboot server: %v", err)
+ }
+
+ if err = waitForStatus(client, server, "REBOOT"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestActionRebuild(t *testing.T) {
+ t.Parallel()
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Attempting to rebuild server %s", server.ID)
+
+ rebuildOpts := servers.RebuildOpts{
+ Name: tools.RandomString("ACPTTEST", 16),
+ AdminPass: tools.MakeNewPassword(server.AdminPass),
+ ImageID: choices.ImageID,
+ }
+
+ rebuilt, err := servers.Rebuild(client, server.ID, rebuildOpts).Extract()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if rebuilt.ID != server.ID {
+ t.Errorf("Expected rebuilt server ID of [%s]; got [%s]", server.ID, rebuilt.ID)
+ }
+
+ if err = waitForStatus(client, rebuilt, "REBUILD"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, rebuilt, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func resizeServer(t *testing.T, client *gophercloud.ServiceClient, server *servers.Server, choices *ComputeChoices) {
+ if err := waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Attempting to resize server [%s]", server.ID)
+
+ opts := &servers.ResizeOpts{
+ FlavorRef: choices.FlavorIDResize,
+ }
+ if res := servers.Resize(client, server.ID, opts); res.Err != nil {
+ t.Fatal(res.Err)
+ }
+
+ if err := waitForStatus(client, server, "VERIFY_RESIZE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestActionResizeConfirm(t *testing.T) {
+ t.Parallel()
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+ resizeServer(t, client, server, choices)
+
+ t.Logf("Attempting to confirm resize for server %s", server.ID)
+
+ if res := servers.ConfirmResize(client, server.ID); res.Err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestActionResizeRevert(t *testing.T) {
+ t.Parallel()
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+ resizeServer(t, client, server, choices)
+
+ t.Logf("Attempting to revert resize for server %s", server.ID)
+
+ if res := servers.RevertResize(client, server.ID); res.Err != nil {
+ t.Fatal(err)
+ }
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServerMetadata(t *testing.T) {
+ t.Parallel()
+
+ choices, err := ComputeChoicesFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := createServer(t, client, choices)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer servers.Delete(client, server.ID)
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+
+ metadata, err := servers.UpdateMetadata(client, server.ID, servers.MetadataOpts{
+ "foo": "bar",
+ "this": "that",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("UpdateMetadata result: %+v\n", metadata)
+
+ err = servers.DeleteMetadatum(client, server.ID, "foo").ExtractErr()
+ th.AssertNoErr(t, err)
+
+ metadata, err = servers.CreateMetadatum(client, server.ID, servers.MetadatumOpts{
+ "foo": "baz",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("CreateMetadatum result: %+v\n", metadata)
+
+ metadata, err = servers.Metadatum(client, server.ID, "foo").Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadatum result: %+v\n", metadata)
+ th.AssertEquals(t, "baz", metadata["foo"])
+
+ metadata, err = servers.Metadata(client, server.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadata result: %+v\n", metadata)
+
+ metadata, err = servers.ResetMetadata(client, server.ID, servers.MetadataOpts{}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("ResetMetadata result: %+v\n", metadata)
+ th.AssertDeepEquals(t, map[string]string{}, metadata)
+}
diff --git a/acceptance/openstack/compute/v2/tenantnetworks_test.go b/acceptance/openstack/compute/v2/tenantnetworks_test.go
new file mode 100644
index 0000000..a92e8bf
--- /dev/null
+++ b/acceptance/openstack/compute/v2/tenantnetworks_test.go
@@ -0,0 +1,109 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func getNetworkID(t *testing.T, client *gophercloud.ServiceClient, networkName string) (string, error) {
+ allPages, err := tenantnetworks.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ networkList, err := tenantnetworks.ExtractNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ networkID := ""
+ for _, network := range networkList {
+ t.Logf("Network: %v", network)
+ if network.Name == networkName {
+ networkID = network.ID
+ }
+ }
+
+ t.Logf("Found network ID for %s: %s\n", networkName, networkID)
+
+ return networkID, nil
+}
+
+func createNetworkServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices, networkID string) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ networks := make([]servers.Network, 1)
+ networks[0] = servers.Network{
+ UUID: networkID,
+ }
+
+ server, err := servers.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ Networks: networks,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func TestTenantNetworks(t *testing.T) {
+ networkName := os.Getenv("OS_NETWORK_NAME")
+ if networkName == "" {
+ t.Fatalf("OS_NETWORK_NAME must be set")
+ }
+
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ networkID, err := getNetworkID(t, client, networkName)
+ if err != nil {
+ t.Fatalf("Unable to get network ID: %v", err)
+ }
+
+ server, err := createNetworkServer(t, client, choices, networkID)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(client, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ allPages, err := tenantnetworks.List(client).AllPages()
+ allNetworks, err := tenantnetworks.ExtractNetworks(allPages)
+ th.AssertNoErr(t, err)
+ t.Logf("Retrieved all %d networks: %+v", len(allNetworks), allNetworks)
+}
diff --git a/acceptance/openstack/compute/v2/volumeattach_test.go b/acceptance/openstack/compute/v2/volumeattach_test.go
new file mode 100644
index 0000000..34634c9
--- /dev/null
+++ b/acceptance/openstack/compute/v2/volumeattach_test.go
@@ -0,0 +1,125 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newBlockClient(t *testing.T) (*gophercloud.ServiceClient, error) {
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := openstack.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ return openstack.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+func createVAServer(t *testing.T, computeClient *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err := servers.Create(computeClient, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func createVAVolume(t *testing.T, blockClient *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ volume, err := volumes.Create(blockClient, &volumes.CreateOpts{
+ Size: 1,
+ Name: "gophercloud-test-volume",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ err = volumes.WaitForStatus(blockClient, volume.ID, "available", 60)
+ th.AssertNoErr(t, err)
+ }()
+
+ return volume, err
+}
+
+func createVolumeAttachment(t *testing.T, computeClient *gophercloud.ServiceClient, blockClient *gophercloud.ServiceClient, serverId string, volumeId string) {
+ va, err := volumeattach.Create(computeClient, serverId, &volumeattach.CreateOpts{
+ VolumeID: volumeId,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ err = volumes.WaitForStatus(blockClient, volumeId, "in-use", 60)
+ th.AssertNoErr(t, err)
+ err = volumeattach.Delete(computeClient, serverId, va.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ err = volumes.WaitForStatus(blockClient, volumeId, "available", 60)
+ th.AssertNoErr(t, err)
+ }()
+}
+
+func TestAttachVolume(t *testing.T) {
+ choices, err := ComputeChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ computeClient, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ blockClient, err := newBlockClient(t)
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ server, err := createVAServer(t, computeClient, choices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(computeClient, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = waitForStatus(computeClient, server, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ volume, err := createVAVolume(t, blockClient)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer func() {
+ err = volumes.Delete(blockClient, volume.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Volume deleted.")
+ }()
+
+ createVolumeAttachment(t, computeClient, blockClient, server.ID, volume.ID)
+
+}
diff --git a/acceptance/openstack/db/v1/common.go b/acceptance/openstack/db/v1/common.go
new file mode 100644
index 0000000..f7ffc37
--- /dev/null
+++ b/acceptance/openstack/db/v1/common.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := openstack.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := openstack.NewDBV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+ th.AssertNoErr(t, err)
+
+ return c
+}
+
+type context struct {
+ test *testing.T
+ client *gophercloud.ServiceClient
+ instanceID string
+ DBIDs []string
+ users []string
+}
+
+func newContext(t *testing.T) context {
+ return context{
+ test: t,
+ client: newClient(t),
+ }
+}
+
+func (c context) Logf(msg string, args ...interface{}) {
+ if len(args) > 0 {
+ c.test.Logf(msg, args...)
+ } else {
+ c.test.Log(msg)
+ }
+}
+
+func (c context) AssertNoErr(err error) {
+ th.AssertNoErr(c.test, err)
+}
+
+func (c context) WaitUntilActive(id string) {
+ err := gophercloud.WaitFor(60, func() (bool, error) {
+ inst, err := instances.Get(c.client, id).Extract()
+ if err != nil {
+ return false, err
+ }
+ if inst.Status == "ACTIVE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/openstack/db/v1/database_test.go b/acceptance/openstack/db/v1/database_test.go
new file mode 100644
index 0000000..2fd3175
--- /dev/null
+++ b/acceptance/openstack/db/v1/database_test.go
@@ -0,0 +1,45 @@
+// +build acceptance db
+
+package v1
+
+import (
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func (c context) createDBs() {
+ opts := db.BatchCreateOpts{
+ db.CreateOpts{Name: "db1"},
+ db.CreateOpts{Name: "db2"},
+ db.CreateOpts{Name: "db3"},
+ }
+
+ err := db.Create(c.client, c.instanceID, opts).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Created three databases on instance %s: db1, db2, db3", c.instanceID)
+}
+
+func (c context) listDBs() {
+ c.Logf("Listing databases on instance %s", c.instanceID)
+
+ err := db.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ dbList, err := db.ExtractDBs(page)
+ c.AssertNoErr(err)
+
+ for _, db := range dbList {
+ c.Logf("DB: %#v", db)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c context) deleteDBs() {
+ for _, id := range []string{"db1", "db2", "db3"} {
+ err := db.Delete(c.client, c.instanceID, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted DB %s", id)
+ }
+}
diff --git a/acceptance/openstack/db/v1/flavor_test.go b/acceptance/openstack/db/v1/flavor_test.go
new file mode 100644
index 0000000..46f986c
--- /dev/null
+++ b/acceptance/openstack/db/v1/flavor_test.go
@@ -0,0 +1,31 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func (c context) listFlavors() {
+ c.Logf("Listing flavors")
+
+ err := flavors.List(c.client).EachPage(func(page pagination.Page) (bool, error) {
+ flavorList, err := flavors.ExtractFlavors(page)
+ c.AssertNoErr(err)
+
+ for _, f := range flavorList {
+ c.Logf("Flavor: ID [%s] Name [%s] RAM [%d]", f.ID, f.Name, f.RAM)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c context) getFlavor() {
+ flavor, err := flavors.Get(c.client, "1").Extract()
+ c.Logf("Getting flavor %s", flavor.ID)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/openstack/db/v1/instance_test.go b/acceptance/openstack/db/v1/instance_test.go
new file mode 100644
index 0000000..dfded21
--- /dev/null
+++ b/acceptance/openstack/db/v1/instance_test.go
@@ -0,0 +1,138 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const envDSType = "DATASTORE_TYPE_ID"
+
+func TestRunner(t *testing.T) {
+ c := newContext(t)
+
+ // FLAVOR tests
+ c.listFlavors()
+ c.getFlavor()
+
+ // INSTANCE tests
+ c.createInstance()
+ c.listInstances()
+ c.getInstance()
+ c.isRootEnabled()
+ c.enableRootUser()
+ c.isRootEnabled()
+ c.restartInstance()
+ //c.resizeInstance()
+ //c.resizeVol()
+
+ // DATABASE tests
+ c.createDBs()
+ c.listDBs()
+
+ // USER tests
+ c.createUsers()
+ c.listUsers()
+
+ // TEARDOWN
+ c.deleteUsers()
+ c.deleteDBs()
+ c.deleteInstance()
+}
+
+func (c context) createInstance() {
+ if os.Getenv(envDSType) == "" {
+ c.test.Fatalf("%s must be set as an environment var", envDSType)
+ }
+
+ opts := instances.CreateOpts{
+ FlavorRef: "2",
+ Size: 5,
+ Name: tools.RandomString("gopher_db", 5),
+ Datastore: &instances.DatastoreOpts{Type: os.Getenv(envDSType)},
+ }
+
+ instance, err := instances.Create(c.client, opts).Extract()
+ th.AssertNoErr(c.test, err)
+
+ c.Logf("Restarting %s. Waiting...", instance.ID)
+ c.WaitUntilActive(instance.ID)
+ c.Logf("Created Instance %s", instance.ID)
+
+ c.instanceID = instance.ID
+}
+
+func (c context) listInstances() {
+ c.Logf("Listing instances")
+
+ err := instances.List(c.client).EachPage(func(page pagination.Page) (bool, error) {
+ instanceList, err := instances.ExtractInstances(page)
+ c.AssertNoErr(err)
+
+ for _, i := range instanceList {
+ c.Logf("Instance: ID [%s] Name [%s] Status [%s] VolSize [%d] Datastore Type [%s]",
+ i.ID, i.Name, i.Status, i.Volume.Size, i.Datastore.Type)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c context) getInstance() {
+ instance, err := instances.Get(c.client, c.instanceID).Extract()
+ c.AssertNoErr(err)
+ c.Logf("Getting instance: %s", instance.ID)
+}
+
+func (c context) deleteInstance() {
+ err := instances.Delete(c.client, c.instanceID).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted instance %s", c.instanceID)
+}
+
+func (c context) enableRootUser() {
+ _, err := instances.EnableRootUser(c.client, c.instanceID).Extract()
+ c.AssertNoErr(err)
+ c.Logf("Enabled root user on %s", c.instanceID)
+}
+
+func (c context) isRootEnabled() {
+ enabled, err := instances.IsRootEnabled(c.client, c.instanceID)
+ c.AssertNoErr(err)
+ c.Logf("Is root enabled? %d", enabled)
+}
+
+func (c context) restartInstance() {
+ id := c.instanceID
+ err := instances.Restart(c.client, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Restarting %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Restarted %s", id)
+}
+
+func (c context) resizeInstance() {
+ id := c.instanceID
+ err := instances.Resize(c.client, id, "3").ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Resizing %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Resized %s with flavorRef %s", id, "2")
+}
+
+func (c context) resizeVol() {
+ id := c.instanceID
+ err := instances.ResizeVolume(c.client, id, 4).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Resizing volume of %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Resized the volume of %s to %d GB", id, 2)
+}
diff --git a/acceptance/openstack/db/v1/pkg.go b/acceptance/openstack/db/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/db/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/db/v1/user_test.go b/acceptance/openstack/db/v1/user_test.go
new file mode 100644
index 0000000..25a4794
--- /dev/null
+++ b/acceptance/openstack/db/v1/user_test.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ u "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func (c context) createUsers() {
+ users := []string{
+ tools.RandomString("user_", 5),
+ tools.RandomString("user_", 5),
+ tools.RandomString("user_", 5),
+ }
+
+ db1 := db.CreateOpts{Name: "db1"}
+ db2 := db.CreateOpts{Name: "db2"}
+ db3 := db.CreateOpts{Name: "db3"}
+
+ opts := u.BatchCreateOpts{
+ u.CreateOpts{
+ Name: users[0],
+ Password: tools.RandomString("", 5),
+ Databases: db.BatchCreateOpts{db1, db2, db3},
+ },
+ u.CreateOpts{
+ Name: users[1],
+ Password: tools.RandomString("", 5),
+ Databases: db.BatchCreateOpts{db1, db2},
+ },
+ u.CreateOpts{
+ Name: users[2],
+ Password: tools.RandomString("", 5),
+ Databases: db.BatchCreateOpts{db3},
+ },
+ }
+
+ err := u.Create(c.client, c.instanceID, opts).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Created three users on instance %s: %s, %s, %s", c.instanceID, users[0], users[1], users[2])
+ c.users = users
+}
+
+func (c context) listUsers() {
+ c.Logf("Listing databases on instance %s", c.instanceID)
+
+ err := db.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ dbList, err := db.ExtractDBs(page)
+ c.AssertNoErr(err)
+
+ for _, db := range dbList {
+ c.Logf("DB: %#v", db)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c context) deleteUsers() {
+ for _, id := range c.DBIDs {
+ err := db.Delete(c.client, c.instanceID, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted DB %s", id)
+ }
+}
diff --git a/acceptance/openstack/identity/v2/extension_test.go b/acceptance/openstack/identity/v2/extension_test.go
new file mode 100644
index 0000000..d1fa1e3
--- /dev/null
+++ b/acceptance/openstack/identity/v2/extension_test.go
@@ -0,0 +1,46 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ extensions2 "github.com/rackspace/gophercloud/openstack/identity/v2/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestEnumerateExtensions(t *testing.T) {
+ service := authenticatedClient(t)
+
+ t.Logf("Extensions available on this identity endpoint:")
+ count := 0
+ err := extensions2.List(service).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ extensions, err := extensions2.ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+
+ for i, ext := range extensions {
+ t.Logf("[%02d] name=[%s] namespace=[%s]", i, ext.Name, ext.Namespace)
+ t.Logf(" alias=[%s] updated=[%s]", ext.Alias, ext.Updated)
+ t.Logf(" description=[%s]", ext.Description)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func TestGetExtension(t *testing.T) {
+ service := authenticatedClient(t)
+
+ ext, err := extensions2.Get(service, "OS-KSCRUD").Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckEquals(t, "OpenStack Keystone User CRUD", ext.Name)
+ th.CheckEquals(t, "http://docs.openstack.org/identity/api/ext/OS-KSCRUD/v1.0", ext.Namespace)
+ th.CheckEquals(t, "OS-KSCRUD", ext.Alias)
+ th.CheckEquals(t, "OpenStack extensions to Keystone v2.0 API enabling User Operations.", ext.Description)
+}
diff --git a/acceptance/openstack/identity/v2/identity_test.go b/acceptance/openstack/identity/v2/identity_test.go
new file mode 100644
index 0000000..96bf1fd
--- /dev/null
+++ b/acceptance/openstack/identity/v2/identity_test.go
@@ -0,0 +1,47 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func v2AuthOptions(t *testing.T) gophercloud.AuthOptions {
+ // Obtain credentials from the environment.
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ // Trim out unused fields. Prefer authentication by API key to password.
+ ao.UserID, ao.DomainID, ao.DomainName = "", "", ""
+ if ao.APIKey != "" {
+ ao.Password = ""
+ }
+
+ return ao
+}
+
+func createClient(t *testing.T, auth bool) *gophercloud.ServiceClient {
+ ao := v2AuthOptions(t)
+
+ provider, err := openstack.NewClient(ao.IdentityEndpoint)
+ th.AssertNoErr(t, err)
+
+ if auth {
+ err = openstack.AuthenticateV2(provider, ao)
+ th.AssertNoErr(t, err)
+ }
+
+ return openstack.NewIdentityV2(provider)
+}
+
+func unauthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ return createClient(t, false)
+}
+
+func authenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ return createClient(t, true)
+}
diff --git a/acceptance/openstack/identity/v2/pkg.go b/acceptance/openstack/identity/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/identity/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/identity/v2/role_test.go b/acceptance/openstack/identity/v2/role_test.go
new file mode 100644
index 0000000..ba243fe
--- /dev/null
+++ b/acceptance/openstack/identity/v2/role_test.go
@@ -0,0 +1,58 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestRoles(t *testing.T) {
+ client := authenticatedClient(t)
+
+ tenantID := findTenant(t, client)
+ userID := createUser(t, client, tenantID)
+ roleID := listRoles(t, client)
+
+ addUserRole(t, client, tenantID, userID, roleID)
+
+ deleteUserRole(t, client, tenantID, userID, roleID)
+
+ deleteUser(t, client, userID)
+}
+
+func listRoles(t *testing.T, client *gophercloud.ServiceClient) string {
+ var roleID string
+
+ err := roles.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ roleList, err := roles.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ for _, role := range roleList {
+ t.Logf("Listing role: ID [%s] Name [%s]", role.ID, role.Name)
+ roleID = role.ID
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ return roleID
+}
+
+func addUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+ err := roles.AddUserRole(client, tenantID, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Added role %s to user %s", roleID, userID)
+}
+
+func deleteUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+ err := roles.DeleteUserRole(client, tenantID, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Removed role %s from user %s", roleID, userID)
+}
diff --git a/acceptance/openstack/identity/v2/tenant_test.go b/acceptance/openstack/identity/v2/tenant_test.go
new file mode 100644
index 0000000..578fc48
--- /dev/null
+++ b/acceptance/openstack/identity/v2/tenant_test.go
@@ -0,0 +1,32 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ tenants2 "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestEnumerateTenants(t *testing.T) {
+ service := authenticatedClient(t)
+
+ t.Logf("Tenants to which your current token grants access:")
+ count := 0
+ err := tenants2.List(service, nil).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ tenants, err := tenants2.ExtractTenants(page)
+ th.AssertNoErr(t, err)
+ for i, tenant := range tenants {
+ t.Logf("[%02d] name=[%s] id=[%s] description=[%s] enabled=[%v]",
+ i, tenant.Name, tenant.ID, tenant.Description, tenant.Enabled)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/identity/v2/token_test.go b/acceptance/openstack/identity/v2/token_test.go
new file mode 100644
index 0000000..e01b3b3
--- /dev/null
+++ b/acceptance/openstack/identity/v2/token_test.go
@@ -0,0 +1,54 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticateAndValidate(t *testing.T) {
+ // 1. TestAuthenticate
+ ao := v2AuthOptions(t)
+ service := unauthenticatedClient(t)
+
+ // Authenticated!
+ result := tokens2.Create(service, tokens2.WrapOptions(ao))
+
+ // Extract and print the token.
+ token, err := result.ExtractToken()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Acquired token: [%s]", token.ID)
+ t.Logf("The token will expire at: [%s]", token.ExpiresAt.String())
+ t.Logf("The token is valid for tenant: [%#v]", token.Tenant)
+
+ // Extract and print the service catalog.
+ catalog, err := result.ExtractServiceCatalog()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Acquired service catalog listing [%d] services", len(catalog.Entries))
+ for i, entry := range catalog.Entries {
+ t.Logf("[%02d]: name=[%s], type=[%s]", i, entry.Name, entry.Type)
+ for _, endpoint := range entry.Endpoints {
+ t.Logf(" - region=[%s] publicURL=[%s]", endpoint.Region, endpoint.PublicURL)
+ }
+ }
+
+ // 2. TestValidate
+ client := authenticatedClient(t)
+
+ // Validate Token!
+ getResult := tokens2.Get(client, token.ID)
+
+ // Extract and print the user.
+ user, err := getResult.ExtractUser()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Acquired User: [%s]", user.Name)
+ t.Logf("The User id: [%s]", user.ID)
+ t.Logf("The User username: [%s]", user.UserName)
+ t.Logf("The User roles: [%#v]", user.Roles)
+}
diff --git a/acceptance/openstack/identity/v2/user_test.go b/acceptance/openstack/identity/v2/user_test.go
new file mode 100644
index 0000000..fe73d19
--- /dev/null
+++ b/acceptance/openstack/identity/v2/user_test.go
@@ -0,0 +1,127 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestUsers(t *testing.T) {
+ client := authenticatedClient(t)
+
+ tenantID := findTenant(t, client)
+
+ userID := createUser(t, client, tenantID)
+
+ listUsers(t, client)
+
+ getUser(t, client, userID)
+
+ updateUser(t, client, userID)
+
+ listUserRoles(t, client, tenantID, userID)
+
+ deleteUser(t, client, userID)
+}
+
+func findTenant(t *testing.T, client *gophercloud.ServiceClient) string {
+ var tenantID string
+ err := tenants.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ tenantList, err := tenants.ExtractTenants(page)
+ th.AssertNoErr(t, err)
+
+ for _, t := range tenantList {
+ tenantID = t.ID
+ break
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ return tenantID
+}
+
+func createUser(t *testing.T, client *gophercloud.ServiceClient, tenantID string) string {
+ t.Log("Creating user")
+
+ opts := users.CreateOpts{
+ Name: tools.RandomString("user_", 5),
+ Enabled: users.Disabled,
+ TenantID: tenantID,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := users.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created user %s on tenant %s", user.ID, tenantID)
+
+ return user.ID
+}
+
+func listUsers(t *testing.T, client *gophercloud.ServiceClient) {
+ err := users.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ userList, err := users.ExtractUsers(page)
+ th.AssertNoErr(t, err)
+
+ for _, user := range userList {
+ t.Logf("Listing user: ID [%s] Name [%s] Email [%s] Enabled? [%s]",
+ user.ID, user.Name, user.Email, strconv.FormatBool(user.Enabled))
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ _, err := users.Get(client, userID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting user %s", userID)
+}
+
+func updateUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ opts := users.UpdateOpts{Name: tools.RandomString("new_name", 5), Email: "new@foo.com"}
+ user, err := users.Update(client, userID, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated user %s: Name [%s] Email [%s]", userID, user.Name, user.Email)
+}
+
+func listUserRoles(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID string) {
+ count := 0
+ err := users.ListRoles(client, tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+
+ roleList, err := users.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Listing roles for user %s", userID)
+
+ for _, r := range roleList {
+ t.Logf("- %s (%s)", r.Name, r.ID)
+ }
+
+ return true, nil
+ })
+
+ if count == 0 {
+ t.Logf("No roles for user %s", userID)
+ }
+
+ th.AssertNoErr(t, err)
+}
+
+func deleteUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ res := users.Delete(client, userID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted user %s", userID)
+}
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
new file mode 100644
index 0000000..ea893c2
--- /dev/null
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -0,0 +1,111 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
+ services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func TestListEndpoints(t *testing.T) {
+ // Create a service client.
+ serviceClient := createAuthenticatedClient(t)
+ if serviceClient == nil {
+ return
+ }
+
+ // Use the service to list all available endpoints.
+ pager := endpoints3.List(serviceClient, endpoints3.ListOpts{})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ endpoints, err := endpoints3.ExtractEndpoints(page)
+ if err != nil {
+ t.Fatalf("Error extracting endpoings: %v", err)
+ }
+
+ for _, endpoint := range endpoints {
+ t.Logf("Endpoint: %8s %10s %9s %s",
+ endpoint.ID,
+ endpoint.Availability,
+ endpoint.Name,
+ endpoint.URL)
+ }
+
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("Unexpected error while iterating endpoint pages: %v", err)
+ }
+}
+
+func TestNavigateCatalog(t *testing.T) {
+ // Create a service client.
+ client := createAuthenticatedClient(t)
+ if client == nil {
+ return
+ }
+
+ var compute *services3.Service
+ var endpoint *endpoints3.Endpoint
+
+ // Discover the service we're interested in.
+ servicePager := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+ err := servicePager.EachPage(func(page pagination.Page) (bool, error) {
+ part, err := services3.ExtractServices(page)
+ if err != nil {
+ return false, err
+ }
+ if compute != nil {
+ t.Fatalf("Expected one service, got more than one page")
+ return false, nil
+ }
+ if len(part) != 1 {
+ t.Fatalf("Expected one service, got %d", len(part))
+ return false, nil
+ }
+
+ compute = &part[0]
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("Unexpected error iterating pages: %v", err)
+ }
+
+ if compute == nil {
+ t.Fatalf("No compute service found.")
+ }
+
+ // Enumerate the endpoints available for this service.
+ computePager := endpoints3.List(client, endpoints3.ListOpts{
+ Availability: gophercloud.AvailabilityPublic,
+ ServiceID: compute.ID,
+ })
+ err = computePager.EachPage(func(page pagination.Page) (bool, error) {
+ part, err := endpoints3.ExtractEndpoints(page)
+ if err != nil {
+ return false, err
+ }
+ if endpoint != nil {
+ t.Fatalf("Expected one endpoint, got more than one page")
+ return false, nil
+ }
+ if len(part) != 1 {
+ t.Fatalf("Expected one endpoint, got %d", len(part))
+ return false, nil
+ }
+
+ endpoint = &part[0]
+ return true, nil
+ })
+
+ if endpoint == nil {
+ t.Fatalf("No endpoint found.")
+ }
+
+ t.Logf("Success. The compute endpoint is at %s.", endpoint.URL)
+}
diff --git a/acceptance/openstack/identity/v3/identity_test.go b/acceptance/openstack/identity/v3/identity_test.go
new file mode 100644
index 0000000..ce64345
--- /dev/null
+++ b/acceptance/openstack/identity/v3/identity_test.go
@@ -0,0 +1,39 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func createAuthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ // Obtain credentials from the environment.
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ // Trim out unused fields.
+ ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+
+ if ao.UserID == "" {
+ t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+ return nil
+ }
+
+ // Create a client and manually authenticate against v3.
+ providerClient, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ t.Fatalf("Unable to instantiate client: %v", err)
+ }
+
+ err = openstack.AuthenticateV3(providerClient, ao)
+ if err != nil {
+ t.Fatalf("Unable to authenticate against identity v3: %v", err)
+ }
+
+ // Create a service client.
+ return openstack.NewIdentityV3(providerClient)
+}
diff --git a/acceptance/openstack/identity/v3/pkg.go b/acceptance/openstack/identity/v3/pkg.go
new file mode 100644
index 0000000..eac3ae9
--- /dev/null
+++ b/acceptance/openstack/identity/v3/pkg.go
@@ -0,0 +1 @@
+package v3
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
new file mode 100644
index 0000000..082bd11
--- /dev/null
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -0,0 +1,36 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func TestListServices(t *testing.T) {
+ // Create a service client.
+ serviceClient := createAuthenticatedClient(t)
+ if serviceClient == nil {
+ return
+ }
+
+ // Use the client to list all available services.
+ pager := services3.List(serviceClient, services3.ListOpts{})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ parts, err := services3.ExtractServices(page)
+ if err != nil {
+ return false, err
+ }
+
+ t.Logf("--- Page ---")
+ for _, service := range parts {
+ t.Logf("Service: %32s %15s %10s %s", service.ID, service.Type, service.Name, *service.Description)
+ }
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("Unexpected error traversing pages: %v", err)
+ }
+}
diff --git a/acceptance/openstack/identity/v3/token_test.go b/acceptance/openstack/identity/v3/token_test.go
new file mode 100644
index 0000000..4342ade
--- /dev/null
+++ b/acceptance/openstack/identity/v3/token_test.go
@@ -0,0 +1,42 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack"
+ tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+)
+
+func TestGetToken(t *testing.T) {
+ // Obtain credentials from the environment.
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to acquire credentials: %v", err)
+ }
+
+ // Trim out unused fields. Skip if we don't have a UserID.
+ ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+ if ao.UserID == "" {
+ t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+ return
+ }
+
+ // Create an unauthenticated client.
+ provider, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ t.Fatalf("Unable to instantiate client: %v", err)
+ }
+
+ // Create a service client.
+ service := openstack.NewIdentityV3(provider)
+
+ // Use the service to create a token.
+ token, err := tokens3.Create(service, ao, nil).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get token: %v", err)
+ }
+
+ t.Logf("Acquired token: %s", token.ID)
+}
diff --git a/acceptance/openstack/networking/v2/apiversion_test.go b/acceptance/openstack/networking/v2/apiversion_test.go
new file mode 100644
index 0000000..99e1d01
--- /dev/null
+++ b/acceptance/openstack/networking/v2/apiversion_test.go
@@ -0,0 +1,51 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/apiversions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListAPIVersions(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ pager := apiversions.ListVersions(Client)
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ versions, err := apiversions.ExtractAPIVersions(page)
+ th.AssertNoErr(t, err)
+
+ for _, v := range versions {
+ t.Logf("API Version: ID [%s] Status [%s]", v.ID, v.Status)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
+
+func TestListAPIResources(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ pager := apiversions.ListVersionResources(Client, "v2.0")
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ vrs, err := apiversions.ExtractVersionResources(page)
+ th.AssertNoErr(t, err)
+
+ for _, vr := range vrs {
+ t.Logf("Network: Name [%s] Collection [%s]", vr.Name, vr.Collection)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
diff --git a/acceptance/openstack/networking/v2/common.go b/acceptance/openstack/networking/v2/common.go
new file mode 100644
index 0000000..1efac2c
--- /dev/null
+++ b/acceptance/openstack/networking/v2/common.go
@@ -0,0 +1,39 @@
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var Client *gophercloud.ServiceClient
+
+func NewClient() (*gophercloud.ServiceClient, error) {
+ opts, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ provider, err := openstack.AuthenticatedClient(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{
+ Name: "neutron",
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+func Setup(t *testing.T) {
+ client, err := NewClient()
+ th.AssertNoErr(t, err)
+ Client = client
+}
+
+func Teardown() {
+ Client = nil
+}
diff --git a/acceptance/openstack/networking/v2/extension_test.go b/acceptance/openstack/networking/v2/extension_test.go
new file mode 100644
index 0000000..edcbba4
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extension_test.go
@@ -0,0 +1,45 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListExts(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ pager := extensions.List(Client)
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ exts, err := extensions.ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+
+ for _, ext := range exts {
+ t.Logf("Extension: Name [%s] Description [%s]", ext.Name, ext.Description)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
+
+func TestGetExt(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ ext, err := extensions.Get(Client, "service-type").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, ext.Updated, "2013-01-20T00:00:00-00:00")
+ th.AssertEquals(t, ext.Name, "Neutron Service Type Management")
+ th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/neutron/service-type/api/v1.0")
+ th.AssertEquals(t, ext.Alias, "service-type")
+ th.AssertEquals(t, ext.Description, "API for retrieving service providers for Neutron advanced services")
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go
new file mode 100644
index 0000000..80246b6
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go
@@ -0,0 +1,116 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func firewallSetup(t *testing.T) string {
+ base.Setup(t)
+ return createPolicy(t, &policies.CreateOpts{})
+}
+
+func firewallTeardown(t *testing.T, policyID string) {
+ defer base.Teardown()
+ deletePolicy(t, policyID)
+}
+
+func TestFirewall(t *testing.T) {
+ policyID := firewallSetup(t)
+ defer firewallTeardown(t, policyID)
+
+ firewallID := createFirewall(t, &firewalls.CreateOpts{
+ Name: "gophercloud test",
+ Description: "acceptance test",
+ PolicyID: policyID,
+ })
+
+ waitForFirewallToBeActive(t, firewallID)
+
+ listFirewalls(t)
+
+ updateFirewall(t, firewallID, &firewalls.UpdateOpts{
+ Description: "acceptance test updated",
+ })
+
+ waitForFirewallToBeActive(t, firewallID)
+
+ deleteFirewall(t, firewallID)
+
+ waitForFirewallToBeDeleted(t, firewallID)
+}
+
+func createFirewall(t *testing.T, opts *firewalls.CreateOpts) string {
+ f, err := firewalls.Create(base.Client, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created firewall: %#v", opts)
+ return f.ID
+}
+
+func listFirewalls(t *testing.T) {
+ err := firewalls.List(base.Client, firewalls.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ firewallList, err := firewalls.ExtractFirewalls(page)
+ if err != nil {
+ t.Errorf("Failed to extract firewalls: %v", err)
+ return false, err
+ }
+
+ for _, r := range firewallList {
+ t.Logf("Listing firewalls: ID [%s]", r.ID)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func updateFirewall(t *testing.T, firewallID string, opts *firewalls.UpdateOpts) {
+ f, err := firewalls.Update(base.Client, firewallID, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated firewall ID [%s]", f.ID)
+}
+
+func getFirewall(t *testing.T, firewallID string) *firewalls.Firewall {
+ f, err := firewalls.Get(base.Client, firewallID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting firewall ID [%s]", f.ID)
+ return f
+}
+
+func deleteFirewall(t *testing.T, firewallID string) {
+ res := firewalls.Delete(base.Client, firewallID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted firewall %s", firewallID)
+}
+
+func waitForFirewallToBeActive(t *testing.T, firewallID string) {
+ for i := 0; i < 10; i++ {
+ fw := getFirewall(t, firewallID)
+ if fw.Status == "ACTIVE" {
+ break
+ }
+ time.Sleep(time.Second)
+ }
+}
+
+func waitForFirewallToBeDeleted(t *testing.T, firewallID string) {
+ for i := 0; i < 10; i++ {
+ err := firewalls.Get(base.Client, firewallID).Err
+ if err != nil {
+ httpStatus := err.(*gophercloud.UnexpectedResponseCodeError)
+ if httpStatus.Actual == 404 {
+ return
+ }
+ }
+ time.Sleep(time.Second)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go b/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go
new file mode 100644
index 0000000..206bf33
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go
@@ -0,0 +1 @@
+package fwaas
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go
new file mode 100644
index 0000000..fdca22e
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go
@@ -0,0 +1,107 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func firewallPolicySetup(t *testing.T) string {
+ base.Setup(t)
+ return createRule(t, &rules.CreateOpts{
+ Protocol: "tcp",
+ Action: "allow",
+ })
+}
+
+func firewallPolicyTeardown(t *testing.T, ruleID string) {
+ defer base.Teardown()
+ deleteRule(t, ruleID)
+}
+
+func TestFirewallPolicy(t *testing.T) {
+ ruleID := firewallPolicySetup(t)
+ defer firewallPolicyTeardown(t, ruleID)
+
+ policyID := createPolicy(t, &policies.CreateOpts{
+ Name: "gophercloud test",
+ Description: "acceptance test",
+ Rules: []string{
+ ruleID,
+ },
+ })
+
+ listPolicies(t)
+
+ updatePolicy(t, policyID, &policies.UpdateOpts{
+ Description: "acceptance test updated",
+ })
+
+ getPolicy(t, policyID)
+
+ removeRuleFromPolicy(t, policyID, ruleID)
+
+ addRuleToPolicy(t, policyID, ruleID)
+
+ deletePolicy(t, policyID)
+}
+
+func createPolicy(t *testing.T, opts *policies.CreateOpts) string {
+ p, err := policies.Create(base.Client, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created policy: %#v", opts)
+ return p.ID
+}
+
+func listPolicies(t *testing.T) {
+ err := policies.List(base.Client, policies.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ policyList, err := policies.ExtractPolicies(page)
+ if err != nil {
+ t.Errorf("Failed to extract policies: %v", err)
+ return false, err
+ }
+
+ for _, p := range policyList {
+ t.Logf("Listing policies: ID [%s]", p.ID)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func updatePolicy(t *testing.T, policyID string, opts *policies.UpdateOpts) {
+ p, err := policies.Update(base.Client, policyID, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated policy ID [%s]", p.ID)
+}
+
+func removeRuleFromPolicy(t *testing.T, policyID string, ruleID string) {
+ err := policies.RemoveRule(base.Client, policyID, ruleID)
+ th.AssertNoErr(t, err)
+ t.Logf("Removed rule [%s] from policy ID [%s]", ruleID, policyID)
+}
+
+func addRuleToPolicy(t *testing.T, policyID string, ruleID string) {
+ err := policies.InsertRule(base.Client, policyID, ruleID, "", "")
+ th.AssertNoErr(t, err)
+ t.Logf("Inserted rule [%s] into policy ID [%s]", ruleID, policyID)
+}
+
+func getPolicy(t *testing.T, policyID string) {
+ p, err := policies.Get(base.Client, policyID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting policy ID [%s]", p.ID)
+}
+
+func deletePolicy(t *testing.T, policyID string) {
+ res := policies.Delete(base.Client, policyID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted policy %s", policyID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go
new file mode 100644
index 0000000..144aa09
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go
@@ -0,0 +1,84 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestFirewallRules(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ ruleID := createRule(t, &rules.CreateOpts{
+ Name: "gophercloud_test",
+ Description: "acceptance test",
+ Protocol: "tcp",
+ Action: "allow",
+ DestinationIPAddress: "192.168.0.0/24",
+ DestinationPort: "22",
+ })
+
+ listRules(t)
+
+ destinationIPAddress := "192.168.1.0/24"
+ destinationPort := ""
+ sourcePort := "1234"
+
+ updateRule(t, ruleID, &rules.UpdateOpts{
+ DestinationIPAddress: &destinationIPAddress,
+ DestinationPort: &destinationPort,
+ SourcePort: &sourcePort,
+ })
+
+ getRule(t, ruleID)
+
+ deleteRule(t, ruleID)
+}
+
+func createRule(t *testing.T, opts *rules.CreateOpts) string {
+ r, err := rules.Create(base.Client, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created rule: %#v", opts)
+ return r.ID
+}
+
+func listRules(t *testing.T) {
+ err := rules.List(base.Client, rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ ruleList, err := rules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract rules: %v", err)
+ return false, err
+ }
+
+ for _, r := range ruleList {
+ t.Logf("Listing rules: ID [%s]", r.ID)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func updateRule(t *testing.T, ruleID string, opts *rules.UpdateOpts) {
+ r, err := rules.Update(base.Client, ruleID, *opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated rule ID [%s]", r.ID)
+}
+
+func getRule(t *testing.T, ruleID string) {
+ r, err := rules.Get(base.Client, ruleID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting rule ID [%s]", r.ID)
+}
+
+func deleteRule(t *testing.T, ruleID string) {
+ res := rules.Delete(base.Client, ruleID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted rule %s", ruleID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/layer3_test.go b/acceptance/openstack/networking/v2/extensions/layer3_test.go
new file mode 100644
index 0000000..63e0be3
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/layer3_test.go
@@ -0,0 +1,300 @@
+// +build acceptance networking layer3ext
+
+package extensions
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/external"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const (
+ cidr1 = "10.0.0.1/24"
+ cidr2 = "20.0.0.1/24"
+)
+
+func TestAll(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ testRouter(t)
+ testFloatingIP(t)
+}
+
+func testRouter(t *testing.T) {
+ // Setup: Create network
+ networkID := createNetwork(t)
+
+ // Create router
+ routerID := createRouter(t, networkID)
+
+ // Lists routers
+ listRouters(t)
+
+ // Update router
+ updateRouter(t, routerID)
+
+ // Get router
+ getRouter(t, routerID)
+
+ // Create new subnet. Note: this subnet will be deleted when networkID is deleted
+ subnetID := createSubnet(t, networkID, cidr2)
+
+ // Add interface
+ addInterface(t, routerID, subnetID)
+
+ // Remove interface
+ removeInterface(t, routerID, subnetID)
+
+ // Delete router
+ deleteRouter(t, routerID)
+
+ // Cleanup
+ deleteNetwork(t, networkID)
+}
+
+func testFloatingIP(t *testing.T) {
+ // Setup external network
+ extNetworkID := createNetwork(t)
+
+ // Setup internal network, subnet and port
+ intNetworkID, subnetID, portID := createInternalTopology(t)
+
+ // Now the important part: we need to allow the external network to talk to
+ // the internal subnet. For this we need a router that has an interface to
+ // the internal subnet.
+ routerID := bridgeIntSubnetWithExtNetwork(t, extNetworkID, subnetID)
+
+ // Create floating IP
+ ipID := createFloatingIP(t, extNetworkID, portID)
+
+ // Get floating IP
+ getFloatingIP(t, ipID)
+
+ // Update floating IP
+ updateFloatingIP(t, ipID, portID)
+
+ // Delete floating IP
+ deleteFloatingIP(t, ipID)
+
+ // Remove the internal subnet interface
+ removeInterface(t, routerID, subnetID)
+
+ // Delete router and external network
+ deleteRouter(t, routerID)
+ deleteNetwork(t, extNetworkID)
+
+ // Delete internal port and network
+ deletePort(t, portID)
+ deleteNetwork(t, intNetworkID)
+}
+
+func createNetwork(t *testing.T) string {
+ t.Logf("Creating a network")
+
+ asu := true
+ opts := external.CreateOpts{
+ Parent: networks.CreateOpts{Name: "sample_network", AdminStateUp: &asu},
+ External: true,
+ }
+ n, err := networks.Create(base.Client, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ if n.ID == "" {
+ t.Fatalf("No ID returned when creating a network")
+ }
+
+ createSubnet(t, n.ID, cidr1)
+
+ t.Logf("Network created: ID [%s]", n.ID)
+
+ return n.ID
+}
+
+func deleteNetwork(t *testing.T, networkID string) {
+ t.Logf("Deleting network %s", networkID)
+ networks.Delete(base.Client, networkID)
+}
+
+func deletePort(t *testing.T, portID string) {
+ t.Logf("Deleting port %s", portID)
+ ports.Delete(base.Client, portID)
+}
+
+func createInternalTopology(t *testing.T) (string, string, string) {
+ t.Logf("Creating an internal network (for port)")
+ opts := networks.CreateOpts{Name: "internal_network"}
+ n, err := networks.Create(base.Client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ // A subnet is also needed
+ subnetID := createSubnet(t, n.ID, cidr2)
+
+ t.Logf("Creating an internal port on network %s", n.ID)
+ p, err := ports.Create(base.Client, ports.CreateOpts{
+ NetworkID: n.ID,
+ Name: "fixed_internal_port",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ return n.ID, subnetID, p.ID
+}
+
+func bridgeIntSubnetWithExtNetwork(t *testing.T, networkID, subnetID string) string {
+ // Create router with external gateway info
+ routerID := createRouter(t, networkID)
+
+ // Add interface for internal subnet
+ addInterface(t, routerID, subnetID)
+
+ return routerID
+}
+
+func createSubnet(t *testing.T, networkID, cidr string) string {
+ t.Logf("Creating a subnet for network %s", networkID)
+
+ iFalse := false
+ s, err := subnets.Create(base.Client, subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: cidr,
+ IPVersion: subnets.IPv4,
+ Name: "my_subnet",
+ EnableDHCP: &iFalse,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Subnet created: ID [%s]", s.ID)
+
+ return s.ID
+}
+
+func createRouter(t *testing.T, networkID string) string {
+ t.Logf("Creating a router for network %s", networkID)
+
+ asu := false
+ gwi := routers.GatewayInfo{NetworkID: networkID}
+ r, err := routers.Create(base.Client, routers.CreateOpts{
+ Name: "foo_router",
+ AdminStateUp: &asu,
+ GatewayInfo: &gwi,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ if r.ID == "" {
+ t.Fatalf("No ID returned when creating a router")
+ }
+
+ t.Logf("Router created: ID [%s]", r.ID)
+
+ return r.ID
+}
+
+func listRouters(t *testing.T) {
+ pager := routers.List(base.Client, routers.ListOpts{})
+
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ routerList, err := routers.ExtractRouters(page)
+ th.AssertNoErr(t, err)
+
+ for _, r := range routerList {
+ t.Logf("Listing router: ID [%s] Name [%s] Status [%s] GatewayInfo [%#v]",
+ r.ID, r.Name, r.Status, r.GatewayInfo)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updateRouter(t *testing.T, routerID string) {
+ _, err := routers.Update(base.Client, routerID, routers.UpdateOpts{
+ Name: "another_name",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+}
+
+func getRouter(t *testing.T, routerID string) {
+ r, err := routers.Get(base.Client, routerID).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting router: ID [%s] Name [%s] Status [%s]", r.ID, r.Name, r.Status)
+}
+
+func addInterface(t *testing.T, routerID, subnetID string) {
+ ir, err := routers.AddInterface(base.Client, routerID, routers.InterfaceOpts{SubnetID: subnetID}).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Interface added to router %s: SubnetID [%s] PortID [%s]", routerID, ir.SubnetID, ir.PortID)
+}
+
+func removeInterface(t *testing.T, routerID, subnetID string) {
+ ir, err := routers.RemoveInterface(base.Client, routerID, routers.InterfaceOpts{SubnetID: subnetID}).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Interface %s removed from %s", ir.ID, routerID)
+}
+
+func deleteRouter(t *testing.T, routerID string) {
+ t.Logf("Deleting router %s", routerID)
+
+ res := routers.Delete(base.Client, routerID)
+
+ th.AssertNoErr(t, res.Err)
+}
+
+func createFloatingIP(t *testing.T, networkID, portID string) string {
+ t.Logf("Creating floating IP on network [%s] with port [%s]", networkID, portID)
+
+ opts := floatingips.CreateOpts{
+ FloatingNetworkID: networkID,
+ PortID: portID,
+ }
+
+ ip, err := floatingips.Create(base.Client, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Floating IP created: ID [%s] Status [%s] Fixed (internal) IP: [%s] Floating (external) IP: [%s]",
+ ip.ID, ip.Status, ip.FixedIP, ip.FloatingIP)
+
+ return ip.ID
+}
+
+func getFloatingIP(t *testing.T, ipID string) {
+ ip, err := floatingips.Get(base.Client, ipID).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting floating IP: ID [%s] Status [%s]", ip.ID, ip.Status)
+}
+
+func updateFloatingIP(t *testing.T, ipID, portID string) {
+ t.Logf("Disassociate all ports from IP %s", ipID)
+ _, err := floatingips.Update(base.Client, ipID, floatingips.UpdateOpts{PortID: ""}).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Re-associate the port %s", portID)
+ _, err = floatingips.Update(base.Client, ipID, floatingips.UpdateOpts{PortID: portID}).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func deleteFloatingIP(t *testing.T, ipID string) {
+ t.Logf("Deleting IP %s", ipID)
+ res := floatingips.Delete(base.Client, ipID)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/common.go b/acceptance/openstack/networking/v2/extensions/lbaas/common.go
new file mode 100644
index 0000000..27dfe5f
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/common.go
@@ -0,0 +1,78 @@
+package lbaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func SetupTopology(t *testing.T) (string, string) {
+ // create network
+ n, err := networks.Create(base.Client, networks.CreateOpts{Name: "tmp_network"}).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created network %s", n.ID)
+
+ // create subnet
+ s, err := subnets.Create(base.Client, subnets.CreateOpts{
+ NetworkID: n.ID,
+ CIDR: "192.168.199.0/24",
+ IPVersion: subnets.IPv4,
+ Name: "tmp_subnet",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created subnet %s", s.ID)
+
+ return n.ID, s.ID
+}
+
+func DeleteTopology(t *testing.T, networkID string) {
+ res := networks.Delete(base.Client, networkID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted network %s", networkID)
+}
+
+func CreatePool(t *testing.T, subnetID string) string {
+ p, err := pools.Create(base.Client, pools.CreateOpts{
+ LBMethod: pools.LBMethodRoundRobin,
+ Protocol: "HTTP",
+ Name: "tmp_pool",
+ SubnetID: subnetID,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created pool %s", p.ID)
+
+ return p.ID
+}
+
+func DeletePool(t *testing.T, poolID string) {
+ res := pools.Delete(base.Client, poolID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted pool %s", poolID)
+}
+
+func CreateMonitor(t *testing.T) string {
+ m, err := monitors.Create(base.Client, monitors.CreateOpts{
+ Delay: 10,
+ Timeout: 10,
+ MaxRetries: 3,
+ Type: monitors.TypeHTTP,
+ ExpectedCodes: "200",
+ URLPath: "/login",
+ HTTPMethod: "GET",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created monitor ID [%s]", m.ID)
+
+ return m.ID
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go
new file mode 100644
index 0000000..9b60582
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go
@@ -0,0 +1,95 @@
+// +build acceptance networking lbaas lbaasmember
+
+package lbaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/members"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestMembers(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // setup
+ networkID, subnetID := SetupTopology(t)
+ poolID := CreatePool(t, subnetID)
+
+ // create member
+ memberID := createMember(t, poolID)
+
+ // list members
+ listMembers(t)
+
+ // update member
+ updateMember(t, memberID)
+
+ // get member
+ getMember(t, memberID)
+
+ // delete member
+ deleteMember(t, memberID)
+
+ // teardown
+ DeletePool(t, poolID)
+ DeleteTopology(t, networkID)
+}
+
+func createMember(t *testing.T, poolID string) string {
+ m, err := members.Create(base.Client, members.CreateOpts{
+ Address: "192.168.199.1",
+ ProtocolPort: 8080,
+ PoolID: poolID,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created member: ID [%s] Status [%s] Weight [%d] Address [%s] Port [%d]",
+ m.ID, m.Status, m.Weight, m.Address, m.ProtocolPort)
+
+ return m.ID
+}
+
+func listMembers(t *testing.T) {
+ err := members.List(base.Client, members.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ memberList, err := members.ExtractMembers(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ for _, m := range memberList {
+ t.Logf("Listing member: ID [%s] Status [%s]", m.ID, m.Status)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updateMember(t *testing.T, memberID string) {
+ m, err := members.Update(base.Client, memberID, members.UpdateOpts{AdminStateUp: true}).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updated member ID [%s]", m.ID)
+}
+
+func getMember(t *testing.T, memberID string) {
+ m, err := members.Get(base.Client, memberID).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting member ID [%s]", m.ID)
+}
+
+func deleteMember(t *testing.T, memberID string) {
+ res := members.Delete(base.Client, memberID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted member %s", memberID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go
new file mode 100644
index 0000000..9056fff
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go
@@ -0,0 +1,77 @@
+// +build acceptance networking lbaas lbaasmonitor
+
+package lbaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestMonitors(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // create monitor
+ monitorID := CreateMonitor(t)
+
+ // list monitors
+ listMonitors(t)
+
+ // update monitor
+ updateMonitor(t, monitorID)
+
+ // get monitor
+ getMonitor(t, monitorID)
+
+ // delete monitor
+ deleteMonitor(t, monitorID)
+}
+
+func listMonitors(t *testing.T) {
+ err := monitors.List(base.Client, monitors.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ monitorList, err := monitors.ExtractMonitors(page)
+ if err != nil {
+ t.Errorf("Failed to extract monitors: %v", err)
+ return false, err
+ }
+
+ for _, m := range monitorList {
+ t.Logf("Listing monitor: ID [%s] Type [%s] Delay [%ds] Timeout [%d] Retries [%d] Status [%s]",
+ m.ID, m.Type, m.Delay, m.Timeout, m.MaxRetries, m.Status)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updateMonitor(t *testing.T, monitorID string) {
+ opts := monitors.UpdateOpts{Delay: 10, Timeout: 10, MaxRetries: 3}
+ m, err := monitors.Update(base.Client, monitorID, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updated monitor ID [%s]", m.ID)
+}
+
+func getMonitor(t *testing.T, monitorID string) {
+ m, err := monitors.Get(base.Client, monitorID).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting monitor ID [%s]: URL path [%s] HTTP Method [%s] Accepted codes [%s]",
+ m.ID, m.URLPath, m.HTTPMethod, m.ExpectedCodes)
+}
+
+func deleteMonitor(t *testing.T, monitorID string) {
+ res := monitors.Delete(base.Client, monitorID)
+
+ th.AssertNoErr(t, res.Err)
+
+ t.Logf("Deleted monitor %s", monitorID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go b/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go
new file mode 100644
index 0000000..f5a7df7
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go
@@ -0,0 +1 @@
+package lbaas
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go
new file mode 100644
index 0000000..8194064
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go
@@ -0,0 +1,98 @@
+// +build acceptance networking lbaas lbaaspool
+
+package lbaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPools(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // setup
+ networkID, subnetID := SetupTopology(t)
+
+ // create pool
+ poolID := CreatePool(t, subnetID)
+
+ // list pools
+ listPools(t)
+
+ // update pool
+ updatePool(t, poolID)
+
+ // get pool
+ getPool(t, poolID)
+
+ // create monitor
+ monitorID := CreateMonitor(t)
+
+ // associate health monitor
+ associateMonitor(t, poolID, monitorID)
+
+ // disassociate health monitor
+ disassociateMonitor(t, poolID, monitorID)
+
+ // delete pool
+ DeletePool(t, poolID)
+
+ // teardown
+ DeleteTopology(t, networkID)
+}
+
+func listPools(t *testing.T) {
+ err := pools.List(base.Client, pools.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ poolList, err := pools.ExtractPools(page)
+ if err != nil {
+ t.Errorf("Failed to extract pools: %v", err)
+ return false, err
+ }
+
+ for _, p := range poolList {
+ t.Logf("Listing pool: ID [%s] Name [%s] Status [%s] LB algorithm [%s]", p.ID, p.Name, p.Status, p.LBMethod)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updatePool(t *testing.T, poolID string) {
+ opts := pools.UpdateOpts{Name: "SuperPool", LBMethod: pools.LBMethodLeastConnections}
+ p, err := pools.Update(base.Client, poolID, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updated pool ID [%s]", p.ID)
+}
+
+func getPool(t *testing.T, poolID string) {
+ p, err := pools.Get(base.Client, poolID).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting pool ID [%s]", p.ID)
+}
+
+func associateMonitor(t *testing.T, poolID, monitorID string) {
+ res := pools.AssociateMonitor(base.Client, poolID, monitorID)
+
+ th.AssertNoErr(t, res.Err)
+
+ t.Logf("Associated pool %s with monitor %s", poolID, monitorID)
+}
+
+func disassociateMonitor(t *testing.T, poolID, monitorID string) {
+ res := pools.DisassociateMonitor(base.Client, poolID, monitorID)
+
+ th.AssertNoErr(t, res.Err)
+
+ t.Logf("Disassociated pool %s with monitor %s", poolID, monitorID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go
new file mode 100644
index 0000000..c8dff2d
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go
@@ -0,0 +1,101 @@
+// +build acceptance networking lbaas lbaasvip
+
+package lbaas
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/lbaas/vips"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestVIPs(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // setup
+ networkID, subnetID := SetupTopology(t)
+ poolID := CreatePool(t, subnetID)
+
+ // create VIP
+ VIPID := createVIP(t, subnetID, poolID)
+
+ // list VIPs
+ listVIPs(t)
+
+ // update VIP
+ updateVIP(t, VIPID)
+
+ // get VIP
+ getVIP(t, VIPID)
+
+ // delete VIP
+ deleteVIP(t, VIPID)
+
+ // teardown
+ DeletePool(t, poolID)
+ DeleteTopology(t, networkID)
+}
+
+func createVIP(t *testing.T, subnetID, poolID string) string {
+ p, err := vips.Create(base.Client, vips.CreateOpts{
+ Protocol: "HTTP",
+ Name: "New_VIP",
+ AdminStateUp: vips.Up,
+ SubnetID: subnetID,
+ PoolID: poolID,
+ ProtocolPort: 80,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created pool %s", p.ID)
+
+ return p.ID
+}
+
+func listVIPs(t *testing.T) {
+ err := vips.List(base.Client, vips.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ vipList, err := vips.ExtractVIPs(page)
+ if err != nil {
+ t.Errorf("Failed to extract VIPs: %v", err)
+ return false, err
+ }
+
+ for _, vip := range vipList {
+ t.Logf("Listing VIP: ID [%s] Name [%s] Address [%s] Port [%s] Connection Limit [%d]",
+ vip.ID, vip.Name, vip.Address, vip.ProtocolPort, vip.ConnLimit)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func updateVIP(t *testing.T, VIPID string) {
+ i1000 := 1000
+ _, err := vips.Update(base.Client, VIPID, vips.UpdateOpts{ConnLimit: &i1000}).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updated VIP ID [%s]", VIPID)
+}
+
+func getVIP(t *testing.T, VIPID string) {
+ vip, err := vips.Get(base.Client, VIPID).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Getting VIP ID [%s]: Status [%s]", vip.ID, vip.Status)
+}
+
+func deleteVIP(t *testing.T, VIPID string) {
+ res := vips.Delete(base.Client, VIPID)
+
+ th.AssertNoErr(t, res.Err)
+
+ t.Logf("Deleted VIP %s", VIPID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/pkg.go b/acceptance/openstack/networking/v2/extensions/pkg.go
new file mode 100644
index 0000000..aeec0fa
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/pkg.go
@@ -0,0 +1 @@
+package extensions
diff --git a/acceptance/openstack/networking/v2/extensions/provider_test.go b/acceptance/openstack/networking/v2/extensions/provider_test.go
new file mode 100644
index 0000000..f10c9d9
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/provider_test.go
@@ -0,0 +1,68 @@
+// +build acceptance networking
+
+package extensions
+
+import (
+ "strconv"
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // Create a network
+ n, err := networks.Create(base.Client, networks.CreateOpts{Name: "sample_network", AdminStateUp: networks.Up}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Name, "sample_network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ networkID := n.ID
+
+ // List networks
+ pager := networks.List(base.Client, networks.ListOpts{Limit: 2})
+ err = pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ networkList, err := networks.ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range networkList {
+ t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+ n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+
+ // Get a network
+ if networkID == "" {
+ t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+ }
+ n, err = networks.Get(base.Client, networkID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{})
+ th.AssertEquals(t, n.Name, "sample_network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.Shared, false)
+ th.AssertEquals(t, n.ID, networkID)
+
+ // Update network
+ n, err = networks.Update(base.Client, networkID, networks.UpdateOpts{Name: "new_network_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Name, "new_network_name")
+
+ // Delete network
+ res := networks.Delete(base.Client, networkID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateMultipleNetworks(t *testing.T) {
+ //networks.CreateMany()
+}
diff --git a/acceptance/openstack/networking/v2/extensions/security_test.go b/acceptance/openstack/networking/v2/extensions/security_test.go
new file mode 100644
index 0000000..7d75292
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/security_test.go
@@ -0,0 +1,171 @@
+// +build acceptance networking security
+
+package extensions
+
+import (
+ "testing"
+
+ base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/groups"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSecurityGroups(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // create security group
+ groupID := createSecGroup(t)
+
+ // delete security group
+ defer deleteSecGroup(t, groupID)
+
+ // list security group
+ listSecGroups(t)
+
+ // get security group
+ getSecGroup(t, groupID)
+
+ // create port with security group
+ networkID, portID := createPort(t, groupID)
+
+ // teardown
+ defer deleteNetwork(t, networkID)
+
+ // delete port
+ defer deletePort(t, portID)
+}
+
+func TestSecurityGroupRules(t *testing.T) {
+ base.Setup(t)
+ defer base.Teardown()
+
+ // create security group
+ groupID := createSecGroup(t)
+
+ defer deleteSecGroup(t, groupID)
+
+ // create security group rule
+ ruleID := createSecRule(t, groupID)
+
+ // delete security group rule
+ defer deleteSecRule(t, ruleID)
+
+ // list security group rule
+ listSecRules(t)
+
+ // get security group rule
+ getSecRule(t, ruleID)
+}
+
+func createSecGroup(t *testing.T) string {
+ sg, err := groups.Create(base.Client, groups.CreateOpts{
+ Name: "new-webservers",
+ Description: "security group for webservers",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created security group %s", sg.ID)
+
+ return sg.ID
+}
+
+func listSecGroups(t *testing.T) {
+ err := groups.List(base.Client, groups.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ list, err := groups.ExtractGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract secgroups: %v", err)
+ return false, err
+ }
+
+ for _, sg := range list {
+ t.Logf("Listing security group: ID [%s] Name [%s]", sg.ID, sg.Name)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getSecGroup(t *testing.T, id string) {
+ sg, err := groups.Get(base.Client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting security group: ID [%s] Name [%s] Description [%s]", sg.ID, sg.Name, sg.Description)
+}
+
+func createPort(t *testing.T, groupID string) (string, string) {
+ n, err := networks.Create(base.Client, networks.CreateOpts{Name: "tmp_network"}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created network %s", n.ID)
+
+ opts := ports.CreateOpts{
+ NetworkID: n.ID,
+ Name: "my_port",
+ SecurityGroups: []string{groupID},
+ }
+ p, err := ports.Create(base.Client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created port %s with security group %s", p.ID, groupID)
+
+ return n.ID, p.ID
+}
+
+func deleteSecGroup(t *testing.T, groupID string) {
+ res := groups.Delete(base.Client, groupID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted security group %s", groupID)
+}
+
+func createSecRule(t *testing.T, groupID string) string {
+ r, err := rules.Create(base.Client, rules.CreateOpts{
+ Direction: "ingress",
+ PortRangeMin: 80,
+ EtherType: "IPv4",
+ PortRangeMax: 80,
+ Protocol: "tcp",
+ SecGroupID: groupID,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created security group rule %s", r.ID)
+
+ return r.ID
+}
+
+func listSecRules(t *testing.T) {
+ err := rules.List(base.Client, rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ list, err := rules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract sec rules: %v", err)
+ return false, err
+ }
+
+ for _, r := range list {
+ t.Logf("Listing security rule: ID [%s]", r.ID)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getSecRule(t *testing.T, id string) {
+ r, err := rules.Get(base.Client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting security rule: ID [%s] Direction [%s] EtherType [%s] Protocol [%s]",
+ r.ID, r.Direction, r.EtherType, r.Protocol)
+}
+
+func deleteSecRule(t *testing.T, id string) {
+ res := rules.Delete(base.Client, id)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted security rule %s", id)
+}
diff --git a/acceptance/openstack/networking/v2/network_test.go b/acceptance/openstack/networking/v2/network_test.go
new file mode 100644
index 0000000..be8a3a1
--- /dev/null
+++ b/acceptance/openstack/networking/v2/network_test.go
@@ -0,0 +1,68 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Create a network
+ n, err := networks.Create(Client, networks.CreateOpts{Name: "sample_network", AdminStateUp: networks.Up}).Extract()
+ th.AssertNoErr(t, err)
+ defer networks.Delete(Client, n.ID)
+ th.AssertEquals(t, n.Name, "sample_network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ networkID := n.ID
+
+ // List networks
+ pager := networks.List(Client, networks.ListOpts{Limit: 2})
+ err = pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ networkList, err := networks.ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range networkList {
+ t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+ n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+
+ // Get a network
+ if networkID == "" {
+ t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+ }
+ n, err = networks.Get(Client, networkID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{})
+ th.AssertEquals(t, n.Name, "sample_network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.Shared, false)
+ th.AssertEquals(t, n.ID, networkID)
+
+ // Update network
+ n, err = networks.Update(Client, networkID, networks.UpdateOpts{Name: "new_network_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Name, "new_network_name")
+
+ // Delete network
+ res := networks.Delete(Client, networkID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateMultipleNetworks(t *testing.T) {
+ //networks.CreateMany()
+}
diff --git a/acceptance/openstack/networking/v2/pkg.go b/acceptance/openstack/networking/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/networking/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/networking/v2/port_test.go b/acceptance/openstack/networking/v2/port_test.go
new file mode 100644
index 0000000..03e8e27
--- /dev/null
+++ b/acceptance/openstack/networking/v2/port_test.go
@@ -0,0 +1,117 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPortCRUD(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Setup network
+ t.Log("Setting up network")
+ networkID, err := createNetwork()
+ th.AssertNoErr(t, err)
+ defer networks.Delete(Client, networkID)
+
+ // Setup subnet
+ t.Logf("Setting up subnet on network %s", networkID)
+ subnetID, err := createSubnet(networkID)
+ th.AssertNoErr(t, err)
+ defer subnets.Delete(Client, subnetID)
+
+ // Create port
+ t.Logf("Create port based on subnet %s", subnetID)
+ portID := createPort(t, networkID, subnetID)
+
+ // List ports
+ t.Logf("Listing all ports")
+ listPorts(t)
+
+ // Get port
+ if portID == "" {
+ t.Fatalf("In order to retrieve a port, the portID must be set")
+ }
+ p, err := ports.Get(Client, portID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, p.ID, portID)
+
+ // Update port
+ p, err = ports.Update(Client, portID, ports.UpdateOpts{Name: "new_port_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, p.Name, "new_port_name")
+
+ // Delete port
+ res := ports.Delete(Client, portID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func createPort(t *testing.T, networkID, subnetID string) string {
+ enable := false
+ opts := ports.CreateOpts{
+ NetworkID: networkID,
+ Name: "my_port",
+ AdminStateUp: &enable,
+ FixedIPs: []ports.IP{ports.IP{SubnetID: subnetID}},
+ }
+ p, err := ports.Create(Client, opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, p.NetworkID, networkID)
+ th.AssertEquals(t, p.Name, "my_port")
+ th.AssertEquals(t, p.AdminStateUp, false)
+
+ return p.ID
+}
+
+func listPorts(t *testing.T) {
+ count := 0
+ pager := ports.List(Client, ports.ListOpts{})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("--- Page ---")
+
+ portList, err := ports.ExtractPorts(page)
+ th.AssertNoErr(t, err)
+
+ for _, p := range portList {
+ t.Logf("Port: ID [%s] Name [%s] Status [%s] MAC addr [%s] Fixed IPs [%#v] Security groups [%#v]",
+ p.ID, p.Name, p.Status, p.MACAddress, p.FixedIPs, p.SecurityGroups)
+ }
+
+ return true, nil
+ })
+
+ th.CheckNoErr(t, err)
+
+ if count == 0 {
+ t.Logf("No pages were iterated over when listing ports")
+ }
+}
+
+func createNetwork() (string, error) {
+ res, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: networks.Up}).Extract()
+ return res.ID, err
+}
+
+func createSubnet(networkID string) (string, error) {
+ s, err := subnets.Create(Client, subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: "192.168.199.0/24",
+ IPVersion: subnets.IPv4,
+ Name: "my_subnet",
+ EnableDHCP: subnets.Down,
+ }).Extract()
+ return s.ID, err
+}
+
+func TestPortBatchCreate(t *testing.T) {
+ // todo
+}
diff --git a/acceptance/openstack/networking/v2/subnet_test.go b/acceptance/openstack/networking/v2/subnet_test.go
new file mode 100644
index 0000000..097a303
--- /dev/null
+++ b/acceptance/openstack/networking/v2/subnet_test.go
@@ -0,0 +1,86 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ pager := subnets.List(Client, subnets.ListOpts{Limit: 2})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ subnetList, err := subnets.ExtractSubnets(page)
+ th.AssertNoErr(t, err)
+
+ for _, s := range subnetList {
+ t.Logf("Subnet: ID [%s] Name [%s] IP Version [%d] CIDR [%s] GatewayIP [%s]",
+ s.ID, s.Name, s.IPVersion, s.CIDR, s.GatewayIP)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
+
+func TestCRUD(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Setup network
+ t.Log("Setting up network")
+ n, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: networks.Up}).Extract()
+ th.AssertNoErr(t, err)
+ networkID := n.ID
+ defer networks.Delete(Client, networkID)
+
+ // Create subnet
+ t.Log("Create subnet")
+ enable := false
+ opts := subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: "192.168.199.0/24",
+ IPVersion: subnets.IPv4,
+ Name: "my_subnet",
+ EnableDHCP: &enable,
+ }
+ s, err := subnets.Create(Client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.NetworkID, networkID)
+ th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.Name, "my_subnet")
+ th.AssertEquals(t, s.EnableDHCP, false)
+ subnetID := s.ID
+
+ // Get subnet
+ t.Log("Getting subnet")
+ s, err = subnets.Get(Client, subnetID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, s.ID, subnetID)
+
+ // Update subnet
+ t.Log("Update subnet")
+ s, err = subnets.Update(Client, subnetID, subnets.UpdateOpts{Name: "new_subnet_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, s.Name, "new_subnet_name")
+
+ // Delete subnet
+ t.Log("Delete subnet")
+ res := subnets.Delete(Client, subnetID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestBatchCreate(t *testing.T) {
+ // todo
+}
diff --git a/acceptance/openstack/objectstorage/v1/accounts_test.go b/acceptance/openstack/objectstorage/v1/accounts_test.go
new file mode 100644
index 0000000..24cc62b
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/accounts_test.go
@@ -0,0 +1,50 @@
+// +build acceptance
+
+package v1
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/objectstorage/v1/accounts"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAccounts(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ // Update an account's metadata.
+ updateres := accounts.Update(client, accounts.UpdateOpts{Metadata: metadata})
+ t.Logf("Update Account Response: %+v\n", updateres)
+ updateHeaders, err := updateres.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Update Account Response Headers: %+v\n", updateHeaders)
+
+ // Defer the deletion of the metadata set above.
+ defer func() {
+ tempMap := make(map[string]string)
+ for k := range metadata {
+ tempMap[k] = ""
+ }
+ updateres = accounts.Update(client, accounts.UpdateOpts{Metadata: tempMap})
+ th.AssertNoErr(t, updateres.Err)
+ }()
+
+ // Extract the custom metadata from the 'Get' response.
+ res := accounts.Get(client, nil)
+
+ h, err := res.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Get Account Response Headers: %+v\n", h)
+
+ am, err := res.ExtractMetadata()
+ th.AssertNoErr(t, err)
+ for k := range metadata {
+ if am[k] != metadata[strings.Title(k)] {
+ t.Errorf("Expected custom metadata with key: %s", k)
+ return
+ }
+ }
+}
diff --git a/acceptance/openstack/objectstorage/v1/common.go b/acceptance/openstack/objectstorage/v1/common.go
new file mode 100644
index 0000000..1eac681
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/common.go
@@ -0,0 +1,28 @@
+// +build acceptance
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var metadata = map[string]string{"gopher": "cloud"}
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := openstack.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := openstack.NewObjectStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+ th.AssertNoErr(t, err)
+ return c
+}
diff --git a/acceptance/openstack/objectstorage/v1/containers_test.go b/acceptance/openstack/objectstorage/v1/containers_test.go
new file mode 100644
index 0000000..8328a4f
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/containers_test.go
@@ -0,0 +1,137 @@
+// +build acceptance
+
+package v1
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+// numContainers is the number of containers to create for testing.
+var numContainers = 2
+
+func TestContainers(t *testing.T) {
+ // Create a new client to execute the HTTP requests. See common.go for newClient body.
+ client := newClient(t)
+
+ // Create a slice of random container names.
+ cNames := make([]string, numContainers)
+ for i := 0; i < numContainers; i++ {
+ cNames[i] = tools.RandomString("gophercloud-test-container-", 8)
+ }
+
+ // Create numContainers containers.
+ for i := 0; i < len(cNames); i++ {
+ res := containers.Create(client, cNames[i], nil)
+ th.AssertNoErr(t, res.Err)
+ }
+ // Delete the numContainers containers after function completion.
+ defer func() {
+ for i := 0; i < len(cNames); i++ {
+ res := containers.Delete(client, cNames[i])
+ th.AssertNoErr(t, res.Err)
+ }
+ }()
+
+ // List the numContainer names that were just created. To just list those,
+ // the 'prefix' parameter is used.
+ err := containers.List(client, &containers.ListOpts{Full: true, Prefix: "gophercloud-test-container-"}).EachPage(func(page pagination.Page) (bool, error) {
+ containerList, err := containers.ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range containerList {
+ t.Logf("Container: Name [%s] Count [%d] Bytes [%d]",
+ n.Name, n.Count, n.Bytes)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ // List the info for the numContainer containers that were created.
+ err = containers.List(client, &containers.ListOpts{Full: false, Prefix: "gophercloud-test-container-"}).EachPage(func(page pagination.Page) (bool, error) {
+ containerList, err := containers.ExtractNames(page)
+ th.AssertNoErr(t, err)
+ for _, n := range containerList {
+ t.Logf("Container: Name [%s]", n)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ // Update one of the numContainer container metadata.
+ updateres := containers.Update(client, cNames[0], &containers.UpdateOpts{Metadata: metadata})
+ th.AssertNoErr(t, updateres.Err)
+ // After the tests are done, delete the metadata that was set.
+ defer func() {
+ tempMap := make(map[string]string)
+ for k := range metadata {
+ tempMap[k] = ""
+ }
+ res := containers.Update(client, cNames[0], &containers.UpdateOpts{Metadata: tempMap})
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ // Retrieve a container's metadata.
+ cm, err := containers.Get(client, cNames[0]).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ for k := range metadata {
+ if cm[k] != metadata[strings.Title(k)] {
+ t.Errorf("Expected custom metadata with key: %s", k)
+ }
+ }
+}
+
+func TestListAllContainers(t *testing.T) {
+ // Create a new client to execute the HTTP requests. See common.go for newClient body.
+ client := newClient(t)
+
+ numContainers := 20
+
+ // Create a slice of random container names.
+ cNames := make([]string, numContainers)
+ for i := 0; i < numContainers; i++ {
+ cNames[i] = tools.RandomString("gophercloud-test-container-", 8)
+ }
+
+ // Create numContainers containers.
+ for i := 0; i < len(cNames); i++ {
+ res := containers.Create(client, cNames[i], nil)
+ th.AssertNoErr(t, res.Err)
+ }
+ // Delete the numContainers containers after function completion.
+ defer func() {
+ for i := 0; i < len(cNames); i++ {
+ res := containers.Delete(client, cNames[i])
+ th.AssertNoErr(t, res.Err)
+ }
+ }()
+
+ // List all the numContainer names that were just created. To just list those,
+ // the 'prefix' parameter is used.
+ allPages, err := containers.List(client, &containers.ListOpts{Full: true, Limit: 5, Prefix: "gophercloud-test-container-"}).AllPages()
+ th.AssertNoErr(t, err)
+ containerInfoList, err := containers.ExtractInfo(allPages)
+ th.AssertNoErr(t, err)
+ for _, n := range containerInfoList {
+ t.Logf("Container: Name [%s] Count [%d] Bytes [%d]",
+ n.Name, n.Count, n.Bytes)
+ }
+ th.AssertEquals(t, numContainers, len(containerInfoList))
+
+ // List the info for all the numContainer containers that were created.
+ allPages, err = containers.List(client, &containers.ListOpts{Full: false, Limit: 2, Prefix: "gophercloud-test-container-"}).AllPages()
+ th.AssertNoErr(t, err)
+ containerNamesList, err := containers.ExtractNames(allPages)
+ th.AssertNoErr(t, err)
+ for _, n := range containerNamesList {
+ t.Logf("Container: Name [%s]", n)
+ }
+ th.AssertEquals(t, numContainers, len(containerNamesList))
+}
diff --git a/acceptance/openstack/objectstorage/v1/objects_test.go b/acceptance/openstack/objectstorage/v1/objects_test.go
new file mode 100644
index 0000000..a8de338
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/objects_test.go
@@ -0,0 +1,119 @@
+// +build acceptance
+
+package v1
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+// numObjects is the number of objects to create for testing.
+var numObjects = 2
+
+func TestObjects(t *testing.T) {
+ // Create a provider client for executing the HTTP request.
+ // See common.go for more information.
+ client := newClient(t)
+
+ // Make a slice of length numObjects to hold the random object names.
+ oNames := make([]string, numObjects)
+ for i := 0; i < len(oNames); i++ {
+ oNames[i] = tools.RandomString("test-object-", 8)
+ }
+
+ // Create a container to hold the test objects.
+ cName := tools.RandomString("test-container-", 8)
+ header, err := containers.Create(client, cName, nil).ExtractHeader()
+ th.AssertNoErr(t, err)
+ t.Logf("Create object headers: %+v\n", header)
+
+ // Defer deletion of the container until after testing.
+ defer func() {
+ res := containers.Delete(client, cName)
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ // Create a slice of buffers to hold the test object content.
+ oContents := make([]*bytes.Buffer, numObjects)
+ for i := 0; i < numObjects; i++ {
+ oContents[i] = bytes.NewBuffer([]byte(tools.RandomString("", 10)))
+ res := objects.Create(client, cName, oNames[i], oContents[i], nil)
+ th.AssertNoErr(t, res.Err)
+ }
+ // Delete the objects after testing.
+ defer func() {
+ for i := 0; i < numObjects; i++ {
+ res := objects.Delete(client, cName, oNames[i], nil)
+ th.AssertNoErr(t, res.Err)
+ }
+ }()
+
+ ons := make([]string, 0, len(oNames))
+ err = objects.List(client, cName, &objects.ListOpts{Full: false, Prefix: "test-object-"}).EachPage(func(page pagination.Page) (bool, error) {
+ names, err := objects.ExtractNames(page)
+ th.AssertNoErr(t, err)
+ ons = append(ons, names...)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, len(ons), len(oNames))
+
+ ois := make([]objects.Object, 0, len(oNames))
+ err = objects.List(client, cName, &objects.ListOpts{Full: true, Prefix: "test-object-"}).EachPage(func(page pagination.Page) (bool, error) {
+ info, err := objects.ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ ois = append(ois, info...)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, len(ois), len(oNames))
+
+ // Copy the contents of one object to another.
+ copyres := objects.Copy(client, cName, oNames[0], &objects.CopyOpts{Destination: cName + "/" + oNames[1]})
+ th.AssertNoErr(t, copyres.Err)
+
+ // Download one of the objects that was created above.
+ o1Content, err := objects.Download(client, cName, oNames[0], nil).ExtractContent()
+ th.AssertNoErr(t, err)
+
+ // Download the another object that was create above.
+ o2Content, err := objects.Download(client, cName, oNames[1], nil).ExtractContent()
+ th.AssertNoErr(t, err)
+
+ // Compare the two object's contents to test that the copy worked.
+ th.AssertEquals(t, string(o2Content), string(o1Content))
+
+ // Update an object's metadata.
+ updateres := objects.Update(client, cName, oNames[0], &objects.UpdateOpts{Metadata: metadata})
+ th.AssertNoErr(t, updateres.Err)
+
+ // Delete the object's metadata after testing.
+ defer func() {
+ tempMap := make(map[string]string)
+ for k := range metadata {
+ tempMap[k] = ""
+ }
+ res := objects.Update(client, cName, oNames[0], &objects.UpdateOpts{Metadata: tempMap})
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ // Retrieve an object's metadata.
+ om, err := objects.Get(client, cName, oNames[0], nil).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ for k := range metadata {
+ if om[k] != metadata[strings.Title(k)] {
+ t.Errorf("Expected custom metadata with key: %s", k)
+ return
+ }
+ }
+}
diff --git a/acceptance/openstack/orchestration/v1/buildinfo_test.go b/acceptance/openstack/orchestration/v1/buildinfo_test.go
new file mode 100644
index 0000000..05a5e1d
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/buildinfo_test.go
@@ -0,0 +1,20 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/buildinfo"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBuildInfo(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ bi, err := buildinfo.Get(client).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved build info: %+v\n", bi)
+}
diff --git a/acceptance/openstack/orchestration/v1/common.go b/acceptance/openstack/orchestration/v1/common.go
new file mode 100644
index 0000000..2c28dcb
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/common.go
@@ -0,0 +1,44 @@
+// +build acceptance
+
+package v1
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var template = fmt.Sprintf(`
+{
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {},
+ "resources": {
+ "hello_world": {
+ "type":"OS::Nova::Server",
+ "properties": {
+ "flavor": "%s",
+ "image": "%s",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+}`, os.Getenv("OS_FLAVOR_ID"), os.Getenv("OS_IMAGE_ID"))
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := openstack.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := openstack.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := openstack.NewOrchestrationV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+ th.AssertNoErr(t, err)
+ return c
+}
diff --git a/acceptance/openstack/orchestration/v1/hello-compute.json b/acceptance/openstack/orchestration/v1/hello-compute.json
new file mode 100644
index 0000000..11cfc80
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/hello-compute.json
@@ -0,0 +1,13 @@
+{
+ "heat_template_version": "2013-05-23",
+ "resources": {
+ "compute_instance": {
+ "type": "OS::Nova::Server",
+ "properties": {
+ "flavor": "m1.small",
+ "image": "cirros-0.3.2-x86_64-disk",
+ "name": "Single Compute Instance"
+ }
+ }
+ }
+}
diff --git a/acceptance/openstack/orchestration/v1/stackevents_test.go b/acceptance/openstack/orchestration/v1/stackevents_test.go
new file mode 100644
index 0000000..e356c86
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackevents_test.go
@@ -0,0 +1,68 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackevents"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackEvents(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+ resourceName := "hello_world"
+ var eventID string
+
+ createOpts := stacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ err = stackevents.List(client, stackName, stack.ID, nil).EachPage(func(page pagination.Page) (bool, error) {
+ events, err := stackevents.ExtractEvents(page)
+ th.AssertNoErr(t, err)
+ t.Logf("listed events: %+v\n", events)
+ eventID = events[0].ID
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ err = stackevents.ListResourceEvents(client, stackName, stack.ID, resourceName, nil).EachPage(func(page pagination.Page) (bool, error) {
+ resourceEvents, err := stackevents.ExtractEvents(page)
+ th.AssertNoErr(t, err)
+ t.Logf("listed resource events: %+v\n", resourceEvents)
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ event, err := stackevents.Get(client, stackName, stack.ID, resourceName, eventID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved event: %+v\n", event)
+}
diff --git a/acceptance/openstack/orchestration/v1/stackresources_test.go b/acceptance/openstack/orchestration/v1/stackresources_test.go
new file mode 100644
index 0000000..b614f1c
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackresources_test.go
@@ -0,0 +1,62 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackresources"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackResources(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+
+ createOpts := stacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ resourceName := "hello_world"
+ resource, err := stackresources.Get(client, stackName, stack.ID, resourceName).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack resource: %+v\n", resource)
+
+ metadata, err := stackresources.Metadata(client, stackName, stack.ID, resourceName).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack resource metadata: %+v\n", metadata)
+
+ err = stackresources.List(client, stackName, stack.ID, stackresources.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ resources, err := stackresources.ExtractResources(page)
+ th.AssertNoErr(t, err)
+ t.Logf("resources: %+v\n", resources)
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/orchestration/v1/stacks_test.go b/acceptance/openstack/orchestration/v1/stacks_test.go
new file mode 100644
index 0000000..db31cd4
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacks_test.go
@@ -0,0 +1,153 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStacks(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"
+ createOpts := stacks.CreateOpts{
+ Name: stackName1,
+ Template: template,
+ 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 := stacks.UpdateOpts{
+ Template: template,
+ 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 := stacks.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)
+}
+
+// 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
new file mode 100644
index 0000000..22d5e88
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
@@ -0,0 +1,75 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacktemplates"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackTemplates(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+
+ createOpts := stacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ tmpl, err := stacktemplates.Get(client, stackName, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved template: %+v\n", tmpl)
+
+ 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": {
+ "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",
+ },
+ },
+ },
+ }`}
+ validatedTemplate, err := stacktemplates.Validate(client, validateOpts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("validated template: %+v\n", validatedTemplate)
+}
diff --git a/acceptance/openstack/pkg.go b/acceptance/openstack/pkg.go
new file mode 100644
index 0000000..3a8ecdb
--- /dev/null
+++ b/acceptance/openstack/pkg.go
@@ -0,0 +1,4 @@
+// +build acceptance
+
+package openstack
+
diff --git a/acceptance/rackspace/blockstorage/v1/common.go b/acceptance/rackspace/blockstorage/v1/common.go
new file mode 100644
index 0000000..e9fdd99
--- /dev/null
+++ b/acceptance/rackspace/blockstorage/v1/common.go
@@ -0,0 +1,38 @@
+// +build acceptance
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient() (*gophercloud.ServiceClient, error) {
+ opts, err := rackspace.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+ opts = tools.OnlyRS(opts)
+ region := os.Getenv("RS_REGION")
+
+ provider, err := rackspace.AuthenticatedClient(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return rackspace.NewBlockStorageV1(provider, gophercloud.EndpointOpts{
+ Region: region,
+ })
+}
+
+func setup(t *testing.T) *gophercloud.ServiceClient {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ return client
+}
diff --git a/acceptance/rackspace/blockstorage/v1/snapshot_test.go b/acceptance/rackspace/blockstorage/v1/snapshot_test.go
new file mode 100644
index 0000000..25b2cfe
--- /dev/null
+++ b/acceptance/rackspace/blockstorage/v1/snapshot_test.go
@@ -0,0 +1,82 @@
+// +build acceptance blockstorage snapshots
+
+package v1
+
+import (
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/blockstorage/v1/snapshots"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSnapshots(t *testing.T) {
+ client := setup(t)
+ volID := testVolumeCreate(t, client)
+
+ t.Log("Creating snapshots")
+ s := testSnapshotCreate(t, client, volID)
+ id := s.ID
+
+ t.Log("Listing snapshots")
+ testSnapshotList(t, client)
+
+ t.Logf("Getting snapshot %s", id)
+ testSnapshotGet(t, client, id)
+
+ t.Logf("Updating snapshot %s", id)
+ testSnapshotUpdate(t, client, id)
+
+ t.Logf("Deleting snapshot %s", id)
+ testSnapshotDelete(t, client, id)
+ s.WaitUntilDeleted(client, -1)
+
+ t.Logf("Deleting volume %s", volID)
+ testVolumeDelete(t, client, volID)
+}
+
+func testSnapshotCreate(t *testing.T, client *gophercloud.ServiceClient, volID string) *snapshots.Snapshot {
+ opts := snapshots.CreateOpts{VolumeID: volID, Name: "snapshot-001"}
+ s, err := snapshots.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created snapshot %s", s.ID)
+
+ t.Logf("Waiting for new snapshot to become available...")
+ start := time.Now().Second()
+ s.WaitUntilComplete(client, -1)
+ t.Logf("Snapshot completed after %ds", time.Now().Second()-start)
+
+ return s
+}
+
+func testSnapshotList(t *testing.T, client *gophercloud.ServiceClient) {
+ snapshots.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ sList, err := snapshots.ExtractSnapshots(page)
+ th.AssertNoErr(t, err)
+
+ for _, s := range sList {
+ t.Logf("Snapshot: ID [%s] Name [%s] Volume ID [%s] Progress [%s] Created [%s]",
+ s.ID, s.Name, s.VolumeID, s.Progress, s.CreatedAt)
+ }
+
+ return true, nil
+ })
+}
+
+func testSnapshotGet(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ _, err := snapshots.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func testSnapshotUpdate(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ _, err := snapshots.Update(client, id, snapshots.UpdateOpts{Name: "new_name"}).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func testSnapshotDelete(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ res := snapshots.Delete(client, id)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted snapshot %s", id)
+}
diff --git a/acceptance/rackspace/blockstorage/v1/volume_test.go b/acceptance/rackspace/blockstorage/v1/volume_test.go
new file mode 100644
index 0000000..f86f9ad
--- /dev/null
+++ b/acceptance/rackspace/blockstorage/v1/volume_test.go
@@ -0,0 +1,71 @@
+// +build acceptance blockstorage volumes
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/blockstorage/v1/volumes"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestVolumes(t *testing.T) {
+ client := setup(t)
+
+ t.Logf("Listing volumes")
+ testVolumeList(t, client)
+
+ t.Logf("Creating volume")
+ volumeID := testVolumeCreate(t, client)
+
+ t.Logf("Getting volume %s", volumeID)
+ testVolumeGet(t, client, volumeID)
+
+ t.Logf("Updating volume %s", volumeID)
+ testVolumeUpdate(t, client, volumeID)
+
+ t.Logf("Deleting volume %s", volumeID)
+ testVolumeDelete(t, client, volumeID)
+}
+
+func testVolumeList(t *testing.T, client *gophercloud.ServiceClient) {
+ volumes.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ vList, err := volumes.ExtractVolumes(page)
+ th.AssertNoErr(t, err)
+
+ for _, v := range vList {
+ t.Logf("Volume: ID [%s] Name [%s] Type [%s] Created [%s]", v.ID, v.Name,
+ v.VolumeType, v.CreatedAt)
+ }
+
+ return true, nil
+ })
+}
+
+func testVolumeCreate(t *testing.T, client *gophercloud.ServiceClient) string {
+ vol, err := volumes.Create(client, os.CreateOpts{Size: 75}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created volume: ID [%s] Size [%s]", vol.ID, vol.Size)
+ return vol.ID
+}
+
+func testVolumeGet(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ vol, err := volumes.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created volume: ID [%s] Size [%s]", vol.ID, vol.Size)
+}
+
+func testVolumeUpdate(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ vol, err := volumes.Update(client, id, volumes.UpdateOpts{Name: "new_name"}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created volume: ID [%s] Name [%s]", vol.ID, vol.Name)
+}
+
+func testVolumeDelete(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ res := volumes.Delete(client, id)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted volume %s", id)
+}
diff --git a/acceptance/rackspace/blockstorage/v1/volume_type_test.go b/acceptance/rackspace/blockstorage/v1/volume_type_test.go
new file mode 100644
index 0000000..716f2b9
--- /dev/null
+++ b/acceptance/rackspace/blockstorage/v1/volume_type_test.go
@@ -0,0 +1,46 @@
+// +build acceptance blockstorage volumetypes
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/blockstorage/v1/volumetypes"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAll(t *testing.T) {
+ client := setup(t)
+
+ t.Logf("Listing volume types")
+ id := testList(t, client)
+
+ t.Logf("Getting volume type %s", id)
+ testGet(t, client, id)
+}
+
+func testList(t *testing.T, client *gophercloud.ServiceClient) string {
+ var lastID string
+
+ volumetypes.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ typeList, err := volumetypes.ExtractVolumeTypes(page)
+ th.AssertNoErr(t, err)
+
+ for _, vt := range typeList {
+ t.Logf("Volume type: ID [%s] Name [%s]", vt.ID, vt.Name)
+ lastID = vt.ID
+ }
+
+ return true, nil
+ })
+
+ return lastID
+}
+
+func testGet(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ vt, err := volumetypes.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Volume: ID [%s] Name [%s]", vt.ID, vt.Name)
+}
diff --git a/acceptance/rackspace/cdn/v1/base_test.go b/acceptance/rackspace/cdn/v1/base_test.go
new file mode 100644
index 0000000..135f5b3
--- /dev/null
+++ b/acceptance/rackspace/cdn/v1/base_test.go
@@ -0,0 +1,32 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/cdn/v1/base"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBaseOps(t *testing.T) {
+ client := newClient(t)
+ t.Log("Retrieving Home Document")
+ testHomeDocumentGet(t, client)
+
+ t.Log("Pinging root URL")
+ testPing(t, client)
+}
+
+func testHomeDocumentGet(t *testing.T, client *gophercloud.ServiceClient) {
+ hd, err := base.Get(client).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Retrieved home document: %+v", *hd)
+}
+
+func testPing(t *testing.T, client *gophercloud.ServiceClient) {
+ err := base.Ping(client).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Successfully pinged root URL")
+}
diff --git a/acceptance/rackspace/cdn/v1/common.go b/acceptance/rackspace/cdn/v1/common.go
new file mode 100644
index 0000000..2333ca7
--- /dev/null
+++ b/acceptance/rackspace/cdn/v1/common.go
@@ -0,0 +1,23 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := rackspace.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := rackspace.NewCDNV1(client, gophercloud.EndpointOpts{})
+ th.AssertNoErr(t, err)
+ return c
+}
diff --git a/acceptance/rackspace/cdn/v1/flavor_test.go b/acceptance/rackspace/cdn/v1/flavor_test.go
new file mode 100644
index 0000000..f26cff0
--- /dev/null
+++ b/acceptance/rackspace/cdn/v1/flavor_test.go
@@ -0,0 +1,47 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/cdn/v1/flavors"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestFlavor(t *testing.T) {
+ client := newClient(t)
+
+ t.Log("Listing Flavors")
+ id := testFlavorsList(t, client)
+
+ t.Log("Retrieving Flavor")
+ testFlavorGet(t, client, id)
+}
+
+func testFlavorsList(t *testing.T, client *gophercloud.ServiceClient) string {
+ var id string
+ err := flavors.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ flavorList, err := os.ExtractFlavors(page)
+ th.AssertNoErr(t, err)
+
+ for _, flavor := range flavorList {
+ t.Logf("Listing flavor: ID [%s] Providers [%+v]", flavor.ID, flavor.Providers)
+ id = flavor.ID
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ return id
+}
+
+func testFlavorGet(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ flavor, err := flavors.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Retrieved Flavor: %+v", *flavor)
+}
diff --git a/acceptance/rackspace/cdn/v1/service_test.go b/acceptance/rackspace/cdn/v1/service_test.go
new file mode 100644
index 0000000..c19c241
--- /dev/null
+++ b/acceptance/rackspace/cdn/v1/service_test.go
@@ -0,0 +1,93 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/services"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/cdn/v1/services"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestService(t *testing.T) {
+ client := newClient(t)
+
+ t.Log("Creating Service")
+ loc := testServiceCreate(t, client, "test-site-1")
+ t.Logf("Created service at location: %s", loc)
+
+ defer testServiceDelete(t, client, loc)
+
+ t.Log("Updating Service")
+ testServiceUpdate(t, client, loc)
+
+ t.Log("Retrieving Service")
+ testServiceGet(t, client, loc)
+
+ t.Log("Listing Services")
+ testServiceList(t, client)
+}
+
+func testServiceCreate(t *testing.T, client *gophercloud.ServiceClient, name string) string {
+ createOpts := os.CreateOpts{
+ Name: name,
+ Domains: []os.Domain{
+ os.Domain{
+ Domain: "www." + name + ".com",
+ },
+ },
+ Origins: []os.Origin{
+ os.Origin{
+ Origin: name + ".com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ FlavorID: "cdn",
+ }
+ l, err := services.Create(client, createOpts).Extract()
+ th.AssertNoErr(t, err)
+ return l
+}
+
+func testServiceGet(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ s, err := services.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Retrieved service: %+v", *s)
+}
+
+func testServiceUpdate(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ opts := os.UpdateOpts{
+ os.Append{
+ Value: os.Domain{Domain: "newDomain.com", Protocol: "http"},
+ },
+ }
+
+ loc, err := services.Update(client, id, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Successfully updated service at location: %s", loc)
+}
+
+func testServiceList(t *testing.T, client *gophercloud.ServiceClient) {
+ err := services.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ serviceList, err := os.ExtractServices(page)
+ th.AssertNoErr(t, err)
+
+ for _, service := range serviceList {
+ t.Logf("Listing service: %+v", service)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func testServiceDelete(t *testing.T, client *gophercloud.ServiceClient, id string) {
+ err := services.Delete(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Successfully deleted service (%s)", id)
+}
diff --git a/acceptance/rackspace/cdn/v1/serviceasset_test.go b/acceptance/rackspace/cdn/v1/serviceasset_test.go
new file mode 100644
index 0000000..c32bf25
--- /dev/null
+++ b/acceptance/rackspace/cdn/v1/serviceasset_test.go
@@ -0,0 +1,32 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ osServiceAssets "github.com/rackspace/gophercloud/openstack/cdn/v1/serviceassets"
+ "github.com/rackspace/gophercloud/rackspace/cdn/v1/serviceassets"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestServiceAsset(t *testing.T) {
+ client := newClient(t)
+
+ t.Log("Creating Service")
+ loc := testServiceCreate(t, client, "test-site-2")
+ t.Logf("Created service at location: %s", loc)
+
+ t.Log("Deleting Service Assets")
+ testServiceAssetDelete(t, client, loc)
+}
+
+func testServiceAssetDelete(t *testing.T, client *gophercloud.ServiceClient, url string) {
+ deleteOpts := osServiceAssets.DeleteOpts{
+ All: true,
+ }
+ err := serviceassets.Delete(client, url, deleteOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Log("Successfully deleted all Service Assets")
+}
diff --git a/acceptance/rackspace/client_test.go b/acceptance/rackspace/client_test.go
new file mode 100644
index 0000000..61214c0
--- /dev/null
+++ b/acceptance/rackspace/client_test.go
@@ -0,0 +1,28 @@
+// +build acceptance
+
+package rackspace
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClient(t *testing.T) {
+ // Obtain credentials from the environment.
+ ao, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := rackspace.AuthenticatedClient(tools.OnlyRS(ao))
+ if err != nil {
+ t.Fatalf("Unable to authenticate: %v", err)
+ }
+
+ if client.TokenID == "" {
+ t.Errorf("No token ID assigned to the client")
+ }
+
+ t.Logf("Client successfully acquired a token: %v", client.TokenID)
+}
diff --git a/acceptance/rackspace/compute/v2/bootfromvolume_test.go b/acceptance/rackspace/compute/v2/bootfromvolume_test.go
new file mode 100644
index 0000000..d7e6aa7
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/bootfromvolume_test.go
@@ -0,0 +1,49 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/bootfromvolume"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBootFromVolume(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ options, err := optionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ name := tools.RandomString("Gophercloud-", 8)
+ t.Logf("Creating server [%s].", name)
+
+ bd := []osBFV.BlockDevice{
+ osBFV.BlockDevice{
+ UUID: options.imageID,
+ SourceType: osBFV.Image,
+ VolumeSize: 10,
+ },
+ }
+
+ server, err := bootfromvolume.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: "performance1-1",
+ BlockDevice: bd,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created server: %+v\n", server)
+ defer deleteServer(t, client, server)
+
+ getServer(t, client, server)
+
+ listServers(t, client)
+}
diff --git a/acceptance/rackspace/compute/v2/compute_test.go b/acceptance/rackspace/compute/v2/compute_test.go
new file mode 100644
index 0000000..3ca6dc9
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/compute_test.go
@@ -0,0 +1,60 @@
+// +build acceptance
+
+package v2
+
+import (
+ "errors"
+ "os"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+)
+
+func newClient() (*gophercloud.ServiceClient, error) {
+ // Obtain credentials from the environment.
+ options, err := rackspace.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+ options = tools.OnlyRS(options)
+ region := os.Getenv("RS_REGION")
+
+ if options.Username == "" {
+ return nil, errors.New("Please provide a Rackspace username as RS_USERNAME.")
+ }
+ if options.APIKey == "" {
+ return nil, errors.New("Please provide a Rackspace API key as RS_API_KEY.")
+ }
+ if region == "" {
+ return nil, errors.New("Please provide a Rackspace region as RS_REGION.")
+ }
+
+ client, err := rackspace.AuthenticatedClient(options)
+ if err != nil {
+ return nil, err
+ }
+
+ return rackspace.NewComputeV2(client, gophercloud.EndpointOpts{
+ Region: region,
+ })
+}
+
+type serverOpts struct {
+ imageID string
+ flavorID string
+}
+
+func optionsFromEnv() (*serverOpts, error) {
+ options := &serverOpts{
+ imageID: os.Getenv("RS_IMAGE_ID"),
+ flavorID: os.Getenv("RS_FLAVOR_ID"),
+ }
+ if options.imageID == "" {
+ return nil, errors.New("Please provide a valid Rackspace image ID as RS_IMAGE_ID")
+ }
+ if options.flavorID == "" {
+ return nil, errors.New("Please provide a valid Rackspace flavor ID as RS_FLAVOR_ID")
+ }
+ return options, nil
+}
diff --git a/acceptance/rackspace/compute/v2/flavors_test.go b/acceptance/rackspace/compute/v2/flavors_test.go
new file mode 100644
index 0000000..4618ecc
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/flavors_test.go
@@ -0,0 +1,61 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/flavors"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListFlavors(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ count := 0
+ err = flavors.ListDetail(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("-- Page %0d --", count)
+
+ fs, err := flavors.ExtractFlavors(page)
+ th.AssertNoErr(t, err)
+
+ for i, flavor := range fs {
+ t.Logf("[%02d] id=[%s]", i, flavor.ID)
+ t.Logf(" name=[%s]", flavor.Name)
+ t.Logf(" disk=[%d]", flavor.Disk)
+ t.Logf(" RAM=[%d]", flavor.RAM)
+ t.Logf(" rxtx_factor=[%f]", flavor.RxTxFactor)
+ t.Logf(" swap=[%d]", flavor.Swap)
+ t.Logf(" VCPUs=[%d]", flavor.VCPUs)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No flavors listed!")
+ }
+}
+
+func TestGetFlavor(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ options, err := optionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ flavor, err := flavors.Get(client, options.flavorID).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Requested flavor:")
+ t.Logf(" id=[%s]", flavor.ID)
+ t.Logf(" name=[%s]", flavor.Name)
+ t.Logf(" disk=[%d]", flavor.Disk)
+ t.Logf(" RAM=[%d]", flavor.RAM)
+ t.Logf(" rxtx_factor=[%f]", flavor.RxTxFactor)
+ t.Logf(" swap=[%d]", flavor.Swap)
+ t.Logf(" VCPUs=[%d]", flavor.VCPUs)
+}
diff --git a/acceptance/rackspace/compute/v2/images_test.go b/acceptance/rackspace/compute/v2/images_test.go
new file mode 100644
index 0000000..5e36c2e
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/images_test.go
@@ -0,0 +1,63 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/images"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListImages(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ count := 0
+ err = images.ListDetail(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("-- Page %02d --", count)
+
+ is, err := images.ExtractImages(page)
+ th.AssertNoErr(t, err)
+
+ for i, image := range is {
+ t.Logf("[%02d] id=[%s]", i, image.ID)
+ t.Logf(" name=[%s]", image.Name)
+ t.Logf(" created=[%s]", image.Created)
+ t.Logf(" updated=[%s]", image.Updated)
+ t.Logf(" min disk=[%d]", image.MinDisk)
+ t.Logf(" min RAM=[%d]", image.MinRAM)
+ t.Logf(" progress=[%d]", image.Progress)
+ t.Logf(" status=[%s]", image.Status)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count < 1 {
+ t.Errorf("Expected at least one page of images.")
+ }
+}
+
+func TestGetImage(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ options, err := optionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ image, err := images.Get(client, options.imageID).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Requested image:")
+ t.Logf(" id=[%s]", image.ID)
+ t.Logf(" name=[%s]", image.Name)
+ t.Logf(" created=[%s]", image.Created)
+ t.Logf(" updated=[%s]", image.Updated)
+ t.Logf(" min disk=[%d]", image.MinDisk)
+ t.Logf(" min RAM=[%d]", image.MinRAM)
+ t.Logf(" progress=[%d]", image.Progress)
+ t.Logf(" status=[%s]", image.Status)
+}
diff --git a/acceptance/rackspace/compute/v2/keypairs_test.go b/acceptance/rackspace/compute/v2/keypairs_test.go
new file mode 100644
index 0000000..9bd6eb4
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/keypairs_test.go
@@ -0,0 +1,87 @@
+// +build acceptance rackspace
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/keypairs"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func deleteKeyPair(t *testing.T, client *gophercloud.ServiceClient, name string) {
+ err := keypairs.Delete(client, name).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Successfully deleted key [%s].", name)
+}
+
+func TestCreateKeyPair(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ name := tools.RandomString("createdkey-", 8)
+ k, err := keypairs.Create(client, os.CreateOpts{Name: name}).Extract()
+ th.AssertNoErr(t, err)
+ defer deleteKeyPair(t, client, name)
+
+ t.Logf("Created a new keypair:")
+ t.Logf(" name=[%s]", k.Name)
+ t.Logf(" fingerprint=[%s]", k.Fingerprint)
+ t.Logf(" publickey=[%s]", tools.Elide(k.PublicKey))
+ t.Logf(" privatekey=[%s]", tools.Elide(k.PrivateKey))
+ t.Logf(" userid=[%s]", k.UserID)
+}
+
+func TestImportKeyPair(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ name := tools.RandomString("importedkey-", 8)
+ pubkey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDlIQ3r+zd97kb9Hzmujd3V6pbO53eb3Go4q2E8iqVGWQfZTrFdL9KACJnqJIm9HmncfRkUTxE37hqeGCCv8uD+ZPmPiZG2E60OX1mGDjbbzAyReRwYWXgXHopggZTLak5k4mwZYaxwaufbVBDRn847e01lZnaXaszEToLM37NLw+uz29sl3TwYy2R0RGHPwPc160aWmdLjSyd1Nd4c9pvvOP/EoEuBjIC6NJJwg2Rvg9sjjx9jYj0QUgc8CqKLN25oMZ69kNJzlFylKRUoeeVr89txlR59yehJWk6Uw6lYFTdJmcmQOFVAJ12RMmS1hLWCM8UzAgtw+EDa0eqBxBDl smash@winter"
+
+ k, err := keypairs.Create(client, os.CreateOpts{
+ Name: name,
+ PublicKey: pubkey,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer deleteKeyPair(t, client, name)
+
+ th.CheckEquals(t, pubkey, k.PublicKey)
+ th.CheckEquals(t, "", k.PrivateKey)
+
+ t.Logf("Imported an existing keypair:")
+ t.Logf(" name=[%s]", k.Name)
+ t.Logf(" fingerprint=[%s]", k.Fingerprint)
+ t.Logf(" publickey=[%s]", tools.Elide(k.PublicKey))
+ t.Logf(" privatekey=[%s]", tools.Elide(k.PrivateKey))
+ t.Logf(" userid=[%s]", k.UserID)
+}
+
+func TestListKeyPairs(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ count := 0
+ err = keypairs.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("--- %02d ---", count)
+
+ ks, err := keypairs.ExtractKeyPairs(page)
+ th.AssertNoErr(t, err)
+
+ for i, keypair := range ks {
+ t.Logf("[%02d] name=[%s]", i, keypair.Name)
+ t.Logf(" fingerprint=[%s]", keypair.Fingerprint)
+ t.Logf(" publickey=[%s]", tools.Elide(keypair.PublicKey))
+ t.Logf(" privatekey=[%s]", tools.Elide(keypair.PrivateKey))
+ t.Logf(" userid=[%s]", keypair.UserID)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/rackspace/compute/v2/networks_test.go b/acceptance/rackspace/compute/v2/networks_test.go
new file mode 100644
index 0000000..e8fc4d3
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/networks_test.go
@@ -0,0 +1,53 @@
+// +build acceptance rackspace
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/networks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNetworks(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ // Create a network
+ n, err := networks.Create(client, networks.CreateOpts{Label: "sample_network", CIDR: "172.20.0.0/24"}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created network: %+v\n", n)
+ defer networks.Delete(client, n.ID)
+ th.AssertEquals(t, n.Label, "sample_network")
+ th.AssertEquals(t, n.CIDR, "172.20.0.0/24")
+ networkID := n.ID
+
+ // List networks
+ pager := networks.List(client)
+ err = pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ networkList, err := networks.ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range networkList {
+ t.Logf("Network: ID [%s] Label [%s] CIDR [%s]",
+ n.ID, n.Label, n.CIDR)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+
+ // Get a network
+ if networkID == "" {
+ t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+ }
+ n, err = networks.Get(client, networkID).Extract()
+ t.Logf("Retrieved Network: %+v\n", n)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.CIDR, "172.20.0.0/24")
+ th.AssertEquals(t, n.Label, "sample_network")
+ th.AssertEquals(t, n.ID, networkID)
+}
diff --git a/acceptance/rackspace/compute/v2/pkg.go b/acceptance/rackspace/compute/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/rackspace/compute/v2/servers_test.go b/acceptance/rackspace/compute/v2/servers_test.go
new file mode 100644
index 0000000..a8b5937
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/servers_test.go
@@ -0,0 +1,217 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+ oskey "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/keypairs"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func createServerKeyPair(t *testing.T, client *gophercloud.ServiceClient) *oskey.KeyPair {
+ name := tools.RandomString("importedkey-", 8)
+ pubkey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDlIQ3r+zd97kb9Hzmujd3V6pbO53eb3Go4q2E8iqVGWQfZTrFdL9KACJnqJIm9HmncfRkUTxE37hqeGCCv8uD+ZPmPiZG2E60OX1mGDjbbzAyReRwYWXgXHopggZTLak5k4mwZYaxwaufbVBDRn847e01lZnaXaszEToLM37NLw+uz29sl3TwYy2R0RGHPwPc160aWmdLjSyd1Nd4c9pvvOP/EoEuBjIC6NJJwg2Rvg9sjjx9jYj0QUgc8CqKLN25oMZ69kNJzlFylKRUoeeVr89txlR59yehJWk6Uw6lYFTdJmcmQOFVAJ12RMmS1hLWCM8UzAgtw+EDa0eqBxBDl smash@winter"
+
+ k, err := keypairs.Create(client, oskey.CreateOpts{
+ Name: name,
+ PublicKey: pubkey,
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ return k
+}
+
+func createServer(t *testing.T, client *gophercloud.ServiceClient, keyName string) *os.Server {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ options, err := optionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ name := tools.RandomString("Gophercloud-", 8)
+
+ pwd := tools.MakeNewPassword("")
+
+ opts := &servers.CreateOpts{
+ Name: name,
+ ImageRef: options.imageID,
+ FlavorRef: options.flavorID,
+ DiskConfig: diskconfig.Manual,
+ AdminPass: pwd,
+ }
+
+ if keyName != "" {
+ opts.KeyPair = keyName
+ }
+
+ t.Logf("Creating server [%s].", name)
+ s, err := servers.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Creating server.")
+
+ err = servers.WaitForStatus(client, s.ID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+ t.Logf("Server created successfully.")
+
+ th.CheckEquals(t, pwd, s.AdminPass)
+
+ return s
+}
+
+func logServer(t *testing.T, server *os.Server, index int) {
+ if index == -1 {
+ t.Logf(" id=[%s]", server.ID)
+ } else {
+ t.Logf("[%02d] id=[%s]", index, server.ID)
+ }
+ t.Logf(" name=[%s]", server.Name)
+ t.Logf(" tenant ID=[%s]", server.TenantID)
+ t.Logf(" user ID=[%s]", server.UserID)
+ t.Logf(" updated=[%s]", server.Updated)
+ t.Logf(" created=[%s]", server.Created)
+ t.Logf(" host ID=[%s]", server.HostID)
+ t.Logf(" access IPv4=[%s]", server.AccessIPv4)
+ t.Logf(" access IPv6=[%s]", server.AccessIPv6)
+ t.Logf(" image=[%v]", server.Image)
+ t.Logf(" flavor=[%v]", server.Flavor)
+ t.Logf(" addresses=[%v]", server.Addresses)
+ t.Logf(" metadata=[%v]", server.Metadata)
+ t.Logf(" links=[%v]", server.Links)
+ t.Logf(" keyname=[%s]", server.KeyName)
+ t.Logf(" admin password=[%s]", server.AdminPass)
+ t.Logf(" status=[%s]", server.Status)
+ t.Logf(" progress=[%d]", server.Progress)
+}
+
+func getServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.Get")
+
+ details, err := servers.Get(client, server.ID).Extract()
+ th.AssertNoErr(t, err)
+ logServer(t, details, -1)
+}
+
+func updateServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.Get")
+
+ opts := os.UpdateOpts{
+ Name: "updated-server",
+ }
+ updatedServer, err := servers.Update(client, server.ID, opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "updated-server", updatedServer.Name)
+ logServer(t, updatedServer, -1)
+}
+
+func listServers(t *testing.T, client *gophercloud.ServiceClient) {
+ t.Logf("> servers.List")
+
+ count := 0
+ err := servers.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("--- Page %02d ---", count)
+
+ s, err := servers.ExtractServers(page)
+ th.AssertNoErr(t, err)
+ for index, server := range s {
+ logServer(t, &server, index)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func changeAdminPassword(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.ChangeAdminPassword")
+
+ original := server.AdminPass
+
+ t.Logf("Changing server password.")
+ err := servers.ChangeAdminPassword(client, server.ID, tools.MakeNewPassword(original)).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ err = servers.WaitForStatus(client, server.ID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+ t.Logf("Password changed successfully.")
+}
+
+func rebootServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.Reboot")
+
+ err := servers.Reboot(client, server.ID, os.HardReboot).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ err = servers.WaitForStatus(client, server.ID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Server successfully rebooted.")
+}
+
+func rebuildServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.Rebuild")
+
+ options, err := optionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ opts := servers.RebuildOpts{
+ Name: tools.RandomString("RenamedGopher", 16),
+ AdminPass: tools.MakeNewPassword(server.AdminPass),
+ ImageID: options.imageID,
+ DiskConfig: diskconfig.Manual,
+ }
+ after, err := servers.Rebuild(client, server.ID, opts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, after.ID, server.ID)
+
+ err = servers.WaitForStatus(client, after.ID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Server successfully rebuilt.")
+ logServer(t, after, -1)
+}
+
+func deleteServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Server) {
+ t.Logf("> servers.Delete")
+
+ res := servers.Delete(client, server.ID)
+ th.AssertNoErr(t, res.Err)
+
+ t.Logf("Server deleted successfully.")
+}
+
+func deleteServerKeyPair(t *testing.T, client *gophercloud.ServiceClient, k *oskey.KeyPair) {
+ t.Logf("> keypairs.Delete")
+
+ err := keypairs.Delete(client, k.Name).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Keypair deleted successfully.")
+}
+
+func TestServerOperations(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ kp := createServerKeyPair(t, client)
+ defer deleteServerKeyPair(t, client, kp)
+
+ server := createServer(t, client, kp.Name)
+ defer deleteServer(t, client, server)
+
+ getServer(t, client, server)
+ updateServer(t, client, server)
+ listServers(t, client)
+ changeAdminPassword(t, client, server)
+ rebootServer(t, client, server)
+ rebuildServer(t, client, server)
+}
diff --git a/acceptance/rackspace/compute/v2/virtualinterfaces_test.go b/acceptance/rackspace/compute/v2/virtualinterfaces_test.go
new file mode 100644
index 0000000..39475e1
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/virtualinterfaces_test.go
@@ -0,0 +1,53 @@
+// +build acceptance rackspace
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/networks"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/virtualinterfaces"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestVirtualInterfaces(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ // Create a server
+ server := createServer(t, client, "")
+ t.Logf("Created Server: %v\n", server)
+ defer deleteServer(t, client, server)
+ serverID := server.ID
+
+ // Create a network
+ n, err := networks.Create(client, networks.CreateOpts{Label: "sample_network", CIDR: "172.20.0.0/24"}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created Network: %v\n", n)
+ defer networks.Delete(client, n.ID)
+ networkID := n.ID
+
+ // Create a virtual interface
+ vi, err := virtualinterfaces.Create(client, serverID, networkID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created virtual interface: %+v\n", vi)
+ defer virtualinterfaces.Delete(client, serverID, vi.ID)
+
+ // List virtual interfaces
+ pager := virtualinterfaces.List(client, serverID)
+ err = pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ virtualinterfacesList, err := virtualinterfaces.ExtractVirtualInterfaces(page)
+ th.AssertNoErr(t, err)
+
+ for _, vi := range virtualinterfacesList {
+ t.Logf("Virtual Interface: ID [%s] MAC Address [%s] IP Addresses [%v]",
+ vi.ID, vi.MACAddress, vi.IPAddresses)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
diff --git a/acceptance/rackspace/compute/v2/volumeattach_test.go b/acceptance/rackspace/compute/v2/volumeattach_test.go
new file mode 100644
index 0000000..9848e2e
--- /dev/null
+++ b/acceptance/rackspace/compute/v2/volumeattach_test.go
@@ -0,0 +1,130 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack"
+ osVolumes "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ osVolumeAttach "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ osServers "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/rackspace"
+ "github.com/rackspace/gophercloud/rackspace/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/volumeattach"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newBlockClient(t *testing.T) (*gophercloud.ServiceClient, error) {
+ ao, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := rackspace.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ return openstack.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("RS_REGION_NAME"),
+ })
+}
+
+func createVAServer(t *testing.T, computeClient *gophercloud.ServiceClient, choices *serverOpts) (*osServers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s\n", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err := servers.Create(computeClient, osServers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.flavorID,
+ ImageRef: choices.imageID,
+ AdminPass: pwd,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ th.AssertEquals(t, pwd, server.AdminPass)
+
+ return server, err
+}
+
+func createVAVolume(t *testing.T, blockClient *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ volume, err := volumes.Create(blockClient, &osVolumes.CreateOpts{
+ Size: 80,
+ Name: "gophercloud-test-volume",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ err = osVolumes.WaitForStatus(blockClient, volume.ID, "available", 60)
+ th.AssertNoErr(t, err)
+ }()
+
+ return volume, err
+}
+
+func createVolumeAttachment(t *testing.T, computeClient *gophercloud.ServiceClient, blockClient *gophercloud.ServiceClient, serverID string, volumeID string) {
+ va, err := volumeattach.Create(computeClient, serverID, &osVolumeAttach.CreateOpts{
+ VolumeID: volumeID,
+ }).Extract()
+ th.AssertNoErr(t, err)
+ defer func() {
+ err = osVolumes.WaitForStatus(blockClient, volumeID, "in-use", 60)
+ th.AssertNoErr(t, err)
+ err = volumeattach.Delete(computeClient, serverID, va.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ err = osVolumes.WaitForStatus(blockClient, volumeID, "available", 60)
+ th.AssertNoErr(t, err)
+ }()
+ t.Logf("Attached volume to server: %+v", va)
+}
+
+func TestAttachVolume(t *testing.T) {
+ choices, err := optionsFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ computeClient, err := newClient()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ blockClient, err := newBlockClient(t)
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ server, err := createVAServer(t, computeClient, choices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer func() {
+ servers.Delete(computeClient, server.ID)
+ t.Logf("Server deleted.")
+ }()
+
+ if err = osServers.WaitForStatus(computeClient, server.ID, "ACTIVE", 300); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+
+ volume, err := createVAVolume(t, blockClient)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer func() {
+ err = volumes.Delete(blockClient, volume.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Volume deleted.")
+ }()
+
+ createVolumeAttachment(t, computeClient, blockClient, server.ID, volume.ID)
+
+}
diff --git a/acceptance/rackspace/db/v1/backup_test.go b/acceptance/rackspace/db/v1/backup_test.go
new file mode 100644
index 0000000..522aace
--- /dev/null
+++ b/acceptance/rackspace/db/v1/backup_test.go
@@ -0,0 +1,84 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/rackspace/gophercloud/rackspace/db/v1/backups"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+)
+
+func (c *context) createBackup() {
+ opts := backups.CreateOpts{
+ Name: tools.RandomString("backup_", 5),
+ InstanceID: c.instanceID,
+ }
+
+ backup, err := backups.Create(c.client, opts).Extract()
+
+ c.Logf("Created backup %#v", backup)
+ c.AssertNoErr(err)
+
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ b, err := backups.Get(c.client, backup.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if b.Status == "COMPLETED" {
+ return true, nil
+ }
+ return false, nil
+ })
+ c.AssertNoErr(err)
+
+ c.backupID = backup.ID
+}
+
+func (c *context) getBackup() {
+ backup, err := backups.Get(c.client, c.backupID).Extract()
+ c.AssertNoErr(err)
+ c.Logf("Getting backup %s", backup.ID)
+}
+
+func (c *context) listAllBackups() {
+ c.Logf("Listing backups")
+
+ err := backups.List(c.client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ backupList, err := backups.ExtractBackups(page)
+ c.AssertNoErr(err)
+
+ for _, b := range backupList {
+ c.Logf("Backup: %#v", b)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) listInstanceBackups() {
+ c.Logf("Listing backups for instance %s", c.instanceID)
+
+ err := instances.ListBackups(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ backupList, err := backups.ExtractBackups(page)
+ c.AssertNoErr(err)
+
+ for _, b := range backupList {
+ c.Logf("Backup: %#v", b)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) deleteBackup() {
+ err := backups.Delete(c.client, c.backupID).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted backup %s", c.backupID)
+}
diff --git a/acceptance/rackspace/db/v1/common.go b/acceptance/rackspace/db/v1/common.go
new file mode 100644
index 0000000..24512b9
--- /dev/null
+++ b/acceptance/rackspace/db/v1/common.go
@@ -0,0 +1,73 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ opts, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+ opts = tools.OnlyRS(opts)
+
+ client, err := rackspace.AuthenticatedClient(opts)
+ th.AssertNoErr(t, err)
+
+ c, err := rackspace.NewDBV1(client, gophercloud.EndpointOpts{
+ Region: "IAD",
+ })
+ th.AssertNoErr(t, err)
+
+ return c
+}
+
+type context struct {
+ test *testing.T
+ client *gophercloud.ServiceClient
+ instanceID string
+ DBIDs []string
+ replicaID string
+ backupID string
+ configGroupID string
+ users []string
+}
+
+func newContext(t *testing.T) context {
+ return context{
+ test: t,
+ client: newClient(t),
+ }
+}
+
+func (c context) Logf(msg string, args ...interface{}) {
+ if len(args) > 0 {
+ c.test.Logf(msg, args...)
+ } else {
+ c.test.Log(msg)
+ }
+}
+
+func (c context) AssertNoErr(err error) {
+ th.AssertNoErr(c.test, err)
+}
+
+func (c context) WaitUntilActive(id string) {
+ err := gophercloud.WaitFor(60, func() (bool, error) {
+ inst, err := instances.Get(c.client, id).Extract()
+ if err != nil {
+ return false, err
+ }
+ if inst.Status == "ACTIVE" {
+ return true, nil
+ }
+ return false, nil
+ })
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/db/v1/config_group_test.go b/acceptance/rackspace/db/v1/config_group_test.go
new file mode 100644
index 0000000..81bd40a
--- /dev/null
+++ b/acceptance/rackspace/db/v1/config_group_test.go
@@ -0,0 +1,93 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/configurations"
+ "github.com/rackspace/gophercloud/pagination"
+ config "github.com/rackspace/gophercloud/rackspace/db/v1/configurations"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+)
+
+func (c *context) createConfigGrp() {
+ opts := os.CreateOpts{
+ Name: tools.RandomString("config_", 5),
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ "join_buffer_size": 900000,
+ },
+ }
+
+ cg, err := config.Create(c.client, opts).Extract()
+
+ c.AssertNoErr(err)
+ c.Logf("Created config group %#v", cg)
+
+ c.configGroupID = cg.ID
+}
+
+func (c *context) getConfigGrp() {
+ cg, err := config.Get(c.client, c.configGroupID).Extract()
+ c.Logf("Getting config group: %#v", cg)
+ c.AssertNoErr(err)
+}
+
+func (c *context) updateConfigGrp() {
+ opts := os.UpdateOpts{
+ Name: tools.RandomString("new_name_", 5),
+ Values: map[string]interface{}{
+ "connect_timeout": 250,
+ },
+ }
+ err := config.Update(c.client, c.configGroupID, opts).ExtractErr()
+ c.Logf("Updated config group %s", c.configGroupID)
+ c.AssertNoErr(err)
+}
+
+func (c *context) replaceConfigGrp() {
+ opts := os.UpdateOpts{
+ Values: map[string]interface{}{
+ "big_tables": 1,
+ },
+ }
+
+ err := config.Replace(c.client, c.configGroupID, opts).ExtractErr()
+ c.Logf("Replaced values for config group %s", c.configGroupID)
+ c.AssertNoErr(err)
+}
+
+func (c *context) associateInstanceWithConfigGrp() {
+ err := instances.AssociateWithConfigGroup(c.client, c.instanceID, c.configGroupID).ExtractErr()
+ c.Logf("Associated instance %s with config group %s", c.instanceID, c.configGroupID)
+ c.AssertNoErr(err)
+}
+
+func (c *context) listConfigGrpInstances() {
+ c.Logf("Listing all instances associated with config group %s", c.configGroupID)
+
+ err := config.ListInstances(c.client, c.configGroupID).EachPage(func(page pagination.Page) (bool, error) {
+ instanceList, err := instances.ExtractInstances(page)
+ c.AssertNoErr(err)
+
+ for _, instance := range instanceList {
+ c.Logf("Instance: %#v", instance)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) deleteConfigGrp() {
+ err := config.Delete(c.client, c.configGroupID).ExtractErr()
+ c.Logf("Deleted config group %s", c.configGroupID)
+ c.AssertNoErr(err)
+}
+
+func (c *context) detachInstanceFromGrp() {
+ err := instances.DetachFromConfigGroup(c.client, c.instanceID).ExtractErr()
+ c.Logf("Detached instance %s from config groups", c.instanceID)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/db/v1/database_test.go b/acceptance/rackspace/db/v1/database_test.go
new file mode 100644
index 0000000..d5c448f
--- /dev/null
+++ b/acceptance/rackspace/db/v1/database_test.go
@@ -0,0 +1,54 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func (c *context) createDBs() {
+ dbs := []string{
+ tools.RandomString("db_", 5),
+ tools.RandomString("db_", 5),
+ tools.RandomString("db_", 5),
+ }
+
+ opts := db.BatchCreateOpts{
+ db.CreateOpts{Name: dbs[0]},
+ db.CreateOpts{Name: dbs[1]},
+ db.CreateOpts{Name: dbs[2]},
+ }
+
+ err := db.Create(c.client, c.instanceID, opts).ExtractErr()
+ c.Logf("Created three databases on instance %s: %s, %s, %s", c.instanceID, dbs[0], dbs[1], dbs[2])
+ c.AssertNoErr(err)
+
+ c.DBIDs = dbs
+}
+
+func (c *context) listDBs() {
+ c.Logf("Listing databases on instance %s", c.instanceID)
+
+ err := db.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ dbList, err := db.ExtractDBs(page)
+ c.AssertNoErr(err)
+
+ for _, db := range dbList {
+ c.Logf("DB: %#v", db)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) deleteDBs() {
+ for _, id := range c.DBIDs {
+ err := db.Delete(c.client, c.instanceID, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted DB %s", id)
+ }
+}
diff --git a/acceptance/rackspace/db/v1/flavor_test.go b/acceptance/rackspace/db/v1/flavor_test.go
new file mode 100644
index 0000000..0d6e6df
--- /dev/null
+++ b/acceptance/rackspace/db/v1/flavor_test.go
@@ -0,0 +1,32 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ os "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/flavors"
+)
+
+func (c context) listFlavors() {
+ c.Logf("Listing flavors")
+
+ err := flavors.List(c.client).EachPage(func(page pagination.Page) (bool, error) {
+ flavorList, err := os.ExtractFlavors(page)
+ c.AssertNoErr(err)
+
+ for _, f := range flavorList {
+ c.Logf("Flavor: ID [%s] Name [%s] RAM [%d]", f.ID, f.Name, f.RAM)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c context) getFlavor() {
+ flavor, err := flavors.Get(c.client, "1").Extract()
+ c.Logf("Getting flavor %s", flavor.ID)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/db/v1/instance_test.go b/acceptance/rackspace/db/v1/instance_test.go
new file mode 100644
index 0000000..b5540e3
--- /dev/null
+++ b/acceptance/rackspace/db/v1/instance_test.go
@@ -0,0 +1,169 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestRunner(t *testing.T) {
+ c := newContext(t)
+
+ // FLAVOR tests
+ c.listFlavors()
+ c.getFlavor()
+
+ // INSTANCE tests
+ c.createInstance()
+ c.listInstances()
+ c.getInstance()
+ c.isRootEnabled()
+ c.enableRootUser()
+ c.isRootEnabled()
+ c.restartInstance()
+ c.resizeInstance()
+ c.resizeVol()
+ c.getDefaultConfig()
+
+ // REPLICA tests
+ c.createReplica()
+ c.detachReplica()
+
+ // BACKUP tests
+ c.createBackup()
+ c.getBackup()
+ c.listAllBackups()
+ c.listInstanceBackups()
+ c.deleteBackup()
+
+ // CONFIG GROUP tests
+ c.createConfigGrp()
+ c.getConfigGrp()
+ c.updateConfigGrp()
+ c.replaceConfigGrp()
+ c.associateInstanceWithConfigGrp()
+ c.listConfigGrpInstances()
+ c.detachInstanceFromGrp()
+ c.deleteConfigGrp()
+
+ // DATABASE tests
+ c.createDBs()
+ c.listDBs()
+
+ // USER tests
+ c.createUsers()
+ c.listUsers()
+ c.changeUserPwd()
+ c.getUser()
+ c.updateUser()
+ c.listUserAccess()
+ c.revokeUserAccess()
+ c.grantUserAccess()
+
+ // TEARDOWN
+ c.deleteUsers()
+ c.deleteDBs()
+
+ c.restartInstance()
+ c.WaitUntilActive(c.instanceID)
+
+ c.deleteInstance(c.replicaID)
+ c.deleteInstance(c.instanceID)
+}
+
+func (c *context) createInstance() {
+ opts := instances.CreateOpts{
+ FlavorRef: "1",
+ Size: 1,
+ Name: tools.RandomString("gopher_db", 5),
+ }
+
+ instance, err := instances.Create(c.client, opts).Extract()
+ th.AssertNoErr(c.test, err)
+
+ c.Logf("Creating %s. Waiting...", instance.ID)
+ c.WaitUntilActive(instance.ID)
+ c.Logf("Created instance %s", instance.ID)
+
+ c.instanceID = instance.ID
+}
+
+func (c *context) listInstances() {
+ c.Logf("Listing instances")
+
+ err := instances.List(c.client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ instanceList, err := instances.ExtractInstances(page)
+ c.AssertNoErr(err)
+
+ for _, i := range instanceList {
+ c.Logf("Instance: ID [%s] Name [%s] Status [%s] VolSize [%d] Datastore Type [%s]",
+ i.ID, i.Name, i.Status, i.Volume.Size, i.Datastore.Type)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) getInstance() {
+ instance, err := instances.Get(c.client, c.instanceID).Extract()
+ c.AssertNoErr(err)
+ c.Logf("Getting instance: %#v", instance)
+}
+
+func (c *context) deleteInstance(id string) {
+ err := instances.Delete(c.client, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted instance %s", id)
+}
+
+func (c *context) enableRootUser() {
+ _, err := instances.EnableRootUser(c.client, c.instanceID).Extract()
+ c.AssertNoErr(err)
+ c.Logf("Enabled root user on %s", c.instanceID)
+}
+
+func (c *context) isRootEnabled() {
+ enabled, err := instances.IsRootEnabled(c.client, c.instanceID)
+ c.AssertNoErr(err)
+ c.Logf("Is root enabled? %s", enabled)
+}
+
+func (c *context) restartInstance() {
+ id := c.instanceID
+ err := instances.Restart(c.client, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Restarting %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Restarted %s", id)
+}
+
+func (c *context) resizeInstance() {
+ id := c.instanceID
+ err := instances.Resize(c.client, id, "2").ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Resizing %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Resized %s with flavorRef %s", id, "2")
+}
+
+func (c *context) resizeVol() {
+ id := c.instanceID
+ err := instances.ResizeVolume(c.client, id, 2).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Resizing volume of %s. Waiting...", id)
+ c.WaitUntilActive(id)
+ c.Logf("Resized the volume of %s to %d GB", id, 2)
+}
+
+func (c *context) getDefaultConfig() {
+ config, err := instances.GetDefaultConfig(c.client, c.instanceID).Extract()
+ c.Logf("Default config group for instance %s: %#v", c.instanceID, config)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/db/v1/pkg.go b/acceptance/rackspace/db/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/rackspace/db/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/rackspace/db/v1/replica_test.go b/acceptance/rackspace/db/v1/replica_test.go
new file mode 100644
index 0000000..89edf9d
--- /dev/null
+++ b/acceptance/rackspace/db/v1/replica_test.go
@@ -0,0 +1,33 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func (c *context) createReplica() {
+ opts := instances.CreateOpts{
+ FlavorRef: "2",
+ Size: 1,
+ Name: tools.RandomString("gopher_db", 5),
+ ReplicaOf: c.instanceID,
+ }
+
+ repl, err := instances.Create(c.client, opts).Extract()
+ th.AssertNoErr(c.test, err)
+
+ c.Logf("Creating replica of %s. Waiting...", c.instanceID)
+ c.WaitUntilActive(repl.ID)
+ c.Logf("Created replica %#v", repl)
+
+ c.replicaID = repl.ID
+}
+
+func (c *context) detachReplica() {
+ err := instances.DetachReplica(c.client, c.replicaID).ExtractErr()
+ c.Logf("Detached replica %s", c.replicaID)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/db/v1/user_test.go b/acceptance/rackspace/db/v1/user_test.go
new file mode 100644
index 0000000..0488f5d
--- /dev/null
+++ b/acceptance/rackspace/db/v1/user_test.go
@@ -0,0 +1,125 @@
+// +build acceptance db rackspace
+
+package v1
+
+import (
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/users"
+)
+
+func (c *context) createUsers() {
+ c.users = []string{
+ tools.RandomString("user_", 5),
+ tools.RandomString("user_", 5),
+ tools.RandomString("user_", 5),
+ }
+
+ db1 := db.CreateOpts{Name: c.DBIDs[0]}
+ db2 := db.CreateOpts{Name: c.DBIDs[1]}
+ db3 := db.CreateOpts{Name: c.DBIDs[2]}
+
+ opts := os.BatchCreateOpts{
+ os.CreateOpts{
+ Name: c.users[0],
+ Password: tools.RandomString("db_", 5),
+ Databases: db.BatchCreateOpts{db1, db2, db3},
+ },
+ os.CreateOpts{
+ Name: c.users[1],
+ Password: tools.RandomString("db_", 5),
+ Databases: db.BatchCreateOpts{db1, db2},
+ },
+ os.CreateOpts{
+ Name: c.users[2],
+ Password: tools.RandomString("db_", 5),
+ Databases: db.BatchCreateOpts{db3},
+ },
+ }
+
+ err := users.Create(c.client, c.instanceID, opts).ExtractErr()
+ c.Logf("Created three users on instance %s: %s, %s, %s", c.instanceID, c.users[0], c.users[1], c.users[2])
+ c.AssertNoErr(err)
+}
+
+func (c *context) listUsers() {
+ c.Logf("Listing users on instance %s", c.instanceID)
+
+ err := os.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ uList, err := os.ExtractUsers(page)
+ c.AssertNoErr(err)
+
+ for _, u := range uList {
+ c.Logf("User: %#v", u)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) deleteUsers() {
+ for _, id := range c.users {
+ err := users.Delete(c.client, c.instanceID, id).ExtractErr()
+ c.AssertNoErr(err)
+ c.Logf("Deleted user %s", id)
+ }
+}
+
+func (c *context) changeUserPwd() {
+ opts := os.BatchCreateOpts{}
+
+ for _, name := range c.users[:1] {
+ opts = append(opts, os.CreateOpts{Name: name, Password: tools.RandomString("", 5)})
+ }
+
+ err := users.ChangePassword(c.client, c.instanceID, opts).ExtractErr()
+ c.Logf("Updated 2 users' passwords")
+ c.AssertNoErr(err)
+}
+
+func (c *context) getUser() {
+ user, err := users.Get(c.client, c.instanceID, c.users[0]).Extract()
+ c.Logf("Getting user %s", user)
+ c.AssertNoErr(err)
+}
+
+func (c *context) updateUser() {
+ opts := users.UpdateOpts{Name: tools.RandomString("new_name_", 5)}
+ err := users.Update(c.client, c.instanceID, c.users[0], opts).ExtractErr()
+ c.Logf("Updated user %s", c.users[0])
+ c.AssertNoErr(err)
+ c.users[0] = opts.Name
+}
+
+func (c *context) listUserAccess() {
+ err := users.ListAccess(c.client, c.instanceID, c.users[0]).EachPage(func(page pagination.Page) (bool, error) {
+ dbList, err := users.ExtractDBs(page)
+ c.AssertNoErr(err)
+
+ for _, db := range dbList {
+ c.Logf("User %s has access to DB: %#v", db)
+ }
+
+ return true, nil
+ })
+
+ c.AssertNoErr(err)
+}
+
+func (c *context) grantUserAccess() {
+ opts := db.BatchCreateOpts{db.CreateOpts{Name: c.DBIDs[0]}}
+ err := users.GrantAccess(c.client, c.instanceID, c.users[0], opts).ExtractErr()
+ c.Logf("Granted access for user %s to DB %s", c.users[0], c.DBIDs[0])
+ c.AssertNoErr(err)
+}
+
+func (c *context) revokeUserAccess() {
+ dbName, userName := c.DBIDs[0], c.users[0]
+ err := users.RevokeAccess(c.client, c.instanceID, userName, dbName).ExtractErr()
+ c.Logf("Revoked access for user %s to DB %s", userName, dbName)
+ c.AssertNoErr(err)
+}
diff --git a/acceptance/rackspace/identity/v2/extension_test.go b/acceptance/rackspace/identity/v2/extension_test.go
new file mode 100644
index 0000000..a50e015
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/extension_test.go
@@ -0,0 +1,54 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ extensions2 "github.com/rackspace/gophercloud/rackspace/identity/v2/extensions"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestExtensions(t *testing.T) {
+ service := authenticatedClient(t)
+
+ t.Logf("Extensions available on this identity endpoint:")
+ count := 0
+ var chosen string
+ err := extensions2.List(service).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ extensions, err := extensions2.ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+
+ for i, ext := range extensions {
+ if chosen == "" {
+ chosen = ext.Alias
+ }
+
+ t.Logf("[%02d] name=[%s] namespace=[%s]", i, ext.Name, ext.Namespace)
+ t.Logf(" alias=[%s] updated=[%s]", ext.Alias, ext.Updated)
+ t.Logf(" description=[%s]", ext.Description)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ if chosen == "" {
+ t.Logf("No extensions found.")
+ return
+ }
+
+ ext, err := extensions2.Get(service, chosen).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Detail for extension [%s]:", chosen)
+ t.Logf(" name=[%s]", ext.Name)
+ t.Logf(" namespace=[%s]", ext.Namespace)
+ t.Logf(" alias=[%s]", ext.Alias)
+ t.Logf(" updated=[%s]", ext.Updated)
+ t.Logf(" description=[%s]", ext.Description)
+}
diff --git a/acceptance/rackspace/identity/v2/identity_test.go b/acceptance/rackspace/identity/v2/identity_test.go
new file mode 100644
index 0000000..1182982
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/identity_test.go
@@ -0,0 +1,50 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func rackspaceAuthOptions(t *testing.T) gophercloud.AuthOptions {
+ // Obtain credentials from the environment.
+ options, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+ options = tools.OnlyRS(options)
+
+ if options.Username == "" {
+ t.Fatal("Please provide a Rackspace username as RS_USERNAME.")
+ }
+ if options.APIKey == "" {
+ t.Fatal("Please provide a Rackspace API key as RS_API_KEY.")
+ }
+
+ return options
+}
+
+func createClient(t *testing.T, auth bool) *gophercloud.ServiceClient {
+ ao := rackspaceAuthOptions(t)
+
+ provider, err := rackspace.NewClient(ao.IdentityEndpoint)
+ th.AssertNoErr(t, err)
+
+ if auth {
+ err = rackspace.Authenticate(provider, ao)
+ th.AssertNoErr(t, err)
+ }
+
+ return rackspace.NewIdentityV2(provider)
+}
+
+func unauthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ return createClient(t, false)
+}
+
+func authenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+ return createClient(t, true)
+}
diff --git a/acceptance/rackspace/identity/v2/pkg.go b/acceptance/rackspace/identity/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/rackspace/identity/v2/role_test.go b/acceptance/rackspace/identity/v2/role_test.go
new file mode 100644
index 0000000..efaeb75
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/role_test.go
@@ -0,0 +1,59 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/identity/v2/roles"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestRoles(t *testing.T) {
+ client := authenticatedClient(t)
+
+ userID := createUser(t, client)
+ roleID := listRoles(t, client)
+
+ addUserRole(t, client, userID, roleID)
+
+ deleteUserRole(t, client, userID, roleID)
+
+ deleteUser(t, client, userID)
+}
+
+func listRoles(t *testing.T, client *gophercloud.ServiceClient) string {
+ var roleID string
+
+ err := roles.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ roleList, err := os.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ for _, role := range roleList {
+ t.Logf("Listing role: ID [%s] Name [%s]", role.ID, role.Name)
+ roleID = role.ID
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ return roleID
+}
+
+func addUserRole(t *testing.T, client *gophercloud.ServiceClient, userID, roleID string) {
+ err := roles.AddUserRole(client, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Added role %s to user %s", roleID, userID)
+}
+
+func deleteUserRole(t *testing.T, client *gophercloud.ServiceClient, userID, roleID string) {
+ err := roles.DeleteUserRole(client, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Removed role %s from user %s", roleID, userID)
+}
diff --git a/acceptance/rackspace/identity/v2/tenant_test.go b/acceptance/rackspace/identity/v2/tenant_test.go
new file mode 100644
index 0000000..6081a49
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/tenant_test.go
@@ -0,0 +1,37 @@
+// +build acceptance
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ rstenants "github.com/rackspace/gophercloud/rackspace/identity/v2/tenants"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTenants(t *testing.T) {
+ service := authenticatedClient(t)
+
+ t.Logf("Tenants available to the currently issued token:")
+ count := 0
+ err := rstenants.List(service, nil).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ tenants, err := rstenants.ExtractTenants(page)
+ th.AssertNoErr(t, err)
+
+ for i, tenant := range tenants {
+ t.Logf("[%02d] id=[%s]", i, tenant.ID)
+ t.Logf(" name=[%s] enabled=[%v]", i, tenant.Name, tenant.Enabled)
+ t.Logf(" description=[%s]", tenant.Description)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No tenants listed for your current token.")
+ }
+}
diff --git a/acceptance/rackspace/identity/v2/tokens_test.go b/acceptance/rackspace/identity/v2/tokens_test.go
new file mode 100644
index 0000000..95ee7e6
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/tokens_test.go
@@ -0,0 +1,61 @@
+// +build acceptance
+
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ "github.com/rackspace/gophercloud/rackspace/identity/v2/tokens"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func rackspaceAuthOptions(t *testing.T) gophercloud.AuthOptions {
+ // Obtain credentials from the environment.
+ options, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+ options = tools.OnlyRS(options)
+
+ if options.Username == "" {
+ t.Fatal("Please provide a Rackspace username as RS_USERNAME.")
+ }
+ if options.APIKey == "" {
+ t.Fatal("Please provide a Rackspace API key as RS_API_KEY.")
+ }
+
+ return options
+}
+
+func createClient(t *testing.T, auth bool) *gophercloud.ServiceClient {
+ ao := rackspaceAuthOptions(t)
+
+ provider, err := rackspace.NewClient(ao.IdentityEndpoint)
+ th.AssertNoErr(t, err)
+
+ if auth {
+ err = rackspace.Authenticate(provider, ao)
+ th.AssertNoErr(t, err)
+ }
+
+ return rackspace.NewIdentityV2(provider)
+}
+
+func TestTokenAuth(t *testing.T) {
+ authedClient := createClient(t, true)
+ token := authedClient.TokenID
+
+ tenantID := os.Getenv("RS_TENANT_ID")
+ if tenantID == "" {
+ t.Skip("You must set RS_TENANT_ID environment variable to run this test")
+ }
+
+ authOpts := tokens.AuthOptions{}
+ authOpts.TenantID = tenantID
+ authOpts.Token = token
+
+ _, err := tokens.Create(authedClient, authOpts).ExtractToken()
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/rackspace/identity/v2/user_test.go b/acceptance/rackspace/identity/v2/user_test.go
new file mode 100644
index 0000000..28c0c83
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/user_test.go
@@ -0,0 +1,93 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/identity/v2/users"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestUsers(t *testing.T) {
+ client := authenticatedClient(t)
+
+ userID := createUser(t, client)
+
+ listUsers(t, client)
+
+ getUser(t, client, userID)
+
+ updateUser(t, client, userID)
+
+ resetApiKey(t, client, userID)
+
+ deleteUser(t, client, userID)
+}
+
+func createUser(t *testing.T, client *gophercloud.ServiceClient) string {
+ t.Log("Creating user")
+
+ opts := users.CreateOpts{
+ Username: tools.RandomString("user_", 5),
+ Enabled: os.Disabled,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := users.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created user %s", user.ID)
+
+ return user.ID
+}
+
+func listUsers(t *testing.T, client *gophercloud.ServiceClient) {
+ err := users.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ userList, err := os.ExtractUsers(page)
+ th.AssertNoErr(t, err)
+
+ for _, user := range userList {
+ t.Logf("Listing user: ID [%s] Username [%s] Email [%s] Enabled? [%s]",
+ user.ID, user.Username, user.Email, strconv.FormatBool(user.Enabled))
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ _, err := users.Get(client, userID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting user %s", userID)
+}
+
+func updateUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ opts := users.UpdateOpts{Username: tools.RandomString("new_name", 5), Email: "new@foo.com"}
+ user, err := users.Update(client, userID, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated user %s: Username [%s] Email [%s]", userID, user.Username, user.Email)
+}
+
+func deleteUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ res := users.Delete(client, userID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted user %s", userID)
+}
+
+func resetApiKey(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ key, err := users.ResetAPIKey(client, userID).Extract()
+ th.AssertNoErr(t, err)
+
+ if key.APIKey == "" {
+ t.Fatal("failed to reset API key for user")
+ }
+
+ t.Logf("Reset API key for user %s to %s", key.Username, key.APIKey)
+}
diff --git a/acceptance/rackspace/lb/v1/acl_test.go b/acceptance/rackspace/lb/v1/acl_test.go
new file mode 100644
index 0000000..7a38027
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/acl_test.go
@@ -0,0 +1,94 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/acl"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/lbs"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestACL(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ createACL(t, client, lbID)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ networkIDs := showACL(t, client, lbID)
+
+ deleteNetworkItem(t, client, lbID, networkIDs[0])
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ bulkDeleteACL(t, client, lbID, networkIDs[1:2])
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ deleteACL(t, client, lbID)
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ deleteLB(t, client, lbID)
+}
+
+func createACL(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ opts := acl.CreateOpts{
+ acl.CreateOpt{Address: "206.160.163.21", Type: acl.DENY},
+ acl.CreateOpt{Address: "206.160.165.11", Type: acl.DENY},
+ acl.CreateOpt{Address: "206.160.165.12", Type: acl.DENY},
+ acl.CreateOpt{Address: "206.160.165.13", Type: acl.ALLOW},
+ }
+
+ err := acl.Create(client, lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created ACL items for LB %d", lbID)
+}
+
+func showACL(t *testing.T, client *gophercloud.ServiceClient, lbID int) []int {
+ ids := []int{}
+
+ err := acl.List(client, lbID).EachPage(func(page pagination.Page) (bool, error) {
+ accessList, err := acl.ExtractAccessList(page)
+ th.AssertNoErr(t, err)
+
+ for _, i := range accessList {
+ t.Logf("Listing network item: ID [%s] Address [%s] Type [%s]", i.ID, i.Address, i.Type)
+ ids = append(ids, i.ID)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ return ids
+}
+
+func deleteNetworkItem(t *testing.T, client *gophercloud.ServiceClient, lbID, itemID int) {
+ err := acl.Delete(client, lbID, itemID).ExtractErr()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted network item %d", itemID)
+}
+
+func bulkDeleteACL(t *testing.T, client *gophercloud.ServiceClient, lbID int, items []int) {
+ err := acl.BulkDelete(client, lbID, items).ExtractErr()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted network items %s", intsToStr(items))
+}
+
+func deleteACL(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := acl.DeleteAll(client, lbID).ExtractErr()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted ACL from LB %d", lbID)
+}
diff --git a/acceptance/rackspace/lb/v1/common.go b/acceptance/rackspace/lb/v1/common.go
new file mode 100644
index 0000000..4ce05e6
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/common.go
@@ -0,0 +1,62 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newProvider() (*gophercloud.ProviderClient, error) {
+ opts, err := rackspace.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+ opts = tools.OnlyRS(opts)
+
+ return rackspace.AuthenticatedClient(opts)
+}
+
+func newClient() (*gophercloud.ServiceClient, error) {
+ provider, err := newProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ return rackspace.NewLBV1(provider, gophercloud.EndpointOpts{
+ Region: os.Getenv("RS_REGION"),
+ })
+}
+
+func newComputeClient() (*gophercloud.ServiceClient, error) {
+ provider, err := newProvider()
+ if err != nil {
+ return nil, err
+ }
+
+ return rackspace.NewComputeV2(provider, gophercloud.EndpointOpts{
+ Region: os.Getenv("RS_REGION"),
+ })
+}
+
+func setup(t *testing.T) *gophercloud.ServiceClient {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ return client
+}
+
+func intsToStr(ids []int) string {
+ strIDs := []string{}
+ for _, id := range ids {
+ strIDs = append(strIDs, strconv.Itoa(id))
+ }
+ return strings.Join(strIDs, ", ")
+}
diff --git a/acceptance/rackspace/lb/v1/lb_test.go b/acceptance/rackspace/lb/v1/lb_test.go
new file mode 100644
index 0000000..c67ddec
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/lb_test.go
@@ -0,0 +1,214 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/lbs"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/vips"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestLBs(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 3)
+ id := ids[0]
+
+ listLBProtocols(t, client)
+
+ listLBAlgorithms(t, client)
+
+ listLBs(t, client)
+
+ getLB(t, client, id)
+
+ checkLBLogging(t, client, id)
+
+ checkErrorPage(t, client, id)
+
+ getStats(t, client, id)
+
+ updateLB(t, client, id)
+
+ deleteLB(t, client, id)
+
+ batchDeleteLBs(t, client, ids[1:])
+}
+
+func createLB(t *testing.T, client *gophercloud.ServiceClient, count int) []int {
+ ids := []int{}
+
+ for i := 0; i < count; i++ {
+ opts := lbs.CreateOpts{
+ Name: tools.RandomString("test_", 5),
+ Port: 80,
+ Protocol: "HTTP",
+ VIPs: []vips.VIP{
+ vips.VIP{Type: vips.PUBLIC},
+ },
+ }
+
+ lb, err := lbs.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created LB %d - waiting for it to build...", lb.ID)
+ waitForLB(client, lb.ID, lbs.ACTIVE)
+ t.Logf("LB %d has reached ACTIVE state", lb.ID)
+
+ ids = append(ids, lb.ID)
+ }
+
+ return ids
+}
+
+func waitForLB(client *gophercloud.ServiceClient, id int, state lbs.Status) {
+ gophercloud.WaitFor(60, func() (bool, error) {
+ lb, err := lbs.Get(client, id).Extract()
+ if err != nil {
+ return false, err
+ }
+ if lb.Status != state {
+ return false, nil
+ }
+ return true, nil
+ })
+}
+
+func listLBProtocols(t *testing.T, client *gophercloud.ServiceClient) {
+ err := lbs.ListProtocols(client).EachPage(func(page pagination.Page) (bool, error) {
+ pList, err := lbs.ExtractProtocols(page)
+ th.AssertNoErr(t, err)
+
+ for _, p := range pList {
+ t.Logf("Listing protocol: Name [%s]", p.Name)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func listLBAlgorithms(t *testing.T, client *gophercloud.ServiceClient) {
+ err := lbs.ListAlgorithms(client).EachPage(func(page pagination.Page) (bool, error) {
+ aList, err := lbs.ExtractAlgorithms(page)
+ th.AssertNoErr(t, err)
+
+ for _, a := range aList {
+ t.Logf("Listing algorithm: Name [%s]", a.Name)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func listLBs(t *testing.T, client *gophercloud.ServiceClient) {
+ err := lbs.List(client, lbs.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ lbList, err := lbs.ExtractLBs(page)
+ th.AssertNoErr(t, err)
+
+ for _, lb := range lbList {
+ t.Logf("Listing LB: ID [%d] Name [%s] Protocol [%s] Status [%s] Node count [%d] Port [%d]",
+ lb.ID, lb.Name, lb.Protocol, lb.Status, lb.NodeCount, lb.Port)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getLB(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ lb, err := lbs.Get(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting LB %d: Created [%s] VIPs [%#v] Logging [%#v] Persistence [%#v] SourceAddrs [%#v]",
+ lb.ID, lb.Created, lb.VIPs, lb.ConnectionLogging, lb.SessionPersistence, lb.SourceAddrs)
+}
+
+func updateLB(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ opts := lbs.UpdateOpts{
+ Name: tools.RandomString("new_", 5),
+ Protocol: "TCP",
+ HalfClosed: gophercloud.Enabled,
+ Algorithm: "RANDOM",
+ Port: 8080,
+ Timeout: 100,
+ HTTPSRedirect: gophercloud.Disabled,
+ }
+
+ err := lbs.Update(client, id, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Updating LB %d - waiting for it to finish", id)
+ waitForLB(client, id, lbs.ACTIVE)
+ t.Logf("LB %d has reached ACTIVE state", id)
+}
+
+func deleteLB(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ err := lbs.Delete(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted LB %d", id)
+}
+
+func batchDeleteLBs(t *testing.T, client *gophercloud.ServiceClient, ids []int) {
+ err := lbs.BulkDelete(client, ids).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted LB %s", intsToStr(ids))
+}
+
+func checkLBLogging(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ err := lbs.EnableLogging(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Enabled logging for LB %d", id)
+
+ res, err := lbs.IsLoggingEnabled(client, id)
+ th.AssertNoErr(t, err)
+ t.Logf("LB %d log enabled? %s", id, strconv.FormatBool(res))
+
+ waitForLB(client, id, lbs.ACTIVE)
+
+ err = lbs.DisableLogging(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disabled logging for LB %d", id)
+}
+
+func checkErrorPage(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ content, err := lbs.SetErrorPage(client, id, "<html>New content!</html>").Extract()
+ t.Logf("Set error page for LB %d", id)
+
+ content, err = lbs.GetErrorPage(client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Error page for LB %d: %s", id, content)
+
+ err = lbs.DeleteErrorPage(client, id).ExtractErr()
+ t.Logf("Deleted error page for LB %d", id)
+}
+
+func getStats(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ waitForLB(client, id, lbs.ACTIVE)
+
+ stats, err := lbs.GetStats(client, id).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Stats for LB %d: %#v", id, stats)
+}
+
+func checkCaching(t *testing.T, client *gophercloud.ServiceClient, id int) {
+ err := lbs.EnableCaching(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Enabled caching for LB %d", id)
+
+ res, err := lbs.IsContentCached(client, id)
+ th.AssertNoErr(t, err)
+ t.Logf("Is caching enabled for LB? %s", strconv.FormatBool(res))
+
+ err = lbs.DisableCaching(client, id).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disabled caching for LB %d", id)
+}
diff --git a/acceptance/rackspace/lb/v1/monitor_test.go b/acceptance/rackspace/lb/v1/monitor_test.go
new file mode 100644
index 0000000..c1a8e24
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/monitor_test.go
@@ -0,0 +1,60 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/lbs"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/monitors"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestMonitors(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ getMonitor(t, client, lbID)
+
+ updateMonitor(t, client, lbID)
+
+ deleteMonitor(t, client, lbID)
+
+ deleteLB(t, client, lbID)
+}
+
+func getMonitor(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ hm, err := monitors.Get(client, lbID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Health monitor for LB %d: Type [%s] Delay [%d] Timeout [%d] AttemptLimit [%d]",
+ lbID, hm.Type, hm.Delay, hm.Timeout, hm.AttemptLimit)
+}
+
+func updateMonitor(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ opts := monitors.UpdateHTTPMonitorOpts{
+ AttemptLimit: 3,
+ Delay: 10,
+ Timeout: 10,
+ BodyRegex: "hello is it me you're looking for",
+ Path: "/foo",
+ StatusRegex: "200",
+ Type: monitors.HTTP,
+ }
+
+ err := monitors.Update(client, lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+ t.Logf("Updated monitor for LB %d", lbID)
+}
+
+func deleteMonitor(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := monitors.Delete(client, lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+ t.Logf("Deleted monitor for LB %d", lbID)
+}
diff --git a/acceptance/rackspace/lb/v1/node_test.go b/acceptance/rackspace/lb/v1/node_test.go
new file mode 100644
index 0000000..18b9fe7
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/node_test.go
@@ -0,0 +1,175 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/lbs"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/nodes"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNodes(t *testing.T) {
+ client := setup(t)
+
+ serverIP := findServer(t)
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ nodeID := addNodes(t, client, lbID, serverIP)
+
+ listNodes(t, client, lbID)
+
+ getNode(t, client, lbID, nodeID)
+
+ updateNode(t, client, lbID, nodeID)
+
+ listEvents(t, client, lbID)
+
+ deleteNode(t, client, lbID, nodeID)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+ deleteLB(t, client, lbID)
+}
+
+func findServer(t *testing.T) string {
+ var serverIP string
+
+ client, err := newComputeClient()
+ th.AssertNoErr(t, err)
+
+ 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 {
+ serverIP = s.AccessIPv4
+ t.Logf("Found an existing server: ID [%s] Public IP [%s]", s.ID, serverIP)
+ break
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ if serverIP == "" {
+ t.Log("No server found, creating one")
+
+ imageRef := os.Getenv("RS_IMAGE_ID")
+ if imageRef == "" {
+ t.Fatalf("OS var RS_IMAGE_ID undefined")
+ }
+ flavorRef := os.Getenv("RS_FLAVOR_ID")
+ if flavorRef == "" {
+ t.Fatalf("OS var RS_FLAVOR_ID undefined")
+ }
+
+ opts := &servers.CreateOpts{
+ Name: tools.RandomString("lb_test_", 5),
+ ImageRef: imageRef,
+ FlavorRef: flavorRef,
+ DiskConfig: diskconfig.Manual,
+ }
+
+ s, err := servers.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ serverIP = s.AccessIPv4
+
+ t.Logf("Created server %s, waiting for it to build", s.ID)
+ err = servers.WaitForStatus(client, s.ID, "ACTIVE", 300)
+ th.AssertNoErr(t, err)
+ t.Logf("Server created successfully.")
+ }
+
+ return serverIP
+}
+
+func addNodes(t *testing.T, client *gophercloud.ServiceClient, lbID int, serverIP string) int {
+ opts := nodes.CreateOpts{
+ nodes.CreateOpt{
+ Address: serverIP,
+ Port: 80,
+ Condition: nodes.ENABLED,
+ Type: nodes.PRIMARY,
+ },
+ }
+
+ page := nodes.Create(client, lbID, opts)
+
+ nodeList, err := page.ExtractNodes()
+ th.AssertNoErr(t, err)
+
+ var nodeID int
+ for _, n := range nodeList {
+ nodeID = n.ID
+ }
+ if nodeID == 0 {
+ t.Fatalf("nodeID could not be extracted from create response")
+ }
+
+ t.Logf("Added node %d to LB %d", nodeID, lbID)
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ return nodeID
+}
+
+func listNodes(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := nodes.List(client, lbID, nil).EachPage(func(page pagination.Page) (bool, error) {
+ nodeList, err := nodes.ExtractNodes(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range nodeList {
+ t.Logf("Listing node: ID [%d] Address [%s:%d] Status [%s]", n.ID, n.Address, n.Port, n.Status)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func getNode(t *testing.T, client *gophercloud.ServiceClient, lbID int, nodeID int) {
+ node, err := nodes.Get(client, lbID, nodeID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting node %d: Type [%s] Weight [%d]", nodeID, node.Type, node.Weight)
+}
+
+func updateNode(t *testing.T, client *gophercloud.ServiceClient, lbID int, nodeID int) {
+ opts := nodes.UpdateOpts{
+ Weight: gophercloud.IntToPointer(10),
+ Condition: nodes.DRAINING,
+ Type: nodes.SECONDARY,
+ }
+ err := nodes.Update(client, lbID, nodeID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated node %d", nodeID)
+ waitForLB(client, lbID, lbs.ACTIVE)
+}
+
+func listEvents(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ pager := nodes.ListEvents(client, lbID, nodes.ListEventsOpts{})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ eventList, err := nodes.ExtractNodeEvents(page)
+ th.AssertNoErr(t, err)
+
+ for _, e := range eventList {
+ t.Logf("Listing events for node %d: Type [%s] Msg [%s] Severity [%s] Date [%s]",
+ e.NodeID, e.Type, e.DetailedMessage, e.Severity, e.Created)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func deleteNode(t *testing.T, client *gophercloud.ServiceClient, lbID int, nodeID int) {
+ err := nodes.Delete(client, lbID, nodeID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted node %d", nodeID)
+}
diff --git a/acceptance/rackspace/lb/v1/session_test.go b/acceptance/rackspace/lb/v1/session_test.go
new file mode 100644
index 0000000..8d85655
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/session_test.go
@@ -0,0 +1,47 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/sessions"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSession(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ getSession(t, client, lbID)
+
+ enableSession(t, client, lbID)
+ waitForLB(client, lbID, "ACTIVE")
+
+ disableSession(t, client, lbID)
+ waitForLB(client, lbID, "ACTIVE")
+
+ deleteLB(t, client, lbID)
+}
+
+func getSession(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ sp, err := sessions.Get(client, lbID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Session config: Type [%s]", sp.Type)
+}
+
+func enableSession(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ opts := sessions.CreateOpts{Type: sessions.HTTPCOOKIE}
+ err := sessions.Enable(client, lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Enable %s sessions for %d", opts.Type, lbID)
+}
+
+func disableSession(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := sessions.Disable(client, lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disable sessions for %d", lbID)
+}
diff --git a/acceptance/rackspace/lb/v1/throttle_test.go b/acceptance/rackspace/lb/v1/throttle_test.go
new file mode 100644
index 0000000..1cc1235
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/throttle_test.go
@@ -0,0 +1,53 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/throttle"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestThrottle(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ getThrottleConfig(t, client, lbID)
+
+ createThrottleConfig(t, client, lbID)
+ waitForLB(client, lbID, "ACTIVE")
+
+ deleteThrottleConfig(t, client, lbID)
+ waitForLB(client, lbID, "ACTIVE")
+
+ deleteLB(t, client, lbID)
+}
+
+func getThrottleConfig(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ sp, err := throttle.Get(client, lbID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Throttle config: MaxConns [%s]", sp.MaxConnections)
+}
+
+func createThrottleConfig(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ opts := throttle.CreateOpts{
+ MaxConnections: 200,
+ MaxConnectionRate: 100,
+ MinConnections: 0,
+ RateInterval: 10,
+ }
+
+ err := throttle.Create(client, lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Enable throttling for %d", lbID)
+}
+
+func deleteThrottleConfig(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := throttle.Delete(client, lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Disable throttling for %d", lbID)
+}
diff --git a/acceptance/rackspace/lb/v1/vip_test.go b/acceptance/rackspace/lb/v1/vip_test.go
new file mode 100644
index 0000000..bc0c2a8
--- /dev/null
+++ b/acceptance/rackspace/lb/v1/vip_test.go
@@ -0,0 +1,83 @@
+// +build acceptance lbs
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/lbs"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/vips"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestVIPs(t *testing.T) {
+ client := setup(t)
+
+ ids := createLB(t, client, 1)
+ lbID := ids[0]
+
+ listVIPs(t, client, lbID)
+
+ vipIDs := addVIPs(t, client, lbID, 3)
+
+ deleteVIP(t, client, lbID, vipIDs[0])
+
+ bulkDeleteVIPs(t, client, lbID, vipIDs[1:])
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+ deleteLB(t, client, lbID)
+}
+
+func listVIPs(t *testing.T, client *gophercloud.ServiceClient, lbID int) {
+ err := vips.List(client, lbID).EachPage(func(page pagination.Page) (bool, error) {
+ vipList, err := vips.ExtractVIPs(page)
+ th.AssertNoErr(t, err)
+
+ for _, vip := range vipList {
+ t.Logf("Listing VIP: ID [%s] Address [%s] Type [%s] Version [%s]",
+ vip.ID, vip.Address, vip.Type, vip.Version)
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+}
+
+func addVIPs(t *testing.T, client *gophercloud.ServiceClient, lbID, count int) []int {
+ ids := []int{}
+
+ for i := 0; i < count; i++ {
+ opts := vips.CreateOpts{
+ Type: vips.PUBLIC,
+ Version: vips.IPV6,
+ }
+
+ vip, err := vips.Create(client, lbID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created VIP %d", vip.ID)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+
+ ids = append(ids, vip.ID)
+ }
+
+ return ids
+}
+
+func deleteVIP(t *testing.T, client *gophercloud.ServiceClient, lbID, vipID int) {
+ err := vips.Delete(client, lbID, vipID).ExtractErr()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleted VIP %d", vipID)
+
+ waitForLB(client, lbID, lbs.ACTIVE)
+}
+
+func bulkDeleteVIPs(t *testing.T, client *gophercloud.ServiceClient, lbID int, ids []int) {
+ err := vips.BulkDelete(client, lbID, ids).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted VIPs %s", intsToStr(ids))
+}
diff --git a/acceptance/rackspace/networking/v2/common.go b/acceptance/rackspace/networking/v2/common.go
new file mode 100644
index 0000000..8170418
--- /dev/null
+++ b/acceptance/rackspace/networking/v2/common.go
@@ -0,0 +1,39 @@
+package v2
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var Client *gophercloud.ServiceClient
+
+func NewClient() (*gophercloud.ServiceClient, error) {
+ opts, err := rackspace.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ provider, err := rackspace.AuthenticatedClient(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return rackspace.NewNetworkV2(provider, gophercloud.EndpointOpts{
+ Name: "cloudNetworks",
+ Region: os.Getenv("RS_REGION"),
+ })
+}
+
+func Setup(t *testing.T) {
+ client, err := NewClient()
+ th.AssertNoErr(t, err)
+ Client = client
+}
+
+func Teardown() {
+ Client = nil
+}
diff --git a/acceptance/rackspace/networking/v2/network_test.go b/acceptance/rackspace/networking/v2/network_test.go
new file mode 100644
index 0000000..3862123
--- /dev/null
+++ b/acceptance/rackspace/networking/v2/network_test.go
@@ -0,0 +1,65 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/networks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Create a network
+ n, err := networks.Create(Client, os.CreateOpts{Name: "sample_network", AdminStateUp: os.Up}).Extract()
+ th.AssertNoErr(t, err)
+ defer networks.Delete(Client, n.ID)
+ th.AssertEquals(t, "sample_network", n.Name)
+ th.AssertEquals(t, true, n.AdminStateUp)
+ networkID := n.ID
+
+ // List networks
+ pager := networks.List(Client, os.ListOpts{Limit: 2})
+ err = pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ networkList, err := os.ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+
+ for _, n := range networkList {
+ t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+ n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+
+ // Get a network
+ if networkID == "" {
+ t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+ }
+ n, err = networks.Get(Client, networkID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "ACTIVE", n.Status)
+ th.AssertDeepEquals(t, []string{}, n.Subnets)
+ th.AssertEquals(t, "sample_network", n.Name)
+ th.AssertEquals(t, true, n.AdminStateUp)
+ th.AssertEquals(t, false, n.Shared)
+ th.AssertEquals(t, networkID, n.ID)
+
+ // Update network
+ n, err = networks.Update(Client, networkID, os.UpdateOpts{Name: "new_network_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "new_network_name", n.Name)
+
+ // Delete network
+ res := networks.Delete(Client, networkID)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/acceptance/rackspace/networking/v2/port_test.go b/acceptance/rackspace/networking/v2/port_test.go
new file mode 100644
index 0000000..3c42bb2
--- /dev/null
+++ b/acceptance/rackspace/networking/v2/port_test.go
@@ -0,0 +1,116 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ osNetworks "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ osPorts "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ osSubnets "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/networks"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/ports"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/subnets"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPortCRUD(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Setup network
+ t.Log("Setting up network")
+ networkID, err := createNetwork()
+ th.AssertNoErr(t, err)
+ defer networks.Delete(Client, networkID)
+
+ // Setup subnet
+ t.Logf("Setting up subnet on network %s", networkID)
+ subnetID, err := createSubnet(networkID)
+ th.AssertNoErr(t, err)
+ defer subnets.Delete(Client, subnetID)
+
+ // Create port
+ t.Logf("Create port based on subnet %s", subnetID)
+ portID := createPort(t, networkID, subnetID)
+
+ // List ports
+ t.Logf("Listing all ports")
+ listPorts(t)
+
+ // Get port
+ if portID == "" {
+ t.Fatalf("In order to retrieve a port, the portID must be set")
+ }
+ p, err := ports.Get(Client, portID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, portID, p.ID)
+
+ // Update port
+ p, err = ports.Update(Client, portID, osPorts.UpdateOpts{Name: "new_port_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "new_port_name", p.Name)
+
+ // Delete port
+ res := ports.Delete(Client, portID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func createPort(t *testing.T, networkID, subnetID string) string {
+ enable := true
+ opts := osPorts.CreateOpts{
+ NetworkID: networkID,
+ Name: "my_port",
+ AdminStateUp: &enable,
+ FixedIPs: []osPorts.IP{osPorts.IP{SubnetID: subnetID}},
+ }
+ p, err := ports.Create(Client, opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, networkID, p.NetworkID)
+ th.AssertEquals(t, "my_port", p.Name)
+ th.AssertEquals(t, true, p.AdminStateUp)
+
+ return p.ID
+}
+
+func listPorts(t *testing.T) {
+ count := 0
+ pager := ports.List(Client, osPorts.ListOpts{})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ t.Logf("--- Page ---")
+
+ portList, err := osPorts.ExtractPorts(page)
+ th.AssertNoErr(t, err)
+
+ for _, p := range portList {
+ t.Logf("Port: ID [%s] Name [%s] Status [%s] MAC addr [%s] Fixed IPs [%#v] Security groups [%#v]",
+ p.ID, p.Name, p.Status, p.MACAddress, p.FixedIPs, p.SecurityGroups)
+ }
+
+ return true, nil
+ })
+
+ th.CheckNoErr(t, err)
+
+ if count == 0 {
+ t.Logf("No pages were iterated over when listing ports")
+ }
+}
+
+func createNetwork() (string, error) {
+ res, err := networks.Create(Client, osNetworks.CreateOpts{Name: "tmp_network", AdminStateUp: osNetworks.Up}).Extract()
+ return res.ID, err
+}
+
+func createSubnet(networkID string) (string, error) {
+ s, err := subnets.Create(Client, osSubnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: "192.168.199.0/24",
+ IPVersion: osSubnets.IPv4,
+ Name: "my_subnet",
+ EnableDHCP: osSubnets.Down,
+ }).Extract()
+ return s.ID, err
+}
diff --git a/acceptance/rackspace/networking/v2/security_test.go b/acceptance/rackspace/networking/v2/security_test.go
new file mode 100644
index 0000000..ec02991
--- /dev/null
+++ b/acceptance/rackspace/networking/v2/security_test.go
@@ -0,0 +1,165 @@
+// +build acceptance networking security
+
+package v2
+
+import (
+ "testing"
+
+ osGroups "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/groups"
+ osRules "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ osNetworks "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ osPorts "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/pagination"
+ rsNetworks "github.com/rackspace/gophercloud/rackspace/networking/v2/networks"
+ rsPorts "github.com/rackspace/gophercloud/rackspace/networking/v2/ports"
+ rsGroups "github.com/rackspace/gophercloud/rackspace/networking/v2/security/groups"
+ rsRules "github.com/rackspace/gophercloud/rackspace/networking/v2/security/rules"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestSecurityGroups(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // create security group
+ groupID := createSecGroup(t)
+
+ // delete security group
+ defer deleteSecGroup(t, groupID)
+
+ // list security group
+ listSecGroups(t)
+
+ // get security group
+ getSecGroup(t, groupID)
+}
+
+func TestSecurityGroupRules(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // create security group
+ groupID := createSecGroup(t)
+
+ defer deleteSecGroup(t, groupID)
+
+ // create security group rule
+ ruleID := createSecRule(t, groupID)
+
+ // delete security group rule
+ defer deleteSecRule(t, ruleID)
+
+ // list security group rule
+ listSecRules(t)
+
+ // get security group rule
+ getSecRule(t, ruleID)
+}
+
+func createSecGroup(t *testing.T) string {
+ sg, err := rsGroups.Create(Client, osGroups.CreateOpts{
+ Name: "new-webservers",
+ Description: "security group for webservers",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created security group %s", sg.ID)
+
+ return sg.ID
+}
+
+func listSecGroups(t *testing.T) {
+ err := rsGroups.List(Client, osGroups.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ list, err := osGroups.ExtractGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract secgroups: %v", err)
+ return false, err
+ }
+
+ for _, sg := range list {
+ t.Logf("Listing security group: ID [%s] Name [%s]", sg.ID, sg.Name)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getSecGroup(t *testing.T, id string) {
+ sg, err := rsGroups.Get(Client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting security group: ID [%s] Name [%s] Description [%s]", sg.ID, sg.Name, sg.Description)
+}
+
+func createSecGroupPort(t *testing.T, groupID string) (string, string) {
+ n, err := rsNetworks.Create(Client, osNetworks.CreateOpts{Name: "tmp_network"}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created network %s", n.ID)
+
+ opts := osPorts.CreateOpts{
+ NetworkID: n.ID,
+ Name: "my_port",
+ SecurityGroups: []string{groupID},
+ }
+ p, err := rsPorts.Create(Client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created port %s with security group %s", p.ID, groupID)
+
+ return n.ID, p.ID
+}
+
+func deleteSecGroup(t *testing.T, groupID string) {
+ res := rsGroups.Delete(Client, groupID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted security group %s", groupID)
+}
+
+func createSecRule(t *testing.T, groupID string) string {
+ r, err := rsRules.Create(Client, osRules.CreateOpts{
+ Direction: "ingress",
+ PortRangeMin: 80,
+ EtherType: "IPv4",
+ PortRangeMax: 80,
+ Protocol: "tcp",
+ SecGroupID: groupID,
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ t.Logf("Created security group rule %s", r.ID)
+
+ return r.ID
+}
+
+func listSecRules(t *testing.T) {
+ err := rsRules.List(Client, osRules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ list, err := osRules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract sec rules: %v", err)
+ return false, err
+ }
+
+ for _, r := range list {
+ t.Logf("Listing security rule: ID [%s]", r.ID)
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getSecRule(t *testing.T, id string) {
+ r, err := rsRules.Get(Client, id).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting security rule: ID [%s] Direction [%s] EtherType [%s] Protocol [%s]",
+ r.ID, r.Direction, r.EtherType, r.Protocol)
+}
+
+func deleteSecRule(t *testing.T, id string) {
+ res := rsRules.Delete(Client, id)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted security rule %s", id)
+}
diff --git a/acceptance/rackspace/networking/v2/subnet_test.go b/acceptance/rackspace/networking/v2/subnet_test.go
new file mode 100644
index 0000000..c401432
--- /dev/null
+++ b/acceptance/rackspace/networking/v2/subnet_test.go
@@ -0,0 +1,84 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ osNetworks "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ osSubnets "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/networks"
+ "github.com/rackspace/gophercloud/rackspace/networking/v2/subnets"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestListSubnets(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ pager := subnets.List(Client, osSubnets.ListOpts{Limit: 2})
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page ---")
+
+ subnetList, err := osSubnets.ExtractSubnets(page)
+ th.AssertNoErr(t, err)
+
+ for _, s := range subnetList {
+ t.Logf("Subnet: ID [%s] Name [%s] IP Version [%d] CIDR [%s] GatewayIP [%s]",
+ s.ID, s.Name, s.IPVersion, s.CIDR, s.GatewayIP)
+ }
+
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
+
+func TestSubnetCRUD(t *testing.T) {
+ Setup(t)
+ defer Teardown()
+
+ // Setup network
+ t.Log("Setting up network")
+ n, err := networks.Create(Client, osNetworks.CreateOpts{Name: "tmp_network", AdminStateUp: osNetworks.Up}).Extract()
+ th.AssertNoErr(t, err)
+ networkID := n.ID
+ defer networks.Delete(Client, networkID)
+
+ // Create subnet
+ t.Log("Create subnet")
+ enable := false
+ opts := osSubnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: "192.168.199.0/24",
+ IPVersion: osSubnets.IPv4,
+ Name: "my_subnet",
+ EnableDHCP: &enable,
+ }
+ s, err := subnets.Create(Client, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, networkID, s.NetworkID)
+ th.AssertEquals(t, "192.168.199.0/24", s.CIDR)
+ th.AssertEquals(t, 4, s.IPVersion)
+ th.AssertEquals(t, "my_subnet", s.Name)
+ th.AssertEquals(t, false, s.EnableDHCP)
+ subnetID := s.ID
+
+ // Get subnet
+ t.Log("Getting subnet")
+ s, err = subnets.Get(Client, subnetID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, subnetID, s.ID)
+
+ // Update subnet
+ t.Log("Update subnet")
+ s, err = subnets.Update(Client, subnetID, osSubnets.UpdateOpts{Name: "new_subnet_name"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "new_subnet_name", s.Name)
+
+ // Delete subnet
+ t.Log("Delete subnet")
+ res := subnets.Delete(Client, subnetID)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/acceptance/rackspace/objectstorage/v1/accounts_test.go b/acceptance/rackspace/objectstorage/v1/accounts_test.go
new file mode 100644
index 0000000..8b3cde4
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/accounts_test.go
@@ -0,0 +1,38 @@
+// +build acceptance rackspace
+
+package v1
+
+import (
+ "testing"
+
+ raxAccounts "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/accounts"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAccounts(t *testing.T) {
+ c, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ updateHeaders, err := raxAccounts.Update(c, raxAccounts.UpdateOpts{Metadata: map[string]string{"white": "mountains"}}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Update Account Response Headers: %+v\n", updateHeaders)
+ defer func() {
+ updateres := raxAccounts.Update(c, raxAccounts.UpdateOpts{Metadata: map[string]string{"white": ""}})
+ th.AssertNoErr(t, updateres.Err)
+ metadata, err := raxAccounts.Get(c).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadata from Get Account request (after update reverted): %+v\n", metadata)
+ th.CheckEquals(t, metadata["White"], "")
+ }()
+
+ getResp := raxAccounts.Get(c)
+ th.AssertNoErr(t, getResp.Err)
+
+ getHeaders, _ := getResp.Extract()
+ t.Logf("Get Account Response Headers: %+v\n", getHeaders)
+
+ metadata, _ := getResp.ExtractMetadata()
+ t.Logf("Metadata from Get Account request (after update): %+v\n", metadata)
+
+ th.CheckEquals(t, metadata["White"], "mountains")
+}
diff --git a/acceptance/rackspace/objectstorage/v1/bulk_test.go b/acceptance/rackspace/objectstorage/v1/bulk_test.go
new file mode 100644
index 0000000..79013a5
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/bulk_test.go
@@ -0,0 +1,23 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/bulk"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBulk(t *testing.T) {
+ c, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ var options bulk.DeleteOpts
+ options = append(options, "container/object1")
+ res := bulk.Delete(c, options)
+ th.AssertNoErr(t, res.Err)
+ body, err := res.ExtractBody()
+ th.AssertNoErr(t, err)
+ t.Logf("Response body from Bulk Delete Request: %+v\n", body)
+}
diff --git a/acceptance/rackspace/objectstorage/v1/cdncontainers_test.go b/acceptance/rackspace/objectstorage/v1/cdncontainers_test.go
new file mode 100644
index 0000000..0f56f49
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/cdncontainers_test.go
@@ -0,0 +1,66 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "testing"
+
+ osContainers "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+ raxCDNContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/cdncontainers"
+ raxContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/containers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCDNContainers(t *testing.T) {
+ raxClient, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ createres := raxContainers.Create(raxClient, "gophercloud-test", nil)
+ th.AssertNoErr(t, createres.Err)
+ t.Logf("Headers from Create Container request: %+v\n", createres.Header)
+ defer func() {
+ res := raxContainers.Delete(raxClient, "gophercloud-test")
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ raxCDNClient, err := createClient(t, true)
+ th.AssertNoErr(t, err)
+ enableRes := raxCDNContainers.Enable(raxCDNClient, "gophercloud-test", raxCDNContainers.EnableOpts{CDNEnabled: true, TTL: 900})
+ t.Logf("Header map from Enable CDN Container request: %+v\n", enableRes.Header)
+ enableHeader, err := enableRes.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Enable CDN Container request: %+v\n", enableHeader)
+
+ t.Logf("Container Names available to the currently issued token:")
+ count := 0
+ err = raxCDNContainers.List(raxCDNClient, &osContainers.ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ names, err := raxCDNContainers.ExtractNames(page)
+ th.AssertNoErr(t, err)
+
+ for i, name := range names {
+ t.Logf("[%02d] %s", i, name)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No CDN containers listed for your current token.")
+ }
+
+ updateOpts := raxCDNContainers.UpdateOpts{XCDNEnabled: raxCDNContainers.Disabled, XLogRetention: raxCDNContainers.Enabled}
+ updateHeader, err := raxCDNContainers.Update(raxCDNClient, "gophercloud-test", updateOpts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Update CDN Container request: %+v\n", updateHeader)
+
+ getRes := raxCDNContainers.Get(raxCDNClient, "gophercloud-test")
+ getHeader, err := getRes.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Get CDN Container request (after update): %+v\n", getHeader)
+ metadata, err := getRes.ExtractMetadata()
+ t.Logf("Metadata from Get CDN Container request (after update): %+v\n", metadata)
+}
diff --git a/acceptance/rackspace/objectstorage/v1/cdnobjects_test.go b/acceptance/rackspace/objectstorage/v1/cdnobjects_test.go
new file mode 100644
index 0000000..0c0ab8a
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/cdnobjects_test.go
@@ -0,0 +1,50 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "bytes"
+ "testing"
+
+ raxCDNContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/cdncontainers"
+ raxCDNObjects "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/cdnobjects"
+ raxContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/containers"
+ raxObjects "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/objects"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCDNObjects(t *testing.T) {
+ raxClient, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ createContResult := raxContainers.Create(raxClient, "gophercloud-test", nil)
+ th.AssertNoErr(t, createContResult.Err)
+ t.Logf("Headers from Create Container request: %+v\n", createContResult.Header)
+ defer func() {
+ deleteResult := raxContainers.Delete(raxClient, "gophercloud-test")
+ th.AssertNoErr(t, deleteResult.Err)
+ }()
+
+ header, err := raxObjects.Create(raxClient, "gophercloud-test", "test-object", bytes.NewBufferString("gophercloud cdn test"), nil).ExtractHeader()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Create Object request: %+v\n", header)
+ defer func() {
+ deleteResult := raxObjects.Delete(raxClient, "gophercloud-test", "test-object", nil)
+ th.AssertNoErr(t, deleteResult.Err)
+ }()
+
+ raxCDNClient, err := createClient(t, true)
+ th.AssertNoErr(t, err)
+
+ enableHeader, err := raxCDNContainers.Enable(raxCDNClient, "gophercloud-test", raxCDNContainers.EnableOpts{CDNEnabled: true, TTL: 900}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Enable CDN Container request: %+v\n", enableHeader)
+
+ objCDNURL, err := raxCDNObjects.CDNURL(raxCDNClient, "gophercloud-test", "test-object")
+ th.AssertNoErr(t, err)
+ t.Logf("%s CDN URL: %s\n", "test_object", objCDNURL)
+
+ deleteHeader, err := raxCDNObjects.Delete(raxCDNClient, "gophercloud-test", "test-object", nil).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Delete CDN Object request: %+v\n", deleteHeader)
+}
diff --git a/acceptance/rackspace/objectstorage/v1/common.go b/acceptance/rackspace/objectstorage/v1/common.go
new file mode 100644
index 0000000..1ae0727
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/common.go
@@ -0,0 +1,54 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func rackspaceAuthOptions(t *testing.T) gophercloud.AuthOptions {
+ // Obtain credentials from the environment.
+ options, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+ options = tools.OnlyRS(options)
+
+ if options.Username == "" {
+ t.Fatal("Please provide a Rackspace username as RS_USERNAME.")
+ }
+ if options.APIKey == "" {
+ t.Fatal("Please provide a Rackspace API key as RS_API_KEY.")
+ }
+
+ return options
+}
+
+func createClient(t *testing.T, cdn bool) (*gophercloud.ServiceClient, error) {
+ region := os.Getenv("RS_REGION")
+ if region == "" {
+ t.Fatal("Please provide a Rackspace region as RS_REGION")
+ }
+
+ ao := rackspaceAuthOptions(t)
+
+ provider, err := rackspace.NewClient(ao.IdentityEndpoint)
+ th.AssertNoErr(t, err)
+
+ err = rackspace.Authenticate(provider, ao)
+ th.AssertNoErr(t, err)
+
+ if cdn {
+ return rackspace.NewObjectCDNV1(provider, gophercloud.EndpointOpts{
+ Region: region,
+ })
+ }
+
+ return rackspace.NewObjectStorageV1(provider, gophercloud.EndpointOpts{
+ Region: region,
+ })
+}
diff --git a/acceptance/rackspace/objectstorage/v1/containers_test.go b/acceptance/rackspace/objectstorage/v1/containers_test.go
new file mode 100644
index 0000000..c895513
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/containers_test.go
@@ -0,0 +1,90 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "testing"
+
+ osContainers "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+ raxContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/containers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestContainers(t *testing.T) {
+ c, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Containers Info available to the currently issued token:")
+ count := 0
+ err = raxContainers.List(c, &osContainers.ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ containers, err := raxContainers.ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ for i, container := range containers {
+ t.Logf("[%02d] name=[%s]", i, container.Name)
+ t.Logf(" count=[%d]", container.Count)
+ t.Logf(" bytes=[%d]", container.Bytes)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No containers listed for your current token.")
+ }
+
+ t.Logf("Container Names available to the currently issued token:")
+ count = 0
+ err = raxContainers.List(c, &osContainers.ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ names, err := raxContainers.ExtractNames(page)
+ th.AssertNoErr(t, err)
+
+ for i, name := range names {
+ t.Logf("[%02d] %s", i, name)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No containers listed for your current token.")
+ }
+
+ createHeader, err := raxContainers.Create(c, "gophercloud-test", nil).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Create Container request: %+v\n", createHeader)
+ defer func() {
+ deleteres := raxContainers.Delete(c, "gophercloud-test")
+ deleteHeader, err := deleteres.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Delete Container request: %+v\n", deleteres.Header)
+ t.Logf("Headers from Delete Container request: %+v\n", deleteHeader)
+ }()
+
+ updateHeader, err := raxContainers.Update(c, "gophercloud-test", raxContainers.UpdateOpts{Metadata: map[string]string{"white": "mountains"}}).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Update Container request: %+v\n", updateHeader)
+ defer func() {
+ res := raxContainers.Update(c, "gophercloud-test", raxContainers.UpdateOpts{Metadata: map[string]string{"white": ""}})
+ th.AssertNoErr(t, res.Err)
+ metadata, err := raxContainers.Get(c, "gophercloud-test").ExtractMetadata()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadata from Get Container request (after update reverted): %+v\n", metadata)
+ th.CheckEquals(t, metadata["White"], "")
+ }()
+
+ getres := raxContainers.Get(c, "gophercloud-test")
+ getHeader, err := getres.Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Headers from Get Container request (after update): %+v\n", getHeader)
+ metadata, err := getres.ExtractMetadata()
+ t.Logf("Metadata from Get Container request (after update): %+v\n", metadata)
+ th.CheckEquals(t, metadata["White"], "mountains")
+}
diff --git a/acceptance/rackspace/objectstorage/v1/objects_test.go b/acceptance/rackspace/objectstorage/v1/objects_test.go
new file mode 100644
index 0000000..585dea7
--- /dev/null
+++ b/acceptance/rackspace/objectstorage/v1/objects_test.go
@@ -0,0 +1,124 @@
+// +build acceptance rackspace objectstorage v1
+
+package v1
+
+import (
+ "bytes"
+ "testing"
+
+ osObjects "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/rackspace/gophercloud/pagination"
+ raxContainers "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/containers"
+ raxObjects "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/objects"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestObjects(t *testing.T) {
+ c, err := createClient(t, false)
+ th.AssertNoErr(t, err)
+
+ res := raxContainers.Create(c, "gophercloud-test", nil)
+ th.AssertNoErr(t, res.Err)
+
+ defer func() {
+ t.Logf("Deleting container...")
+ res := raxContainers.Delete(c, "gophercloud-test")
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ content := bytes.NewBufferString("Lewis Carroll")
+ options := &osObjects.CreateOpts{ContentType: "text/plain"}
+ createres := raxObjects.Create(c, "gophercloud-test", "o1", content, options)
+ th.AssertNoErr(t, createres.Err)
+
+ defer func() {
+ t.Logf("Deleting object o1...")
+ res := raxObjects.Delete(c, "gophercloud-test", "o1", nil)
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ t.Logf("Objects Info available to the currently issued token:")
+ count := 0
+ err = raxObjects.List(c, "gophercloud-test", &osObjects.ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ objects, err := raxObjects.ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ for i, object := range objects {
+ t.Logf("[%02d] name=[%s]", i, object.Name)
+ t.Logf(" content-type=[%s]", object.ContentType)
+ t.Logf(" bytes=[%d]", object.Bytes)
+ t.Logf(" last-modified=[%s]", object.LastModified)
+ t.Logf(" hash=[%s]", object.Hash)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No objects listed for your current token.")
+ }
+ t.Logf("Container Names available to the currently issued token:")
+ count = 0
+ err = raxObjects.List(c, "gophercloud-test", &osObjects.ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ t.Logf("--- Page %02d ---", count)
+
+ names, err := raxObjects.ExtractNames(page)
+ th.AssertNoErr(t, err)
+
+ for i, name := range names {
+ t.Logf("[%02d] %s", i, name)
+ }
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ if count == 0 {
+ t.Errorf("No objects listed for your current token.")
+ }
+
+ copyres := raxObjects.Copy(c, "gophercloud-test", "o1", &raxObjects.CopyOpts{Destination: "gophercloud-test/o2"})
+ th.AssertNoErr(t, copyres.Err)
+ defer func() {
+ t.Logf("Deleting object o2...")
+ res := raxObjects.Delete(c, "gophercloud-test", "o2", nil)
+ th.AssertNoErr(t, res.Err)
+ }()
+
+ o1Content, err := raxObjects.Download(c, "gophercloud-test", "o1", nil).ExtractContent()
+ th.AssertNoErr(t, err)
+ o2Content, err := raxObjects.Download(c, "gophercloud-test", "o2", nil).ExtractContent()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, string(o2Content), string(o1Content))
+
+ updateres := raxObjects.Update(c, "gophercloud-test", "o2", osObjects.UpdateOpts{Metadata: map[string]string{"white": "mountains"}})
+ th.AssertNoErr(t, updateres.Err)
+ t.Logf("Headers from Update Account request: %+v\n", updateres.Header)
+ defer func() {
+ res := raxObjects.Update(c, "gophercloud-test", "o2", osObjects.UpdateOpts{Metadata: map[string]string{"white": ""}})
+ th.AssertNoErr(t, res.Err)
+ metadata, err := raxObjects.Get(c, "gophercloud-test", "o2", nil).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadata from Get Account request (after update reverted): %+v\n", metadata)
+ th.CheckEquals(t, "", metadata["White"])
+ }()
+
+ getres := raxObjects.Get(c, "gophercloud-test", "o2", nil)
+ th.AssertNoErr(t, getres.Err)
+ t.Logf("Headers from Get Account request (after update): %+v\n", getres.Header)
+ metadata, err := getres.ExtractMetadata()
+ th.AssertNoErr(t, err)
+ t.Logf("Metadata from Get Account request (after update): %+v\n", metadata)
+ th.CheckEquals(t, "mountains", metadata["White"])
+
+ createTempURLOpts := osObjects.CreateTempURLOpts{
+ Method: osObjects.GET,
+ TTL: 600,
+ }
+ tempURL, err := raxObjects.CreateTempURL(c, "gophercloud-test", "o1", createTempURLOpts)
+ th.AssertNoErr(t, err)
+ t.Logf("TempURL for object (%s): %s", "o1", tempURL)
+}
diff --git a/acceptance/rackspace/orchestration/v1/buildinfo_test.go b/acceptance/rackspace/orchestration/v1/buildinfo_test.go
new file mode 100644
index 0000000..42cc048
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/buildinfo_test.go
@@ -0,0 +1,20 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/buildinfo"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestBuildInfo(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ bi, err := buildinfo.Get(client).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved build info: %+v\n", bi)
+}
diff --git a/acceptance/rackspace/orchestration/v1/common.go b/acceptance/rackspace/orchestration/v1/common.go
new file mode 100644
index 0000000..b9d5197
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/common.go
@@ -0,0 +1,45 @@
+// +build acceptance
+
+package v1
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+var template = fmt.Sprintf(`
+{
+ "heat_template_version": "2013-05-23",
+ "description": "Simple template to test heat commands",
+ "parameters": {},
+ "resources": {
+ "hello_world": {
+ "type":"OS::Nova::Server",
+ "properties": {
+ "flavor": "%s",
+ "image": "%s",
+ "user_data": "#!/bin/bash -xv\necho \"hello world\" > /root/hello-world.txt\n"
+ }
+ }
+ }
+}
+`, os.Getenv("RS_FLAVOR_ID"), os.Getenv("RS_IMAGE_ID"))
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := rackspace.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := rackspace.NewOrchestrationV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("RS_REGION_NAME"),
+ })
+ th.AssertNoErr(t, err)
+ return c
+}
diff --git a/acceptance/rackspace/orchestration/v1/stackevents_test.go b/acceptance/rackspace/orchestration/v1/stackevents_test.go
new file mode 100644
index 0000000..9e3fc08
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/stackevents_test.go
@@ -0,0 +1,70 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ osStackEvents "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackevents"
+ osStacks "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stackevents"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stacks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackEvents(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+ resourceName := "hello_world"
+ var eventID string
+
+ createOpts := osStacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ err = stackevents.List(client, stackName, stack.ID, nil).EachPage(func(page pagination.Page) (bool, error) {
+ events, err := osStackEvents.ExtractEvents(page)
+ th.AssertNoErr(t, err)
+ t.Logf("listed events: %+v\n", events)
+ eventID = events[0].ID
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ err = stackevents.ListResourceEvents(client, stackName, stack.ID, resourceName, nil).EachPage(func(page pagination.Page) (bool, error) {
+ resourceEvents, err := osStackEvents.ExtractResourceEvents(page)
+ th.AssertNoErr(t, err)
+ t.Logf("listed resource events: %+v\n", resourceEvents)
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+
+ event, err := stackevents.Get(client, stackName, stack.ID, resourceName, eventID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved event: %+v\n", event)
+}
diff --git a/acceptance/rackspace/orchestration/v1/stackresources_test.go b/acceptance/rackspace/orchestration/v1/stackresources_test.go
new file mode 100644
index 0000000..65926e7
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/stackresources_test.go
@@ -0,0 +1,64 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ osStackResources "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackresources"
+ osStacks "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stackresources"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stacks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackResources(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+
+ createOpts := osStacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ resourceName := "hello_world"
+ resource, err := stackresources.Get(client, stackName, stack.ID, resourceName).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack resource: %+v\n", resource)
+
+ metadata, err := stackresources.Metadata(client, stackName, stack.ID, resourceName).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Got stack resource metadata: %+v\n", metadata)
+
+ err = stackresources.List(client, stackName, stack.ID, nil).EachPage(func(page pagination.Page) (bool, error) {
+ resources, err := osStackResources.ExtractResources(page)
+ th.AssertNoErr(t, err)
+ t.Logf("resources: %+v\n", resources)
+ return false, nil
+ })
+ th.AssertNoErr(t, err)
+}
diff --git a/acceptance/rackspace/orchestration/v1/stacks_test.go b/acceptance/rackspace/orchestration/v1/stacks_test.go
new file mode 100644
index 0000000..61969b5
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/stacks_test.go
@@ -0,0 +1,154 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ osStacks "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stacks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStacks(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"
+ createOpts := osStacks.CreateOpts{
+ Name: stackName1,
+ Template: template,
+ 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{
+ Template: template,
+ 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)
+}
+
+// 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
new file mode 100644
index 0000000..e4ccd9e
--- /dev/null
+++ b/acceptance/rackspace/orchestration/v1/stacktemplates_test.go
@@ -0,0 +1,77 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ osStacks "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ osStacktemplates "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacktemplates"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/rackspace/orchestration/v1/stacktemplates"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestStackTemplates(t *testing.T) {
+ // Create a provider client for making the HTTP requests.
+ // See common.go in this directory for more information.
+ client := newClient(t)
+
+ stackName := "postman_stack_2"
+
+ createOpts := osStacks.CreateOpts{
+ Name: stackName,
+ Template: template,
+ 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, stackName, stack.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Deleted stack (%s)", stackName)
+ }()
+ err = gophercloud.WaitFor(60, func() (bool, error) {
+ getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+ if getStack.Status == "CREATE_COMPLETE" {
+ return true, nil
+ }
+ return false, nil
+ })
+
+ tmpl, err := stacktemplates.Get(client, stackName, stack.ID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("retrieved template: %+v\n", tmpl)
+
+ 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": {
+ "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",
+ },
+ },
+ },
+ }`}
+ validatedTemplate, err := stacktemplates.Validate(client, validateOpts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("validated template: %+v\n", validatedTemplate)
+}
diff --git a/acceptance/rackspace/pkg.go b/acceptance/rackspace/pkg.go
new file mode 100644
index 0000000..5d17b32
--- /dev/null
+++ b/acceptance/rackspace/pkg.go
@@ -0,0 +1 @@
+package rackspace
diff --git a/acceptance/rackspace/rackconnect/v3/cloudnetworks_test.go b/acceptance/rackspace/rackconnect/v3/cloudnetworks_test.go
new file mode 100644
index 0000000..2c6287e
--- /dev/null
+++ b/acceptance/rackspace/rackconnect/v3/cloudnetworks_test.go
@@ -0,0 +1,36 @@
+// +build acceptance
+
+package v3
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/rackconnect/v3/cloudnetworks"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCloudNetworks(t *testing.T) {
+ c := newClient(t)
+ cnID := testListNetworks(t, c)
+ testGetNetworks(t, c, cnID)
+}
+
+func testListNetworks(t *testing.T, c *gophercloud.ServiceClient) string {
+ allPages, err := cloudnetworks.List(c).AllPages()
+ th.AssertNoErr(t, err)
+ allcn, err := cloudnetworks.ExtractCloudNetworks(allPages)
+ fmt.Printf("Listing all cloud networks: %+v\n\n", allcn)
+ var cnID string
+ if len(allcn) > 0 {
+ cnID = allcn[0].ID
+ }
+ return cnID
+}
+
+func testGetNetworks(t *testing.T, c *gophercloud.ServiceClient, id string) {
+ cn, err := cloudnetworks.Get(c, id).Extract()
+ th.AssertNoErr(t, err)
+ fmt.Printf("Retrieved cloud network: %+v\n\n", cn)
+}
diff --git a/acceptance/rackspace/rackconnect/v3/common.go b/acceptance/rackspace/rackconnect/v3/common.go
new file mode 100644
index 0000000..8c75314
--- /dev/null
+++ b/acceptance/rackspace/rackconnect/v3/common.go
@@ -0,0 +1,26 @@
+// +build acceptance
+
+package v3
+
+import (
+ "os"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+ ao, err := rackspace.AuthOptionsFromEnv()
+ th.AssertNoErr(t, err)
+
+ client, err := rackspace.AuthenticatedClient(ao)
+ th.AssertNoErr(t, err)
+
+ c, err := rackspace.NewRackConnectV3(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("RS_REGION_NAME"),
+ })
+ th.AssertNoErr(t, err)
+ return c
+}
diff --git a/acceptance/rackspace/rackconnect/v3/lbpools_test.go b/acceptance/rackspace/rackconnect/v3/lbpools_test.go
new file mode 100644
index 0000000..85ac931
--- /dev/null
+++ b/acceptance/rackspace/rackconnect/v3/lbpools_test.go
@@ -0,0 +1,71 @@
+// +build acceptance
+
+package v3
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/rackconnect/v3/lbpools"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestLBPools(t *testing.T) {
+ c := newClient(t)
+ pID := testListPools(t, c)
+ testGetPools(t, c, pID)
+ nID := testListNodes(t, c, pID)
+ testListNodeDetails(t, c, pID)
+ testGetNode(t, c, pID, nID)
+ testGetNodeDetails(t, c, pID, nID)
+}
+
+func testListPools(t *testing.T, c *gophercloud.ServiceClient) string {
+ allPages, err := lbpools.List(c).AllPages()
+ th.AssertNoErr(t, err)
+ allp, err := lbpools.ExtractPools(allPages)
+ fmt.Printf("Listing all LB pools: %+v\n\n", allp)
+ var pID string
+ if len(allp) > 0 {
+ pID = allp[0].ID
+ }
+ return pID
+}
+
+func testGetPools(t *testing.T, c *gophercloud.ServiceClient, pID string) {
+ p, err := lbpools.Get(c, pID).Extract()
+ th.AssertNoErr(t, err)
+ fmt.Printf("Retrieved LB pool: %+v\n\n", p)
+}
+
+func testListNodes(t *testing.T, c *gophercloud.ServiceClient, pID string) string {
+ allPages, err := lbpools.ListNodes(c, pID).AllPages()
+ th.AssertNoErr(t, err)
+ alln, err := lbpools.ExtractNodes(allPages)
+ fmt.Printf("Listing all LB pool nodes for pool (%s): %+v\n\n", pID, alln)
+ var nID string
+ if len(alln) > 0 {
+ nID = alln[0].ID
+ }
+ return nID
+}
+
+func testListNodeDetails(t *testing.T, c *gophercloud.ServiceClient, pID string) {
+ allPages, err := lbpools.ListNodesDetails(c, pID).AllPages()
+ th.AssertNoErr(t, err)
+ alln, err := lbpools.ExtractNodesDetails(allPages)
+ fmt.Printf("Listing all LB pool nodes details for pool (%s): %+v\n\n", pID, alln)
+}
+
+func testGetNode(t *testing.T, c *gophercloud.ServiceClient, pID, nID string) {
+ n, err := lbpools.GetNode(c, pID, nID).Extract()
+ th.AssertNoErr(t, err)
+ fmt.Printf("Retrieved LB node: %+v\n\n", n)
+}
+
+func testGetNodeDetails(t *testing.T, c *gophercloud.ServiceClient, pID, nID string) {
+ n, err := lbpools.GetNodeDetails(c, pID, nID).Extract()
+ th.AssertNoErr(t, err)
+ fmt.Printf("Retrieved LB node details: %+v\n\n", n)
+}
diff --git a/acceptance/rackspace/rackconnect/v3/publicips_test.go b/acceptance/rackspace/rackconnect/v3/publicips_test.go
new file mode 100644
index 0000000..8dc6270
--- /dev/null
+++ b/acceptance/rackspace/rackconnect/v3/publicips_test.go
@@ -0,0 +1,45 @@
+// +build acceptance
+
+package v3
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/rackconnect/v3/publicips"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPublicIPs(t *testing.T) {
+ c := newClient(t)
+ ipID := testListIPs(t, c)
+ sID := testGetIP(t, c, ipID)
+ testListIPsForServer(t, c, sID)
+}
+
+func testListIPs(t *testing.T, c *gophercloud.ServiceClient) string {
+ allPages, err := publicips.List(c).AllPages()
+ th.AssertNoErr(t, err)
+ allip, err := publicips.ExtractPublicIPs(allPages)
+ fmt.Printf("Listing all public IPs: %+v\n\n", allip)
+ var ipID string
+ if len(allip) > 0 {
+ ipID = allip[0].ID
+ }
+ return ipID
+}
+
+func testGetIP(t *testing.T, c *gophercloud.ServiceClient, ipID string) string {
+ ip, err := publicips.Get(c, ipID).Extract()
+ th.AssertNoErr(t, err)
+ fmt.Printf("Retrieved public IP (%s): %+v\n\n", ipID, ip)
+ return ip.CloudServer.ID
+}
+
+func testListIPsForServer(t *testing.T, c *gophercloud.ServiceClient, sID string) {
+ allPages, err := publicips.ListForServer(c, sID).AllPages()
+ th.AssertNoErr(t, err)
+ allip, err := publicips.ExtractPublicIPs(allPages)
+ fmt.Printf("Listing all public IPs for server (%s): %+v\n\n", sID, allip)
+}
diff --git a/acceptance/tools/pkg.go b/acceptance/tools/pkg.go
new file mode 100644
index 0000000..f7eca12
--- /dev/null
+++ b/acceptance/tools/pkg.go
@@ -0,0 +1 @@
+package tools
diff --git a/acceptance/tools/tools.go b/acceptance/tools/tools.go
new file mode 100644
index 0000000..35679b7
--- /dev/null
+++ b/acceptance/tools/tools.go
@@ -0,0 +1,89 @@
+// +build acceptance common
+
+package tools
+
+import (
+ "crypto/rand"
+ "errors"
+ mrand "math/rand"
+ "os"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// ErrTimeout is returned if WaitFor takes longer than 300 second to happen.
+var ErrTimeout = errors.New("Timed out")
+
+// OnlyRS overrides the default Gophercloud behavior of using OS_-prefixed environment variables
+// if RS_ variables aren't present. Otherwise, they'll stomp over each other here in the acceptance
+// tests, where you need to have both defined.
+func OnlyRS(original gophercloud.AuthOptions) gophercloud.AuthOptions {
+ if os.Getenv("RS_AUTH_URL") == "" {
+ original.IdentityEndpoint = ""
+ }
+ if os.Getenv("RS_USERNAME") == "" {
+ original.Username = ""
+ }
+ if os.Getenv("RS_PASSWORD") == "" {
+ original.Password = ""
+ }
+ if os.Getenv("RS_API_KEY") == "" {
+ original.APIKey = ""
+ }
+ return original
+}
+
+// WaitFor polls a predicate function once per second to wait for a certain state to arrive.
+func WaitFor(predicate func() (bool, error)) error {
+ for i := 0; i < 300; i++ {
+ time.Sleep(1 * time.Second)
+
+ satisfied, err := predicate()
+ if err != nil {
+ return err
+ }
+ if satisfied {
+ return nil
+ }
+ }
+ return ErrTimeout
+}
+
+// MakeNewPassword generates a new string that's guaranteed to be different than the given one.
+func MakeNewPassword(oldPass string) string {
+ randomPassword := RandomString("", 16)
+ for randomPassword == oldPass {
+ randomPassword = RandomString("", 16)
+ }
+ return randomPassword
+}
+
+// RandomString generates a string of given length, but random content.
+// All content will be within the ASCII graphic character set.
+// (Implementation from Even Shaw's contribution on
+// http://stackoverflow.com/questions/12771930/what-is-the-fastest-way-to-generate-a-long-random-string-in-go).
+func RandomString(prefix string, n int) string {
+ const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ var bytes = make([]byte, n)
+ rand.Read(bytes)
+ for i, b := range bytes {
+ bytes[i] = alphanum[b%byte(len(alphanum))]
+ }
+ 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 {
+ if len(value) > 40 {
+ return value[0:37] + "..."
+ }
+ return value
+}
diff --git a/auth_options.go b/auth_options.go
new file mode 100644
index 0000000..d26e16a
--- /dev/null
+++ b/auth_options.go
@@ -0,0 +1,50 @@
+package gophercloud
+
+/*
+AuthOptions stores information needed to authenticate to an OpenStack cluster.
+You can populate one manually, or use a provider's AuthOptionsFromEnv() function
+to read relevant information from the standard environment variables. Pass one
+to a provider's AuthenticatedClient function to authenticate and obtain a
+ProviderClient representing an active session on that provider.
+
+Its fields are the union of those recognized by each identity implementation and
+provider.
+*/
+type AuthOptions struct {
+ // IdentityEndpoint specifies the HTTP endpoint that is required to work with
+ // the Identity API of the appropriate version. While it's ultimately needed by
+ // all of the identity services, it will often be populated by a provider-level
+ // function.
+ IdentityEndpoint string
+
+ // Username is required if using Identity V2 API. Consult with your provider's
+ // control panel to discover your account's username. In Identity V3, either
+ // UserID or a combination of Username and DomainID or DomainName are needed.
+ Username, UserID string
+
+ // Exactly one of Password or APIKey is required for the Identity V2 and V3
+ // APIs. Consult with your provider's control panel to discover your account's
+ // preferred method of authentication.
+ Password, APIKey string
+
+ // At most one of DomainID and DomainName must be provided if using Username
+ // with Identity V3. Otherwise, either are optional.
+ DomainID, DomainName string
+
+ // The TenantID and TenantName fields are optional for the Identity V2 API.
+ // Some providers allow you to specify a TenantName instead of the TenantId.
+ // Some require both. Your provider's authentication policies will determine
+ // how these fields influence authentication.
+ TenantID, TenantName string
+
+ // AllowReauth should be set to true if you grant permission for Gophercloud to
+ // cache your credentials in memory, and to allow Gophercloud to attempt to
+ // re-authenticate automatically if/when your token expires. If you set it to
+ // false, it will not cache these settings, but re-authentication will not be
+ // possible. This setting defaults to false.
+ AllowReauth bool
+
+ // TokenID allows users to authenticate (possibly as another user) with an
+ // authentication token ID.
+ TokenID string
+}
diff --git a/auth_results.go b/auth_results.go
new file mode 100644
index 0000000..856a233
--- /dev/null
+++ b/auth_results.go
@@ -0,0 +1,14 @@
+package gophercloud
+
+import "time"
+
+// AuthResults [deprecated] is a leftover type from the v0.x days. It was
+// intended to describe common functionality among identity service results, but
+// is not actually used anywhere.
+type AuthResults interface {
+ // TokenID returns the token's ID value from the authentication response.
+ TokenID() (string, error)
+
+ // ExpiresAt retrieves the token's expiration time.
+ ExpiresAt() (time.Time, error)
+}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..fb81a9d
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,67 @@
+/*
+Package gophercloud provides a multi-vendor interface to OpenStack-compatible
+clouds. The library has a three-level hierarchy: providers, services, and
+resources.
+
+Provider structs represent the service providers that offer and manage a
+collection of services. Examples of providers include: OpenStack, Rackspace,
+HP. These are defined like so:
+
+ opts := gophercloud.AuthOptions{
+ IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+ Username: "{username}",
+ Password: "{password}",
+ TenantID: "{tenant_id}",
+ }
+
+ provider, err := openstack.AuthenticatedClient(opts)
+
+Service structs are specific to a provider and handle all of the logic and
+operations for a particular OpenStack service. Examples of services include:
+Compute, Object Storage, Block Storage. In order to define one, you need to
+pass in the parent provider, like so:
+
+ opts := gophercloud.EndpointOpts{Region: "RegionOne"}
+
+ client := openstack.NewComputeV2(provider, opts)
+
+Resource structs are the domain models that services make use of in order
+to work with and represent the state of API resources:
+
+ server, err := servers.Get(client, "{serverId}").Extract()
+
+Intermediate Result structs are returned for API operations, which allow
+generic access to the HTTP headers, response body, and any errors associated
+with the network transaction. To turn a result into a usable resource struct,
+you must call the Extract method which is chained to the response, or an
+Extract function from an applicable extension:
+
+ result := servers.Get(client, "{serverId}")
+
+ // Attempt to extract the disk configuration from the OS-DCF disk config
+ // extension:
+ config, err := diskconfig.ExtractGet(result)
+
+All requests that enumerate a collection return a Pager struct that is used to
+iterate through the results one page at a time. Use the EachPage method on that
+Pager to handle each successive Page in a closure, then use the appropriate
+extraction method from that request's package to interpret that Page as a slice
+of results:
+
+ err := servers.List(client, nil).EachPage(func (page pagination.Page) (bool, error) {
+ s, err := servers.ExtractServers(page)
+ if err != nil {
+ return false, err
+ }
+
+ // Handle the []servers.Server slice.
+
+ // Return "false" or an error to prematurely stop fetching new pages.
+ return true, nil
+ })
+
+This top-level package contains utility functions and data types that are used
+throughout the provider and service packages. Of particular note for end users
+are the AuthOptions and EndpointOpts structs.
+*/
+package gophercloud
diff --git a/endpoint_search.go b/endpoint_search.go
new file mode 100644
index 0000000..5189431
--- /dev/null
+++ b/endpoint_search.go
@@ -0,0 +1,92 @@
+package gophercloud
+
+import "errors"
+
+var (
+ // ErrServiceNotFound is returned when no service in a service catalog matches
+ // the provided EndpointOpts. This is generally returned by provider service
+ // factory methods like "NewComputeV2()" and can mean that a service is not
+ // enabled for your account.
+ ErrServiceNotFound = errors.New("No suitable service could be found in the service catalog.")
+
+ // ErrEndpointNotFound is returned when no available endpoints match the
+ // provided EndpointOpts. This is also generally returned by provider service
+ // factory methods, and usually indicates that a region was specified
+ // incorrectly.
+ ErrEndpointNotFound = errors.New("No suitable endpoint could be found in the service catalog.")
+)
+
+// Availability indicates to whom a specific service endpoint is accessible:
+// the internet at large, internal networks only, or only to administrators.
+// Different identity services use different terminology for these. Identity v2
+// lists them as different kinds of URLs within the service catalog ("adminURL",
+// "internalURL", and "publicURL"), while v3 lists them as "Interfaces" in an
+// endpoint's response.
+type Availability string
+
+const (
+ // AvailabilityAdmin indicates that an endpoint is only available to
+ // administrators.
+ AvailabilityAdmin Availability = "admin"
+
+ // AvailabilityPublic indicates that an endpoint is available to everyone on
+ // the internet.
+ AvailabilityPublic Availability = "public"
+
+ // AvailabilityInternal indicates that an endpoint is only available within
+ // the cluster's internal network.
+ AvailabilityInternal Availability = "internal"
+)
+
+// EndpointOpts specifies search criteria used by queries against an
+// OpenStack service catalog. The options must contain enough information to
+// unambiguously identify one, and only one, endpoint within the catalog.
+//
+// Usually, these are passed to service client factory functions in a provider
+// package, like "rackspace.NewComputeV2()".
+type EndpointOpts struct {
+ // Type [required] is the service type for the client (e.g., "compute",
+ // "object-store"). Generally, this will be supplied by the service client
+ // function, but a user-given value will be honored if provided.
+ Type string
+
+ // Name [optional] is the service name for the client (e.g., "nova") as it
+ // appears in the service catalog. Services can have the same Type but a
+ // different Name, which is why both Type and Name are sometimes needed.
+ Name string
+
+ // Region [required] is the geographic region in which the endpoint resides,
+ // generally specifying which datacenter should house your resources.
+ // Required only for services that span multiple regions.
+ Region string
+
+ // Availability [optional] is the visibility of the endpoint to be returned.
+ // Valid types include the constants AvailabilityPublic, AvailabilityInternal,
+ // or AvailabilityAdmin from this package.
+ //
+ // Availability is not required, and defaults to AvailabilityPublic. Not all
+ // providers or services offer all Availability options.
+ Availability Availability
+}
+
+/*
+EndpointLocator is an internal function to be used by provider implementations.
+
+It provides an implementation that locates a single endpoint from a service
+catalog for a specific ProviderClient based on user-provided EndpointOpts. The
+provider then uses it to discover related ServiceClients.
+*/
+type EndpointLocator func(EndpointOpts) (string, error)
+
+// ApplyDefaults is an internal method to be used by provider implementations.
+//
+// It sets EndpointOpts fields if not already set, including a default type.
+// Currently, EndpointOpts.Availability defaults to the public endpoint.
+func (eo *EndpointOpts) ApplyDefaults(t string) {
+ if eo.Type == "" {
+ eo.Type = t
+ }
+ if eo.Availability == "" {
+ eo.Availability = AvailabilityPublic
+ }
+}
diff --git a/endpoint_search_test.go b/endpoint_search_test.go
new file mode 100644
index 0000000..3457453
--- /dev/null
+++ b/endpoint_search_test.go
@@ -0,0 +1,19 @@
+package gophercloud
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestApplyDefaultsToEndpointOpts(t *testing.T) {
+ eo := EndpointOpts{Availability: AvailabilityPublic}
+ eo.ApplyDefaults("compute")
+ expected := EndpointOpts{Availability: AvailabilityPublic, Type: "compute"}
+ th.CheckDeepEquals(t, expected, eo)
+
+ eo = EndpointOpts{Type: "compute"}
+ eo.ApplyDefaults("object-store")
+ expected = EndpointOpts{Availability: AvailabilityPublic, Type: "compute"}
+ th.CheckDeepEquals(t, expected, eo)
+}
diff --git a/openstack/auth_env.go b/openstack/auth_env.go
new file mode 100644
index 0000000..a4402b6
--- /dev/null
+++ b/openstack/auth_env.go
@@ -0,0 +1,58 @@
+package openstack
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/rackspace/gophercloud"
+)
+
+var nilOptions = gophercloud.AuthOptions{}
+
+// ErrNoAuthUrl, ErrNoUsername, and ErrNoPassword errors indicate of the required OS_AUTH_URL, OS_USERNAME, or OS_PASSWORD
+// environment variables, respectively, remain undefined. See the AuthOptions() function for more details.
+var (
+ ErrNoAuthURL = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
+ ErrNoUsername = fmt.Errorf("Environment variable OS_USERNAME needs to be set.")
+ ErrNoPassword = fmt.Errorf("Environment variable OS_PASSWORD needs to be set.")
+)
+
+// AuthOptions fills out an identity.AuthOptions structure with the settings found on the various OpenStack
+// OS_* environment variables. The following variables provide sources of truth: OS_AUTH_URL, OS_USERNAME,
+// OS_PASSWORD, OS_TENANT_ID, and OS_TENANT_NAME. Of these, OS_USERNAME, OS_PASSWORD, and OS_AUTH_URL must
+// have settings, or an error will result. OS_TENANT_ID and OS_TENANT_NAME are optional.
+func AuthOptionsFromEnv() (gophercloud.AuthOptions, error) {
+ authURL := os.Getenv("OS_AUTH_URL")
+ username := os.Getenv("OS_USERNAME")
+ userID := os.Getenv("OS_USERID")
+ password := os.Getenv("OS_PASSWORD")
+ tenantID := os.Getenv("OS_TENANT_ID")
+ tenantName := os.Getenv("OS_TENANT_NAME")
+ domainID := os.Getenv("OS_DOMAIN_ID")
+ domainName := os.Getenv("OS_DOMAIN_NAME")
+
+ if authURL == "" {
+ return nilOptions, ErrNoAuthURL
+ }
+
+ if username == "" && userID == "" {
+ return nilOptions, ErrNoUsername
+ }
+
+ if password == "" {
+ return nilOptions, ErrNoPassword
+ }
+
+ ao := gophercloud.AuthOptions{
+ IdentityEndpoint: authURL,
+ UserID: userID,
+ Username: username,
+ Password: password,
+ TenantID: tenantID,
+ TenantName: tenantName,
+ DomainID: domainID,
+ DomainName: domainName,
+ }
+
+ return ao, nil
+}
diff --git a/openstack/blockstorage/v1/apiversions/doc.go b/openstack/blockstorage/v1/apiversions/doc.go
new file mode 100644
index 0000000..e3af39f
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/doc.go
@@ -0,0 +1,3 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Block Storage service, code-named Cinder.
+package apiversions
diff --git a/openstack/blockstorage/v1/apiversions/requests.go b/openstack/blockstorage/v1/apiversions/requests.go
new file mode 100644
index 0000000..bb2c259
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/requests.go
@@ -0,0 +1,21 @@
+package apiversions
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List lists all the Cinder API versions available to end-users.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(c, listURL(c), func(r pagination.PageResult) pagination.Page {
+ return APIVersionPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get will retrieve the volume type with the provided ID. To extract the volume
+// type from the result, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, v string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, v), &res.Body, nil)
+ return res
+}
diff --git a/openstack/blockstorage/v1/apiversions/requests_test.go b/openstack/blockstorage/v1/apiversions/requests_test.go
new file mode 100644
index 0000000..56b5e4f
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/requests_test.go
@@ -0,0 +1,145 @@
+package apiversions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `{
+ "versions": [
+ {
+ "status": "CURRENT",
+ "updated": "2012-01-04T11:33:21Z",
+ "id": "v1.0",
+ "links": [
+ {
+ "href": "http://23.253.228.211:8776/v1/",
+ "rel": "self"
+ }
+ ]
+ },
+ {
+ "status": "CURRENT",
+ "updated": "2012-11-21T11:33:21Z",
+ "id": "v2.0",
+ "links": [
+ {
+ "href": "http://23.253.228.211:8776/v2/",
+ "rel": "self"
+ }
+ ]
+ }
+ ]
+ }`)
+ })
+
+ count := 0
+
+ List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractAPIVersions(page)
+ if err != nil {
+ t.Errorf("Failed to extract API versions: %v", err)
+ return false, err
+ }
+
+ expected := []APIVersion{
+ APIVersion{
+ ID: "v1.0",
+ Status: "CURRENT",
+ Updated: "2012-01-04T11:33:21Z",
+ },
+ APIVersion{
+ ID: "v2.0",
+ Status: "CURRENT",
+ Updated: "2012-11-21T11:33:21Z",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestAPIInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `{
+ "version": {
+ "status": "CURRENT",
+ "updated": "2012-01-04T11:33:21Z",
+ "media-types": [
+ {
+ "base": "application/xml",
+ "type": "application/vnd.openstack.volume+xml;version=1"
+ },
+ {
+ "base": "application/json",
+ "type": "application/vnd.openstack.volume+json;version=1"
+ }
+ ],
+ "id": "v1.0",
+ "links": [
+ {
+ "href": "http://23.253.228.211:8776/v1/",
+ "rel": "self"
+ },
+ {
+ "href": "http://jorgew.github.com/block-storage-api/content/os-block-storage-1.0.pdf",
+ "type": "application/pdf",
+ "rel": "describedby"
+ },
+ {
+ "href": "http://docs.rackspacecloud.com/servers/api/v1.1/application.wadl",
+ "type": "application/vnd.sun.wadl+xml",
+ "rel": "describedby"
+ }
+ ]
+ }
+ }`)
+ })
+
+ actual, err := Get(client.ServiceClient(), "v1").Extract()
+ if err != nil {
+ t.Errorf("Failed to extract version: %v", err)
+ }
+
+ expected := APIVersion{
+ ID: "v1.0",
+ Status: "CURRENT",
+ Updated: "2012-01-04T11:33:21Z",
+ }
+
+ th.AssertEquals(t, actual.ID, expected.ID)
+ th.AssertEquals(t, actual.Status, expected.Status)
+ th.AssertEquals(t, actual.Updated, expected.Updated)
+}
diff --git a/openstack/blockstorage/v1/apiversions/results.go b/openstack/blockstorage/v1/apiversions/results.go
new file mode 100644
index 0000000..7b0df11
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/results.go
@@ -0,0 +1,58 @@
+package apiversions
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// APIVersion represents an API version for Cinder.
+type APIVersion struct {
+ ID string `json:"id" mapstructure:"id"` // unique identifier
+ Status string `json:"status" mapstructure:"status"` // current status
+ Updated string `json:"updated" mapstructure:"updated"` // date last updated
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+ is, err := ExtractAPIVersions(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(page pagination.Page) ([]APIVersion, error) {
+ var resp struct {
+ Versions []APIVersion `mapstructure:"versions"`
+ }
+
+ err := mapstructure.Decode(page.(APIVersionPage).Body, &resp)
+
+ return resp.Versions, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts an API version resource.
+func (r GetResult) Extract() (*APIVersion, error) {
+ var resp struct {
+ Version *APIVersion `mapstructure:"version"`
+ }
+
+ err := mapstructure.Decode(r.Body, &resp)
+
+ return resp.Version, err
+}
diff --git a/openstack/blockstorage/v1/apiversions/urls.go b/openstack/blockstorage/v1/apiversions/urls.go
new file mode 100644
index 0000000..56f8260
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func getURL(c *gophercloud.ServiceClient, version string) string {
+ return c.ServiceURL(strings.TrimRight(version, "/") + "/")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("")
+}
diff --git a/openstack/blockstorage/v1/apiversions/urls_test.go b/openstack/blockstorage/v1/apiversions/urls_test.go
new file mode 100644
index 0000000..37e9142
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/urls_test.go
@@ -0,0 +1,26 @@
+package apiversions
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "v1")
+ expected := endpoint + "v1/"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/blockstorage/v1/snapshots/doc.go b/openstack/blockstorage/v1/snapshots/doc.go
new file mode 100644
index 0000000..198f830
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/doc.go
@@ -0,0 +1,5 @@
+// Package snapshots provides information and interaction with snapshots in the
+// OpenStack Block Storage service. A snapshot is a point in time copy of the
+// data contained in an external storage volume, and can be controlled
+// programmatically.
+package snapshots
diff --git a/openstack/blockstorage/v1/snapshots/fixtures.go b/openstack/blockstorage/v1/snapshots/fixtures.go
new file mode 100644
index 0000000..d1461fb
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/fixtures.go
@@ -0,0 +1,114 @@
+package snapshots
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/snapshots", 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, `
+ {
+ "snapshots": [
+ {
+ "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+ "display_name": "snapshot-001"
+ },
+ {
+ "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+ "display_name": "snapshot-002"
+ }
+ ]
+ }
+ `)
+ })
+}
+
+func MockGetResponse(t *testing.T) {
+ th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "snapshot": {
+ "display_name": "snapshot-001",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+}
+
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/snapshots", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "snapshot": {
+ "volume_id": "1234",
+ "display_name": "snapshot-001"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "snapshot": {
+ "volume_id": "1234",
+ "display_name": "snapshot-001",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+}
+
+func MockUpdateMetadataResponse(t *testing.T) {
+ th.Mux.HandleFunc("/snapshots/123/metadata", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "metadata": {
+ "key": "v1"
+ }
+ }
+ `)
+
+ fmt.Fprintf(w, `
+ {
+ "metadata": {
+ "key": "v1"
+ }
+ }
+ `)
+ })
+}
+
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/blockstorage/v1/snapshots/requests.go b/openstack/blockstorage/v1/snapshots/requests.go
new file mode 100644
index 0000000..71936e5
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/requests.go
@@ -0,0 +1,206 @@
+package snapshots
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToSnapshotCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a Snapshot. This object is passed to
+// the snapshots.Create function. For more information about these parameters,
+// see the Snapshot object.
+type CreateOpts struct {
+ // OPTIONAL
+ Description string
+ // OPTIONAL
+ Force bool
+ // OPTIONAL
+ Metadata map[string]interface{}
+ // OPTIONAL
+ Name string
+ // REQUIRED
+ VolumeID string
+}
+
+// ToSnapshotCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToSnapshotCreateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.VolumeID == "" {
+ return nil, fmt.Errorf("Required CreateOpts field 'VolumeID' not set.")
+ }
+ s["volume_id"] = opts.VolumeID
+
+ if opts.Description != "" {
+ s["display_description"] = opts.Description
+ }
+ if opts.Force == true {
+ s["force"] = opts.Force
+ }
+ if opts.Metadata != nil {
+ s["metadata"] = opts.Metadata
+ }
+ if opts.Name != "" {
+ s["display_name"] = opts.Name
+ }
+
+ return map[string]interface{}{"snapshot": s}, nil
+}
+
+// Create will create a new Snapshot based on the values in CreateOpts. To
+// extract the Snapshot object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToSnapshotCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return res
+}
+
+// Delete will delete the existing Snapshot with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
+
+// Get retrieves the Snapshot with the provided ID. To extract the Snapshot
+// object from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToSnapshotListQuery() (string, error)
+}
+
+// ListOpts hold options for listing Snapshots. It is passed to the
+// snapshots.List function.
+type ListOpts struct {
+ Name string `q:"display_name"`
+ Status string `q:"status"`
+ VolumeID string `q:"volume_id"`
+}
+
+// ToSnapshotListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToSnapshotListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns Snapshots optionally limited by the conditions provided in
+// ListOpts.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client)
+ if opts != nil {
+ query, err := opts.ToSnapshotListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return ListResult{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, url, createPage)
+}
+
+// UpdateMetadataOptsBuilder allows extensions to add additional parameters to
+// the Update request.
+type UpdateMetadataOptsBuilder interface {
+ ToSnapshotUpdateMetadataMap() (map[string]interface{}, error)
+}
+
+// UpdateMetadataOpts contain options for updating an existing Snapshot. This
+// object is passed to the snapshots.Update function. For more information
+// about the parameters, see the Snapshot object.
+type UpdateMetadataOpts struct {
+ Metadata map[string]interface{}
+}
+
+// ToSnapshotUpdateMetadataMap assembles a request body based on the contents of
+// an UpdateMetadataOpts.
+func (opts UpdateMetadataOpts) ToSnapshotUpdateMetadataMap() (map[string]interface{}, error) {
+ v := make(map[string]interface{})
+
+ if opts.Metadata != nil {
+ v["metadata"] = opts.Metadata
+ }
+
+ return v, nil
+}
+
+// UpdateMetadata will update the Snapshot with provided information. To
+// extract the updated Snapshot from the response, call the ExtractMetadata
+// method on the UpdateMetadataResult.
+func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts UpdateMetadataOptsBuilder) UpdateMetadataResult {
+ var res UpdateMetadataResult
+
+ reqBody, err := opts.ToSnapshotUpdateMetadataMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Put(updateMetadataURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// IDFromName is a convienience function that returns a snapshot's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ snapshotCount := 0
+ snapshotID := ""
+ if name == "" {
+ return "", fmt.Errorf("A snapshot name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ snapshotList, err := ExtractSnapshots(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range snapshotList {
+ if s.Name == name {
+ snapshotCount++
+ snapshotID = s.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch snapshotCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find snapshot: %s", name)
+ case 1:
+ return snapshotID, nil
+ default:
+ return "", fmt.Errorf("Found %d snapshots matching %s", snapshotCount, name)
+ }
+}
diff --git a/openstack/blockstorage/v1/snapshots/requests_test.go b/openstack/blockstorage/v1/snapshots/requests_test.go
new file mode 100644
index 0000000..d0f9e88
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/requests_test.go
@@ -0,0 +1,104 @@
+package snapshots
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractSnapshots(page)
+ if err != nil {
+ t.Errorf("Failed to extract snapshots: %v", err)
+ return false, err
+ }
+
+ expected := []Snapshot{
+ Snapshot{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "snapshot-001",
+ },
+ Snapshot{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "snapshot-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ v, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, v.Name, "snapshot-001")
+ th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := CreateOpts{VolumeID: "1234", Name: "snapshot-001"}
+ n, err := Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.VolumeID, "1234")
+ th.AssertEquals(t, n.Name, "snapshot-001")
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestUpdateMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUpdateMetadataResponse(t)
+
+ expected := map[string]interface{}{"key": "v1"}
+
+ options := &UpdateMetadataOpts{
+ Metadata: map[string]interface{}{
+ "key": "v1",
+ },
+ }
+
+ actual, err := UpdateMetadata(client.ServiceClient(), "123", options).ExtractMetadata()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, actual, expected)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+
+ res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/blockstorage/v1/snapshots/results.go b/openstack/blockstorage/v1/snapshots/results.go
new file mode 100644
index 0000000..e595798
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/results.go
@@ -0,0 +1,123 @@
+package snapshots
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Snapshot contains all the information associated with an OpenStack Snapshot.
+type Snapshot struct {
+ // Currect status of the Snapshot.
+ Status string `mapstructure:"status"`
+
+ // Display name.
+ Name string `mapstructure:"display_name"`
+
+ // Instances onto which the Snapshot is attached.
+ Attachments []string `mapstructure:"attachments"`
+
+ // Logical group.
+ AvailabilityZone string `mapstructure:"availability_zone"`
+
+ // Is the Snapshot bootable?
+ Bootable string `mapstructure:"bootable"`
+
+ // Date created.
+ CreatedAt string `mapstructure:"created_at"`
+
+ // Display description.
+ Description string `mapstructure:"display_discription"`
+
+ // See VolumeType object for more information.
+ VolumeType string `mapstructure:"volume_type"`
+
+ // ID of the Snapshot from which this Snapshot was created.
+ SnapshotID string `mapstructure:"snapshot_id"`
+
+ // ID of the Volume from which this Snapshot was created.
+ VolumeID string `mapstructure:"volume_id"`
+
+ // User-defined key-value pairs.
+ Metadata map[string]string `mapstructure:"metadata"`
+
+ // Unique identifier.
+ ID string `mapstructure:"id"`
+
+ // Size of the Snapshot, in GB.
+ Size int `mapstructure:"size"`
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ListResult is a pagination.Pager that is returned from a call to the List function.
+type ListResult struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Snapshots.
+func (r ListResult) IsEmpty() (bool, error) {
+ volumes, err := ExtractSnapshots(r)
+ if err != nil {
+ return true, err
+ }
+ return len(volumes) == 0, nil
+}
+
+// ExtractSnapshots extracts and returns Snapshots. It is used while iterating over a snapshots.List call.
+func ExtractSnapshots(page pagination.Page) ([]Snapshot, error) {
+ var response struct {
+ Snapshots []Snapshot `json:"snapshots"`
+ }
+
+ err := mapstructure.Decode(page.(ListResult).Body, &response)
+ return response.Snapshots, err
+}
+
+// UpdateMetadataResult contains the response body and error from an UpdateMetadata request.
+type UpdateMetadataResult struct {
+ commonResult
+}
+
+// ExtractMetadata returns the metadata from a response from snapshots.UpdateMetadata.
+func (r UpdateMetadataResult) ExtractMetadata() (map[string]interface{}, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ m := r.Body.(map[string]interface{})["metadata"]
+ return m.(map[string]interface{}), nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Snapshot object out of the commonResult object.
+func (r commonResult) Extract() (*Snapshot, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Snapshot *Snapshot `json:"snapshot"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Snapshot, err
+}
diff --git a/openstack/blockstorage/v1/snapshots/urls.go b/openstack/blockstorage/v1/snapshots/urls.go
new file mode 100644
index 0000000..4d635e8
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/urls.go
@@ -0,0 +1,27 @@
+package snapshots
+
+import "github.com/rackspace/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("snapshots")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("snapshots", id)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return deleteURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return createURL(c)
+}
+
+func metadataURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("snapshots", id, "metadata")
+}
+
+func updateMetadataURL(c *gophercloud.ServiceClient, id string) string {
+ return metadataURL(c, id)
+}
diff --git a/openstack/blockstorage/v1/snapshots/urls_test.go b/openstack/blockstorage/v1/snapshots/urls_test.go
new file mode 100644
index 0000000..feacf7f
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/urls_test.go
@@ -0,0 +1,50 @@
+package snapshots
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "snapshots"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "snapshots/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "snapshots/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "snapshots"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestMetadataURL(t *testing.T) {
+ actual := metadataURL(endpointClient(), "foo")
+ expected := endpoint + "snapshots/foo/metadata"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateMetadataURL(t *testing.T) {
+ actual := updateMetadataURL(endpointClient(), "foo")
+ expected := endpoint + "snapshots/foo/metadata"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/blockstorage/v1/snapshots/util.go b/openstack/blockstorage/v1/snapshots/util.go
new file mode 100644
index 0000000..64cdc60
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/util.go
@@ -0,0 +1,22 @@
+package snapshots
+
+import (
+ "github.com/rackspace/gophercloud"
+)
+
+// WaitForStatus will continually poll the resource, checking for a particular
+// status. It will do this for the amount of seconds defined.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := Get(c, id).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if current.Status == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/openstack/blockstorage/v1/volumes/doc.go b/openstack/blockstorage/v1/volumes/doc.go
new file mode 100644
index 0000000..307b8b1
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/doc.go
@@ -0,0 +1,5 @@
+// Package volumes provides information and interaction with volumes in the
+// OpenStack Block Storage service. A volume is a detachable block storage
+// device, akin to a USB hard drive. It can only be attached to one instance at
+// a time.
+package volumes
diff --git a/openstack/blockstorage/v1/volumes/requests.go b/openstack/blockstorage/v1/volumes/requests.go
new file mode 100644
index 0000000..3e9243a
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/requests.go
@@ -0,0 +1,236 @@
+package volumes
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToVolumeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a Volume. This object is passed to
+// the volumes.Create function. For more information about these parameters,
+// see the Volume object.
+type CreateOpts struct {
+ // OPTIONAL
+ Availability string
+ // OPTIONAL
+ Description string
+ // OPTIONAL
+ Metadata map[string]string
+ // OPTIONAL
+ Name string
+ // REQUIRED
+ Size int
+ // OPTIONAL
+ SnapshotID, SourceVolID, ImageID string
+ // OPTIONAL
+ VolumeType string
+}
+
+// ToVolumeCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) {
+ v := make(map[string]interface{})
+
+ if opts.Size == 0 {
+ return nil, fmt.Errorf("Required CreateOpts field 'Size' not set.")
+ }
+ v["size"] = opts.Size
+
+ if opts.Availability != "" {
+ v["availability_zone"] = opts.Availability
+ }
+ if opts.Description != "" {
+ v["display_description"] = opts.Description
+ }
+ if opts.ImageID != "" {
+ v["imageRef"] = opts.ImageID
+ }
+ if opts.Metadata != nil {
+ v["metadata"] = opts.Metadata
+ }
+ if opts.Name != "" {
+ v["display_name"] = opts.Name
+ }
+ if opts.SourceVolID != "" {
+ v["source_volid"] = opts.SourceVolID
+ }
+ if opts.SnapshotID != "" {
+ v["snapshot_id"] = opts.SnapshotID
+ }
+ if opts.VolumeType != "" {
+ v["volume_type"] = opts.VolumeType
+ }
+
+ return map[string]interface{}{"volume": v}, nil
+}
+
+// Create will create a new Volume based on the values in CreateOpts. To extract
+// the Volume object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToVolumeCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return res
+}
+
+// Delete will delete the existing Volume with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
+
+// Get retrieves the Volume with the provided ID. To extract the Volume object
+// from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToVolumeListQuery() (string, error)
+}
+
+// ListOpts holds options for listing Volumes. It is passed to the volumes.List
+// function.
+type ListOpts struct {
+ // admin-only option. Set it to true to see all tenant volumes.
+ AllTenants bool `q:"all_tenants"`
+ // List only volumes that contain Metadata.
+ Metadata map[string]string `q:"metadata"`
+ // List only volumes that have Name as the display name.
+ Name string `q:"name"`
+ // List only volumes that have a status of Status.
+ Status string `q:"status"`
+}
+
+// ToVolumeListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToVolumeListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns Volumes optionally limited by the conditions provided in ListOpts.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client)
+ if opts != nil {
+ query, err := opts.ToVolumeListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return ListResult{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToVolumeUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contain options for updating an existing Volume. This object is passed
+// to the volumes.Update function. For more information about the parameters, see
+// the Volume object.
+type UpdateOpts struct {
+ // OPTIONAL
+ Name string
+ // OPTIONAL
+ Description string
+ // OPTIONAL
+ Metadata map[string]string
+}
+
+// ToVolumeUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) {
+ v := make(map[string]interface{})
+
+ if opts.Description != "" {
+ v["display_description"] = opts.Description
+ }
+ if opts.Metadata != nil {
+ v["metadata"] = opts.Metadata
+ }
+ if opts.Name != "" {
+ v["display_name"] = opts.Name
+ }
+
+ return map[string]interface{}{"volume": v}, nil
+}
+
+// Update will update the Volume with provided information. To extract the updated
+// Volume from the response, call the Extract method on the UpdateResult.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToVolumeUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Put(updateURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ volumeCount := 0
+ volumeID := ""
+ if name == "" {
+ return "", fmt.Errorf("A volume name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ volumeList, err := ExtractVolumes(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range volumeList {
+ if s.Name == name {
+ volumeCount++
+ volumeID = s.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch volumeCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find volume: %s", name)
+ case 1:
+ return volumeID, nil
+ default:
+ return "", fmt.Errorf("Found %d volumes matching %s", volumeCount, name)
+ }
+}
diff --git a/openstack/blockstorage/v1/volumes/requests_test.go b/openstack/blockstorage/v1/volumes/requests_test.go
new file mode 100644
index 0000000..75c2bbc
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/requests_test.go
@@ -0,0 +1,123 @@
+package volumes
+
+import (
+ "testing"
+
+ fixtures "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes/testing"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockListResponse(t)
+
+ count := 0
+
+ List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVolumes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volumes: %v", err)
+ return false, err
+ }
+
+ expected := []Volume{
+ Volume{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ },
+ Volume{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestListAll(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockListResponse(t)
+
+ allPages, err := List(client.ServiceClient(), &ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := ExtractVolumes(allPages)
+ th.AssertNoErr(t, err)
+
+ expected := []Volume{
+ Volume{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ },
+ Volume{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockGetResponse(t)
+
+ v, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, v.Name, "vol-001")
+ th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertEquals(t, v.Attachments[0]["device"], "/dev/vde")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockCreateResponse(t)
+
+ options := &CreateOpts{Size: 75}
+ n, err := Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Size, 4)
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockDeleteResponse(t)
+
+ res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixtures.MockUpdateResponse(t)
+
+ options := UpdateOpts{Name: "vol-002"}
+ v, err := Update(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22", options).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "vol-002", v.Name)
+}
diff --git a/openstack/blockstorage/v1/volumes/results.go b/openstack/blockstorage/v1/volumes/results.go
new file mode 100644
index 0000000..2fd4ef1
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/results.go
@@ -0,0 +1,113 @@
+package volumes
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Volume contains all the information associated with an OpenStack Volume.
+type Volume struct {
+ // Current status of the volume.
+ Status string `mapstructure:"status"`
+
+ // Human-readable display name for the volume.
+ Name string `mapstructure:"display_name"`
+
+ // Instances onto which the volume is attached.
+ Attachments []map[string]interface{} `mapstructure:"attachments"`
+
+ // This parameter is no longer used.
+ AvailabilityZone string `mapstructure:"availability_zone"`
+
+ // Indicates whether this is a bootable volume.
+ Bootable string `mapstructure:"bootable"`
+
+ // The date when this volume was created.
+ CreatedAt string `mapstructure:"created_at"`
+
+ // Human-readable description for the volume.
+ Description string `mapstructure:"display_description"`
+
+ // The type of volume to create, either SATA or SSD.
+ VolumeType string `mapstructure:"volume_type"`
+
+ // The ID of the snapshot from which the volume was created
+ SnapshotID string `mapstructure:"snapshot_id"`
+
+ // The ID of another block storage volume from which the current volume was created
+ SourceVolID string `mapstructure:"source_volid"`
+
+ // Arbitrary key-value pairs defined by the user.
+ Metadata map[string]string `mapstructure:"metadata"`
+
+ // Unique identifier for the volume.
+ ID string `mapstructure:"id"`
+
+ // Size of the volume in GB.
+ Size int `mapstructure:"size"`
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ListResult is a pagination.pager that is returned from a call to the List function.
+type ListResult struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Volumes.
+func (r ListResult) IsEmpty() (bool, error) {
+ volumes, err := ExtractVolumes(r)
+ if err != nil {
+ return true, err
+ }
+ return len(volumes) == 0, nil
+}
+
+// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call.
+func ExtractVolumes(page pagination.Page) ([]Volume, error) {
+ var response struct {
+ Volumes []Volume `json:"volumes"`
+ }
+
+ err := mapstructure.Decode(page.(ListResult).Body, &response)
+ return response.Volumes, err
+}
+
+// UpdateResult contains the response body and error from an Update request.
+type UpdateResult struct {
+ commonResult
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Volume object out of the commonResult object.
+func (r commonResult) Extract() (*Volume, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Volume *Volume `json:"volume"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Volume, err
+}
diff --git a/openstack/blockstorage/v1/volumes/testing/doc.go b/openstack/blockstorage/v1/volumes/testing/doc.go
new file mode 100644
index 0000000..2f66ba5
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/doc.go
@@ -0,0 +1,7 @@
+/*
+This is package created is to hold fixtures (which imports testing),
+so that importing volumes package does not inadvertently import testing into production code
+More information here:
+https://github.com/rackspace/gophercloud/issues/473
+*/
+package testing
diff --git a/openstack/blockstorage/v1/volumes/testing/fixtures.go b/openstack/blockstorage/v1/volumes/testing/fixtures.go
new file mode 100644
index 0000000..3df7653
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/fixtures.go
@@ -0,0 +1,113 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes", 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, `
+ {
+ "volumes": [
+ {
+ "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+ "display_name": "vol-001"
+ },
+ {
+ "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+ "display_name": "vol-002"
+ }
+ ]
+ }
+ `)
+ })
+}
+
+func MockGetResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "volume": {
+ "display_name": "vol-001",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "attachments": [
+ {
+ "device": "/dev/vde",
+ "server_id": "a740d24b-dc5b-4d59-ac75-53971c2920ba",
+ "id": "d6da11e5-2ed3-413e-88d8-b772ba62193d",
+ "volume_id": "d6da11e5-2ed3-413e-88d8-b772ba62193d"
+ }
+ ]
+ }
+}
+ `)
+ })
+}
+
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "volume": {
+ "size": 75
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "volume": {
+ "size": 4,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+}
+
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+func MockUpdateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "volume": {
+ "display_name": "vol-002",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+ }
+ `)
+ })
+}
diff --git a/openstack/blockstorage/v1/volumes/urls.go b/openstack/blockstorage/v1/volumes/urls.go
new file mode 100644
index 0000000..29629a1
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/urls.go
@@ -0,0 +1,23 @@
+package volumes
+
+import "github.com/rackspace/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("volumes")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return createURL(c)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("volumes", id)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return deleteURL(c, id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return deleteURL(c, id)
+}
diff --git a/openstack/blockstorage/v1/volumes/urls_test.go b/openstack/blockstorage/v1/volumes/urls_test.go
new file mode 100644
index 0000000..a95270e
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/urls_test.go
@@ -0,0 +1,44 @@
+package volumes
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "volumes"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "volumes"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "volumes/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "volumes/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "volumes/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/blockstorage/v1/volumes/util.go b/openstack/blockstorage/v1/volumes/util.go
new file mode 100644
index 0000000..1dda695
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/util.go
@@ -0,0 +1,22 @@
+package volumes
+
+import (
+ "github.com/rackspace/gophercloud"
+)
+
+// WaitForStatus will continually poll the resource, checking for a particular
+// status. It will do this for the amount of seconds defined.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := Get(c, id).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if current.Status == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/openstack/blockstorage/v1/volumetypes/doc.go b/openstack/blockstorage/v1/volumetypes/doc.go
new file mode 100644
index 0000000..793084f
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/doc.go
@@ -0,0 +1,9 @@
+// Package volumetypes provides information and interaction with volume types
+// in the OpenStack Block Storage service. A volume type indicates the type of
+// a block storage volume, such as SATA, SCSCI, SSD, etc. These can be
+// customized or defined by the OpenStack admin.
+//
+// You can also define extra_specs associated with your volume types. For
+// instance, you could have a VolumeType=SATA, with extra_specs (RPM=10000,
+// RAID-Level=5) . Extra_specs are defined and customized by the admin.
+package volumetypes
diff --git a/openstack/blockstorage/v1/volumetypes/fixtures.go b/openstack/blockstorage/v1/volumetypes/fixtures.go
new file mode 100644
index 0000000..e3326ea
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/fixtures.go
@@ -0,0 +1,60 @@
+package volumetypes
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types", 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, `
+ {
+ "volume_types": [
+ {
+ "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+ "name": "vol-type-001",
+ "extra_specs": {
+ "capabilities": "gpu"
+ }
+ },
+ {
+ "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+ "name": "vol-type-002",
+ "extra_specs": {}
+ }
+ ]
+ }
+ `)
+ })
+}
+
+func MockGetResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "volume_type": {
+ "name": "vol-type-001",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "extra_specs": {
+ "serverNumber": "2"
+ }
+ }
+}
+ `)
+ })
+}
diff --git a/openstack/blockstorage/v1/volumetypes/requests.go b/openstack/blockstorage/v1/volumetypes/requests.go
new file mode 100644
index 0000000..1673d13
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/requests.go
@@ -0,0 +1,76 @@
+package volumetypes
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToVolumeTypeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts are options for creating a volume type.
+type CreateOpts struct {
+ // OPTIONAL. See VolumeType.
+ ExtraSpecs map[string]interface{}
+ // OPTIONAL. See VolumeType.
+ Name string
+}
+
+// ToVolumeTypeCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToVolumeTypeCreateMap() (map[string]interface{}, error) {
+ vt := make(map[string]interface{})
+
+ if opts.ExtraSpecs != nil {
+ vt["extra_specs"] = opts.ExtraSpecs
+ }
+ if opts.Name != "" {
+ vt["name"] = opts.Name
+ }
+
+ return map[string]interface{}{"volume_type": vt}, nil
+}
+
+// Create will create a new volume. To extract the created volume type object,
+// call the Extract method on the CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToVolumeTypeCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return res
+}
+
+// Delete will delete the volume type with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
+
+// Get will retrieve the volume type with the provided ID. To extract the volume
+// type from the result, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, err := client.Get(getURL(client, id), &res.Body, nil)
+ res.Err = err
+ return res
+}
+
+// List returns all volume types.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return ListResult{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, listURL(client), createPage)
+}
diff --git a/openstack/blockstorage/v1/volumetypes/requests_test.go b/openstack/blockstorage/v1/volumetypes/requests_test.go
new file mode 100644
index 0000000..8d40bfe
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/requests_test.go
@@ -0,0 +1,118 @@
+package volumetypes
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVolumeTypes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volume types: %v", err)
+ return false, err
+ }
+
+ expected := []VolumeType{
+ VolumeType{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-type-001",
+ ExtraSpecs: map[string]interface{}{
+ "capabilities": "gpu",
+ },
+ },
+ VolumeType{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-type-002",
+ ExtraSpecs: map[string]interface{}{},
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ vt, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, vt.ExtraSpecs, map[string]interface{}{"serverNumber": "2"})
+ th.AssertEquals(t, vt.Name, "vol-type-001")
+ th.AssertEquals(t, vt.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/types", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "volume_type": {
+ "name": "vol-type-001"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "volume_type": {
+ "name": "vol-type-001",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+
+ options := &CreateOpts{Name: "vol-type-001"}
+ n, err := Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Name, "vol-type-001")
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+
+ err := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/blockstorage/v1/volumetypes/results.go b/openstack/blockstorage/v1/volumetypes/results.go
new file mode 100644
index 0000000..c049a04
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/results.go
@@ -0,0 +1,72 @@
+package volumetypes
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// VolumeType contains all information associated with an OpenStack Volume Type.
+type VolumeType struct {
+ ExtraSpecs map[string]interface{} `json:"extra_specs" mapstructure:"extra_specs"` // user-defined metadata
+ ID string `json:"id" mapstructure:"id"` // unique identifier
+ Name string `json:"name" mapstructure:"name"` // display name
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ListResult is a pagination.Pager that is returned from a call to the List function.
+type ListResult struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Volume Types.
+func (r ListResult) IsEmpty() (bool, error) {
+ volumeTypes, err := ExtractVolumeTypes(r)
+ if err != nil {
+ return true, err
+ }
+ return len(volumeTypes) == 0, nil
+}
+
+// ExtractVolumeTypes extracts and returns Volume Types.
+func ExtractVolumeTypes(page pagination.Page) ([]VolumeType, error) {
+ var response struct {
+ VolumeTypes []VolumeType `mapstructure:"volume_types"`
+ }
+
+ err := mapstructure.Decode(page.(ListResult).Body, &response)
+ return response.VolumeTypes, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Volume Type object out of the commonResult object.
+func (r commonResult) Extract() (*VolumeType, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ VolumeType *VolumeType `json:"volume_type" mapstructure:"volume_type"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.VolumeType, err
+}
diff --git a/openstack/blockstorage/v1/volumetypes/urls.go b/openstack/blockstorage/v1/volumetypes/urls.go
new file mode 100644
index 0000000..cf8367b
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/urls.go
@@ -0,0 +1,19 @@
+package volumetypes
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("types")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return listURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("types", id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
diff --git a/openstack/blockstorage/v1/volumetypes/urls_test.go b/openstack/blockstorage/v1/volumetypes/urls_test.go
new file mode 100644
index 0000000..44016e2
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/urls_test.go
@@ -0,0 +1,38 @@
+package volumetypes
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "types"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "types"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "types/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "types/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/cdn/v1/base/doc.go b/openstack/cdn/v1/base/doc.go
new file mode 100644
index 0000000..f78d4f7
--- /dev/null
+++ b/openstack/cdn/v1/base/doc.go
@@ -0,0 +1,4 @@
+// Package base provides information and interaction with the base API
+// resource in the OpenStack CDN service. This API resource allows for
+// retrieving the Home Document and pinging the root URL.
+package base
diff --git a/openstack/cdn/v1/base/fixtures.go b/openstack/cdn/v1/base/fixtures.go
new file mode 100644
index 0000000..19b5ece
--- /dev/null
+++ b/openstack/cdn/v1/base/fixtures.go
@@ -0,0 +1,53 @@
+package base
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleGetSuccessfully creates an HTTP handler at `/` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "resources": {
+ "rel/cdn": {
+ "href-template": "services{?marker,limit}",
+ "href-vars": {
+ "marker": "param/marker",
+ "limit": "param/limit"
+ },
+ "hints": {
+ "allow": [
+ "GET"
+ ],
+ "formats": {
+ "application/json": {}
+ }
+ }
+ }
+ }
+ }
+ `)
+
+ })
+}
+
+// HandlePingSuccessfully creates an HTTP handler at `/ping` on the test handler
+// mux that responds with a `Ping` response.
+func HandlePingSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/cdn/v1/base/requests.go b/openstack/cdn/v1/base/requests.go
new file mode 100644
index 0000000..dd221bc
--- /dev/null
+++ b/openstack/cdn/v1/base/requests.go
@@ -0,0 +1,21 @@
+package base
+
+import "github.com/rackspace/gophercloud"
+
+// Get retrieves the home document, allowing the user to discover the
+// entire API.
+func Get(c *gophercloud.ServiceClient) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c), &res.Body, nil)
+ return res
+}
+
+// Ping retrieves a ping to the server.
+func Ping(c *gophercloud.ServiceClient) PingResult {
+ var res PingResult
+ _, res.Err = c.Get(pingURL(c), nil, &gophercloud.RequestOpts{
+ OkCodes: []int{204},
+ MoreHeaders: map[string]string{"Accept": ""},
+ })
+ return res
+}
diff --git a/openstack/cdn/v1/base/requests_test.go b/openstack/cdn/v1/base/requests_test.go
new file mode 100644
index 0000000..2c20a71
--- /dev/null
+++ b/openstack/cdn/v1/base/requests_test.go
@@ -0,0 +1,43 @@
+package base
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetHomeDocument(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(fake.ServiceClient()).Extract()
+ th.CheckNoErr(t, err)
+
+ expected := HomeDocument{
+ "rel/cdn": map[string]interface{}{
+ "href-template": "services{?marker,limit}",
+ "href-vars": map[string]interface{}{
+ "marker": "param/marker",
+ "limit": "param/limit",
+ },
+ "hints": map[string]interface{}{
+ "allow": []string{"GET"},
+ "formats": map[string]interface{}{
+ "application/json": map[string]interface{}{},
+ },
+ },
+ },
+ }
+ th.CheckDeepEquals(t, expected, *actual)
+}
+
+func TestPing(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePingSuccessfully(t)
+
+ err := Ping(fake.ServiceClient()).ExtractErr()
+ th.CheckNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/base/results.go b/openstack/cdn/v1/base/results.go
new file mode 100644
index 0000000..bef1da8
--- /dev/null
+++ b/openstack/cdn/v1/base/results.go
@@ -0,0 +1,35 @@
+package base
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// HomeDocument is a resource that contains all the resources for the CDN API.
+type HomeDocument map[string]interface{}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a home document resource.
+func (r GetResult) Extract() (*HomeDocument, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ submap, ok := r.Body.(map[string]interface{})["resources"]
+ if !ok {
+ return nil, errors.New("Unexpected HomeDocument structure")
+ }
+ casted := HomeDocument(submap.(map[string]interface{}))
+
+ return &casted, nil
+}
+
+// PingResult represents the result of a Ping operation.
+type PingResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/base/urls.go b/openstack/cdn/v1/base/urls.go
new file mode 100644
index 0000000..a95e18b
--- /dev/null
+++ b/openstack/cdn/v1/base/urls.go
@@ -0,0 +1,11 @@
+package base
+
+import "github.com/rackspace/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL()
+}
+
+func pingURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("ping")
+}
diff --git a/openstack/cdn/v1/flavors/doc.go b/openstack/cdn/v1/flavors/doc.go
new file mode 100644
index 0000000..d406698
--- /dev/null
+++ b/openstack/cdn/v1/flavors/doc.go
@@ -0,0 +1,6 @@
+// Package flavors provides information and interaction with the flavors API
+// resource in the OpenStack CDN service. This API resource allows for
+// listing flavors and retrieving a specific flavor.
+//
+// A flavor is a mapping configuration to a CDN provider.
+package flavors
diff --git a/openstack/cdn/v1/flavors/fixtures.go b/openstack/cdn/v1/flavors/fixtures.go
new file mode 100644
index 0000000..d7ec1a0
--- /dev/null
+++ b/openstack/cdn/v1/flavors/fixtures.go
@@ -0,0 +1,82 @@
+package flavors
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleListCDNFlavorsSuccessfully creates an HTTP handler at `/flavors` on the test handler mux
+// that responds with a `List` response.
+func HandleListCDNFlavorsSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/flavors", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "flavors": [
+ {
+ "id": "europe",
+ "providers": [
+ {
+ "provider": "Fastly",
+ "links": [
+ {
+ "href": "http://www.fastly.com",
+ "rel": "provider_url"
+ }
+ ]
+ }
+ ],
+ "links": [
+ {
+ "href": "https://www.poppycdn.io/v1.0/flavors/europe",
+ "rel": "self"
+ }
+ ]
+ }
+ ]
+ }
+ `)
+ })
+}
+
+// HandleGetCDNFlavorSuccessfully creates an HTTP handler at `/flavors/{id}` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetCDNFlavorSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/flavors/asia", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "id" : "asia",
+ "providers" : [
+ {
+ "provider" : "ChinaCache",
+ "links": [
+ {
+ "href": "http://www.chinacache.com",
+ "rel": "provider_url"
+ }
+ ]
+ }
+ ],
+ "links": [
+ {
+ "href": "https://www.poppycdn.io/v1.0/flavors/asia",
+ "rel": "self"
+ }
+ ]
+ }
+ `)
+ })
+}
diff --git a/openstack/cdn/v1/flavors/requests.go b/openstack/cdn/v1/flavors/requests.go
new file mode 100644
index 0000000..8755a95
--- /dev/null
+++ b/openstack/cdn/v1/flavors/requests.go
@@ -0,0 +1,22 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a single page of CDN flavors.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(c)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Get retrieves a specific flavor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
diff --git a/openstack/cdn/v1/flavors/requests_test.go b/openstack/cdn/v1/flavors/requests_test.go
new file mode 100644
index 0000000..f731738
--- /dev/null
+++ b/openstack/cdn/v1/flavors/requests_test.go
@@ -0,0 +1,89 @@
+package flavors
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListCDNFlavorsSuccessfully(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractFlavors(page)
+ if err != nil {
+ t.Errorf("Failed to extract flavors: %v", err)
+ return false, err
+ }
+
+ expected := []Flavor{
+ Flavor{
+ ID: "europe",
+ Providers: []Provider{
+ Provider{
+ Provider: "Fastly",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://www.fastly.com",
+ Rel: "provider_url",
+ },
+ },
+ },
+ },
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+ Rel: "self",
+ },
+ },
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleGetCDNFlavorSuccessfully(t)
+
+ expected := &Flavor{
+ ID: "asia",
+ Providers: []Provider{
+ Provider{
+ Provider: "ChinaCache",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://www.chinacache.com",
+ Rel: "provider_url",
+ },
+ },
+ },
+ },
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+ Rel: "self",
+ },
+ },
+ }
+
+ actual, err := Get(fake.ServiceClient(), "asia").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/cdn/v1/flavors/results.go b/openstack/cdn/v1/flavors/results.go
new file mode 100644
index 0000000..8cab48b
--- /dev/null
+++ b/openstack/cdn/v1/flavors/results.go
@@ -0,0 +1,71 @@
+package flavors
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Provider represents a provider for a particular flavor.
+type Provider struct {
+ // Specifies the name of the provider. The name must not exceed 64 bytes in
+ // length and is limited to unicode, digits, underscores, and hyphens.
+ Provider string `mapstructure:"provider"`
+ // Specifies a list with an href where rel is provider_url.
+ Links []gophercloud.Link `mapstructure:"links"`
+}
+
+// Flavor represents a mapping configuration to a CDN provider.
+type Flavor struct {
+ // Specifies the name of the flavor. The name must not exceed 64 bytes in
+ // length and is limited to unicode, digits, underscores, and hyphens.
+ ID string `mapstructure:"id"`
+ // Specifies the list of providers mapped to this flavor.
+ Providers []Provider `mapstructure:"providers"`
+ // Specifies the self-navigating JSON document paths.
+ Links []gophercloud.Link `mapstructure:"links"`
+}
+
+// FlavorPage is the page returned by a pager when traversing over a
+// collection of CDN flavors.
+type FlavorPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a FlavorPage contains no Flavors.
+func (r FlavorPage) IsEmpty() (bool, error) {
+ flavors, err := ExtractFlavors(r)
+ if err != nil {
+ return true, err
+ }
+ return len(flavors) == 0, nil
+}
+
+// ExtractFlavors extracts and returns Flavors. It is used while iterating over
+// a flavors.List call.
+func ExtractFlavors(page pagination.Page) ([]Flavor, error) {
+ var response struct {
+ Flavors []Flavor `json:"flavors"`
+ }
+
+ err := mapstructure.Decode(page.(FlavorPage).Body, &response)
+ return response.Flavors, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts a flavor from a GetResult.
+func (r GetResult) Extract() (*Flavor, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res Flavor
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return &res, err
+}
diff --git a/openstack/cdn/v1/flavors/urls.go b/openstack/cdn/v1/flavors/urls.go
new file mode 100644
index 0000000..6eb38d2
--- /dev/null
+++ b/openstack/cdn/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("flavors")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("flavors", id)
+}
diff --git a/openstack/cdn/v1/serviceassets/doc.go b/openstack/cdn/v1/serviceassets/doc.go
new file mode 100644
index 0000000..ceecaa5
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/doc.go
@@ -0,0 +1,7 @@
+// Package serviceassets provides information and interaction with the
+// serviceassets API resource in the OpenStack CDN service. This API resource
+// allows for deleting cached assets.
+//
+// A service distributes assets across the network. Service assets let you
+// interrogate properties about these assets and perform certain actions on them.
+package serviceassets
diff --git a/openstack/cdn/v1/serviceassets/fixtures.go b/openstack/cdn/v1/serviceassets/fixtures.go
new file mode 100644
index 0000000..5c6b5d0
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/fixtures.go
@@ -0,0 +1,19 @@
+package serviceassets
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleDeleteCDNAssetSuccessfully creates an HTTP handler at `/services/{id}/assets` on the test handler mux
+// that responds with a `Delete` response.
+func HandleDeleteCDNAssetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0/assets", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/cdn/v1/serviceassets/requests.go b/openstack/cdn/v1/serviceassets/requests.go
new file mode 100644
index 0000000..1ddc65f
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/requests.go
@@ -0,0 +1,48 @@
+package serviceassets
+
+import (
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// DeleteOptsBuilder allows extensions to add additional parameters to the Delete
+// request.
+type DeleteOptsBuilder interface {
+ ToCDNAssetDeleteParams() (string, error)
+}
+
+// DeleteOpts is a structure that holds options for deleting CDN service assets.
+type DeleteOpts struct {
+ // If all is set to true, specifies that the delete occurs against all of the
+ // assets for the service.
+ All bool `q:"all"`
+ // Specifies the relative URL of the asset to be deleted.
+ URL string `q:"url"`
+}
+
+// ToCDNAssetDeleteParams formats a DeleteOpts into a query string.
+func (opts DeleteOpts) ToCDNAssetDeleteParams() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// Delete accepts a unique service ID or URL and deletes the CDN service asset associated with
+// it. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Delete(c *gophercloud.ServiceClient, idOrURL string, opts DeleteOptsBuilder) DeleteResult {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = deleteURL(c, idOrURL)
+ }
+
+ var res DeleteResult
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
diff --git a/openstack/cdn/v1/serviceassets/requests_test.go b/openstack/cdn/v1/serviceassets/requests_test.go
new file mode 100644
index 0000000..dde7bc1
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/requests_test.go
@@ -0,0 +1,18 @@
+package serviceassets
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleDeleteCDNAssetSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", nil).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/serviceassets/results.go b/openstack/cdn/v1/serviceassets/results.go
new file mode 100644
index 0000000..1d8734b
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/results.go
@@ -0,0 +1,8 @@
+package serviceassets
+
+import "github.com/rackspace/gophercloud"
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/serviceassets/urls.go b/openstack/cdn/v1/serviceassets/urls.go
new file mode 100644
index 0000000..cb0aea8
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/urls.go
@@ -0,0 +1,7 @@
+package serviceassets
+
+import "github.com/rackspace/gophercloud"
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("services", id, "assets")
+}
diff --git a/openstack/cdn/v1/services/doc.go b/openstack/cdn/v1/services/doc.go
new file mode 100644
index 0000000..41f7c60
--- /dev/null
+++ b/openstack/cdn/v1/services/doc.go
@@ -0,0 +1,7 @@
+// Package services provides information and interaction with the services API
+// resource in the OpenStack CDN service. This API resource allows for
+// listing, creating, updating, retrieving, and deleting services.
+//
+// A service represents an application that has its content cached to the edge
+// nodes.
+package services
diff --git a/openstack/cdn/v1/services/errors.go b/openstack/cdn/v1/services/errors.go
new file mode 100644
index 0000000..359584c
--- /dev/null
+++ b/openstack/cdn/v1/services/errors.go
@@ -0,0 +1,7 @@
+package services
+
+import "fmt"
+
+func no(str string) error {
+ return fmt.Errorf("Required parameter %s not provided", str)
+}
diff --git a/openstack/cdn/v1/services/fixtures.go b/openstack/cdn/v1/services/fixtures.go
new file mode 100644
index 0000000..d9bc9f2
--- /dev/null
+++ b/openstack/cdn/v1/services/fixtures.go
@@ -0,0 +1,372 @@
+package services
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleListCDNServiceSuccessfully creates an HTTP handler at `/services` on the test handler mux
+// that responds with a `List` response.
+func HandleListCDNServiceSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `
+ {
+ "links": [
+ {
+ "rel": "next",
+ "href": "https://www.poppycdn.io/v1.0/services?marker=96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0&limit=20"
+ }
+ ],
+ "services": [
+ {
+ "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ "name": "mywebsite.com",
+ "domains": [
+ {
+ "domain": "www.mywebsite.com"
+ }
+ ],
+ "origins": [
+ {
+ "origin": "mywebsite.com",
+ "port": 80,
+ "ssl": false
+ }
+ ],
+ "caching": [
+ {
+ "name": "default",
+ "ttl": 3600
+ },
+ {
+ "name": "home",
+ "ttl": 17200,
+ "rules": [
+ {
+ "name": "index",
+ "request_url": "/index.htm"
+ }
+ ]
+ },
+ {
+ "name": "images",
+ "ttl": 12800,
+ "rules": [
+ {
+ "name": "images",
+ "request_url": "*.png"
+ }
+ ]
+ }
+ ],
+ "restrictions": [
+ {
+ "name": "website only",
+ "rules": [
+ {
+ "name": "mywebsite.com",
+ "referrer": "www.mywebsite.com"
+ }
+ ]
+ }
+ ],
+ "flavor_id": "asia",
+ "status": "deployed",
+ "errors" : [],
+ "links": [
+ {
+ "href": "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ "rel": "self"
+ },
+ {
+ "href": "mywebsite.com.cdn123.poppycdn.net",
+ "rel": "access_url"
+ },
+ {
+ "href": "https://www.poppycdn.io/v1.0/flavors/asia",
+ "rel": "flavor"
+ }
+ ]
+ },
+ {
+ "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ "name": "myothersite.com",
+ "domains": [
+ {
+ "domain": "www.myothersite.com"
+ }
+ ],
+ "origins": [
+ {
+ "origin": "44.33.22.11",
+ "port": 80,
+ "ssl": false
+ },
+ {
+ "origin": "77.66.55.44",
+ "port": 80,
+ "ssl": false,
+ "rules": [
+ {
+ "name": "videos",
+ "request_url": "^/videos/*.m3u"
+ }
+ ]
+ }
+ ],
+ "caching": [
+ {
+ "name": "default",
+ "ttl": 3600
+ }
+ ],
+ "restrictions": [
+ {}
+ ],
+ "flavor_id": "europe",
+ "status": "deployed",
+ "links": [
+ {
+ "href": "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ "rel": "self"
+ },
+ {
+ "href": "myothersite.com.poppycdn.net",
+ "rel": "access_url"
+ },
+ {
+ "href": "https://www.poppycdn.io/v1.0/flavors/europe",
+ "rel": "flavor"
+ }
+ ]
+ }
+ ]
+ }
+ `)
+ case "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1":
+ fmt.Fprintf(w, `{
+ "services": []
+ }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// HandleCreateCDNServiceSuccessfully creates an HTTP handler at `/services` on the test handler mux
+// that responds with a `Create` response.
+func HandleCreateCDNServiceSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services", 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, `
+ {
+ "name": "mywebsite.com",
+ "domains": [
+ {
+ "domain": "www.mywebsite.com"
+ },
+ {
+ "domain": "blog.mywebsite.com"
+ }
+ ],
+ "origins": [
+ {
+ "origin": "mywebsite.com",
+ "port": 80,
+ "ssl": false
+ }
+ ],
+ "restrictions": [
+ {
+ "name": "website only",
+ "rules": [
+ {
+ "name": "mywebsite.com",
+ "referrer": "www.mywebsite.com"
+ }
+ ]
+ }
+ ],
+ "caching": [
+ {
+ "name": "default",
+ "ttl": 3600
+ }
+ ],
+
+ "flavor_id": "cdn"
+ }
+ `)
+ w.Header().Add("Location", "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleGetCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetCDNServiceSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ "name": "mywebsite.com",
+ "domains": [
+ {
+ "domain": "www.mywebsite.com",
+ "protocol": "http"
+ }
+ ],
+ "origins": [
+ {
+ "origin": "mywebsite.com",
+ "port": 80,
+ "ssl": false
+ }
+ ],
+ "caching": [
+ {
+ "name": "default",
+ "ttl": 3600
+ },
+ {
+ "name": "home",
+ "ttl": 17200,
+ "rules": [
+ {
+ "name": "index",
+ "request_url": "/index.htm"
+ }
+ ]
+ },
+ {
+ "name": "images",
+ "ttl": 12800,
+ "rules": [
+ {
+ "name": "images",
+ "request_url": "*.png"
+ }
+ ]
+ }
+ ],
+ "restrictions": [
+ {
+ "name": "website only",
+ "rules": [
+ {
+ "name": "mywebsite.com",
+ "referrer": "www.mywebsite.com"
+ }
+ ]
+ }
+ ],
+ "flavor_id": "cdn",
+ "status": "deployed",
+ "errors" : [],
+ "links": [
+ {
+ "href": "https://global.cdn.api.rackspacecloud.com/v1.0/110011/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ "rel": "self"
+ },
+ {
+ "href": "blog.mywebsite.com.cdn1.raxcdn.com",
+ "rel": "access_url"
+ },
+ {
+ "href": "https://global.cdn.api.rackspacecloud.com/v1.0/110011/flavors/cdn",
+ "rel": "flavor"
+ }
+ ]
+ }
+ `)
+ })
+}
+
+// HandleUpdateCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Update` response.
+func HandleUpdateCDNServiceSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestJSONRequest(t, r, `
+ [
+ {
+ "op": "add",
+ "path": "/domains/-",
+ "value": {"domain": "appended.mocksite4.com"}
+ },
+ {
+ "op": "add",
+ "path": "/domains/4",
+ "value": {"domain": "inserted.mocksite4.com"}
+ },
+ {
+ "op": "add",
+ "path": "/domains",
+ "value": [
+ {"domain": "bulkadded1.mocksite4.com"},
+ {"domain": "bulkadded2.mocksite4.com"}
+ ]
+ },
+ {
+ "op": "replace",
+ "path": "/origins/2",
+ "value": {"origin": "44.33.22.11", "port": 80, "ssl": false}
+ },
+ {
+ "op": "replace",
+ "path": "/origins",
+ "value": [
+ {"origin": "44.33.22.11", "port": 80, "ssl": false},
+ {"origin": "55.44.33.22", "port": 443, "ssl": true}
+ ]
+ },
+ {
+ "op": "remove",
+ "path": "/caching/8"
+ },
+ {
+ "op": "remove",
+ "path": "/caching"
+ },
+ {
+ "op": "replace",
+ "path": "/name",
+ "value": "differentServiceName"
+ }
+ ]
+ `)
+ w.Header().Add("Location", "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleDeleteCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Delete` response.
+func HandleDeleteCDNServiceSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/cdn/v1/services/requests.go b/openstack/cdn/v1/services/requests.go
new file mode 100644
index 0000000..8b37928
--- /dev/null
+++ b/openstack/cdn/v1/services/requests.go
@@ -0,0 +1,378 @@
+package services
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToCDNServiceListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Marker string `q:"marker"`
+ Limit int `q:"limit"`
+}
+
+// ToCDNServiceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToCDNServiceListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// CDN services. It accepts a ListOpts struct, which allows for pagination via
+// marker and limit.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToCDNServiceListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ p := ServicePage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ pager := pagination.NewPager(c, url, createPage)
+ return pager
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToCDNServiceCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // REQUIRED. Specifies the name of the service. The minimum length for name is
+ // 3. The maximum length is 256.
+ Name string
+ // REQUIRED. Specifies a list of domains used by users to access their website.
+ Domains []Domain
+ // REQUIRED. Specifies a list of origin domains or IP addresses where the
+ // original assets are stored.
+ Origins []Origin
+ // REQUIRED. Specifies the CDN provider flavor ID to use. For a list of
+ // flavors, see the operation to list the available flavors. The minimum
+ // length for flavor_id is 1. The maximum length is 256.
+ FlavorID string
+ // OPTIONAL. Specifies the TTL rules for the assets under this service. Supports wildcards for fine-grained control.
+ Caching []CacheRule
+ // OPTIONAL. Specifies the restrictions that define who can access assets (content from the CDN cache).
+ Restrictions []Restriction
+}
+
+// ToCDNServiceCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToCDNServiceCreateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return nil, no("Name")
+ }
+ s["name"] = opts.Name
+
+ if opts.Domains == nil {
+ return nil, no("Domains")
+ }
+ for _, domain := range opts.Domains {
+ if domain.Domain == "" {
+ return nil, no("Domains[].Domain")
+ }
+ }
+ s["domains"] = opts.Domains
+
+ if opts.Origins == nil {
+ return nil, no("Origins")
+ }
+ for _, origin := range opts.Origins {
+ if origin.Origin == "" {
+ return nil, no("Origins[].Origin")
+ }
+ if origin.Rules == nil && len(opts.Origins) > 1 {
+ return nil, no("Origins[].Rules")
+ }
+ for _, rule := range origin.Rules {
+ if rule.Name == "" {
+ return nil, no("Origins[].Rules[].Name")
+ }
+ if rule.RequestURL == "" {
+ return nil, no("Origins[].Rules[].RequestURL")
+ }
+ }
+ }
+ s["origins"] = opts.Origins
+
+ if opts.FlavorID == "" {
+ return nil, no("FlavorID")
+ }
+ s["flavor_id"] = opts.FlavorID
+
+ if opts.Caching != nil {
+ for _, cache := range opts.Caching {
+ if cache.Name == "" {
+ return nil, no("Caching[].Name")
+ }
+ if cache.Rules != nil {
+ for _, rule := range cache.Rules {
+ if rule.Name == "" {
+ return nil, no("Caching[].Rules[].Name")
+ }
+ if rule.RequestURL == "" {
+ return nil, no("Caching[].Rules[].RequestURL")
+ }
+ }
+ }
+ }
+ s["caching"] = opts.Caching
+ }
+
+ if opts.Restrictions != nil {
+ for _, restriction := range opts.Restrictions {
+ if restriction.Name == "" {
+ return nil, no("Restrictions[].Name")
+ }
+ if restriction.Rules != nil {
+ for _, rule := range restriction.Rules {
+ if rule.Name == "" {
+ return nil, no("Restrictions[].Rules[].Name")
+ }
+ }
+ }
+ }
+ s["restrictions"] = opts.Restrictions
+ }
+
+ return s, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new CDN service using the
+// values provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToCDNServiceCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ resp, err := c.Post(createURL(c), &reqBody, nil, nil)
+ res.Header = resp.Header
+ res.Err = err
+ return res
+}
+
+// Get retrieves a specific service based on its URL or its unique ID. For
+// example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Get(c *gophercloud.ServiceClient, idOrURL string) GetResult {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = getURL(c, idOrURL)
+ }
+
+ var res GetResult
+ _, res.Err = c.Get(url, &res.Body, nil)
+ return res
+}
+
+// Path is a JSON pointer location that indicates which service parameter is being added, replaced,
+// or removed.
+type Path struct {
+ baseElement string
+}
+
+func (p Path) renderRoot() string {
+ return "/" + p.baseElement
+}
+
+func (p Path) renderDash() string {
+ return fmt.Sprintf("/%s/-", p.baseElement)
+}
+
+func (p Path) renderIndex(index int64) string {
+ return fmt.Sprintf("/%s/%d", p.baseElement, index)
+}
+
+var (
+ // PathDomains indicates that an update operation is to be performed on a Domain.
+ PathDomains = Path{baseElement: "domains"}
+
+ // PathOrigins indicates that an update operation is to be performed on an Origin.
+ PathOrigins = Path{baseElement: "origins"}
+
+ // PathCaching indicates that an update operation is to be performed on a CacheRule.
+ PathCaching = Path{baseElement: "caching"}
+)
+
+type value interface {
+ toPatchValue() interface{}
+ appropriatePath() Path
+ renderRootOr(func(p Path) string) string
+}
+
+// Patch represents a single update to an existing Service. Multiple updates to a service can be
+// submitted at the same time.
+type Patch interface {
+ ToCDNServiceUpdateMap() map[string]interface{}
+}
+
+// Insertion is a Patch that requests the addition of a value (Domain, Origin, or CacheRule) to
+// a Service at a fixed index. Use an Append instead to append the new value to the end of its
+// collection. Pass it to the Update function as part of the Patch slice.
+type Insertion struct {
+ Index int64
+ Value value
+}
+
+// ToCDNServiceUpdateMap converts an Insertion into a request body fragment suitable for the
+// Update call.
+func (i Insertion) ToCDNServiceUpdateMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "add",
+ "path": i.Value.renderRootOr(func(p Path) string { return p.renderIndex(i.Index) }),
+ "value": i.Value.toPatchValue(),
+ }
+}
+
+// Append is a Patch that requests the addition of a value (Domain, Origin, or CacheRule) to a
+// Service at the end of its respective collection. Use an Insertion instead to insert the value
+// at a fixed index within the collection. Pass this to the Update function as part of its
+// Patch slice.
+type Append struct {
+ Value value
+}
+
+// ToCDNServiceUpdateMap converts an Append into a request body fragment suitable for the
+// Update call.
+func (a Append) ToCDNServiceUpdateMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "add",
+ "path": a.Value.renderRootOr(func(p Path) string { return p.renderDash() }),
+ "value": a.Value.toPatchValue(),
+ }
+}
+
+// Replacement is a Patch that alters a specific service parameter (Domain, Origin, or CacheRule)
+// in-place by index. Pass it to the Update function as part of the Patch slice.
+type Replacement struct {
+ Value value
+ Index int64
+}
+
+// ToCDNServiceUpdateMap converts a Replacement into a request body fragment suitable for the
+// Update call.
+func (r Replacement) ToCDNServiceUpdateMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": r.Value.renderRootOr(func(p Path) string { return p.renderIndex(r.Index) }),
+ "value": r.Value.toPatchValue(),
+ }
+}
+
+// NameReplacement specifically updates the Service name. Pass it to the Update function as part
+// of the Patch slice.
+type NameReplacement struct {
+ NewName string
+}
+
+// ToCDNServiceUpdateMap converts a NameReplacement into a request body fragment suitable for the
+// Update call.
+func (r NameReplacement) ToCDNServiceUpdateMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/name",
+ "value": r.NewName,
+ }
+}
+
+// Removal is a Patch that requests the removal of a service parameter (Domain, Origin, or
+// CacheRule) by index. Pass it to the Update function as part of the Patch slice.
+type Removal struct {
+ Path Path
+ Index int64
+ All bool
+}
+
+// ToCDNServiceUpdateMap converts a Removal into a request body fragment suitable for the
+// Update call.
+func (r Removal) ToCDNServiceUpdateMap() map[string]interface{} {
+ result := map[string]interface{}{"op": "remove"}
+ if r.All {
+ result["path"] = r.Path.renderRoot()
+ } else {
+ result["path"] = r.Path.renderIndex(r.Index)
+ }
+ return result
+}
+
+type UpdateOpts []Patch
+
+// Update accepts a slice of Patch operations (Insertion, Append, Replacement or Removal) and
+// updates an existing CDN service using the values provided. idOrURL can be either the service's
+// URL or its ID. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Update(c *gophercloud.ServiceClient, idOrURL string, opts UpdateOpts) UpdateResult {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = updateURL(c, idOrURL)
+ }
+
+ reqBody := make([]map[string]interface{}, len(opts))
+ for i, patch := range opts {
+ reqBody[i] = patch.ToCDNServiceUpdateMap()
+ }
+
+ resp, err := c.Request("PATCH", url, gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+ var result UpdateResult
+ result.Header = resp.Header
+ result.Err = err
+ return result
+}
+
+// Delete accepts a service's ID or its URL and deletes the CDN service
+// associated with it. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Delete(c *gophercloud.ServiceClient, idOrURL string) DeleteResult {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = deleteURL(c, idOrURL)
+ }
+
+ var res DeleteResult
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
diff --git a/openstack/cdn/v1/services/requests_test.go b/openstack/cdn/v1/services/requests_test.go
new file mode 100644
index 0000000..59e826f
--- /dev/null
+++ b/openstack/cdn/v1/services/requests_test.go
@@ -0,0 +1,358 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListCDNServiceSuccessfully(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractServices(page)
+ if err != nil {
+ t.Errorf("Failed to extract services: %v", err)
+ return false, err
+ }
+
+ expected := []Service{
+ Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []Domain{
+ Domain{
+ Domain: "www.mywebsite.com",
+ },
+ },
+ Origins: []Origin{
+ Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []CacheRule{
+ CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ CacheRule{
+ Name: "home",
+ TTL: 17200,
+ Rules: []TTLRule{
+ TTLRule{
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ CacheRule{
+ Name: "images",
+ TTL: 12800,
+ Rules: []TTLRule{
+ TTLRule{
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []Restriction{
+ Restriction{
+ Name: "website only",
+ Rules: []RestrictionRule{
+ RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "asia",
+ Status: "deployed",
+ Errors: []Error{},
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "mywebsite.com.cdn123.poppycdn.net",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+ Rel: "flavor",
+ },
+ },
+ },
+ Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ Name: "myothersite.com",
+ Domains: []Domain{
+ Domain{
+ Domain: "www.myothersite.com",
+ },
+ },
+ Origins: []Origin{
+ Origin{
+ Origin: "44.33.22.11",
+ Port: 80,
+ SSL: false,
+ },
+ Origin{
+ Origin: "77.66.55.44",
+ Port: 80,
+ SSL: false,
+ Rules: []OriginRule{
+ OriginRule{
+ Name: "videos",
+ RequestURL: "^/videos/*.m3u",
+ },
+ },
+ },
+ },
+ Caching: []CacheRule{
+ CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ Restrictions: []Restriction{},
+ FlavorID: "europe",
+ Status: "deployed",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "myothersite.com.poppycdn.net",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+ Rel: "flavor",
+ },
+ },
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleCreateCDNServiceSuccessfully(t)
+
+ createOpts := CreateOpts{
+ Name: "mywebsite.com",
+ Domains: []Domain{
+ Domain{
+ Domain: "www.mywebsite.com",
+ },
+ Domain{
+ Domain: "blog.mywebsite.com",
+ },
+ },
+ Origins: []Origin{
+ Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Restrictions: []Restriction{
+ Restriction{
+ Name: "website only",
+ Rules: []RestrictionRule{
+ RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ Caching: []CacheRule{
+ CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ FlavorID: "cdn",
+ }
+
+ expected := "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleGetCDNServiceSuccessfully(t)
+
+ expected := &Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []Domain{
+ Domain{
+ Domain: "www.mywebsite.com",
+ Protocol: "http",
+ },
+ },
+ Origins: []Origin{
+ Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []CacheRule{
+ CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ CacheRule{
+ Name: "home",
+ TTL: 17200,
+ Rules: []TTLRule{
+ TTLRule{
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ CacheRule{
+ Name: "images",
+ TTL: 12800,
+ Rules: []TTLRule{
+ TTLRule{
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []Restriction{
+ Restriction{
+ Name: "website only",
+ Rules: []RestrictionRule{
+ RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "cdn",
+ Status: "deployed",
+ Errors: []Error{},
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "blog.mywebsite.com.cdn1.raxcdn.com",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/flavors/cdn",
+ Rel: "flavor",
+ },
+ },
+ }
+
+ actual, err := Get(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestSuccessfulUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleUpdateCDNServiceSuccessfully(t)
+
+ expected := "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+ ops := UpdateOpts{
+ // Append a single Domain
+ Append{Value: Domain{Domain: "appended.mocksite4.com"}},
+ // Insert a single Domain
+ Insertion{
+ Index: 4,
+ Value: Domain{Domain: "inserted.mocksite4.com"},
+ },
+ // Bulk addition
+ Append{
+ Value: DomainList{
+ Domain{Domain: "bulkadded1.mocksite4.com"},
+ Domain{Domain: "bulkadded2.mocksite4.com"},
+ },
+ },
+ // Replace a single Origin
+ Replacement{
+ Index: 2,
+ Value: Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+ },
+ // Bulk replace Origins
+ Replacement{
+ Index: 0, // Ignored
+ Value: OriginList{
+ Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+ Origin{Origin: "55.44.33.22", Port: 443, SSL: true},
+ },
+ },
+ // Remove a single CacheRule
+ Removal{
+ Index: 8,
+ Path: PathCaching,
+ },
+ // Bulk removal
+ Removal{
+ All: true,
+ Path: PathCaching,
+ },
+ // Service name replacement
+ NameReplacement{
+ NewName: "differentServiceName",
+ },
+ }
+
+ actual, err := Update(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", ops).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleDeleteCDNServiceSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/services/results.go b/openstack/cdn/v1/services/results.go
new file mode 100644
index 0000000..33406c4
--- /dev/null
+++ b/openstack/cdn/v1/services/results.go
@@ -0,0 +1,316 @@
+package services
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Domain represents a domain used by users to access their website.
+type Domain struct {
+ // Specifies the domain used to access the assets on their website, for which
+ // a CNAME is given to the CDN provider.
+ Domain string `mapstructure:"domain" json:"domain"`
+ // Specifies the protocol used to access the assets on this domain. Only "http"
+ // or "https" are currently allowed. The default is "http".
+ Protocol string `mapstructure:"protocol" json:"protocol,omitempty"`
+}
+
+func (d Domain) toPatchValue() interface{} {
+ r := make(map[string]interface{})
+ r["domain"] = d.Domain
+ if d.Protocol != "" {
+ r["protocol"] = d.Protocol
+ }
+ return r
+}
+
+func (d Domain) appropriatePath() Path {
+ return PathDomains
+}
+
+func (d Domain) renderRootOr(render func(p Path) string) string {
+ return render(d.appropriatePath())
+}
+
+// DomainList provides a useful way to perform bulk operations in a single Patch.
+type DomainList []Domain
+
+func (list DomainList) toPatchValue() interface{} {
+ r := make([]interface{}, len(list))
+ for i, domain := range list {
+ r[i] = domain.toPatchValue()
+ }
+ return r
+}
+
+func (list DomainList) appropriatePath() Path {
+ return PathDomains
+}
+
+func (list DomainList) renderRootOr(_ func(p Path) string) string {
+ return list.appropriatePath().renderRoot()
+}
+
+// OriginRule represents a rule that defines when an origin should be accessed.
+type OriginRule struct {
+ // Specifies the name of this rule.
+ Name string `mapstructure:"name" json:"name"`
+ // Specifies the request URL this rule should match for this origin to be used. Regex is supported.
+ RequestURL string `mapstructure:"request_url" json:"request_url"`
+}
+
+// Origin specifies a list of origin domains or IP addresses where the original assets are stored.
+type Origin struct {
+ // Specifies the URL or IP address to pull origin content from.
+ Origin string `mapstructure:"origin" json:"origin"`
+ // Specifies the port used to access the origin. The default is port 80.
+ Port int `mapstructure:"port" json:"port,omitempty"`
+ // Specifies whether or not to use HTTPS to access the origin. The default
+ // is false.
+ SSL bool `mapstructure:"ssl" json:"ssl"`
+ // Specifies a collection of rules that define the conditions when this origin
+ // should be accessed. If there is more than one origin, the rules parameter is required.
+ Rules []OriginRule `mapstructure:"rules" json:"rules,omitempty"`
+}
+
+func (o Origin) toPatchValue() interface{} {
+ r := make(map[string]interface{})
+ r["origin"] = o.Origin
+ r["port"] = o.Port
+ r["ssl"] = o.SSL
+ if len(o.Rules) > 0 {
+ r["rules"] = make([]map[string]interface{}, len(o.Rules))
+ for index, rule := range o.Rules {
+ submap := r["rules"].([]map[string]interface{})[index]
+ submap["name"] = rule.Name
+ submap["request_url"] = rule.RequestURL
+ }
+ }
+ return r
+}
+
+func (o Origin) appropriatePath() Path {
+ return PathOrigins
+}
+
+func (o Origin) renderRootOr(render func(p Path) string) string {
+ return render(o.appropriatePath())
+}
+
+// OriginList provides a useful way to perform bulk operations in a single Patch.
+type OriginList []Origin
+
+func (list OriginList) toPatchValue() interface{} {
+ r := make([]interface{}, len(list))
+ for i, origin := range list {
+ r[i] = origin.toPatchValue()
+ }
+ return r
+}
+
+func (list OriginList) appropriatePath() Path {
+ return PathOrigins
+}
+
+func (list OriginList) renderRootOr(_ func(p Path) string) string {
+ return list.appropriatePath().renderRoot()
+}
+
+// TTLRule specifies a rule that determines if a TTL should be applied to an asset.
+type TTLRule struct {
+ // Specifies the name of this rule.
+ Name string `mapstructure:"name" json:"name"`
+ // Specifies the request URL this rule should match for this TTL to be used. Regex is supported.
+ RequestURL string `mapstructure:"request_url" json:"request_url"`
+}
+
+// CacheRule specifies the TTL rules for the assets under this service.
+type CacheRule struct {
+ // Specifies the name of this caching rule. Note: 'default' is a reserved name used for the default TTL setting.
+ Name string `mapstructure:"name" json:"name"`
+ // Specifies the TTL to apply.
+ TTL int `mapstructure:"ttl" json:"ttl"`
+ // Specifies a collection of rules that determine if this TTL should be applied to an asset.
+ Rules []TTLRule `mapstructure:"rules" json:"rules,omitempty"`
+}
+
+func (c CacheRule) toPatchValue() interface{} {
+ r := make(map[string]interface{})
+ r["name"] = c.Name
+ r["ttl"] = c.TTL
+ r["rules"] = make([]map[string]interface{}, len(c.Rules))
+ for index, rule := range c.Rules {
+ submap := r["rules"].([]map[string]interface{})[index]
+ submap["name"] = rule.Name
+ submap["request_url"] = rule.RequestURL
+ }
+ return r
+}
+
+func (c CacheRule) appropriatePath() Path {
+ return PathCaching
+}
+
+func (c CacheRule) renderRootOr(render func(p Path) string) string {
+ return render(c.appropriatePath())
+}
+
+// CacheRuleList provides a useful way to perform bulk operations in a single Patch.
+type CacheRuleList []CacheRule
+
+func (list CacheRuleList) toPatchValue() interface{} {
+ r := make([]interface{}, len(list))
+ for i, rule := range list {
+ r[i] = rule.toPatchValue()
+ }
+ return r
+}
+
+func (list CacheRuleList) appropriatePath() Path {
+ return PathCaching
+}
+
+func (list CacheRuleList) renderRootOr(_ func(p Path) string) string {
+ return list.appropriatePath().renderRoot()
+}
+
+// RestrictionRule specifies a rule that determines if this restriction should be applied to an asset.
+type RestrictionRule struct {
+ // Specifies the name of this rule.
+ Name string `mapstructure:"name" json:"name"`
+ // Specifies the http host that requests must come from.
+ Referrer string `mapstructure:"referrer" json:"referrer,omitempty"`
+}
+
+// Restriction specifies a restriction that defines who can access assets (content from the CDN cache).
+type Restriction struct {
+ // Specifies the name of this restriction.
+ Name string `mapstructure:"name" json:"name"`
+ // Specifies a collection of rules that determine if this TTL should be applied to an asset.
+ Rules []RestrictionRule `mapstructure:"rules" json:"rules"`
+}
+
+// Error specifies an error that occurred during the previous service action.
+type Error struct {
+ // Specifies an error message detailing why there is an error.
+ Message string `mapstructure:"message"`
+}
+
+// Service represents a CDN service resource.
+type Service struct {
+ // Specifies the service ID that represents distributed content. The value is
+ // a UUID, such as 96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0, that is generated by the server.
+ ID string `mapstructure:"id"`
+ // Specifies the name of the service.
+ Name string `mapstructure:"name"`
+ // Specifies a list of domains used by users to access their website.
+ Domains []Domain `mapstructure:"domains"`
+ // Specifies a list of origin domains or IP addresses where the original assets are stored.
+ Origins []Origin `mapstructure:"origins"`
+ // Specifies the TTL rules for the assets under this service. Supports wildcards for fine grained control.
+ Caching []CacheRule `mapstructure:"caching"`
+ // Specifies the restrictions that define who can access assets (content from the CDN cache).
+ Restrictions []Restriction `mapstructure:"restrictions" json:"restrictions,omitempty"`
+ // Specifies the CDN provider flavor ID to use. For a list of flavors, see the operation to list the available flavors.
+ FlavorID string `mapstructure:"flavor_id"`
+ // Specifies the current status of the service.
+ Status string `mapstructure:"status"`
+ // Specifies the list of errors that occurred during the previous service action.
+ Errors []Error `mapstructure:"errors"`
+ // Specifies the self-navigating JSON document paths.
+ Links []gophercloud.Link `mapstructure:"links"`
+}
+
+// ServicePage is the page returned by a pager when traversing over a
+// collection of CDN services.
+type ServicePage struct {
+ pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a ListResult contains no services.
+func (r ServicePage) IsEmpty() (bool, error) {
+ services, err := ExtractServices(r)
+ if err != nil {
+ return true, err
+ }
+ return len(services) == 0, nil
+}
+
+// LastMarker returns the last service in a ListResult.
+func (r ServicePage) LastMarker() (string, error) {
+ services, err := ExtractServices(r)
+ if err != nil {
+ return "", err
+ }
+ if len(services) == 0 {
+ return "", nil
+ }
+ return (services[len(services)-1]).ID, nil
+}
+
+// ExtractServices is a function that takes a ListResult and returns the services' information.
+func ExtractServices(page pagination.Page) ([]Service, error) {
+ var response struct {
+ Services []Service `mapstructure:"services"`
+ }
+
+ err := mapstructure.Decode(page.(ServicePage).Body, &response)
+ return response.Services, err
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that extracts the location of a newly created service.
+func (r CreateResult) Extract() (string, error) {
+ if r.Err != nil {
+ return "", r.Err
+ }
+ if l, ok := r.Header["Location"]; ok && len(l) > 0 {
+ return l[0], nil
+ }
+ return "", nil
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts a service from a GetResult.
+func (r GetResult) Extract() (*Service, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res Service
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return &res, err
+}
+
+// UpdateResult represents the result of a Update operation.
+type UpdateResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that extracts the location of an updated service.
+func (r UpdateResult) Extract() (string, error) {
+ if r.Err != nil {
+ return "", r.Err
+ }
+ if l, ok := r.Header["Location"]; ok && len(l) > 0 {
+ return l[0], nil
+ }
+ return "", nil
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/services/urls.go b/openstack/cdn/v1/services/urls.go
new file mode 100644
index 0000000..d953d4c
--- /dev/null
+++ b/openstack/cdn/v1/services/urls.go
@@ -0,0 +1,23 @@
+package services
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("services")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return listURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("services", id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
diff --git a/openstack/client.go b/openstack/client.go
new file mode 100644
index 0000000..33602a6
--- /dev/null
+++ b/openstack/client.go
@@ -0,0 +1,274 @@
+package openstack
+
+import (
+ "fmt"
+ "net/url"
+
+ "github.com/rackspace/gophercloud"
+ tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+ "github.com/rackspace/gophercloud/openstack/utils"
+)
+
+const (
+ v20 = "v2.0"
+ v30 = "v3.0"
+)
+
+// NewClient prepares an unauthenticated ProviderClient instance.
+// Most users will probably prefer using the AuthenticatedClient function instead.
+// This is useful if you wish to explicitly control the version of the identity service that's used for authentication explicitly,
+// for example.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+ u, err := url.Parse(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ hadPath := u.Path != ""
+ u.Path, u.RawQuery, u.Fragment = "", "", ""
+ base := u.String()
+
+ endpoint = gophercloud.NormalizeURL(endpoint)
+ base = gophercloud.NormalizeURL(base)
+
+ if hadPath {
+ return &gophercloud.ProviderClient{
+ IdentityBase: base,
+ IdentityEndpoint: endpoint,
+ }, nil
+ }
+
+ return &gophercloud.ProviderClient{
+ IdentityBase: base,
+ IdentityEndpoint: "",
+ }, nil
+}
+
+// AuthenticatedClient logs in to an OpenStack cloud found at the identity endpoint specified by options, acquires a token, and
+// returns a Client instance that's ready to operate.
+// It first queries the root identity endpoint to determine which versions of the identity service are supported, then chooses
+// the most recent identity service available to proceed.
+func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
+ client, err := NewClient(options.IdentityEndpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ err = Authenticate(client, options)
+ if err != nil {
+ return nil, err
+ }
+ return client, nil
+}
+
+// Authenticate or re-authenticate against the most recent identity service supported at the provided endpoint.
+func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ versions := []*utils.Version{
+ &utils.Version{ID: v20, Priority: 20, Suffix: "/v2.0/"},
+ &utils.Version{ID: v30, Priority: 30, Suffix: "/v3/"},
+ }
+
+ chosen, endpoint, err := utils.ChooseVersion(client, versions)
+ if err != nil {
+ return err
+ }
+
+ switch chosen.ID {
+ case v20:
+ return v2auth(client, endpoint, options)
+ case v30:
+ return v3auth(client, endpoint, options)
+ default:
+ // The switch statement must be out of date from the versions list.
+ return fmt.Errorf("Unrecognized identity version: %s", chosen.ID)
+ }
+}
+
+// AuthenticateV2 explicitly authenticates against the identity v2 endpoint.
+func AuthenticateV2(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ return v2auth(client, "", options)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+ v2Client := NewIdentityV2(client)
+ if endpoint != "" {
+ v2Client.Endpoint = endpoint
+ }
+
+ result := tokens2.Create(v2Client, tokens2.AuthOptions{AuthOptions: options})
+
+ token, err := result.ExtractToken()
+ if err != nil {
+ return err
+ }
+
+ catalog, err := result.ExtractServiceCatalog()
+ if err != nil {
+ return err
+ }
+
+ if options.AllowReauth {
+ client.ReauthFunc = func() error {
+ client.TokenID = ""
+ return AuthenticateV2(client, options)
+ }
+ }
+ client.TokenID = token.ID
+ client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+ return V2EndpointURL(catalog, opts)
+ }
+
+ return nil
+}
+
+// AuthenticateV3 explicitly authenticates against the identity v3 service.
+func AuthenticateV3(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ return v3auth(client, "", options)
+}
+
+func v3auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+ // Override the generated service endpoint with the one returned by the version endpoint.
+ v3Client := NewIdentityV3(client)
+ if endpoint != "" {
+ v3Client.Endpoint = endpoint
+ }
+
+ var scope *tokens3.Scope
+ if options.TenantID != "" {
+ scope = &tokens3.Scope{
+ ProjectID: options.TenantID,
+ }
+ options.TenantID = ""
+ options.TenantName = ""
+ } else {
+ if options.TenantName != "" {
+ scope = &tokens3.Scope{
+ ProjectName: options.TenantName,
+ DomainID: options.DomainID,
+ DomainName: options.DomainName,
+ }
+ options.TenantName = ""
+ }
+ }
+
+ result := tokens3.Create(v3Client, options, scope)
+
+ token, err := result.ExtractToken()
+ if err != nil {
+ return err
+ }
+
+ catalog, err := result.ExtractServiceCatalog()
+ if err != nil {
+ return err
+ }
+
+ client.TokenID = token.ID
+
+ if options.AllowReauth {
+ client.ReauthFunc = func() error {
+ client.TokenID = ""
+ return AuthenticateV3(client, options)
+ }
+ }
+ client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+ return V3EndpointURL(catalog, opts)
+ }
+
+ return nil
+}
+
+// NewIdentityV2 creates a ServiceClient that may be used to interact with the v2 identity service.
+func NewIdentityV2(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+ v2Endpoint := client.IdentityBase + "v2.0/"
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: v2Endpoint,
+ }
+}
+
+// NewIdentityV3 creates a ServiceClient that may be used to access the v3 identity service.
+func NewIdentityV3(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+ v3Endpoint := client.IdentityBase + "v3/"
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: v3Endpoint,
+ }
+}
+
+// NewObjectStorageV1 creates a ServiceClient that may be used with the v1 object storage package.
+func NewObjectStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("object-store")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewComputeV2 creates a ServiceClient that may be used with the v2 compute package.
+func NewComputeV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("compute")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewNetworkV2 creates a ServiceClient that may be used with the v2 network package.
+func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("network")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: url,
+ ResourceBase: url + "v2.0/",
+ }, nil
+}
+
+// NewBlockStorageV1 creates a ServiceClient that may be used to access the v1 block storage service.
+func NewBlockStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("volume")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewCDNV1 creates a ServiceClient that may be used to access the OpenStack v1
+// CDN service.
+func NewCDNV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("cdn")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewOrchestrationV1 creates a ServiceClient that may be used to access the v1 orchestration service.
+func NewOrchestrationV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("orchestration")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewDBV1 creates a ServiceClient that may be used to access the v1 DB service.
+func NewDBV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("database")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
diff --git a/openstack/client_test.go b/openstack/client_test.go
new file mode 100644
index 0000000..257260c
--- /dev/null
+++ b/openstack/client_test.go
@@ -0,0 +1,161 @@
+package openstack
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV3(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ const ID = "0123456789"
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "stable",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, th.Endpoint()+"v3/", th.Endpoint()+"v2.0/")
+ })
+
+ th.Mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("X-Subject-Token", ID)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{ "token": { "expires_at": "2013-02-02T18:30:59.000000Z" } }`)
+ })
+
+ options := gophercloud.AuthOptions{
+ UserID: "me",
+ Password: "secret",
+ IdentityEndpoint: th.Endpoint(),
+ }
+ client, err := AuthenticatedClient(options)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, ID, client.TokenID)
+}
+
+func TestAuthenticatedClientV2(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "experimental",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, th.Endpoint()+"v3/", th.Endpoint()+"v2.0/")
+ })
+
+ th.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "access": {
+ "token": {
+ "id": "01234567890",
+ "expires": "2014-10-01T10:00:00.000000Z"
+ },
+ "serviceCatalog": [
+ {
+ "name": "Cloud Servers",
+ "type": "compute",
+ "endpoints": [
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://compute.north.host.com/v1/t1000",
+ "internalURL": "https://compute.north.internal/v1/t1000",
+ "region": "North",
+ "versionId": "1",
+ "versionInfo": "https://compute.north.host.com/v1/",
+ "versionList": "https://compute.north.host.com/"
+ },
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://compute.north.host.com/v1.1/t1000",
+ "internalURL": "https://compute.north.internal/v1.1/t1000",
+ "region": "North",
+ "versionId": "1.1",
+ "versionInfo": "https://compute.north.host.com/v1.1/",
+ "versionList": "https://compute.north.host.com/"
+ }
+ ],
+ "endpoints_links": []
+ },
+ {
+ "name": "Cloud Files",
+ "type": "object-store",
+ "endpoints": [
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://storage.north.host.com/v1/t1000",
+ "internalURL": "https://storage.north.internal/v1/t1000",
+ "region": "North",
+ "versionId": "1",
+ "versionInfo": "https://storage.north.host.com/v1/",
+ "versionList": "https://storage.north.host.com/"
+ },
+ {
+ "tenantId": "t1000",
+ "publicURL": "https://storage.south.host.com/v1/t1000",
+ "internalURL": "https://storage.south.internal/v1/t1000",
+ "region": "South",
+ "versionId": "1",
+ "versionInfo": "https://storage.south.host.com/v1/",
+ "versionList": "https://storage.south.host.com/"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ `)
+ })
+
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "secret",
+ IdentityEndpoint: th.Endpoint(),
+ }
+ client, err := AuthenticatedClient(options)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "01234567890", client.TokenID)
+}
diff --git a/openstack/common/README.md b/openstack/common/README.md
new file mode 100644
index 0000000..7b55795
--- /dev/null
+++ b/openstack/common/README.md
@@ -0,0 +1,3 @@
+# Common Resources
+
+This directory is for resources that are shared by multiple services.
diff --git a/openstack/common/extensions/doc.go b/openstack/common/extensions/doc.go
new file mode 100644
index 0000000..4a168f4
--- /dev/null
+++ b/openstack/common/extensions/doc.go
@@ -0,0 +1,15 @@
+// Package extensions provides information and interaction with the different extensions available
+// for an OpenStack service.
+//
+// The purpose of OpenStack API extensions is to:
+//
+// - Introduce new features in the API without requiring a version change.
+// - Introduce vendor-specific niche functionality.
+// - Act as a proving ground for experimental functionalities that might be included in a future
+// version of the API.
+//
+// Extensions usually have tags that prevent conflicts with other extensions that define attributes
+// or resources with the same names, and with core resources and attributes.
+// Because an extension might not be supported by all plug-ins, its availability varies with deployments
+// and the specific plug-in.
+package extensions
diff --git a/openstack/common/extensions/errors.go b/openstack/common/extensions/errors.go
new file mode 100755
index 0000000..aeec0fa
--- /dev/null
+++ b/openstack/common/extensions/errors.go
@@ -0,0 +1 @@
+package extensions
diff --git a/openstack/common/extensions/fixtures.go b/openstack/common/extensions/fixtures.go
new file mode 100644
index 0000000..0ed7de9
--- /dev/null
+++ b/openstack/common/extensions/fixtures.go
@@ -0,0 +1,91 @@
+// +build fixtures
+
+package extensions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Extension results.
+const ListOutput = `
+{
+ "extensions": [
+ {
+ "updated": "2013-01-20T00:00:00-00:00",
+ "name": "Neutron Service Type Management",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ "alias": "service-type",
+ "description": "API for retrieving service providers for Neutron advanced services"
+ }
+ ]
+}`
+
+// GetOutput provides a single Extension result.
+const GetOutput = `
+{
+ "extension": {
+ "updated": "2013-02-03T10:00:00-00:00",
+ "name": "agent",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+ "alias": "agent",
+ "description": "The agent management extension."
+ }
+}
+`
+
+// ListedExtension is the Extension that should be parsed from ListOutput.
+var ListedExtension = Extension{
+ Updated: "2013-01-20T00:00:00-00:00",
+ Name: "Neutron Service Type Management",
+ Links: []interface{}{},
+ Namespace: "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ Alias: "service-type",
+ Description: "API for retrieving service providers for Neutron advanced services",
+}
+
+// ExpectedExtensions is a slice containing the Extension that should be parsed from ListOutput.
+var ExpectedExtensions = []Extension{ListedExtension}
+
+// SingleExtension is the Extension that should be parsed from GetOutput.
+var SingleExtension = &Extension{
+ Updated: "2013-02-03T10:00:00-00:00",
+ Name: "agent",
+ Links: []interface{}{},
+ Namespace: "http://docs.openstack.org/ext/agent/api/v2.0",
+ Alias: "agent",
+ Description: "The agent management extension.",
+}
+
+// HandleListExtensionsSuccessfully creates an HTTP handler at `/extensions` on the test handler
+// mux that response with a list containing a single tenant.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetExtensionSuccessfully creates an HTTP handler at `/extensions/agent` that responds with
+// a JSON payload corresponding to SingleExtension.
+func HandleGetExtensionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, GetOutput)
+ })
+}
diff --git a/openstack/common/extensions/requests.go b/openstack/common/extensions/requests.go
new file mode 100755
index 0000000..0b71085
--- /dev/null
+++ b/openstack/common/extensions/requests.go
@@ -0,0 +1,21 @@
+package extensions
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(ExtensionURL(c, alias), &res.Body, nil)
+ return res
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(c, ListExtensionURL(c), func(r pagination.PageResult) pagination.Page {
+ return ExtensionPage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/common/extensions/requests_test.go b/openstack/common/extensions/requests_test.go
new file mode 100644
index 0000000..6550283
--- /dev/null
+++ b/openstack/common/extensions/requests_test.go
@@ -0,0 +1,38 @@
+package extensions
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListExtensionsSuccessfully(t)
+
+ count := 0
+
+ List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, ExpectedExtensions, actual)
+
+ return true, nil
+ })
+
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetExtensionSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, SingleExtension, actual)
+}
diff --git a/openstack/common/extensions/results.go b/openstack/common/extensions/results.go
new file mode 100755
index 0000000..777d083
--- /dev/null
+++ b/openstack/common/extensions/results.go
@@ -0,0 +1,65 @@
+package extensions
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// GetResult temporarily stores the result of a Get call.
+// Use its Extract() method to interpret it as an Extension.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult as an Extension.
+func (r GetResult) Extract() (*Extension, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Extension *Extension `json:"extension"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Extension, err
+}
+
+// Extension is a struct that represents an OpenStack extension.
+type Extension struct {
+ Updated string `json:"updated" mapstructure:"updated"`
+ Name string `json:"name" mapstructure:"name"`
+ Links []interface{} `json:"links" mapstructure:"links"`
+ Namespace string `json:"namespace" mapstructure:"namespace"`
+ Alias string `json:"alias" mapstructure:"alias"`
+ Description string `json:"description" mapstructure:"description"`
+}
+
+// ExtensionPage is the page returned by a pager when traversing over a collection of extensions.
+type ExtensionPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an ExtensionPage struct is empty.
+func (r ExtensionPage) IsEmpty() (bool, error) {
+ is, err := ExtractExtensions(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of Extension structs.
+// In other words, a generic collection is mapped into a relevant slice.
+func ExtractExtensions(page pagination.Page) ([]Extension, error) {
+ var resp struct {
+ Extensions []Extension `mapstructure:"extensions"`
+ }
+
+ err := mapstructure.Decode(page.(ExtensionPage).Body, &resp)
+
+ return resp.Extensions, err
+}
diff --git a/openstack/common/extensions/urls.go b/openstack/common/extensions/urls.go
new file mode 100644
index 0000000..6460c66
--- /dev/null
+++ b/openstack/common/extensions/urls.go
@@ -0,0 +1,13 @@
+package extensions
+
+import "github.com/rackspace/gophercloud"
+
+// ExtensionURL generates the URL for an extension resource by name.
+func ExtensionURL(c *gophercloud.ServiceClient, name string) string {
+ return c.ServiceURL("extensions", name)
+}
+
+// ListExtensionURL generates the URL for the extensions resource collection.
+func ListExtensionURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("extensions")
+}
diff --git a/openstack/common/extensions/urls_test.go b/openstack/common/extensions/urls_test.go
new file mode 100755
index 0000000..3223b1c
--- /dev/null
+++ b/openstack/common/extensions/urls_test.go
@@ -0,0 +1,26 @@
+package extensions
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestExtensionURL(t *testing.T) {
+ actual := ExtensionURL(endpointClient(), "agent")
+ expected := endpoint + "extensions/agent"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListExtensionURL(t *testing.T) {
+ actual := ListExtensionURL(endpointClient())
+ expected := endpoint + "extensions"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests.go b/openstack/compute/v2/extensions/bootfromvolume/requests.go
new file mode 100644
index 0000000..c8edee0
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/requests.go
@@ -0,0 +1,111 @@
+package bootfromvolume
+
+import (
+ "errors"
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// SourceType represents the type of medium being used to create the volume.
+type SourceType string
+
+const (
+ Volume SourceType = "volume"
+ Snapshot SourceType = "snapshot"
+ Image SourceType = "image"
+)
+
+// BlockDevice is a structure with options for booting a server instance
+// from a volume. The volume may be created from an image, snapshot, or another
+// volume.
+type BlockDevice struct {
+ // BootIndex [optional] is the boot index. It defaults to 0.
+ BootIndex int `json:"boot_index"`
+
+ // DeleteOnTermination [optional] specifies whether or not to delete the attached volume
+ // when the server is deleted. Defaults to `false`.
+ DeleteOnTermination bool `json:"delete_on_termination"`
+
+ // DestinationType [optional] is the type that gets created. Possible values are "volume"
+ // and "local".
+ DestinationType string `json:"destination_type"`
+
+ // SourceType [required] must be one of: "volume", "snapshot", "image".
+ SourceType SourceType `json:"source_type"`
+
+ // UUID [required] is the unique identifier for the volume, snapshot, or image (see above)
+ UUID string `json:"uuid"`
+
+ // VolumeSize [optional] is the size of the volume to create (in gigabytes).
+ VolumeSize int `json:"volume_size"`
+}
+
+// CreateOptsExt is a structure that extends the server `CreateOpts` structure
+// by allowing for a block device mapping.
+type CreateOptsExt struct {
+ servers.CreateOptsBuilder
+ BlockDevice []BlockDevice `json:"block_device_mapping_v2,omitempty"`
+}
+
+// ToServerCreateMap adds the block device mapping option to the base server
+// creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+ base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(opts.BlockDevice) == 0 {
+ return nil, errors.New("Required fields UUID and SourceType not set.")
+ }
+
+ serverMap := base["server"].(map[string]interface{})
+
+ blockDevice := make([]map[string]interface{}, len(opts.BlockDevice))
+
+ for i, bd := range opts.BlockDevice {
+ if string(bd.SourceType) == "" {
+ return nil, errors.New("SourceType must be one of: volume, image, snapshot.")
+ }
+
+ blockDevice[i] = make(map[string]interface{})
+
+ blockDevice[i]["source_type"] = bd.SourceType
+ blockDevice[i]["boot_index"] = strconv.Itoa(bd.BootIndex)
+ blockDevice[i]["delete_on_termination"] = strconv.FormatBool(bd.DeleteOnTermination)
+ blockDevice[i]["volume_size"] = strconv.Itoa(bd.VolumeSize)
+ if bd.UUID != "" {
+ blockDevice[i]["uuid"] = bd.UUID
+ }
+ if bd.DestinationType != "" {
+ blockDevice[i]["destination_type"] = bd.DestinationType
+ }
+
+ }
+ serverMap["block_device_mapping_v2"] = blockDevice
+
+ return base, nil
+}
+
+// Create requests the creation of a server from the given block device mapping.
+func Create(client *gophercloud.ServiceClient, opts servers.CreateOptsBuilder) servers.CreateResult {
+ var res servers.CreateResult
+
+ reqBody, err := opts.ToServerCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Delete imageName and flavorName that come from ToServerCreateMap().
+ // As of Liberty, Boot From Volume is failing if they are passed.
+ delete(reqBody["server"].(map[string]interface{}), "imageName")
+ delete(reqBody["server"].(map[string]interface{}), "flavorName")
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return res
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests_test.go b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go
new file mode 100644
index 0000000..8a7fa74
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go
@@ -0,0 +1,53 @@
+package bootfromvolume
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []BlockDevice{
+ BlockDevice{
+ UUID: "123456",
+ SourceType: Image,
+ DestinationType: "volume",
+ VolumeSize: 10,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "flavorName": "",
+ "imageName": "",
+ "block_device_mapping_v2":[
+ {
+ "uuid":"123456",
+ "source_type":"image",
+ "destination_type":"volume",
+ "boot_index": "0",
+ "delete_on_termination": "false",
+ "volume_size": "10"
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/results.go b/openstack/compute/v2/extensions/bootfromvolume/results.go
new file mode 100644
index 0000000..f60329f
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/results.go
@@ -0,0 +1,10 @@
+package bootfromvolume
+
+import (
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// CreateResult temporarily contains the response from a Create call.
+type CreateResult struct {
+ os.CreateResult
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls.go b/openstack/compute/v2/extensions/bootfromvolume/urls.go
new file mode 100644
index 0000000..0cffe25
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/urls.go
@@ -0,0 +1,7 @@
+package bootfromvolume
+
+import "github.com/rackspace/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("os-volumes_boot")
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls_test.go b/openstack/compute/v2/extensions/bootfromvolume/urls_test.go
new file mode 100644
index 0000000..6ee6477
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/urls_test.go
@@ -0,0 +1,16 @@
+package bootfromvolume
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-volumes_boot", createURL(c))
+}
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..9f27ef1
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests.go
@@ -0,0 +1,95 @@
+package defsecrules
+
+import (
+ "errors"
+
+ "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 = client.Post(rootURL(client), reqBody, &result.Body, &gophercloud.RequestOpts{
+ 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 = client.Get(resourceURL(client, id), &result.Body, nil)
+ 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 = client.Delete(resourceURL(client, id), nil)
+ 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/delegate.go b/openstack/compute/v2/extensions/delegate.go
new file mode 100644
index 0000000..1007909
--- /dev/null
+++ b/openstack/compute/v2/extensions/delegate.go
@@ -0,0 +1,23 @@
+package extensions
+
+import (
+ "github.com/rackspace/gophercloud"
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractExtensions interprets a Page as a slice of Extensions.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+ return common.ExtractExtensions(page)
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+ return common.Get(c, alias)
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return common.List(c)
+}
diff --git a/openstack/compute/v2/extensions/delegate_test.go b/openstack/compute/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..c3c525f
--- /dev/null
+++ b/openstack/compute/v2/extensions/delegate_test.go
@@ -0,0 +1,96 @@
+package extensions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ fmt.Fprintf(w, `
+{
+ "extensions": [
+ {
+ "updated": "2013-01-20T00:00:00-00:00",
+ "name": "Neutron Service Type Management",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ "alias": "service-type",
+ "description": "API for retrieving service providers for Neutron advanced services"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+ List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+
+ expected := []common.Extension{
+ common.Extension{
+ Updated: "2013-01-20T00:00:00-00:00",
+ Name: "Neutron Service Type Management",
+ Links: []interface{}{},
+ Namespace: "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ Alias: "service-type",
+ Description: "API for retrieving service providers for Neutron advanced services",
+ },
+ }
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "extension": {
+ "updated": "2013-02-03T10:00:00-00:00",
+ "name": "agent",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+ "alias": "agent",
+ "description": "The agent management extension."
+ }
+}
+ `)
+ })
+
+ ext, err := Get(client.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, ext.Updated, "2013-02-03T10:00:00-00:00")
+ th.AssertEquals(t, ext.Name, "agent")
+ th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/agent/api/v2.0")
+ th.AssertEquals(t, ext.Alias, "agent")
+ th.AssertEquals(t, ext.Description, "The agent management extension.")
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/doc.go b/openstack/compute/v2/extensions/diskconfig/doc.go
new file mode 100644
index 0000000..80785fa
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/doc.go
@@ -0,0 +1,3 @@
+// Package diskconfig provides information and interaction with the Disk
+// Config extension that works with the OpenStack Compute service.
+package diskconfig
diff --git a/openstack/compute/v2/extensions/diskconfig/requests.go b/openstack/compute/v2/extensions/diskconfig/requests.go
new file mode 100644
index 0000000..7407e0d
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests.go
@@ -0,0 +1,114 @@
+package diskconfig
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// DiskConfig represents one of the two possible settings for the DiskConfig option when creating,
+// rebuilding, or resizing servers: Auto or Manual.
+type DiskConfig string
+
+const (
+ // Auto builds a server with a single partition the size of the target flavor disk and
+ // automatically adjusts the filesystem to fit the entire partition. Auto may only be used with
+ // images and servers that use a single EXT3 partition.
+ Auto DiskConfig = "AUTO"
+
+ // Manual builds a server using whatever partition scheme and filesystem are present in the source
+ // image. If the target flavor disk is larger, the remaining space is left unpartitioned. This
+ // enables images to have non-EXT3 filesystems, multiple partitions, and so on, and enables you
+ // to manage the disk configuration. It also results in slightly shorter boot times.
+ Manual DiskConfig = "MANUAL"
+)
+
+// ErrInvalidDiskConfig is returned if an invalid string is specified for a DiskConfig option.
+var ErrInvalidDiskConfig = errors.New("DiskConfig must be either diskconfig.Auto or diskconfig.Manual.")
+
+// Validate ensures that a DiskConfig contains an appropriate value.
+func (config DiskConfig) validate() error {
+ switch config {
+ case Auto, Manual:
+ return nil
+ default:
+ return ErrInvalidDiskConfig
+ }
+}
+
+// CreateOptsExt adds a DiskConfig option to the base CreateOpts.
+type CreateOptsExt struct {
+ servers.CreateOptsBuilder
+
+ // DiskConfig [optional] controls how the created server's disk is partitioned.
+ DiskConfig DiskConfig `json:"OS-DCF:diskConfig,omitempty"`
+}
+
+// ToServerCreateMap adds the diskconfig option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+ base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if string(opts.DiskConfig) == "" {
+ return base, nil
+ }
+
+ serverMap := base["server"].(map[string]interface{})
+ serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+ return base, nil
+}
+
+// RebuildOptsExt adds a DiskConfig option to the base RebuildOpts.
+type RebuildOptsExt struct {
+ servers.RebuildOptsBuilder
+
+ // DiskConfig [optional] controls how the rebuilt server's disk is partitioned.
+ DiskConfig DiskConfig
+}
+
+// ToServerRebuildMap adds the diskconfig option to the base server rebuild options.
+func (opts RebuildOptsExt) ToServerRebuildMap() (map[string]interface{}, error) {
+ err := opts.DiskConfig.validate()
+ if err != nil {
+ return nil, err
+ }
+
+ base, err := opts.RebuildOptsBuilder.ToServerRebuildMap()
+ if err != nil {
+ return nil, err
+ }
+
+ serverMap := base["rebuild"].(map[string]interface{})
+ serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+ return base, nil
+}
+
+// ResizeOptsExt adds a DiskConfig option to the base server resize options.
+type ResizeOptsExt struct {
+ servers.ResizeOptsBuilder
+
+ // DiskConfig [optional] controls how the resized server's disk is partitioned.
+ DiskConfig DiskConfig
+}
+
+// ToServerResizeMap adds the diskconfig option to the base server creation options.
+func (opts ResizeOptsExt) ToServerResizeMap() (map[string]interface{}, error) {
+ err := opts.DiskConfig.validate()
+ if err != nil {
+ return nil, err
+ }
+
+ base, err := opts.ResizeOptsBuilder.ToServerResizeMap()
+ if err != nil {
+ return nil, err
+ }
+
+ serverMap := base["resize"].(map[string]interface{})
+ serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+ return base, nil
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/requests_test.go b/openstack/compute/v2/extensions/diskconfig/requests_test.go
new file mode 100644
index 0000000..17418a3
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests_test.go
@@ -0,0 +1,89 @@
+package diskconfig
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := CreateOptsExt{
+ CreateOptsBuilder: base,
+ DiskConfig: Manual,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "flavorName": "",
+ "imageName": "",
+ "OS-DCF:diskConfig": "MANUAL"
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestRebuildOpts(t *testing.T) {
+ base := servers.RebuildOpts{
+ Name: "rebuiltserver",
+ AdminPass: "swordfish",
+ ImageID: "asdfasdfasdf",
+ }
+
+ ext := RebuildOptsExt{
+ RebuildOptsBuilder: base,
+ DiskConfig: Auto,
+ }
+
+ actual, err := ext.ToServerRebuildMap()
+ th.AssertNoErr(t, err)
+
+ expected := `
+ {
+ "rebuild": {
+ "name": "rebuiltserver",
+ "imageRef": "asdfasdfasdf",
+ "adminPass": "swordfish",
+ "OS-DCF:diskConfig": "AUTO"
+ }
+ }
+ `
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestResizeOpts(t *testing.T) {
+ base := servers.ResizeOpts{
+ FlavorRef: "performance1-8",
+ }
+
+ ext := ResizeOptsExt{
+ ResizeOptsBuilder: base,
+ DiskConfig: Auto,
+ }
+
+ actual, err := ext.ToServerResizeMap()
+ th.AssertNoErr(t, err)
+
+ expected := `
+ {
+ "resize": {
+ "flavorRef": "performance1-8",
+ "OS-DCF:diskConfig": "AUTO"
+ }
+ }
+ `
+ th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/results.go b/openstack/compute/v2/extensions/diskconfig/results.go
new file mode 100644
index 0000000..10ec2da
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results.go
@@ -0,0 +1,60 @@
+package diskconfig
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func commonExtract(result gophercloud.Result) (*DiskConfig, error) {
+ var resp struct {
+ Server struct {
+ DiskConfig string `mapstructure:"OS-DCF:diskConfig"`
+ } `mapstructure:"server"`
+ }
+
+ err := mapstructure.Decode(result.Body, &resp)
+ if err != nil {
+ return nil, err
+ }
+
+ config := DiskConfig(resp.Server.DiskConfig)
+ return &config, nil
+}
+
+// ExtractGet returns the disk configuration from a servers.Get call.
+func ExtractGet(result servers.GetResult) (*DiskConfig, error) {
+ return commonExtract(result.Result)
+}
+
+// ExtractUpdate returns the disk configuration from a servers.Update call.
+func ExtractUpdate(result servers.UpdateResult) (*DiskConfig, error) {
+ return commonExtract(result.Result)
+}
+
+// ExtractRebuild returns the disk configuration from a servers.Rebuild call.
+func ExtractRebuild(result servers.RebuildResult) (*DiskConfig, error) {
+ return commonExtract(result.Result)
+}
+
+// ExtractDiskConfig returns the DiskConfig setting for a specific server acquired from an
+// servers.ExtractServers call, while iterating through a Pager.
+func ExtractDiskConfig(page pagination.Page, index int) (*DiskConfig, error) {
+ casted := page.(servers.ServerPage).Body
+
+ type server struct {
+ DiskConfig string `mapstructure:"OS-DCF:diskConfig"`
+ }
+ var response struct {
+ Servers []server `mapstructure:"servers"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ config := DiskConfig(response.Servers[index].DiskConfig)
+ return &config, nil
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/results_test.go b/openstack/compute/v2/extensions/diskconfig/results_test.go
new file mode 100644
index 0000000..dd8d2b7
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results_test.go
@@ -0,0 +1,68 @@
+package diskconfig
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestExtractGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ servers.HandleServerGetSuccessfully(t)
+
+ config, err := ExtractGet(servers.Get(client.ServiceClient(), "1234asdf"))
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ servers.HandleServerUpdateSuccessfully(t)
+
+ r := servers.Update(client.ServiceClient(), "1234asdf", servers.UpdateOpts{
+ Name: "new-name",
+ })
+ config, err := ExtractUpdate(r)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractRebuild(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ servers.HandleRebuildSuccessfully(t, servers.SingleServerBody)
+
+ r := servers.Rebuild(client.ServiceClient(), "1234asdf", servers.RebuildOpts{
+ Name: "new-name",
+ AdminPass: "swordfish",
+ ImageID: "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ AccessIPv4: "1.2.3.4",
+ })
+ config, err := ExtractRebuild(r)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ servers.HandleServerListSuccessfully(t)
+
+ pages := 0
+ err := servers.List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ config, err := ExtractDiskConfig(page, 0)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, Manual, *config)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, pages, 1)
+}
diff --git a/openstack/compute/v2/extensions/doc.go b/openstack/compute/v2/extensions/doc.go
new file mode 100644
index 0000000..2b447da
--- /dev/null
+++ b/openstack/compute/v2/extensions/doc.go
@@ -0,0 +1,3 @@
+// Package extensions provides information and interaction with the
+// different extensions available for the OpenStack Compute service.
+package extensions
diff --git a/openstack/compute/v2/extensions/floatingip/doc.go b/openstack/compute/v2/extensions/floatingip/doc.go
new file mode 100644
index 0000000..f74f58c
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/doc.go
@@ -0,0 +1,3 @@
+// Package floatingip provides the ability to manage floating ips through
+// nova-network
+package floatingip
diff --git a/openstack/compute/v2/extensions/floatingip/fixtures.go b/openstack/compute/v2/extensions/floatingip/fixtures.go
new file mode 100644
index 0000000..e47fa4c
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/fixtures.go
@@ -0,0 +1,193 @@
+// +build fixtures
+
+package floatingip
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "floating_ips": [
+ {
+ "fixed_ip": null,
+ "id": 1,
+ "instance_id": null,
+ "ip": "10.10.10.1",
+ "pool": "nova"
+ },
+ {
+ "fixed_ip": "166.78.185.201",
+ "id": 2,
+ "instance_id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "ip": "10.10.10.2",
+ "pool": "nova"
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "floating_ip": {
+ "fixed_ip": "166.78.185.201",
+ "id": 2,
+ "instance_id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "ip": "10.10.10.2",
+ "pool": "nova"
+ }
+}
+`
+
+// CreateOutput is a sample response to a Post call
+const CreateOutput = `
+{
+ "floating_ip": {
+ "fixed_ip": null,
+ "id": 1,
+ "instance_id": null,
+ "ip": "10.10.10.1",
+ "pool": "nova"
+ }
+}
+`
+
+// FirstFloatingIP is the first result in ListOutput.
+var FirstFloatingIP = FloatingIP{
+ ID: "1",
+ IP: "10.10.10.1",
+ Pool: "nova",
+}
+
+// SecondFloatingIP is the first result in ListOutput.
+var SecondFloatingIP = FloatingIP{
+ FixedIP: "166.78.185.201",
+ ID: "2",
+ InstanceID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ IP: "10.10.10.2",
+ Pool: "nova",
+}
+
+// ExpectedFloatingIPsSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedFloatingIPsSlice = []FloatingIP{FirstFloatingIP, SecondFloatingIP}
+
+// CreatedFloatingIP is the parsed result from CreateOutput.
+var CreatedFloatingIP = FloatingIP{
+ ID: "1",
+ IP: "10.10.10.1",
+ Pool: "nova",
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-floating-ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing floating ip
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-floating-ips/2", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new floating ip
+func HandleCreateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-floating-ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "pool": "nova"
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, CreateOutput)
+ })
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing floating ip
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-floating-ips/1", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleAssociateSuccessfully configures the test server to respond to a Post request
+// to associate an allocated floating IP
+func HandleAssociateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "addFloatingIp": {
+ "address": "10.10.10.2"
+ }
+}
+`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleFixedAssociateSucessfully configures the test server to respond to a Post request
+// to associate an allocated floating IP with a specific fixed IP address
+func HandleAssociateFixedSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "addFloatingIp": {
+ "address": "10.10.10.2",
+ "fixed_address": "166.78.185.201"
+ }
+}
+`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleDisassociateSuccessfully configures the test server to respond to a Post request
+// to disassociate an allocated floating IP
+func HandleDisassociateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "removeFloatingIp": {
+ "address": "10.10.10.2"
+ }
+}
+`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/floatingip/requests.go b/openstack/compute/v2/extensions/floatingip/requests.go
new file mode 100644
index 0000000..8206462
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/requests.go
@@ -0,0 +1,171 @@
+package floatingip
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of FloatingIPs.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return FloatingIPsPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ ToFloatingIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies a Floating IP allocation request
+type CreateOpts struct {
+ // Pool is the pool of floating IPs to allocate one from
+ Pool string
+}
+
+// AssociateOpts specifies the required information to associate or disassociate a floating IP to an instance
+type AssociateOpts struct {
+ // ServerID is the UUID of the server
+ ServerID string
+
+ // FixedIP is an optional fixed IP address of the server
+ FixedIP string
+
+ // FloatingIP is the floating IP to associate with an instance
+ FloatingIP string
+}
+
+// ToFloatingIPCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToFloatingIPCreateMap() (map[string]interface{}, error) {
+ if opts.Pool == "" {
+ return nil, errors.New("Missing field required for floating IP creation: Pool")
+ }
+
+ return map[string]interface{}{"pool": opts.Pool}, nil
+}
+
+// ToAssociateMap constructs a request body from AssociateOpts.
+func (opts AssociateOpts) ToAssociateMap() (map[string]interface{}, error) {
+ if opts.ServerID == "" {
+ return nil, errors.New("Required field missing for floating IP association: ServerID")
+ }
+
+ if opts.FloatingIP == "" {
+ return nil, errors.New("Required field missing for floating IP association: FloatingIP")
+ }
+
+ associateInfo := map[string]interface{}{
+ "serverId": opts.ServerID,
+ "floatingIp": opts.FloatingIP,
+ "fixedIp": opts.FixedIP,
+ }
+
+ return associateInfo, nil
+
+}
+
+// Create requests the creation of a new floating IP
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToFloatingIPCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Get returns data about a previously created FloatingIP.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
+
+// Delete requests the deletion of a previous allocated FloatingIP.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
+
+// association / disassociation
+
+// Associate pairs an allocated floating IP with an instance
+// Deprecated. Use AssociateInstance.
+func Associate(client *gophercloud.ServiceClient, serverId, fip string) AssociateResult {
+ var res AssociateResult
+
+ addFloatingIp := make(map[string]interface{})
+ addFloatingIp["address"] = fip
+ reqBody := map[string]interface{}{"addFloatingIp": addFloatingIp}
+
+ _, res.Err = client.Post(associateURL(client, serverId), reqBody, nil, nil)
+ return res
+}
+
+// AssociateInstance pairs an allocated floating IP with an instance.
+func AssociateInstance(client *gophercloud.ServiceClient, opts AssociateOpts) AssociateResult {
+ var res AssociateResult
+
+ associateInfo, err := opts.ToAssociateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ addFloatingIp := make(map[string]interface{})
+ addFloatingIp["address"] = associateInfo["floatingIp"].(string)
+
+ // fixedIp is not required
+ if associateInfo["fixedIp"] != "" {
+ addFloatingIp["fixed_address"] = associateInfo["fixedIp"].(string)
+ }
+
+ serverId := associateInfo["serverId"].(string)
+
+ reqBody := map[string]interface{}{"addFloatingIp": addFloatingIp}
+ _, res.Err = client.Post(associateURL(client, serverId), reqBody, nil, nil)
+ return res
+}
+
+// Disassociate decouples an allocated floating IP from an instance
+// Deprecated. Use DisassociateInstance.
+func Disassociate(client *gophercloud.ServiceClient, serverId, fip string) DisassociateResult {
+ var res DisassociateResult
+
+ removeFloatingIp := make(map[string]interface{})
+ removeFloatingIp["address"] = fip
+ reqBody := map[string]interface{}{"removeFloatingIp": removeFloatingIp}
+
+ _, res.Err = client.Post(disassociateURL(client, serverId), reqBody, nil, nil)
+ return res
+}
+
+// DisassociateInstance decouples an allocated floating IP from an instance
+func DisassociateInstance(client *gophercloud.ServiceClient, opts AssociateOpts) DisassociateResult {
+ var res DisassociateResult
+
+ associateInfo, err := opts.ToAssociateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ removeFloatingIp := make(map[string]interface{})
+ removeFloatingIp["address"] = associateInfo["floatingIp"].(string)
+ reqBody := map[string]interface{}{"removeFloatingIp": removeFloatingIp}
+
+ serverId := associateInfo["serverId"].(string)
+
+ _, res.Err = client.Post(disassociateURL(client, serverId), reqBody, nil, nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/floatingip/requests_test.go b/openstack/compute/v2/extensions/floatingip/requests_test.go
new file mode 100644
index 0000000..4d86fe2
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/requests_test.go
@@ -0,0 +1,123 @@
+package floatingip
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractFloatingIPs(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedFloatingIPsSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), CreateOpts{
+ Pool: "nova",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedFloatingIP, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "2").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &SecondFloatingIP, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteSuccessfully(t)
+
+ err := Delete(client.ServiceClient(), "1").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAssociateDeprecated(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAssociateSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+ fip := "10.10.10.2"
+
+ err := Associate(client.ServiceClient(), serverId, fip).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAssociate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAssociateSuccessfully(t)
+
+ associateOpts := AssociateOpts{
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ FloatingIP: "10.10.10.2",
+ }
+
+ err := AssociateInstance(client.ServiceClient(), associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAssociateFixed(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAssociateFixedSuccessfully(t)
+
+ associateOpts := AssociateOpts{
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ FloatingIP: "10.10.10.2",
+ FixedIP: "166.78.185.201",
+ }
+
+ err := AssociateInstance(client.ServiceClient(), associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisassociateDeprecated(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDisassociateSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+ fip := "10.10.10.2"
+
+ err := Disassociate(client.ServiceClient(), serverId, fip).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisassociateInstance(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDisassociateSuccessfully(t)
+
+ associateOpts := AssociateOpts{
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ FloatingIP: "10.10.10.2",
+ }
+
+ err := DisassociateInstance(client.ServiceClient(), associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/floatingip/results.go b/openstack/compute/v2/extensions/floatingip/results.go
new file mode 100644
index 0000000..be77fa1
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/results.go
@@ -0,0 +1,99 @@
+package floatingip
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// A FloatingIP is an IP that can be associated with an instance
+type FloatingIP struct {
+ // ID is a unique ID of the Floating IP
+ ID string `mapstructure:"id"`
+
+ // FixedIP is the IP of the instance related to the Floating IP
+ FixedIP string `mapstructure:"fixed_ip,omitempty"`
+
+ // InstanceID is the ID of the instance that is using the Floating IP
+ InstanceID string `mapstructure:"instance_id"`
+
+ // IP is the actual Floating IP
+ IP string `mapstructure:"ip"`
+
+ // Pool is the pool of floating IPs that this floating IP belongs to
+ Pool string `mapstructure:"pool"`
+}
+
+// FloatingIPsPage stores a single, only page of FloatingIPs
+// results from a List call.
+type FloatingIPsPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a FloatingIPsPage is empty.
+func (page FloatingIPsPage) IsEmpty() (bool, error) {
+ va, err := ExtractFloatingIPs(page)
+ return len(va) == 0, err
+}
+
+// ExtractFloatingIPs interprets a page of results as a slice of
+// FloatingIPs.
+func ExtractFloatingIPs(page pagination.Page) ([]FloatingIP, error) {
+ casted := page.(FloatingIPsPage).Body
+ var response struct {
+ FloatingIPs []FloatingIP `mapstructure:"floating_ips"`
+ }
+
+ err := mapstructure.WeakDecode(casted, &response)
+
+ return response.FloatingIPs, err
+}
+
+type FloatingIPResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any FloatingIP resource
+// response as a FloatingIP struct.
+func (r FloatingIPResult) Extract() (*FloatingIP, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ FloatingIP *FloatingIP `json:"floating_ip" mapstructure:"floating_ip"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &res)
+ return res.FloatingIP, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a FloatingIP.
+type CreateResult struct {
+ FloatingIPResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a FloatingIP.
+type GetResult struct {
+ FloatingIPResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// AssociateResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type AssociateResult struct {
+ gophercloud.ErrResult
+}
+
+// DisassociateResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DisassociateResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/floatingip/urls.go b/openstack/compute/v2/extensions/floatingip/urls.go
new file mode 100644
index 0000000..54198f8
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/urls.go
@@ -0,0 +1,37 @@
+package floatingip
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-floating-ips"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
+
+func serverURL(c *gophercloud.ServiceClient, serverId string) string {
+ return c.ServiceURL("servers/" + serverId + "/action")
+}
+
+func associateURL(c *gophercloud.ServiceClient, serverId string) string {
+ return serverURL(c, serverId)
+}
+
+func disassociateURL(c *gophercloud.ServiceClient, serverId string) string {
+ return serverURL(c, serverId)
+}
diff --git a/openstack/compute/v2/extensions/floatingip/urls_test.go b/openstack/compute/v2/extensions/floatingip/urls_test.go
new file mode 100644
index 0000000..f73d6fb
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingip/urls_test.go
@@ -0,0 +1,60 @@
+package floatingip
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-floating-ips", listURL(c))
+}
+
+func TestCreateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-floating-ips", createURL(c))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-floating-ips/"+id, getURL(c, id))
+}
+
+func TestDeleteURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-floating-ips/"+id, deleteURL(c, id))
+}
+
+func TestAssociateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/action", associateURL(c, serverId))
+}
+
+func TestDisassociateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/action", disassociateURL(c, serverId))
+}
diff --git a/openstack/compute/v2/extensions/keypairs/doc.go b/openstack/compute/v2/extensions/keypairs/doc.go
new file mode 100644
index 0000000..856f41b
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/doc.go
@@ -0,0 +1,3 @@
+// Package keypairs provides information and interaction with the Keypairs
+// extension for the OpenStack Compute service.
+package keypairs
diff --git a/openstack/compute/v2/extensions/keypairs/fixtures.go b/openstack/compute/v2/extensions/keypairs/fixtures.go
new file mode 100644
index 0000000..d10af99
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/fixtures.go
@@ -0,0 +1,171 @@
+// +build fixtures
+
+package keypairs
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "keypairs": [
+ {
+ "keypair": {
+ "fingerprint": "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a",
+ "name": "firstkey",
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n"
+ }
+ },
+ {
+ "keypair": {
+ "fingerprint": "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+ "name": "secondkey",
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n"
+ }
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "keypair": {
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n",
+ "name": "firstkey",
+ "fingerprint": "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a"
+ }
+}
+`
+
+// CreateOutput is a sample response to a Create call.
+const CreateOutput = `
+{
+ "keypair": {
+ "fingerprint": "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+ "name": "createdkey",
+ "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7\nDUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ\n9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5QIDAQAB\nAoGAE5XO1mDhORy9COvsg+kYPUhB1GsCYxh+v88wG7HeFDKBY6KUc/Kxo6yoGn5T\nTjRjekyi2KoDZHz4VlIzyZPwFS4I1bf3oCunVoAKzgLdmnTtvRNMC5jFOGc2vUgP\n9bSyRj3S1R4ClVk2g0IDeagko/jc8zzLEYuIK+fbkds79YECQQDt3vcevgegnkga\ntF4NsDmmBPRkcSHCqrANP/7vFcBQN3czxeYYWX3DK07alu6GhH1Y4sHbdm616uU0\nll7xbDzxAkEAzAtN2IyftNygV2EGiaGgqLyo/tD9+Vui2qCQplqe4jvWh/5Sparl\nOjmKo+uAW+hLrLVMnHzRWxbWU8hirH5FNQJATO+ZxCK4etXXAnQmG41NCAqANWB2\nB+2HJbH2NcQ2QHvAHUm741JGn/KI/aBlo7KEjFRDWUVUB5ji64BbUwCsMQJBAIku\nLGcjnBf/oLk+XSPZC2eGd2Ph5G5qYmH0Q2vkTx+wtTn3DV+eNsDfgMtWAJVJ5t61\ngU1QSXyhLPVlKpnnxuUCQC+xvvWjWtsLaFtAsZywJiqLxQzHts8XLGZptYJ5tLWV\nrtmYtBcJCN48RrgQHry/xWYeA4K/AFQpXfNPgprQ96Q=\n-----END RSA PRIVATE KEY-----\n",
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+ "user_id": "fake"
+ }
+}
+`
+
+// ImportOutput is a sample response to a Create call that provides its own public key.
+const ImportOutput = `
+{
+ "keypair": {
+ "fingerprint": "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c",
+ "name": "importedkey",
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+ "user_id": "fake"
+ }
+}
+`
+
+// FirstKeyPair is the first result in ListOutput.
+var FirstKeyPair = KeyPair{
+ Name: "firstkey",
+ Fingerprint: "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n",
+}
+
+// SecondKeyPair is the second result in ListOutput.
+var SecondKeyPair = KeyPair{
+ Name: "secondkey",
+ Fingerprint: "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+}
+
+// ExpectedKeyPairSlice is the slice of results that should be parsed from ListOutput, in the expected
+// order.
+var ExpectedKeyPairSlice = []KeyPair{FirstKeyPair, SecondKeyPair}
+
+// CreatedKeyPair is the parsed result from CreatedOutput.
+var CreatedKeyPair = KeyPair{
+ Name: "createdkey",
+ Fingerprint: "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+ PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7\nDUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ\n9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5QIDAQAB\nAoGAE5XO1mDhORy9COvsg+kYPUhB1GsCYxh+v88wG7HeFDKBY6KUc/Kxo6yoGn5T\nTjRjekyi2KoDZHz4VlIzyZPwFS4I1bf3oCunVoAKzgLdmnTtvRNMC5jFOGc2vUgP\n9bSyRj3S1R4ClVk2g0IDeagko/jc8zzLEYuIK+fbkds79YECQQDt3vcevgegnkga\ntF4NsDmmBPRkcSHCqrANP/7vFcBQN3czxeYYWX3DK07alu6GhH1Y4sHbdm616uU0\nll7xbDzxAkEAzAtN2IyftNygV2EGiaGgqLyo/tD9+Vui2qCQplqe4jvWh/5Sparl\nOjmKo+uAW+hLrLVMnHzRWxbWU8hirH5FNQJATO+ZxCK4etXXAnQmG41NCAqANWB2\nB+2HJbH2NcQ2QHvAHUm741JGn/KI/aBlo7KEjFRDWUVUB5ji64BbUwCsMQJBAIku\nLGcjnBf/oLk+XSPZC2eGd2Ph5G5qYmH0Q2vkTx+wtTn3DV+eNsDfgMtWAJVJ5t61\ngU1QSXyhLPVlKpnnxuUCQC+xvvWjWtsLaFtAsZywJiqLxQzHts8XLGZptYJ5tLWV\nrtmYtBcJCN48RrgQHry/xWYeA4K/AFQpXfNPgprQ96Q=\n-----END RSA PRIVATE KEY-----\n",
+ UserID: "fake",
+}
+
+// ImportedKeyPair is the parsed result from ImportOutput.
+var ImportedKeyPair = KeyPair{
+ Name: "importedkey",
+ Fingerprint: "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+ UserID: "fake",
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request for "firstkey".
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-keypairs/firstkey", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request for a new
+// keypair called "createdkey".
+func HandleCreateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "keypair": { "name": "createdkey" } }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, CreateOutput)
+ })
+}
+
+// HandleImportSuccessfully configures the test server to respond to an Import request for an
+// existing keypair called "importedkey".
+func HandleImportSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+ {
+ "keypair": {
+ "name": "importedkey",
+ "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova"
+ }
+ }
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ImportOutput)
+ })
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// keypair called "deletedkey".
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-keypairs/deletedkey", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/keypairs/requests.go b/openstack/compute/v2/extensions/keypairs/requests.go
new file mode 100644
index 0000000..c56ee67
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/requests.go
@@ -0,0 +1,102 @@
+package keypairs
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsExt adds a KeyPair option to the base CreateOpts.
+type CreateOptsExt struct {
+ servers.CreateOptsBuilder
+ KeyName string `json:"key_name,omitempty"`
+}
+
+// ToServerCreateMap adds the key_name and, optionally, key_data options to
+// the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+ base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.KeyName == "" {
+ return base, nil
+ }
+
+ serverMap := base["server"].(map[string]interface{})
+ serverMap["key_name"] = opts.KeyName
+
+ return base, nil
+}
+
+// List returns a Pager that allows you to iterate over a collection of KeyPairs.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return KeyPairPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ ToKeyPairCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies keypair creation or import parameters.
+type CreateOpts struct {
+ // Name [required] is a friendly name to refer to this KeyPair in other services.
+ Name string
+
+ // PublicKey [optional] is a pregenerated OpenSSH-formatted public key. If provided, this key
+ // will be imported and no new key will be created.
+ PublicKey string
+}
+
+// ToKeyPairCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToKeyPairCreateMap() (map[string]interface{}, error) {
+ if opts.Name == "" {
+ return nil, errors.New("Missing field required for keypair creation: Name")
+ }
+
+ keypair := make(map[string]interface{})
+ keypair["name"] = opts.Name
+ if opts.PublicKey != "" {
+ keypair["public_key"] = opts.PublicKey
+ }
+
+ return map[string]interface{}{"keypair": keypair}, nil
+}
+
+// Create requests the creation of a new keypair on the server, or to import a pre-existing
+// keypair.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToKeyPairCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Get returns public data about a previously uploaded KeyPair.
+func Get(client *gophercloud.ServiceClient, name string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, name), &res.Body, nil)
+ return res
+}
+
+// Delete requests the deletion of a previous stored KeyPair from the server.
+func Delete(client *gophercloud.ServiceClient, name string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, name), nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/keypairs/requests_test.go b/openstack/compute/v2/extensions/keypairs/requests_test.go
new file mode 100644
index 0000000..67d1833
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/requests_test.go
@@ -0,0 +1,71 @@
+package keypairs
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractKeyPairs(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedKeyPairSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), CreateOpts{
+ Name: "createdkey",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedKeyPair, actual)
+}
+
+func TestImport(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleImportSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), CreateOpts{
+ Name: "importedkey",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &ImportedKeyPair, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "firstkey").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstKeyPair, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteSuccessfully(t)
+
+ err := Delete(client.ServiceClient(), "deletedkey").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/keypairs/results.go b/openstack/compute/v2/extensions/keypairs/results.go
new file mode 100644
index 0000000..f1a0d8e
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/results.go
@@ -0,0 +1,94 @@
+package keypairs
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// KeyPair is an SSH key known to the OpenStack cluster that is available to be injected into
+// servers.
+type KeyPair struct {
+ // Name is used to refer to this keypair from other services within this region.
+ Name string `mapstructure:"name"`
+
+ // Fingerprint is a short sequence of bytes that can be used to authenticate or validate a longer
+ // public key.
+ Fingerprint string `mapstructure:"fingerprint"`
+
+ // PublicKey is the public key from this pair, in OpenSSH format. "ssh-rsa AAAAB3Nz..."
+ PublicKey string `mapstructure:"public_key"`
+
+ // PrivateKey is the private key from this pair, in PEM format.
+ // "-----BEGIN RSA PRIVATE KEY-----\nMIICXA..." It is only present if this keypair was just
+ // returned from a Create call
+ PrivateKey string `mapstructure:"private_key"`
+
+ // UserID is the user who owns this keypair.
+ UserID string `mapstructure:"user_id"`
+}
+
+// KeyPairPage stores a single, only page of KeyPair results from a List call.
+type KeyPairPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a KeyPairPage is empty.
+func (page KeyPairPage) IsEmpty() (bool, error) {
+ ks, err := ExtractKeyPairs(page)
+ return len(ks) == 0, err
+}
+
+// ExtractKeyPairs interprets a page of results as a slice of KeyPairs.
+func ExtractKeyPairs(page pagination.Page) ([]KeyPair, error) {
+ type pair struct {
+ KeyPair KeyPair `mapstructure:"keypair"`
+ }
+
+ var resp struct {
+ KeyPairs []pair `mapstructure:"keypairs"`
+ }
+
+ err := mapstructure.Decode(page.(KeyPairPage).Body, &resp)
+ results := make([]KeyPair, len(resp.KeyPairs))
+ for i, pair := range resp.KeyPairs {
+ results[i] = pair.KeyPair
+ }
+ return results, err
+}
+
+type keyPairResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any KeyPair resource response as a KeyPair struct.
+func (r keyPairResult) Extract() (*KeyPair, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ KeyPair *KeyPair `json:"keypair" mapstructure:"keypair"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+ return res.KeyPair, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a KeyPair.
+type CreateResult struct {
+ keyPairResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a KeyPair.
+type GetResult struct {
+ keyPairResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/keypairs/urls.go b/openstack/compute/v2/extensions/keypairs/urls.go
new file mode 100644
index 0000000..702f532
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/urls.go
@@ -0,0 +1,25 @@
+package keypairs
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-keypairs"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, name string) string {
+ return c.ServiceURL(resourcePath, name)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, name string) string {
+ return getURL(c, name)
+}
diff --git a/openstack/compute/v2/extensions/keypairs/urls_test.go b/openstack/compute/v2/extensions/keypairs/urls_test.go
new file mode 100644
index 0000000..60efd2a
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/urls_test.go
@@ -0,0 +1,40 @@
+package keypairs
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-keypairs", listURL(c))
+}
+
+func TestCreateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-keypairs", createURL(c))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-keypairs/wat", getURL(c, "wat"))
+}
+
+func TestDeleteURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-keypairs/wat", deleteURL(c, "wat"))
+}
diff --git a/openstack/compute/v2/extensions/networks/doc.go b/openstack/compute/v2/extensions/networks/doc.go
new file mode 100644
index 0000000..fafe4a0
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/doc.go
@@ -0,0 +1,2 @@
+// Package network provides the ability to manage nova-networks
+package networks
diff --git a/openstack/compute/v2/extensions/networks/fixtures.go b/openstack/compute/v2/extensions/networks/fixtures.go
new file mode 100644
index 0000000..12b9485
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/fixtures.go
@@ -0,0 +1,209 @@
+// +build fixtures
+
+package networks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "networks": [
+ {
+ "bridge": "br100",
+ "bridge_interface": "eth0",
+ "broadcast": "10.0.0.7",
+ "cidr": "10.0.0.0/29",
+ "cidr_v6": null,
+ "created_at": "2011-08-15 06:19:19.387525",
+ "deleted": false,
+ "deleted_at": null,
+ "dhcp_start": "10.0.0.3",
+ "dns1": null,
+ "dns2": null,
+ "gateway": "10.0.0.1",
+ "gateway_v6": null,
+ "host": "nsokolov-desktop",
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf047",
+ "injected": false,
+ "label": "mynet_0",
+ "multi_host": false,
+ "netmask": "255.255.255.248",
+ "netmask_v6": null,
+ "priority": null,
+ "project_id": "1234",
+ "rxtx_base": null,
+ "updated_at": "2011-08-16 09:26:13.048257",
+ "vlan": 100,
+ "vpn_private_address": "10.0.0.2",
+ "vpn_public_address": "127.0.0.1",
+ "vpn_public_port": 1000
+ },
+ {
+ "bridge": "br101",
+ "bridge_interface": "eth0",
+ "broadcast": "10.0.0.15",
+ "cidr": "10.0.0.10/29",
+ "cidr_v6": null,
+ "created_at": "2011-08-15 06:19:19.885495",
+ "deleted": false,
+ "deleted_at": null,
+ "dhcp_start": "10.0.0.11",
+ "dns1": null,
+ "dns2": null,
+ "gateway": "10.0.0.9",
+ "gateway_v6": null,
+ "host": null,
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+ "injected": false,
+ "label": "mynet_1",
+ "multi_host": false,
+ "netmask": "255.255.255.248",
+ "netmask_v6": null,
+ "priority": null,
+ "project_id": null,
+ "rxtx_base": null,
+ "updated_at": null,
+ "vlan": 101,
+ "vpn_private_address": "10.0.0.10",
+ "vpn_public_address": null,
+ "vpn_public_port": 1001
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "network": {
+ "bridge": "br101",
+ "bridge_interface": "eth0",
+ "broadcast": "10.0.0.15",
+ "cidr": "10.0.0.10/29",
+ "cidr_v6": null,
+ "created_at": "2011-08-15 06:19:19.885495",
+ "deleted": false,
+ "deleted_at": null,
+ "dhcp_start": "10.0.0.11",
+ "dns1": null,
+ "dns2": null,
+ "gateway": "10.0.0.9",
+ "gateway_v6": null,
+ "host": null,
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+ "injected": false,
+ "label": "mynet_1",
+ "multi_host": false,
+ "netmask": "255.255.255.248",
+ "netmask_v6": null,
+ "priority": null,
+ "project_id": null,
+ "rxtx_base": null,
+ "updated_at": null,
+ "vlan": 101,
+ "vpn_private_address": "10.0.0.10",
+ "vpn_public_address": null,
+ "vpn_public_port": 1001
+ }
+}
+`
+
+// FirstNetwork is the first result in ListOutput.
+var nilTime time.Time
+var FirstNetwork = Network{
+ Bridge: "br100",
+ BridgeInterface: "eth0",
+ Broadcast: "10.0.0.7",
+ CIDR: "10.0.0.0/29",
+ CIDRv6: "",
+ CreatedAt: time.Date(2011, 8, 15, 6, 19, 19, 387525000, time.UTC),
+ Deleted: false,
+ DeletedAt: nilTime,
+ DHCPStart: "10.0.0.3",
+ DNS1: "",
+ DNS2: "",
+ Gateway: "10.0.0.1",
+ Gatewayv6: "",
+ Host: "nsokolov-desktop",
+ ID: "20c8acc0-f747-4d71-a389-46d078ebf047",
+ Injected: false,
+ Label: "mynet_0",
+ MultiHost: false,
+ Netmask: "255.255.255.248",
+ Netmaskv6: "",
+ Priority: 0,
+ ProjectID: "1234",
+ RXTXBase: 0,
+ UpdatedAt: time.Date(2011, 8, 16, 9, 26, 13, 48257000, time.UTC),
+ VLAN: 100,
+ VPNPrivateAddress: "10.0.0.2",
+ VPNPublicAddress: "127.0.0.1",
+ VPNPublicPort: 1000,
+}
+
+// SecondNetwork is the second result in ListOutput.
+var SecondNetwork = Network{
+ Bridge: "br101",
+ BridgeInterface: "eth0",
+ Broadcast: "10.0.0.15",
+ CIDR: "10.0.0.10/29",
+ CIDRv6: "",
+ CreatedAt: time.Date(2011, 8, 15, 6, 19, 19, 885495000, time.UTC),
+ Deleted: false,
+ DeletedAt: nilTime,
+ DHCPStart: "10.0.0.11",
+ DNS1: "",
+ DNS2: "",
+ Gateway: "10.0.0.9",
+ Gatewayv6: "",
+ Host: "",
+ ID: "20c8acc0-f747-4d71-a389-46d078ebf000",
+ Injected: false,
+ Label: "mynet_1",
+ MultiHost: false,
+ Netmask: "255.255.255.248",
+ Netmaskv6: "",
+ Priority: 0,
+ ProjectID: "",
+ RXTXBase: 0,
+ UpdatedAt: nilTime,
+ VLAN: 101,
+ VPNPrivateAddress: "10.0.0.10",
+ VPNPublicAddress: "",
+ VPNPublicPort: 1001,
+}
+
+// ExpectedNetworkSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedNetworkSlice = []Network{FirstNetwork, SecondNetwork}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing network.
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-networks/20c8acc0-f747-4d71-a389-46d078ebf000", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
diff --git a/openstack/compute/v2/extensions/networks/requests.go b/openstack/compute/v2/extensions/networks/requests.go
new file mode 100644
index 0000000..eb20387
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/requests.go
@@ -0,0 +1,22 @@
+package networks
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(client)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, url, createPage)
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/networks/requests_test.go b/openstack/compute/v2/extensions/networks/requests_test.go
new file mode 100644
index 0000000..722b3f0
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/requests_test.go
@@ -0,0 +1,37 @@
+package networks
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedNetworkSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "20c8acc0-f747-4d71-a389-46d078ebf000").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &SecondNetwork, actual)
+}
diff --git a/openstack/compute/v2/extensions/networks/results.go b/openstack/compute/v2/extensions/networks/results.go
new file mode 100644
index 0000000..55b361d
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/results.go
@@ -0,0 +1,222 @@
+package networks
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// A Network represents a nova-network that an instance communicates on
+type Network struct {
+ // The Bridge that VIFs on this network are connected to
+ Bridge string `mapstructure:"bridge"`
+
+ // BridgeInterface is what interface is connected to the Bridge
+ BridgeInterface string `mapstructure:"bridge_interface"`
+
+ // The Broadcast address of the network.
+ Broadcast string `mapstructure:"broadcast"`
+
+ // CIDR is the IPv4 subnet.
+ CIDR string `mapstructure:"cidr"`
+
+ // CIDRv6 is the IPv6 subnet.
+ CIDRv6 string `mapstructure:"cidr_v6"`
+
+ // CreatedAt is when the network was created..
+ CreatedAt time.Time `mapstructure:"-"`
+
+ // Deleted shows if the network has been deleted.
+ Deleted bool `mapstructure:"deleted"`
+
+ // DeletedAt is the time when the network was deleted.
+ DeletedAt time.Time `mapstructure:"-"`
+
+ // DHCPStart is the start of the DHCP address range.
+ DHCPStart string `mapstructure:"dhcp_start"`
+
+ // DNS1 is the first DNS server to use through DHCP.
+ DNS1 string `mapstructure:"dns_1"`
+
+ // DNS2 is the first DNS server to use through DHCP.
+ DNS2 string `mapstructure:"dns_2"`
+
+ // Gateway is the network gateway.
+ Gateway string `mapstructure:"gateway"`
+
+ // Gatewayv6 is the IPv6 network gateway.
+ Gatewayv6 string `mapstructure:"gateway_v6"`
+
+ // Host is the host that the network service is running on.
+ Host string `mapstructure:"host"`
+
+ // ID is the UUID of the network.
+ ID string `mapstructure:"id"`
+
+ // Injected determines if network information is injected into the host.
+ Injected bool `mapstructure:"injected"`
+
+ // Label is the common name that the network has..
+ Label string `mapstructure:"label"`
+
+ // MultiHost is if multi-host networking is enablec..
+ MultiHost bool `mapstructure:"multi_host"`
+
+ // Netmask is the network netmask.
+ Netmask string `mapstructure:"netmask"`
+
+ // Netmaskv6 is the IPv6 netmask.
+ Netmaskv6 string `mapstructure:"netmask_v6"`
+
+ // Priority is the network interface priority.
+ Priority int `mapstructure:"priority"`
+
+ // ProjectID is the project associated with this network.
+ ProjectID string `mapstructure:"project_id"`
+
+ // RXTXBase configures bandwidth entitlement.
+ RXTXBase int `mapstructure:"rxtx_base"`
+
+ // UpdatedAt is the time when the network was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+
+ // VLAN is the vlan this network runs on.
+ VLAN int `mapstructure:"vlan"`
+
+ // VPNPrivateAddress is the private address of the CloudPipe VPN.
+ VPNPrivateAddress string `mapstructure:"vpn_private_address"`
+
+ // VPNPublicAddress is the public address of the CloudPipe VPN.
+ VPNPublicAddress string `mapstructure:"vpn_public_address"`
+
+ // VPNPublicPort is the port of the CloudPipe VPN.
+ VPNPublicPort int `mapstructure:"vpn_public_port"`
+}
+
+// NetworkPage stores a single, only page of Networks
+// results from a List call.
+type NetworkPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a NetworkPage is empty.
+func (page NetworkPage) IsEmpty() (bool, error) {
+ va, err := ExtractNetworks(page)
+ return len(va) == 0, err
+}
+
+// ExtractNetworks interprets a page of results as a slice of Networks
+func ExtractNetworks(page pagination.Page) ([]Network, error) {
+ var res struct {
+ Networks []Network `mapstructure:"networks"`
+ }
+
+ err := mapstructure.Decode(page.(NetworkPage).Body, &res)
+
+ var rawNetworks []interface{}
+ body := page.(NetworkPage).Body
+ switch body.(type) {
+ case map[string]interface{}:
+ rawNetworks = body.(map[string]interface{})["networks"].([]interface{})
+ case map[string][]interface{}:
+ rawNetworks = body.(map[string][]interface{})["networks"]
+ default:
+ return res.Networks, fmt.Errorf("Unknown type")
+ }
+
+ for i := range rawNetworks {
+ thisNetwork := rawNetworks[i].(map[string]interface{})
+ if t, ok := thisNetwork["created_at"].(string); ok && t != "" {
+ createdAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Networks, err
+ }
+ res.Networks[i].CreatedAt = createdAt
+ }
+
+ if t, ok := thisNetwork["updated_at"].(string); ok && t != "" {
+ updatedAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Networks, err
+ }
+ res.Networks[i].UpdatedAt = updatedAt
+ }
+
+ if t, ok := thisNetwork["deleted_at"].(string); ok && t != "" {
+ deletedAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Networks, err
+ }
+ res.Networks[i].DeletedAt = deletedAt
+ }
+ }
+
+ return res.Networks, err
+}
+
+type NetworkResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Network resource
+// response as a Network struct.
+func (r NetworkResult) Extract() (*Network, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *Network `json:"network" mapstructure:"network"`
+ }
+
+ config := &mapstructure.DecoderConfig{
+ Result: &res,
+ WeaklyTypedInput: true,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := decoder.Decode(r.Body); err != nil {
+ return nil, err
+ }
+
+ b := r.Body.(map[string]interface{})["network"].(map[string]interface{})
+
+ if t, ok := b["created_at"].(string); ok && t != "" {
+ createdAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Network, err
+ }
+ res.Network.CreatedAt = createdAt
+ }
+
+ if t, ok := b["updated_at"].(string); ok && t != "" {
+ updatedAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Network, err
+ }
+ res.Network.UpdatedAt = updatedAt
+ }
+
+ if t, ok := b["deleted_at"].(string); ok && t != "" {
+ deletedAt, err := time.Parse("2006-01-02 15:04:05.000000", t)
+ if err != nil {
+ return res.Network, err
+ }
+ res.Network.DeletedAt = deletedAt
+ }
+
+ return res.Network, err
+
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a Network.
+type GetResult struct {
+ NetworkResult
+}
diff --git a/openstack/compute/v2/extensions/networks/urls.go b/openstack/compute/v2/extensions/networks/urls.go
new file mode 100644
index 0000000..6966462
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/urls.go
@@ -0,0 +1,17 @@
+package networks
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-networks"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/compute/v2/extensions/networks/urls_test.go b/openstack/compute/v2/extensions/networks/urls_test.go
new file mode 100644
index 0000000..be54c90
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/urls_test.go
@@ -0,0 +1,25 @@
+package networks
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-networks", listURL(c))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-networks/"+id, getURL(c, id))
+}
diff --git a/openstack/compute/v2/extensions/schedulerhints/doc.go b/openstack/compute/v2/extensions/schedulerhints/doc.go
new file mode 100644
index 0000000..0bd4566
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/doc.go
@@ -0,0 +1,3 @@
+// Package schedulerhints enables instances to provide the OpenStack scheduler
+// hints about where they should be placed in the cloud.
+package schedulerhints
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests.go b/openstack/compute/v2/extensions/schedulerhints/requests.go
new file mode 100644
index 0000000..567eef4
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests.go
@@ -0,0 +1,134 @@
+package schedulerhints
+
+import (
+ "fmt"
+ "net"
+ "regexp"
+ "strings"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// SchedulerHints represents a set of scheduling hints that are passed to the
+// OpenStack scheduler
+type SchedulerHints struct {
+ // Group specifies a Server Group to place the instance in.
+ Group string
+
+ // DifferentHost will place the instance on a compute node that does not
+ // host the given instances.
+ DifferentHost []string
+
+ // SameHost will place the instance on a compute node that hosts the given
+ // instances.
+ SameHost []string
+
+ // Query is a conditional statement that results in compute nodes able to
+ // host the instance.
+ Query []interface{}
+
+ // TargetCell specifies a cell name where the instance will be placed.
+ TargetCell string
+
+ // BuildNearHostIP specifies a subnet of compute nodes to host the instance.
+ BuildNearHostIP string
+}
+
+// SchedulerHintsBuilder builds the scheduler hints into a serializable format.
+type SchedulerHintsBuilder interface {
+ ToServerSchedulerHintsMap() (map[string]interface{}, error)
+}
+
+// ToServerSchedulerHintsMap builds the scheduler hints into a serializable format.
+func (opts SchedulerHints) ToServerSchedulerHintsMap() (map[string]interface{}, error) {
+ sh := make(map[string]interface{})
+
+ uuidRegex, _ := regexp.Compile("^[a-z0-9]{8}-[a-z0-9]{4}-[1-5][a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{12}$")
+
+ if opts.Group != "" {
+ if !uuidRegex.MatchString(opts.Group) {
+ return nil, fmt.Errorf("Group must be a UUID")
+ }
+ sh["group"] = opts.Group
+ }
+
+ if len(opts.DifferentHost) > 0 {
+ for _, diffHost := range opts.DifferentHost {
+ if !uuidRegex.MatchString(diffHost) {
+ return nil, fmt.Errorf("The hosts in DifferentHost must be in UUID format.")
+ }
+ }
+ sh["different_host"] = opts.DifferentHost
+ }
+
+ if len(opts.SameHost) > 0 {
+ for _, sameHost := range opts.SameHost {
+ if !uuidRegex.MatchString(sameHost) {
+ return nil, fmt.Errorf("The hosts in SameHost must be in UUID format.")
+ }
+ }
+ sh["same_host"] = opts.SameHost
+ }
+
+ /* Query can be something simple like:
+ [">=", "$free_ram_mb", 1024]
+
+ Or more complex like:
+ ['and',
+ ['>=', '$free_ram_mb', 1024],
+ ['>=', '$free_disk_mb', 200 * 1024]
+ ]
+
+ Because of the possible complexity, just make sure the length is a minimum of 3.
+ */
+ if len(opts.Query) > 0 {
+ if len(opts.Query) < 3 {
+ return nil, fmt.Errorf("Query must be a conditional statement in the format of [op,variable,value]")
+ }
+ sh["query"] = opts.Query
+ }
+
+ if opts.TargetCell != "" {
+ sh["target_cell"] = opts.TargetCell
+ }
+
+ if opts.BuildNearHostIP != "" {
+ if _, _, err := net.ParseCIDR(opts.BuildNearHostIP); err != nil {
+ return nil, fmt.Errorf("BuildNearHostIP must be a valid subnet in the form 192.168.1.1/24")
+ }
+ ipParts := strings.Split(opts.BuildNearHostIP, "/")
+ sh["build_near_host_ip"] = ipParts[0]
+ sh["cidr"] = "/" + ipParts[1]
+ }
+
+ return sh, nil
+}
+
+// CreateOptsExt adds a SchedulerHints option to the base CreateOpts.
+type CreateOptsExt struct {
+ servers.CreateOptsBuilder
+
+ // SchedulerHints provides a set of hints to the scheduler.
+ SchedulerHints SchedulerHintsBuilder
+}
+
+// ToServerCreateMap adds the SchedulerHints option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+ base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ schedulerHints, err := opts.SchedulerHints.ToServerSchedulerHintsMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(schedulerHints) == 0 {
+ return base, nil
+ }
+
+ base["os:scheduler_hints"] = schedulerHints
+
+ return base, nil
+}
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests_test.go b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
new file mode 100644
index 0000000..9b38b35
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
@@ -0,0 +1,130 @@
+package schedulerhints
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ schedulerHints := SchedulerHints{
+ Group: "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+ DifferentHost: []string{
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287",
+ },
+ SameHost: []string{
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287",
+ },
+ Query: []interface{}{">=", "$free_ram_mb", "1024"},
+ TargetCell: "foobar",
+ BuildNearHostIP: "192.168.1.1/24",
+ }
+
+ ext := CreateOptsExt{
+ CreateOptsBuilder: base,
+ SchedulerHints: schedulerHints,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "flavorName": "",
+ "imageName": ""
+ },
+ "os:scheduler_hints": {
+ "group": "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+ "different_host": [
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287"
+ ],
+ "same_host": [
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287"
+ ],
+ "query": [
+ ">=", "$free_ram_mb", "1024"
+ ],
+ "target_cell": "foobar",
+ "build_near_host_ip": "192.168.1.1",
+ "cidr": "/24"
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestCreateOptsWithComplexQuery(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ schedulerHints := SchedulerHints{
+ Group: "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+ DifferentHost: []string{
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287",
+ },
+ SameHost: []string{
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287",
+ },
+ Query: []interface{}{"and", []string{">=", "$free_ram_mb", "1024"}, []string{">=", "$free_disk_mb", "204800"}},
+ TargetCell: "foobar",
+ BuildNearHostIP: "192.168.1.1/24",
+ }
+
+ ext := CreateOptsExt{
+ CreateOptsBuilder: base,
+ SchedulerHints: schedulerHints,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "flavorName": "",
+ "imageName": ""
+ },
+ "os:scheduler_hints": {
+ "group": "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+ "different_host": [
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287"
+ ],
+ "same_host": [
+ "a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+ "8c19174f-4220-44f0-824a-cd1eeef10287"
+ ],
+ "query": [
+ "and",
+ [">=", "$free_ram_mb", "1024"],
+ [">=", "$free_disk_mb", "204800"]
+ ],
+ "target_cell": "foobar",
+ "build_near_host_ip": "192.168.1.1",
+ "cidr": "/24"
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
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..8c42e48
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/fixtures.go
@@ -0,0 +1,267 @@
+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("/servers/%s%s", 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)
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+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)
+ fmt.Fprintf(w, `{}`)
+ })
+}
diff --git a/openstack/compute/v2/extensions/secgroups/requests.go b/openstack/compute/v2/extensions/secgroups/requests.go
new file mode 100644
index 0000000..4cef480
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests.go
@@ -0,0 +1,257 @@
+package secgroups
+
+import (
+ "errors"
+
+ "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 = client.Post(rootURL(client), reqBody, &result.Body, &gophercloud.RequestOpts{
+ 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 = client.Put(resourceURL(client, id), reqBody, &result.Body, &gophercloud.RequestOpts{
+ 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 = client.Get(resourceURL(client, id), &result.Body, nil)
+ 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 = client.Delete(resourceURL(client, id), nil)
+ 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["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 = client.Post(rootRuleURL(client), reqBody, &result.Body, &gophercloud.RequestOpts{
+ 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 = client.Delete(resourceRuleURL(client, id), nil)
+ 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 = client.Post(serverActionURL(client, serverID), actionMap("add", groupName), &result.Body, nil)
+ 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 = client.Post(serverActionURL(client, serverID), actionMap("remove", groupName), &result.Body, nil)
+ 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..dc53fbf
--- /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("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")
+}
diff --git a/openstack/compute/v2/extensions/servergroups/doc.go b/openstack/compute/v2/extensions/servergroups/doc.go
new file mode 100644
index 0000000..1e5ed56
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/doc.go
@@ -0,0 +1,2 @@
+// Package servergroups provides the ability to manage server groups
+package servergroups
diff --git a/openstack/compute/v2/extensions/servergroups/fixtures.go b/openstack/compute/v2/extensions/servergroups/fixtures.go
new file mode 100644
index 0000000..133fd85
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/fixtures.go
@@ -0,0 +1,161 @@
+// +build fixtures
+
+package servergroups
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "server_groups": [
+ {
+ "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+ "name": "test",
+ "policies": [
+ "anti-affinity"
+ ],
+ "members": [],
+ "metadata": {}
+ },
+ {
+ "id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "name": "test2",
+ "policies": [
+ "affinity"
+ ],
+ "members": [],
+ "metadata": {}
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "server_group": {
+ "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+ "name": "test",
+ "policies": [
+ "anti-affinity"
+ ],
+ "members": [],
+ "metadata": {}
+ }
+}
+`
+
+// CreateOutput is a sample response to a Post call
+const CreateOutput = `
+{
+ "server_group": {
+ "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+ "name": "test",
+ "policies": [
+ "anti-affinity"
+ ],
+ "members": [],
+ "metadata": {}
+ }
+}
+`
+
+// FirstServerGroup is the first result in ListOutput.
+var FirstServerGroup = ServerGroup{
+ ID: "616fb98f-46ca-475e-917e-2563e5a8cd19",
+ Name: "test",
+ Policies: []string{
+ "anti-affinity",
+ },
+ Members: []string{},
+ Metadata: map[string]interface{}{},
+}
+
+// SecondServerGroup is the second result in ListOutput.
+var SecondServerGroup = ServerGroup{
+ ID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ Name: "test2",
+ Policies: []string{
+ "affinity",
+ },
+ Members: []string{},
+ Metadata: map[string]interface{}{},
+}
+
+// ExpectedServerGroupSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedServerGroupSlice = []ServerGroup{FirstServerGroup, SecondServerGroup}
+
+// CreatedServerGroup is the parsed result from CreateOutput.
+var CreatedServerGroup = ServerGroup{
+ ID: "616fb98f-46ca-475e-917e-2563e5a8cd19",
+ Name: "test",
+ Policies: []string{
+ "anti-affinity",
+ },
+ Members: []string{},
+ Metadata: map[string]interface{}{},
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-server-groups", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing server group
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-server-groups/4d8c3732-a248-40ed-bebc-539a6ffd25c0", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new server group
+func HandleCreateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-server-groups", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "server_group": {
+ "name": "test",
+ "policies": [
+ "anti-affinity"
+ ]
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, CreateOutput)
+ })
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing server group
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-server-groups/616fb98f-46ca-475e-917e-2563e5a8cd19", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/servergroups/requests.go b/openstack/compute/v2/extensions/servergroups/requests.go
new file mode 100644
index 0000000..1597b43
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/requests.go
@@ -0,0 +1,77 @@
+package servergroups
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of ServerGroups.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return ServerGroupsPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notably, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ ToServerGroupCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies a Server Group allocation request
+type CreateOpts struct {
+ // Name is the name of the server group
+ Name string
+
+ // Policies are the server group policies
+ Policies []string
+}
+
+// ToServerGroupCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToServerGroupCreateMap() (map[string]interface{}, error) {
+ if opts.Name == "" {
+ return nil, errors.New("Missing field required for server group creation: Name")
+ }
+
+ if len(opts.Policies) < 1 {
+ return nil, errors.New("Missing field required for server group creation: Policies")
+ }
+
+ serverGroup := make(map[string]interface{})
+ serverGroup["name"] = opts.Name
+ serverGroup["policies"] = opts.Policies
+
+ return map[string]interface{}{"server_group": serverGroup}, nil
+}
+
+// Create requests the creation of a new Server Group
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToServerGroupCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Get returns data about a previously created ServerGroup.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
+
+// Delete requests the deletion of a previously allocated ServerGroup.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/servergroups/requests_test.go b/openstack/compute/v2/extensions/servergroups/requests_test.go
new file mode 100644
index 0000000..07fec51
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/requests_test.go
@@ -0,0 +1,59 @@
+package servergroups
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractServerGroups(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedServerGroupSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), CreateOpts{
+ Name: "test",
+ Policies: []string{"anti-affinity"},
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedServerGroup, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstServerGroup, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteSuccessfully(t)
+
+ err := Delete(client.ServiceClient(), "616fb98f-46ca-475e-917e-2563e5a8cd19").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/servergroups/results.go b/openstack/compute/v2/extensions/servergroups/results.go
new file mode 100644
index 0000000..d74ee5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/results.go
@@ -0,0 +1,87 @@
+package servergroups
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// A ServerGroup creates a policy for instance placement in the cloud
+type ServerGroup struct {
+ // ID is the unique ID of the Server Group.
+ ID string `mapstructure:"id"`
+
+ // Name is the common name of the server group.
+ Name string `mapstructure:"name"`
+
+ // Polices are the group policies.
+ Policies []string `mapstructure:"policies"`
+
+ // Members are the members of the server group.
+ Members []string `mapstructure:"members"`
+
+ // Metadata includes a list of all user-specified key-value pairs attached to the Server Group.
+ Metadata map[string]interface{}
+}
+
+// ServerGroupsPage stores a single, only page of ServerGroups
+// results from a List call.
+type ServerGroupsPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a ServerGroupsPage is empty.
+func (page ServerGroupsPage) IsEmpty() (bool, error) {
+ va, err := ExtractServerGroups(page)
+ return len(va) == 0, err
+}
+
+// ExtractServerGroups interprets a page of results as a slice of
+// ServerGroups.
+func ExtractServerGroups(page pagination.Page) ([]ServerGroup, error) {
+ casted := page.(ServerGroupsPage).Body
+ var response struct {
+ ServerGroups []ServerGroup `mapstructure:"server_groups"`
+ }
+
+ err := mapstructure.WeakDecode(casted, &response)
+
+ return response.ServerGroups, err
+}
+
+type ServerGroupResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Server Group resource
+// response as a ServerGroup struct.
+func (r ServerGroupResult) Extract() (*ServerGroup, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ ServerGroup *ServerGroup `json:"server_group" mapstructure:"server_group"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &res)
+ return res.ServerGroup, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a ServerGroup.
+type CreateResult struct {
+ ServerGroupResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a ServerGroup.
+type GetResult struct {
+ ServerGroupResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/servergroups/urls.go b/openstack/compute/v2/extensions/servergroups/urls.go
new file mode 100644
index 0000000..074a16c
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/urls.go
@@ -0,0 +1,25 @@
+package servergroups
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-server-groups"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
diff --git a/openstack/compute/v2/extensions/servergroups/urls_test.go b/openstack/compute/v2/extensions/servergroups/urls_test.go
new file mode 100644
index 0000000..bff4dfc
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/urls_test.go
@@ -0,0 +1,42 @@
+package servergroups
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-server-groups", listURL(c))
+}
+
+func TestCreateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-server-groups", createURL(c))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-server-groups/"+id, getURL(c, id))
+}
+
+func TestDeleteURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-server-groups/"+id, deleteURL(c, id))
+}
diff --git a/openstack/compute/v2/extensions/startstop/doc.go b/openstack/compute/v2/extensions/startstop/doc.go
new file mode 100644
index 0000000..d2729f8
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/doc.go
@@ -0,0 +1,5 @@
+/*
+Package startstop provides functionality to start and stop servers that have
+been provisioned by the OpenStack Compute service.
+*/
+package startstop
diff --git a/openstack/compute/v2/extensions/startstop/fixtures.go b/openstack/compute/v2/extensions/startstop/fixtures.go
new file mode 100644
index 0000000..670828a
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/fixtures.go
@@ -0,0 +1,27 @@
+package startstop
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func mockStartServerResponse(t *testing.T, id string) {
+ th.Mux.HandleFunc("/servers/"+id+"/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{"os-start": null}`)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockStopServerResponse(t *testing.T, id string) {
+ th.Mux.HandleFunc("/servers/"+id+"/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{"os-stop": null}`)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/startstop/requests.go b/openstack/compute/v2/extensions/startstop/requests.go
new file mode 100644
index 0000000..0e090e6
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/requests.go
@@ -0,0 +1,23 @@
+package startstop
+
+import "github.com/rackspace/gophercloud"
+
+func actionURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id, "action")
+}
+
+// Start is the operation responsible for starting a Compute server.
+func Start(client *gophercloud.ServiceClient, id string) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ reqBody := map[string]interface{}{"os-start": nil}
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, nil)
+ return res
+}
+
+// Stop is the operation responsible for stopping a Compute server.
+func Stop(client *gophercloud.ServiceClient, id string) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ reqBody := map[string]interface{}{"os-stop": nil}
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/startstop/requests_test.go b/openstack/compute/v2/extensions/startstop/requests_test.go
new file mode 100644
index 0000000..97a121b
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/requests_test.go
@@ -0,0 +1,30 @@
+package startstop
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const serverID = "{serverId}"
+
+func TestStart(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockStartServerResponse(t, serverID)
+
+ err := Start(client.ServiceClient(), serverID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestStop(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockStopServerResponse(t, serverID)
+
+ err := Stop(client.ServiceClient(), serverID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/doc.go b/openstack/compute/v2/extensions/tenantnetworks/doc.go
new file mode 100644
index 0000000..65c46ff
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/doc.go
@@ -0,0 +1,2 @@
+// Package tenantnetworks provides the ability for tenants to see information about the networks they have access to
+package tenantnetworks
diff --git a/openstack/compute/v2/extensions/tenantnetworks/fixtures.go b/openstack/compute/v2/extensions/tenantnetworks/fixtures.go
new file mode 100644
index 0000000..0cfa72a
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/fixtures.go
@@ -0,0 +1,84 @@
+// +build fixtures
+
+package tenantnetworks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "networks": [
+ {
+ "cidr": "10.0.0.0/29",
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf047",
+ "label": "mynet_0"
+ },
+ {
+ "cidr": "10.0.0.10/29",
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+ "label": "mynet_1"
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "network": {
+ "cidr": "10.0.0.10/29",
+ "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+ "label": "mynet_1"
+ }
+}
+`
+
+// FirstNetwork is the first result in ListOutput.
+var nilTime time.Time
+var FirstNetwork = Network{
+ CIDR: "10.0.0.0/29",
+ ID: "20c8acc0-f747-4d71-a389-46d078ebf047",
+ Name: "mynet_0",
+}
+
+// SecondNetwork is the second result in ListOutput.
+var SecondNetwork = Network{
+ CIDR: "10.0.0.10/29",
+ ID: "20c8acc0-f747-4d71-a389-46d078ebf000",
+ Name: "mynet_1",
+}
+
+// ExpectedNetworkSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedNetworkSlice = []Network{FirstNetwork, SecondNetwork}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-tenant-networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing network.
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-tenant-networks/20c8acc0-f747-4d71-a389-46d078ebf000", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/requests.go b/openstack/compute/v2/extensions/tenantnetworks/requests.go
new file mode 100644
index 0000000..3ec13d3
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/requests.go
@@ -0,0 +1,22 @@
+package tenantnetworks
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(client)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, url, createPage)
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/requests_test.go b/openstack/compute/v2/extensions/tenantnetworks/requests_test.go
new file mode 100644
index 0000000..fc4ee4f
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/requests_test.go
@@ -0,0 +1,37 @@
+package tenantnetworks
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNetworks(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedNetworkSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "20c8acc0-f747-4d71-a389-46d078ebf000").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &SecondNetwork, actual)
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/results.go b/openstack/compute/v2/extensions/tenantnetworks/results.go
new file mode 100644
index 0000000..8050092
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/results.go
@@ -0,0 +1,68 @@
+package tenantnetworks
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// A Network represents a nova-network that an instance communicates on
+type Network struct {
+ // CIDR is the IPv4 subnet.
+ CIDR string `mapstructure:"cidr"`
+
+ // ID is the UUID of the network.
+ ID string `mapstructure:"id"`
+
+ // Name is the common name that the network has.
+ Name string `mapstructure:"label"`
+}
+
+// NetworkPage stores a single, only page of Networks
+// results from a List call.
+type NetworkPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a NetworkPage is empty.
+func (page NetworkPage) IsEmpty() (bool, error) {
+ va, err := ExtractNetworks(page)
+ return len(va) == 0, err
+}
+
+// ExtractNetworks interprets a page of results as a slice of Networks
+func ExtractNetworks(page pagination.Page) ([]Network, error) {
+ networks := page.(NetworkPage).Body
+ var res struct {
+ Networks []Network `mapstructure:"networks"`
+ }
+
+ err := mapstructure.WeakDecode(networks, &res)
+
+ return res.Networks, err
+}
+
+type NetworkResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Network resource
+// response as a Network struct.
+func (r NetworkResult) Extract() (*Network, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *Network `json:"network" mapstructure:"network"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+ return res.Network, err
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a Network.
+type GetResult struct {
+ NetworkResult
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/urls.go b/openstack/compute/v2/extensions/tenantnetworks/urls.go
new file mode 100644
index 0000000..2401a5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/urls.go
@@ -0,0 +1,17 @@
+package tenantnetworks
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-tenant-networks"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/urls_test.go b/openstack/compute/v2/extensions/tenantnetworks/urls_test.go
new file mode 100644
index 0000000..39c464e
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/urls_test.go
@@ -0,0 +1,25 @@
+package tenantnetworks
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+
+ th.CheckEquals(t, c.Endpoint+"os-tenant-networks", listURL(c))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ id := "1"
+
+ th.CheckEquals(t, c.Endpoint+"os-tenant-networks/"+id, getURL(c, id))
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/doc.go b/openstack/compute/v2/extensions/volumeattach/doc.go
new file mode 100644
index 0000000..22f68d8
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/doc.go
@@ -0,0 +1,3 @@
+// Package volumeattach provides the ability to attach and detach volumes
+// to instances
+package volumeattach
diff --git a/openstack/compute/v2/extensions/volumeattach/requests.go b/openstack/compute/v2/extensions/volumeattach/requests.go
new file mode 100644
index 0000000..b4ebede
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/requests.go
@@ -0,0 +1,75 @@
+package volumeattach
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of VolumeAttachments.
+func List(client *gophercloud.ServiceClient, serverId string) pagination.Pager {
+ return pagination.NewPager(client, listURL(client, serverId), func(r pagination.PageResult) pagination.Page {
+ return VolumeAttachmentsPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ ToVolumeAttachmentCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies volume attachment creation or import parameters.
+type CreateOpts struct {
+ // Device is the device that the volume will attach to the instance as. Omit for "auto"
+ Device string
+
+ // VolumeID is the ID of the volume to attach to the instance
+ VolumeID string
+}
+
+// ToVolumeAttachmentCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToVolumeAttachmentCreateMap() (map[string]interface{}, error) {
+ if opts.VolumeID == "" {
+ return nil, errors.New("Missing field required for volume attachment creation: VolumeID")
+ }
+
+ volumeAttachment := make(map[string]interface{})
+ volumeAttachment["volumeId"] = opts.VolumeID
+ if opts.Device != "" {
+ volumeAttachment["device"] = opts.Device
+ }
+
+ return map[string]interface{}{"volumeAttachment": volumeAttachment}, nil
+}
+
+// Create requests the creation of a new volume attachment on the server
+func Create(client *gophercloud.ServiceClient, serverId string, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToVolumeAttachmentCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(createURL(client, serverId), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Get returns public data about a previously created VolumeAttachment.
+func Get(client *gophercloud.ServiceClient, serverId, aId string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, serverId, aId), &res.Body, nil)
+ return res
+}
+
+// Delete requests the deletion of a previous stored VolumeAttachment from the server.
+func Delete(client *gophercloud.ServiceClient, serverId, aId string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, serverId, aId), nil)
+ return res
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/requests_test.go b/openstack/compute/v2/extensions/volumeattach/requests_test.go
new file mode 100644
index 0000000..b0a765b
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/requests_test.go
@@ -0,0 +1,94 @@
+package volumeattach
+
+import (
+ "testing"
+
+ fixtures "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach/testing"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// FirstVolumeAttachment is the first result in ListOutput.
+var FirstVolumeAttachment = VolumeAttachment{
+ Device: "/dev/vdd",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+}
+
+// SecondVolumeAttachment is the first result in ListOutput.
+var SecondVolumeAttachment = VolumeAttachment{
+ Device: "/dev/vdc",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+// ExpectedVolumeAttachmentSlide is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedVolumeAttachmentSlice = []VolumeAttachment{FirstVolumeAttachment, SecondVolumeAttachment}
+
+//CreatedVolumeAttachment is the parsed result from CreatedOutput.
+var CreatedVolumeAttachment = VolumeAttachment{
+ Device: "/dev/vdc",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleListSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ count := 0
+ err := List(client.ServiceClient(), serverId).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVolumeAttachments(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedVolumeAttachmentSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleCreateSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := Create(client.ServiceClient(), serverId, CreateOpts{
+ Device: "/dev/vdc",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedVolumeAttachment, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleGetSuccessfully(t)
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := Get(client.ServiceClient(), serverId, aId).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &SecondVolumeAttachment, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleDeleteSuccessfully(t)
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ err := Delete(client.ServiceClient(), serverId, aId).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/results.go b/openstack/compute/v2/extensions/volumeattach/results.go
new file mode 100644
index 0000000..26be39e
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/results.go
@@ -0,0 +1,84 @@
+package volumeattach
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// VolumeAttach controls the attachment of a volume to an instance.
+type VolumeAttachment struct {
+ // ID is a unique id of the attachment
+ ID string `mapstructure:"id"`
+
+ // Device is what device the volume is attached as
+ Device string `mapstructure:"device"`
+
+ // VolumeID is the ID of the attached volume
+ VolumeID string `mapstructure:"volumeId"`
+
+ // ServerID is the ID of the instance that has the volume attached
+ ServerID string `mapstructure:"serverId"`
+}
+
+// VolumeAttachmentsPage stores a single, only page of VolumeAttachments
+// results from a List call.
+type VolumeAttachmentsPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a VolumeAttachmentsPage is empty.
+func (page VolumeAttachmentsPage) IsEmpty() (bool, error) {
+ va, err := ExtractVolumeAttachments(page)
+ return len(va) == 0, err
+}
+
+// ExtractVolumeAttachments interprets a page of results as a slice of
+// VolumeAttachments.
+func ExtractVolumeAttachments(page pagination.Page) ([]VolumeAttachment, error) {
+ casted := page.(VolumeAttachmentsPage).Body
+ var response struct {
+ VolumeAttachments []VolumeAttachment `mapstructure:"volumeAttachments"`
+ }
+
+ err := mapstructure.WeakDecode(casted, &response)
+
+ return response.VolumeAttachments, err
+}
+
+type VolumeAttachmentResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any VolumeAttachment resource
+// response as a VolumeAttachment struct.
+func (r VolumeAttachmentResult) Extract() (*VolumeAttachment, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ VolumeAttachment *VolumeAttachment `json:"volumeAttachment" mapstructure:"volumeAttachment"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+ return res.VolumeAttachment, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a VolumeAttachment.
+type CreateResult struct {
+ VolumeAttachmentResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a VolumeAttachment.
+type GetResult struct {
+ VolumeAttachmentResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/testing/doc.go b/openstack/compute/v2/extensions/volumeattach/testing/doc.go
new file mode 100644
index 0000000..183391a
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/doc.go
@@ -0,0 +1,7 @@
+/*
+This is package created is to hold fixtures (which imports testing),
+so that importing volumeattach package does not inadvertently import testing into production code
+More information here:
+https://github.com/rackspace/gophercloud/issues/473
+*/
+package testing
diff --git a/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go b/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go
new file mode 100644
index 0000000..c469bfb
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go
@@ -0,0 +1,110 @@
+// +build fixtures
+
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+ "volumeAttachments": [
+ {
+ "device": "/dev/vdd",
+ "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+ "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
+ },
+ {
+ "device": "/dev/vdc",
+ "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "volumeAttachment": {
+ "device": "/dev/vdc",
+ "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ }
+}
+`
+
+// CreateOutput is a sample response to a Create call.
+const CreateOutput = `
+{
+ "volumeAttachment": {
+ "device": "/dev/vdc",
+ "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ }
+}
+`
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing attachment
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments/a26887c6-c47b-4654-abb5-dfadf7d3f804", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new attachment
+func HandleCreateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+{
+ "volumeAttachment": {
+ "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ "device": "/dev/vdc"
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, CreateOutput)
+ })
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing attachment
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments/a26887c6-c47b-4654-abb5-dfadf7d3f804", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/urls.go b/openstack/compute/v2/extensions/volumeattach/urls.go
new file mode 100644
index 0000000..9d9d178
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/urls.go
@@ -0,0 +1,25 @@
+package volumeattach
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "os-volume_attachments"
+
+func resourceURL(c *gophercloud.ServiceClient, serverId string) string {
+ return c.ServiceURL("servers", serverId, resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient, serverId string) string {
+ return resourceURL(c, serverId)
+}
+
+func createURL(c *gophercloud.ServiceClient, serverId string) string {
+ return resourceURL(c, serverId)
+}
+
+func getURL(c *gophercloud.ServiceClient, serverId, aId string) string {
+ return c.ServiceURL("servers", serverId, resourcePath, aId)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, serverId, aId string) string {
+ return getURL(c, serverId, aId)
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/urls_test.go b/openstack/compute/v2/extensions/volumeattach/urls_test.go
new file mode 100644
index 0000000..8ee0e42
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/urls_test.go
@@ -0,0 +1,46 @@
+package volumeattach
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/os-volume_attachments", listURL(c, serverId))
+}
+
+func TestCreateURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/os-volume_attachments", createURL(c, serverId))
+}
+
+func TestGetURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/os-volume_attachments/"+aId, getURL(c, serverId, aId))
+}
+
+func TestDeleteURL(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ c := client.ServiceClient()
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+
+ th.CheckEquals(t, c.Endpoint+"servers/"+serverId+"/os-volume_attachments/"+aId, deleteURL(c, serverId, aId))
+}
diff --git a/openstack/compute/v2/flavors/doc.go b/openstack/compute/v2/flavors/doc.go
new file mode 100644
index 0000000..5822e1b
--- /dev/null
+++ b/openstack/compute/v2/flavors/doc.go
@@ -0,0 +1,7 @@
+// Package flavors provides information and interaction with the flavor API
+// resource in the OpenStack Compute service.
+//
+// A flavor is an available hardware configuration for a server. Each flavor
+// has a unique combination of disk space, memory capacity and priority for CPU
+// time.
+package flavors
diff --git a/openstack/compute/v2/flavors/requests.go b/openstack/compute/v2/flavors/requests.go
new file mode 100644
index 0000000..59123aa
--- /dev/null
+++ b/openstack/compute/v2/flavors/requests.go
@@ -0,0 +1,103 @@
+package flavors
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToFlavorListQuery() (string, error)
+}
+
+// ListOpts helps control the results returned by the List() function.
+// For example, a flavor with a minDisk field of 10 will not be returned if you specify MinDisk set to 20.
+// Typically, software will use the last ID of the previous call to List to set the Marker for the current call.
+type ListOpts struct {
+
+ // ChangesSince, if provided, instructs List to return only those things which have changed since the timestamp provided.
+ ChangesSince string `q:"changes-since"`
+
+ // MinDisk and MinRAM, if provided, elides flavors which do not meet your criteria.
+ MinDisk int `q:"minDisk"`
+ MinRAM int `q:"minRam"`
+
+ // Marker and Limit control paging.
+ // Marker instructs List where to start listing from.
+ Marker string `q:"marker"`
+
+ // Limit instructs List to refrain from sending excessively large lists of flavors.
+ Limit int `q:"limit"`
+}
+
+// ToFlavorListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToFlavorListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListDetail instructs OpenStack to provide a list of flavors.
+// You may provide criteria by which List curtails its results for easier processing.
+// See ListOpts for more details.
+func ListDetail(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client)
+ if opts != nil {
+ query, err := opts.ToFlavorListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
+
+// Get instructs OpenStack to provide details on a single flavor, identified by its ID.
+// Use ExtractFlavor to convert its result into a Flavor.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
+
+// IDFromName is a convienience function that returns a flavor's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ flavorCount := 0
+ flavorID := ""
+ if name == "" {
+ return "", fmt.Errorf("A flavor name must be provided.")
+ }
+ pager := ListDetail(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ flavorList, err := ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, f := range flavorList {
+ if f.Name == name {
+ flavorCount++
+ flavorID = f.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch flavorCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find flavor: %s", name)
+ case 1:
+ return flavorID, nil
+ default:
+ return "", fmt.Errorf("Found %d flavors matching %s", flavorCount, name)
+ }
+}
diff --git a/openstack/compute/v2/flavors/requests_test.go b/openstack/compute/v2/flavors/requests_test.go
new file mode 100644
index 0000000..fbd7c33
--- /dev/null
+++ b/openstack/compute/v2/flavors/requests_test.go
@@ -0,0 +1,129 @@
+package flavors
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const tokenID = "blerb"
+
+func TestListFlavors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/flavors/detail", 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")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `
+ {
+ "flavors": [
+ {
+ "id": "1",
+ "name": "m1.tiny",
+ "disk": 1,
+ "ram": 512,
+ "vcpus": 1
+ },
+ {
+ "id": "2",
+ "name": "m2.small",
+ "disk": 10,
+ "ram": 1024,
+ "vcpus": 2
+ }
+ ],
+ "flavors_links": [
+ {
+ "href": "%s/flavors/detail?marker=2",
+ "rel": "next"
+ }
+ ]
+ }
+ `, th.Server.URL)
+ case "2":
+ fmt.Fprintf(w, `{ "flavors": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ pages := 0
+ err := ListDetail(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Flavor{
+ Flavor{ID: "1", Name: "m1.tiny", Disk: 1, RAM: 512, VCPUs: 1},
+ Flavor{ID: "2", Name: "m2.small", Disk: 10, RAM: 1024, VCPUs: 2},
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but was %#v", expected, actual)
+ }
+
+ return true, nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pages != 1 {
+ t.Errorf("Expected one page, got %d", pages)
+ }
+}
+
+func TestGetFlavor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/flavors/12345", 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")
+ fmt.Fprintf(w, `
+ {
+ "flavor": {
+ "id": "1",
+ "name": "m1.tiny",
+ "disk": 1,
+ "ram": 512,
+ "vcpus": 1,
+ "rxtx_factor": 1
+ }
+ }
+ `)
+ })
+
+ actual, err := Get(fake.ServiceClient(), "12345").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get flavor: %v", err)
+ }
+
+ expected := &Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ Disk: 1,
+ RAM: 512,
+ VCPUs: 1,
+ RxTxFactor: 1,
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but was %#v", expected, actual)
+ }
+}
diff --git a/openstack/compute/v2/flavors/results.go b/openstack/compute/v2/flavors/results.go
new file mode 100644
index 0000000..8dddd70
--- /dev/null
+++ b/openstack/compute/v2/flavors/results.go
@@ -0,0 +1,122 @@
+package flavors
+
+import (
+ "errors"
+ "reflect"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ErrCannotInterpret is returned by an Extract call if the response body doesn't have the expected structure.
+var ErrCannotInterpet = errors.New("Unable to interpret a response body.")
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract provides access to the individual Flavor returned by the Get function.
+func (gr GetResult) Extract() (*Flavor, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+
+ var result struct {
+ Flavor Flavor `mapstructure:"flavor"`
+ }
+
+ cfg := &mapstructure.DecoderConfig{
+ DecodeHook: defaulter,
+ Result: &result,
+ }
+ decoder, err := mapstructure.NewDecoder(cfg)
+ if err != nil {
+ return nil, err
+ }
+ err = decoder.Decode(gr.Body)
+ return &result.Flavor, err
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+ // The Id field contains the flavor's unique identifier.
+ // For example, this identifier will be useful when specifying which hardware configuration to use for a new server instance.
+ ID string `mapstructure:"id"`
+
+ // The Disk and RA< fields provide a measure of storage space offered by the flavor, in GB and MB, respectively.
+ Disk int `mapstructure:"disk"`
+ RAM int `mapstructure:"ram"`
+
+ // The Name field provides a human-readable moniker for the flavor.
+ Name string `mapstructure:"name"`
+
+ RxTxFactor float64 `mapstructure:"rxtx_factor"`
+
+ // Swap indicates how much space is reserved for swap.
+ // If not provided, this field will be set to 0.
+ Swap int `mapstructure:"swap"`
+
+ // VCPUs indicates how many (virtual) CPUs are available for this flavor.
+ VCPUs int `mapstructure:"vcpus"`
+}
+
+// FlavorPage contains a single page of the response from a List call.
+type FlavorPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty determines if a page contains any results.
+func (p FlavorPage) IsEmpty() (bool, error) {
+ flavors, err := ExtractFlavors(p)
+ if err != nil {
+ return true, err
+ }
+ return len(flavors) == 0, nil
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (p FlavorPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"flavors_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+func defaulter(from, to reflect.Kind, v interface{}) (interface{}, error) {
+ if (from == reflect.String) && (to == reflect.Int) {
+ return 0, nil
+ }
+ return v, nil
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(page pagination.Page) ([]Flavor, error) {
+ casted := page.(FlavorPage).Body
+ var container struct {
+ Flavors []Flavor `mapstructure:"flavors"`
+ }
+
+ cfg := &mapstructure.DecoderConfig{
+ DecodeHook: defaulter,
+ Result: &container,
+ }
+ decoder, err := mapstructure.NewDecoder(cfg)
+ if err != nil {
+ return container.Flavors, err
+ }
+ err = decoder.Decode(casted)
+ if err != nil {
+ return container.Flavors, err
+ }
+
+ return container.Flavors, nil
+}
diff --git a/openstack/compute/v2/flavors/urls.go b/openstack/compute/v2/flavors/urls.go
new file mode 100644
index 0000000..683c107
--- /dev/null
+++ b/openstack/compute/v2/flavors/urls.go
@@ -0,0 +1,13 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+)
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("flavors", id)
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("flavors", "detail")
+}
diff --git a/openstack/compute/v2/flavors/urls_test.go b/openstack/compute/v2/flavors/urls_test.go
new file mode 100644
index 0000000..069da24
--- /dev/null
+++ b/openstack/compute/v2/flavors/urls_test.go
@@ -0,0 +1,26 @@
+package flavors
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "flavors/foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "flavors/detail"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/images/doc.go b/openstack/compute/v2/images/doc.go
new file mode 100644
index 0000000..0edaa3f
--- /dev/null
+++ b/openstack/compute/v2/images/doc.go
@@ -0,0 +1,7 @@
+// Package images provides information and interaction with the image API
+// resource in the OpenStack Compute service.
+//
+// An image is a collection of files used to create or rebuild a server.
+// Operators provide a number of pre-built OS images by default. You may also
+// create custom images from cloud servers you have launched.
+package images
diff --git a/openstack/compute/v2/images/requests.go b/openstack/compute/v2/images/requests.go
new file mode 100644
index 0000000..1e021ad
--- /dev/null
+++ b/openstack/compute/v2/images/requests.go
@@ -0,0 +1,109 @@
+package images
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToImageListQuery() (string, error)
+}
+
+// ListOpts contain options for limiting the number of Images returned from a call to ListDetail.
+type ListOpts struct {
+ // When the image last changed status (in date-time format).
+ ChangesSince string `q:"changes-since"`
+ // The number of Images to return.
+ Limit int `q:"limit"`
+ // UUID of the Image at which to set a marker.
+ Marker string `q:"marker"`
+ // The name of the Image.
+ Name string `q:"name"`
+ // The name of the Server (in URL format).
+ Server string `q:"server"`
+ // The current status of the Image.
+ Status string `q:"status"`
+ // The value of the type of image (e.g. BASE, SERVER, ALL)
+ Type string `q:"type"`
+}
+
+// ToImageListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToImageListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListDetail enumerates the available images.
+func ListDetail(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listDetailURL(client)
+ if opts != nil {
+ query, err := opts.ToImageListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
+
+// Get acquires additional detail about a specific image by ID.
+// Use ExtractImage() to interpret the result as an openstack Image.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+ _, result.Err = client.Get(getURL(client, id), &result.Body, nil)
+ return result
+}
+
+// Delete deletes the specified image ID.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var result DeleteResult
+ _, result.Err = client.Delete(deleteURL(client, id), nil)
+ return result
+}
+
+// IDFromName is a convienience function that returns an image's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ imageCount := 0
+ imageID := ""
+ if name == "" {
+ return "", fmt.Errorf("An image name must be provided.")
+ }
+ pager := ListDetail(client, &ListOpts{
+ Name: name,
+ })
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ imageList, err := ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range imageList {
+ if i.Name == name {
+ imageCount++
+ imageID = i.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch imageCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find image: %s", name)
+ case 1:
+ return imageID, nil
+ default:
+ return "", fmt.Errorf("Found %d images matching %s", imageCount, name)
+ }
+}
diff --git a/openstack/compute/v2/images/requests_test.go b/openstack/compute/v2/images/requests_test.go
new file mode 100644
index 0000000..93a97bd
--- /dev/null
+++ b/openstack/compute/v2/images/requests_test.go
@@ -0,0 +1,191 @@
+package images
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListImages(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/images/detail", 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")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `
+ {
+ "images": [
+ {
+ "status": "ACTIVE",
+ "updated": "2014-09-23T12:54:56Z",
+ "id": "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+ "OS-EXT-IMG-SIZE:size": 476704768,
+ "name": "F17-x86_64-cfntools",
+ "created": "2014-09-23T12:54:52Z",
+ "minDisk": 0,
+ "progress": 100,
+ "minRam": 0,
+ "metadata": {}
+ },
+ {
+ "status": "ACTIVE",
+ "updated": "2014-09-23T12:51:43Z",
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "OS-EXT-IMG-SIZE:size": 13167616,
+ "name": "cirros-0.3.2-x86_64-disk",
+ "created": "2014-09-23T12:51:42Z",
+ "minDisk": 0,
+ "progress": 100,
+ "minRam": 0,
+ "metadata": {}
+ }
+ ]
+ }
+ `)
+ case "2":
+ fmt.Fprintf(w, `{ "images": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ pages := 0
+ options := &ListOpts{Limit: 2}
+ err := ListDetail(fake.ServiceClient(), options).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Image{
+ Image{
+ ID: "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+ Name: "F17-x86_64-cfntools",
+ Created: "2014-09-23T12:54:52Z",
+ Updated: "2014-09-23T12:54:56Z",
+ MinDisk: 0,
+ MinRAM: 0,
+ Progress: 100,
+ Status: "ACTIVE",
+ },
+ Image{
+ ID: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ Name: "cirros-0.3.2-x86_64-disk",
+ Created: "2014-09-23T12:51:42Z",
+ Updated: "2014-09-23T12:51:43Z",
+ MinDisk: 0,
+ MinRAM: 0,
+ Progress: 100,
+ Status: "ACTIVE",
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Unexpected page contents: expected %#v, got %#v", expected, actual)
+ }
+
+ return false, nil
+ })
+
+ if err != nil {
+ t.Fatalf("EachPage error: %v", err)
+ }
+ if pages != 1 {
+ t.Errorf("Expected one page, got %d", pages)
+ }
+}
+
+func TestGetImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/images/12345678", 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")
+ fmt.Fprintf(w, `
+ {
+ "image": {
+ "status": "ACTIVE",
+ "updated": "2014-09-23T12:54:56Z",
+ "id": "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+ "OS-EXT-IMG-SIZE:size": 476704768,
+ "name": "F17-x86_64-cfntools",
+ "created": "2014-09-23T12:54:52Z",
+ "minDisk": 0,
+ "progress": 100,
+ "minRam": 0,
+ "metadata": {}
+ }
+ }
+ `)
+ })
+
+ actual, err := Get(fake.ServiceClient(), "12345678").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Get: %v", err)
+ }
+
+ expected := &Image{
+ Status: "ACTIVE",
+ Updated: "2014-09-23T12:54:56Z",
+ ID: "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+ Name: "F17-x86_64-cfntools",
+ Created: "2014-09-23T12:54:52Z",
+ MinDisk: 0,
+ Progress: 100,
+ MinRAM: 0,
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but got %#v", expected, actual)
+ }
+}
+
+func TestNextPageURL(t *testing.T) {
+ var page ImagePage
+ var body map[string]interface{}
+ bodyString := []byte(`{"images":{"links":[{"href":"http://192.154.23.87/12345/images/image3","rel":"bookmark"}]}, "images_links":[{"href":"http://192.154.23.87/12345/images/image4","rel":"next"}]}`)
+ err := json.Unmarshal(bodyString, &body)
+ if err != nil {
+ t.Fatalf("Error unmarshaling data into page body: %v", err)
+ }
+ page.Body = body
+
+ expected := "http://192.154.23.87/12345/images/image4"
+ actual, err := page.NextPageURL()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, expected, actual)
+}
+
+// Test Image delete
+func TestDeleteImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/images/12345678", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "12345678")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/compute/v2/images/results.go b/openstack/compute/v2/images/results.go
new file mode 100644
index 0000000..40e814d
--- /dev/null
+++ b/openstack/compute/v2/images/results.go
@@ -0,0 +1,95 @@
+package images
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// GetResult temporarily stores a Get response.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// DeleteResult represents the result of an image.Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Extract interprets a GetResult as an Image.
+func (gr GetResult) Extract() (*Image, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+
+ var decoded struct {
+ Image Image `mapstructure:"image"`
+ }
+
+ err := mapstructure.Decode(gr.Body, &decoded)
+ return &decoded.Image, err
+}
+
+// Image is used for JSON (un)marshalling.
+// It provides a description of an OS image.
+type Image struct {
+ // ID contains the image's unique identifier.
+ ID string
+
+ Created string
+
+ // MinDisk and MinRAM specify the minimum resources a server must provide to be able to install the image.
+ MinDisk int
+ MinRAM int
+
+ // Name provides a human-readable moniker for the OS image.
+ Name string
+
+ // The Progress and Status fields indicate image-creation status.
+ // Any usable image will have 100% progress.
+ Progress int
+ Status string
+
+ Updated string
+}
+
+// ImagePage contains a single page of results from a List operation.
+// Use ExtractImages to convert it into a slice of usable structs.
+type ImagePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Image results.
+func (page ImagePage) IsEmpty() (bool, error) {
+ images, err := ExtractImages(page)
+ if err != nil {
+ return true, err
+ }
+ return len(images) == 0, nil
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page ImagePage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"images_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractImages converts a page of List results into a slice of usable Image structs.
+func ExtractImages(page pagination.Page) ([]Image, error) {
+ casted := page.(ImagePage).Body
+ var results struct {
+ Images []Image `mapstructure:"images"`
+ }
+
+ err := mapstructure.Decode(casted, &results)
+ return results.Images, err
+}
diff --git a/openstack/compute/v2/images/urls.go b/openstack/compute/v2/images/urls.go
new file mode 100644
index 0000000..b1bf103
--- /dev/null
+++ b/openstack/compute/v2/images/urls.go
@@ -0,0 +1,15 @@
+package images
+
+import "github.com/rackspace/gophercloud"
+
+func listDetailURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("images", "detail")
+}
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("images", id)
+}
+
+func deleteURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("images", id)
+}
diff --git a/openstack/compute/v2/images/urls_test.go b/openstack/compute/v2/images/urls_test.go
new file mode 100644
index 0000000..b1ab3d6
--- /dev/null
+++ b/openstack/compute/v2/images/urls_test.go
@@ -0,0 +1,26 @@
+package images
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "images/foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestListDetailURL(t *testing.T) {
+ actual := listDetailURL(endpointClient())
+ expected := endpoint + "images/detail"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/servers/doc.go b/openstack/compute/v2/servers/doc.go
new file mode 100644
index 0000000..fe45671
--- /dev/null
+++ b/openstack/compute/v2/servers/doc.go
@@ -0,0 +1,6 @@
+// Package servers provides information and interaction with the server API
+// resource in the OpenStack Compute service.
+//
+// A server is a virtual machine instance in the compute system. In order for
+// one to be provisioned, a valid flavor and image are required.
+package servers
diff --git a/openstack/compute/v2/servers/fixtures.go b/openstack/compute/v2/servers/fixtures.go
new file mode 100644
index 0000000..4339a16
--- /dev/null
+++ b/openstack/compute/v2/servers/fixtures.go
@@ -0,0 +1,664 @@
+// +build fixtures
+
+package servers
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ServerListBody contains the canned body of a servers.List response.
+const ServerListBody = `
+{
+ "servers": [
+ {
+ "status": "ACTIVE",
+ "updated": "2014-09-25T13:10:10Z",
+ "hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+ "OS-EXT-SRV-ATTR:host": "devstack",
+ "addresses": {
+ "private": [
+ {
+ "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7c:1b:2b",
+ "version": 4,
+ "addr": "10.0.0.32",
+ "OS-EXT-IPS:type": "fixed"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ "rel": "self"
+ },
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ "rel": "bookmark"
+ }
+ ],
+ "key_name": null,
+ "image": {
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "OS-EXT-SRV-ATTR:instance_name": "instance-0000001e",
+ "OS-SRV-USG:launched_at": "2014-09-25T13:10:10.000000",
+ "OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "id": "ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ "security_groups": [
+ {
+ "name": "default"
+ }
+ ],
+ "OS-SRV-USG:terminated_at": null,
+ "OS-EXT-AZ:availability_zone": "nova",
+ "user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+ "name": "herp",
+ "created": "2014-09-25T13:10:02Z",
+ "tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+ "OS-DCF:diskConfig": "MANUAL",
+ "os-extended-volumes:volumes_attached": [],
+ "accessIPv4": "",
+ "accessIPv6": "",
+ "progress": 0,
+ "OS-EXT-STS:power_state": 1,
+ "config_drive": "",
+ "metadata": {}
+ },
+ {
+ "status": "ACTIVE",
+ "updated": "2014-09-25T13:04:49Z",
+ "hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+ "OS-EXT-SRV-ATTR:host": "devstack",
+ "addresses": {
+ "private": [
+ {
+ "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+ "version": 4,
+ "addr": "10.0.0.31",
+ "OS-EXT-IPS:type": "fixed"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "self"
+ },
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "bookmark"
+ }
+ ],
+ "key_name": null,
+ "image": {
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "OS-EXT-SRV-ATTR:instance_name": "instance-0000001d",
+ "OS-SRV-USG:launched_at": "2014-09-25T13:04:49.000000",
+ "OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "id": "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "security_groups": [
+ {
+ "name": "default"
+ }
+ ],
+ "OS-SRV-USG:terminated_at": null,
+ "OS-EXT-AZ:availability_zone": "nova",
+ "user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+ "name": "derp",
+ "created": "2014-09-25T13:04:41Z",
+ "tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+ "OS-DCF:diskConfig": "MANUAL",
+ "os-extended-volumes:volumes_attached": [],
+ "accessIPv4": "",
+ "accessIPv6": "",
+ "progress": 0,
+ "OS-EXT-STS:power_state": 1,
+ "config_drive": "",
+ "metadata": {}
+ }
+ ]
+}
+`
+
+// SingleServerBody is the canned body of a Get request on an existing server.
+const SingleServerBody = `
+{
+ "server": {
+ "status": "ACTIVE",
+ "updated": "2014-09-25T13:04:49Z",
+ "hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+ "OS-EXT-SRV-ATTR:host": "devstack",
+ "addresses": {
+ "private": [
+ {
+ "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+ "version": 4,
+ "addr": "10.0.0.31",
+ "OS-EXT-IPS:type": "fixed"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "self"
+ },
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "bookmark"
+ }
+ ],
+ "key_name": null,
+ "image": {
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "OS-EXT-SRV-ATTR:instance_name": "instance-0000001d",
+ "OS-SRV-USG:launched_at": "2014-09-25T13:04:49.000000",
+ "OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "id": "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "security_groups": [
+ {
+ "name": "default"
+ }
+ ],
+ "OS-SRV-USG:terminated_at": null,
+ "OS-EXT-AZ:availability_zone": "nova",
+ "user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+ "name": "derp",
+ "created": "2014-09-25T13:04:41Z",
+ "tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+ "OS-DCF:diskConfig": "MANUAL",
+ "os-extended-volumes:volumes_attached": [],
+ "accessIPv4": "",
+ "accessIPv6": "",
+ "progress": 0,
+ "OS-EXT-STS:power_state": 1,
+ "config_drive": "",
+ "metadata": {}
+ }
+}
+`
+
+var (
+ // ServerHerp is a Server struct that should correspond to the first result in ServerListBody.
+ ServerHerp = Server{
+ Status: "ACTIVE",
+ Updated: "2014-09-25T13:10:10Z",
+ HostID: "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7c:1b:2b",
+ "version": float64(4),
+ "addr": "10.0.0.32",
+ "OS-EXT-IPS:type": "fixed",
+ },
+ },
+ },
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ "rel": "bookmark",
+ },
+ },
+ Image: map[string]interface{}{
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "1",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+ "rel": "bookmark",
+ },
+ },
+ },
+ ID: "ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+ UserID: "9349aff8be7545ac9d2f1d00999a23cd",
+ Name: "herp",
+ Created: "2014-09-25T13:10:02Z",
+ TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+ Metadata: map[string]interface{}{},
+ SecurityGroups: []map[string]interface{}{
+ map[string]interface{}{
+ "name": "default",
+ },
+ },
+ }
+
+ // ServerDerp is a Server struct that should correspond to the second server in ServerListBody.
+ ServerDerp = Server{
+ Status: "ACTIVE",
+ Updated: "2014-09-25T13:04:49Z",
+ HostID: "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+ "version": float64(4),
+ "addr": "10.0.0.31",
+ "OS-EXT-IPS:type": "fixed",
+ },
+ },
+ },
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ "rel": "bookmark",
+ },
+ },
+ Image: map[string]interface{}{
+ "id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "1",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+ "rel": "bookmark",
+ },
+ },
+ },
+ ID: "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+ UserID: "9349aff8be7545ac9d2f1d00999a23cd",
+ Name: "derp",
+ Created: "2014-09-25T13:04:41Z",
+ TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+ Metadata: map[string]interface{}{},
+ SecurityGroups: []map[string]interface{}{
+ map[string]interface{}{
+ "name": "default",
+ },
+ },
+ }
+)
+
+// HandleServerCreationSuccessfully sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "server": {
+ "name": "derp",
+ "imageRef": "f90f6034-2570-4974-8351-6b49732ef2eb",
+ "flavorRef": "1"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleServerListSuccessfully sets up the test server to respond to a server List request.
+func HandleServerListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/detail", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, ServerListBody)
+ case "9e5476bd-a4ec-4653-93d6-72c93aa682ba":
+ fmt.Fprintf(w, `{ "servers": [] }`)
+ default:
+ t.Fatalf("/servers/detail invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandleServerDeletionSuccessfully sets up the test server to respond to a server deletion request.
+func HandleServerDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/asdfasdfasdf", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleServerGetSuccessfully sets up the test server to respond to a server Get request.
+func HandleServerGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ fmt.Fprintf(w, SingleServerBody)
+ })
+}
+
+// HandleServerUpdateSuccessfully sets up the test server to respond to a server Update request.
+func HandleServerUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestJSONRequest(t, r, `{ "server": { "name": "new-name" } }`)
+
+ fmt.Fprintf(w, SingleServerBody)
+ })
+}
+
+// HandleAdminPasswordChangeSuccessfully sets up the test server to respond to a server password
+// change request.
+func HandleAdminPasswordChangeSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "changePassword": { "adminPass": "new-password" } }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleRebootSuccessfully sets up the test server to respond to a reboot request with success.
+func HandleRebootSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "reboot": { "type": "SOFT" } }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleRebuildSuccessfully sets up the test server to respond to a rebuild request with success.
+func HandleRebuildSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `
+ {
+ "rebuild": {
+ "name": "new-name",
+ "adminPass": "swordfish",
+ "imageRef": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ "accessIPv4": "1.2.3.4"
+ }
+ }
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleServerRescueSuccessfully sets up the test server to respond to a server Rescue request.
+func HandleServerRescueSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "rescue": { "adminPass": "1234567890" } }`)
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{ "adminPass": "1234567890" }`))
+ })
+}
+
+// HandleMetadatumGetSuccessfully sets up the test server to respond to a metadatum Get request.
+func HandleMetadatumGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ w.Write([]byte(`{ "meta": {"foo":"bar"}}`))
+ })
+}
+
+// HandleMetadatumCreateSuccessfully sets up the test server to respond to a metadatum Create request.
+func HandleMetadatumCreateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "meta": {
+ "foo": "bar"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ w.Write([]byte(`{ "meta": {"foo":"bar"}}`))
+ })
+}
+
+// HandleMetadatumDeleteSuccessfully sets up the test server to respond to a metadatum Delete request.
+func HandleMetadatumDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleMetadataGetSuccessfully sets up the test server to respond to a metadata Get request.
+func HandleMetadataGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{ "metadata": {"foo":"bar", "this":"that"}}`))
+ })
+}
+
+// HandleMetadataResetSuccessfully sets up the test server to respond to a metadata Create request.
+func HandleMetadataResetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "metadata": {
+ "foo": "bar",
+ "this": "that"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ w.Write([]byte(`{ "metadata": {"foo":"bar", "this":"that"}}`))
+ })
+}
+
+// HandleMetadataUpdateSuccessfully sets up the test server to respond to a metadata Update request.
+func HandleMetadataUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "metadata": {
+ "foo": "baz",
+ "this": "those"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ w.Write([]byte(`{ "metadata": {"foo":"baz", "this":"those"}}`))
+ })
+}
+
+// ListAddressesExpected represents an expected repsonse from a ListAddresses request.
+var ListAddressesExpected = map[string][]Address{
+ "public": []Address{
+ Address{
+ Version: 4,
+ Address: "80.56.136.39",
+ },
+ Address{
+ Version: 6,
+ Address: "2001:4800:790e:510:be76:4eff:fe04:82a8",
+ },
+ },
+ "private": []Address{
+ Address{
+ Version: 4,
+ Address: "10.880.3.154",
+ },
+ },
+}
+
+// HandleAddressListSuccessfully sets up the test server to respond to a ListAddresses request.
+func HandleAddressListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/asdfasdfasdf/ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "addresses": {
+ "public": [
+ {
+ "version": 4,
+ "addr": "50.56.176.35"
+ },
+ {
+ "version": 6,
+ "addr": "2001:4800:780e:510:be76:4eff:fe04:84a8"
+ }
+ ],
+ "private": [
+ {
+ "version": 4,
+ "addr": "10.180.3.155"
+ }
+ ]
+ }
+ }`)
+ })
+}
+
+// ListNetworkAddressesExpected represents an expected repsonse from a ListAddressesByNetwork request.
+var ListNetworkAddressesExpected = []Address{
+ Address{
+ Version: 4,
+ Address: "50.56.176.35",
+ },
+ Address{
+ Version: 6,
+ Address: "2001:4800:780e:510:be76:4eff:fe04:84a8",
+ },
+}
+
+// HandleNetworkAddressListSuccessfully sets up the test server to respond to a ListAddressesByNetwork request.
+func HandleNetworkAddressListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/asdfasdfasdf/ips/public", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "public": [
+ {
+ "version": 4,
+ "addr": "50.56.176.35"
+ },
+ {
+ "version": 6,
+ "addr": "2001:4800:780e:510:be76:4eff:fe04:84a8"
+ }
+ ]
+ }`)
+ })
+}
+
+// HandleCreateServerImageSuccessfully sets up the test server to respond to a TestCreateServerImage request.
+func HandleCreateServerImageSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/serverimage/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ w.Header().Add("Location", "https://0.0.0.0/images/xxxx-xxxxx-xxxxx-xxxx")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go
new file mode 100644
index 0000000..f9839d9
--- /dev/null
+++ b/openstack/compute/v2/servers/requests.go
@@ -0,0 +1,852 @@
+package servers
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToServerListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the server attributes you want to see returned. Marker and Limit are used
+// for pagination.
+type ListOpts struct {
+ // A time/date stamp for when the server last changed status.
+ ChangesSince string `q:"changes-since"`
+
+ // Name of the image in URL format.
+ Image string `q:"image"`
+
+ // Name of the flavor in URL format.
+ Flavor string `q:"flavor"`
+
+ // Name of the server as a string; can be queried with regular expressions.
+ // Realize that ?name=bob returns both bob and bobb. If you need to match bob
+ // only, you can use a regular expression matching the syntax of the
+ // underlying database server implemented for Compute.
+ Name string `q:"name"`
+
+ // Value of the status of the server so that you can filter on "ACTIVE" for example.
+ Status string `q:"status"`
+
+ // Name of the host as a string.
+ Host string `q:"host"`
+
+ // UUID of the server at which you want to set a marker.
+ Marker string `q:"marker"`
+
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+
+ // Bool to show all tenants
+ AllTenants bool `q:"all_tenants"`
+}
+
+// ToServerListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToServerListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List makes a request against the API to list servers accessible to you.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listDetailURL(client)
+
+ if opts != nil {
+ query, err := opts.ToServerListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return ServerPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+// The CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ ToServerCreateMap() (map[string]interface{}, error)
+}
+
+// Network is used within CreateOpts to control a new server's network attachments.
+type Network struct {
+ // UUID of a nova-network to attach to the newly provisioned server.
+ // Required unless Port is provided.
+ UUID string
+
+ // Port of a neutron network to attach to the newly provisioned server.
+ // Required unless UUID is provided.
+ Port string
+
+ // FixedIP [optional] specifies a fixed IPv4 address to be used on this network.
+ FixedIP string
+}
+
+// Personality is an array of files that are injected into the server at launch.
+type Personality []*File
+
+// File is used within CreateOpts and RebuildOpts to inject a file into the server at launch.
+// File implements the json.Marshaler interface, so when a Create or Rebuild operation is requested,
+// json.Marshal will call File's MarshalJSON method.
+type File struct {
+ // Path of the file
+ Path string
+ // Contents of the file. Maximum content size is 255 bytes.
+ Contents []byte
+}
+
+// MarshalJSON marshals the escaped file, base64 encoding the contents.
+func (f *File) MarshalJSON() ([]byte, error) {
+ file := struct {
+ Path string `json:"path"`
+ Contents string `json:"contents"`
+ }{
+ Path: f.Path,
+ Contents: base64.StdEncoding.EncodeToString(f.Contents),
+ }
+ return json.Marshal(file)
+}
+
+// CreateOpts specifies server creation parameters.
+type CreateOpts struct {
+ // Name [required] is the name to assign to the newly launched server.
+ Name string
+
+ // ImageRef [optional; required if ImageName is not provided] is the ID or full
+ // URL to the image that contains the server's OS and initial state.
+ // Also optional if using the boot-from-volume extension.
+ ImageRef string
+
+ // ImageName [optional; required if ImageRef is not provided] is the name of the
+ // image that contains the server's OS and initial state.
+ // Also optional if using the boot-from-volume extension.
+ ImageName string
+
+ // FlavorRef [optional; required if FlavorName is not provided] is the ID or
+ // full URL to the flavor that describes the server's specs.
+ FlavorRef string
+
+ // FlavorName [optional; required if FlavorRef is not provided] is the name of
+ // the flavor that describes the server's specs.
+ FlavorName string
+
+ // SecurityGroups [optional] lists the names of the security groups to which this server should belong.
+ SecurityGroups []string
+
+ // UserData [optional] contains configuration information or scripts to use upon launch.
+ // Create will base64-encode it for you.
+ UserData []byte
+
+ // AvailabilityZone [optional] in which to launch the server.
+ AvailabilityZone string
+
+ // Networks [optional] dictates how this server will be attached to available networks.
+ // By default, the server will be attached to all isolated networks for the tenant.
+ Networks []Network
+
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string
+
+ // Personality [optional] includes files to inject into the server at launch.
+ // Create will base64-encode file contents for you.
+ Personality Personality
+
+ // ConfigDrive [optional] enables metadata injection through a configuration drive.
+ ConfigDrive bool
+
+ // AdminPass [optional] sets the root user password. If not set, a randomly-generated
+ // password will be created and returned in the response.
+ AdminPass string
+
+ // AccessIPv4 [optional] specifies an IPv4 address for the instance.
+ AccessIPv4 string
+
+ // AccessIPv6 [optional] specifies an IPv6 address for the instance.
+ AccessIPv6 string
+}
+
+// ToServerCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) {
+ server := make(map[string]interface{})
+
+ server["name"] = opts.Name
+ server["imageRef"] = opts.ImageRef
+ server["imageName"] = opts.ImageName
+ server["flavorRef"] = opts.FlavorRef
+ server["flavorName"] = opts.FlavorName
+
+ if opts.UserData != nil {
+ encoded := base64.StdEncoding.EncodeToString(opts.UserData)
+ server["user_data"] = &encoded
+ }
+ if opts.ConfigDrive {
+ server["config_drive"] = "true"
+ }
+ if opts.AvailabilityZone != "" {
+ server["availability_zone"] = opts.AvailabilityZone
+ }
+ if opts.Metadata != nil {
+ server["metadata"] = opts.Metadata
+ }
+ if opts.AdminPass != "" {
+ server["adminPass"] = opts.AdminPass
+ }
+ if opts.AccessIPv4 != "" {
+ server["accessIPv4"] = opts.AccessIPv4
+ }
+ if opts.AccessIPv6 != "" {
+ server["accessIPv6"] = opts.AccessIPv6
+ }
+
+ if len(opts.SecurityGroups) > 0 {
+ securityGroups := make([]map[string]interface{}, len(opts.SecurityGroups))
+ for i, groupName := range opts.SecurityGroups {
+ securityGroups[i] = map[string]interface{}{"name": groupName}
+ }
+ server["security_groups"] = securityGroups
+ }
+
+ if len(opts.Networks) > 0 {
+ networks := make([]map[string]interface{}, len(opts.Networks))
+ for i, net := range opts.Networks {
+ networks[i] = make(map[string]interface{})
+ if net.UUID != "" {
+ networks[i]["uuid"] = net.UUID
+ }
+ if net.Port != "" {
+ networks[i]["port"] = net.Port
+ }
+ if net.FixedIP != "" {
+ networks[i]["fixed_ip"] = net.FixedIP
+ }
+ }
+ server["networks"] = networks
+ }
+
+ if len(opts.Personality) > 0 {
+ server["personality"] = opts.Personality
+ }
+
+ return map[string]interface{}{"server": server}, nil
+}
+
+// Create requests a server to be provisioned to the user in the current tenant.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToServerCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // If ImageRef isn't provided, use ImageName to ascertain the image ID.
+ if reqBody["server"].(map[string]interface{})["imageRef"].(string) == "" {
+ imageName := reqBody["server"].(map[string]interface{})["imageName"].(string)
+ if imageName == "" {
+ res.Err = errors.New("One and only one of ImageRef and ImageName must be provided.")
+ return res
+ }
+ imageID, err := images.IDFromName(client, imageName)
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ reqBody["server"].(map[string]interface{})["imageRef"] = imageID
+ }
+ delete(reqBody["server"].(map[string]interface{}), "imageName")
+
+ // If FlavorRef isn't provided, use FlavorName to ascertain the flavor ID.
+ if reqBody["server"].(map[string]interface{})["flavorRef"].(string) == "" {
+ flavorName := reqBody["server"].(map[string]interface{})["flavorName"].(string)
+ if flavorName == "" {
+ res.Err = errors.New("One and only one of FlavorRef and FlavorName must be provided.")
+ return res
+ }
+ flavorID, err := flavors.IDFromName(client, flavorName)
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ reqBody["server"].(map[string]interface{})["flavorRef"] = flavorID
+ }
+ delete(reqBody["server"].(map[string]interface{}), "flavorName")
+
+ _, res.Err = client.Post(listURL(client), reqBody, &res.Body, nil)
+ return res
+}
+
+// Delete requests that a server previously provisioned be removed from your account.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(deleteURL(client, id), nil)
+ return res
+}
+
+// Get requests details on a single server, by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+ _, result.Err = client.Get(getURL(client, id), &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ })
+ return result
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToServerUpdateMap() map[string]interface{}
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts struct {
+ // Name [optional] changes the displayed name of the server.
+ // The server host name will *not* change.
+ // Server names are not constrained to be unique, even within the same tenant.
+ Name string
+
+ // AccessIPv4 [optional] provides a new IPv4 address for the instance.
+ AccessIPv4 string
+
+ // AccessIPv6 [optional] provides a new IPv6 address for the instance.
+ AccessIPv6 string
+}
+
+// ToServerUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToServerUpdateMap() map[string]interface{} {
+ server := make(map[string]string)
+ if opts.Name != "" {
+ server["name"] = opts.Name
+ }
+ if opts.AccessIPv4 != "" {
+ server["accessIPv4"] = opts.AccessIPv4
+ }
+ if opts.AccessIPv6 != "" {
+ server["accessIPv6"] = opts.AccessIPv6
+ }
+ return map[string]interface{}{"server": server}
+}
+
+// Update requests that various attributes of the indicated server be changed.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+ reqBody := opts.ToServerUpdateMap()
+ _, result.Err = client.Put(updateURL(client, id), reqBody, &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return result
+}
+
+// ChangeAdminPassword alters the administrator or root password for a specified server.
+func ChangeAdminPassword(client *gophercloud.ServiceClient, id, newPassword string) ActionResult {
+ var req struct {
+ ChangePassword struct {
+ AdminPass string `json:"adminPass"`
+ } `json:"changePassword"`
+ }
+
+ req.ChangePassword.AdminPass = newPassword
+
+ var res ActionResult
+ _, res.Err = client.Post(actionURL(client, id), req, nil, nil)
+ return res
+}
+
+// ErrArgument errors occur when an argument supplied to a package function
+// fails to fall within acceptable values. For example, the Reboot() function
+// expects the "how" parameter to be one of HardReboot or SoftReboot. These
+// constants are (currently) strings, leading someone to wonder if they can pass
+// other string values instead, perhaps in an effort to break the API of their
+// provider. Reboot() returns this error in this situation.
+//
+// Function identifies which function was called/which function is generating
+// the error.
+// Argument identifies which formal argument was responsible for producing the
+// error.
+// Value provides the value as it was passed into the function.
+type ErrArgument struct {
+ Function, Argument string
+ Value interface{}
+}
+
+// Error yields a useful diagnostic for debugging purposes.
+func (e *ErrArgument) Error() string {
+ return fmt.Sprintf("Bad argument in call to %s, formal parameter %s, value %#v", e.Function, e.Argument, e.Value)
+}
+
+func (e *ErrArgument) String() string {
+ return e.Error()
+}
+
+// RebootMethod describes the mechanisms by which a server reboot can be requested.
+type RebootMethod string
+
+// These constants determine how a server should be rebooted.
+// See the Reboot() function for further details.
+const (
+ SoftReboot RebootMethod = "SOFT"
+ HardReboot RebootMethod = "HARD"
+ OSReboot = SoftReboot
+ PowerCycle = HardReboot
+)
+
+// Reboot requests that a given server reboot.
+// Two methods exist for rebooting a server:
+//
+// HardReboot (aka PowerCycle) restarts the server instance by physically cutting power to the machine, or if a VM,
+// terminating it at the hypervisor level.
+// It's done. Caput. Full stop.
+// Then, after a brief while, power is restored or the VM instance restarted.
+//
+// SoftReboot (aka OSReboot) simply tells the OS to restart under its own procedures.
+// E.g., in Linux, asking it to enter runlevel 6, or executing "sudo shutdown -r now", or by asking Windows to restart the machine.
+func Reboot(client *gophercloud.ServiceClient, id string, how RebootMethod) ActionResult {
+ var res ActionResult
+
+ if (how != SoftReboot) && (how != HardReboot) {
+ res.Err = &ErrArgument{
+ Function: "Reboot",
+ Argument: "how",
+ Value: how,
+ }
+ return res
+ }
+
+ reqBody := struct {
+ C map[string]string `json:"reboot"`
+ }{
+ map[string]string{"type": string(how)},
+ }
+
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, nil)
+ return res
+}
+
+// RebuildOptsBuilder is an interface that allows extensions to override the
+// default behaviour of rebuild options
+type RebuildOptsBuilder interface {
+ ToServerRebuildMap() (map[string]interface{}, error)
+}
+
+// RebuildOpts represents the configuration options used in a server rebuild
+// operation
+type RebuildOpts struct {
+ // Required. The ID of the image you want your server to be provisioned on
+ ImageID string
+
+ // Name to set the server to
+ Name string
+
+ // Required. The server's admin password
+ AdminPass string
+
+ // AccessIPv4 [optional] provides a new IPv4 address for the instance.
+ AccessIPv4 string
+
+ // AccessIPv6 [optional] provides a new IPv6 address for the instance.
+ AccessIPv6 string
+
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string
+
+ // Personality [optional] includes files to inject into the server at launch.
+ // Rebuild will base64-encode file contents for you.
+ Personality Personality
+}
+
+// ToServerRebuildMap formats a RebuildOpts struct into a map for use in JSON
+func (opts RebuildOpts) ToServerRebuildMap() (map[string]interface{}, error) {
+ var err error
+ server := make(map[string]interface{})
+
+ if opts.AdminPass == "" {
+ err = fmt.Errorf("AdminPass is required")
+ }
+
+ if opts.ImageID == "" {
+ err = fmt.Errorf("ImageID is required")
+ }
+
+ if err != nil {
+ return server, err
+ }
+
+ server["name"] = opts.Name
+ server["adminPass"] = opts.AdminPass
+ server["imageRef"] = opts.ImageID
+
+ if opts.AccessIPv4 != "" {
+ server["accessIPv4"] = opts.AccessIPv4
+ }
+
+ if opts.AccessIPv6 != "" {
+ server["accessIPv6"] = opts.AccessIPv6
+ }
+
+ if opts.Metadata != nil {
+ server["metadata"] = opts.Metadata
+ }
+
+ if len(opts.Personality) > 0 {
+ server["personality"] = opts.Personality
+ }
+
+ return map[string]interface{}{"rebuild": server}, nil
+}
+
+// Rebuild will reprovision the server according to the configuration options
+// provided in the RebuildOpts struct.
+func Rebuild(client *gophercloud.ServiceClient, id string, opts RebuildOptsBuilder) RebuildResult {
+ var result RebuildResult
+
+ if id == "" {
+ result.Err = fmt.Errorf("ID is required")
+ return result
+ }
+
+ reqBody, err := opts.ToServerRebuildMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = client.Post(actionURL(client, id), reqBody, &result.Body, nil)
+ return result
+}
+
+// ResizeOptsBuilder is an interface that allows extensions to override the default structure of
+// a Resize request.
+type ResizeOptsBuilder interface {
+ ToServerResizeMap() (map[string]interface{}, error)
+}
+
+// ResizeOpts represents the configuration options used to control a Resize operation.
+type ResizeOpts struct {
+ // FlavorRef is the ID of the flavor you wish your server to become.
+ FlavorRef string
+}
+
+// ToServerResizeMap formats a ResizeOpts as a map that can be used as a JSON request body for the
+// Resize request.
+func (opts ResizeOpts) ToServerResizeMap() (map[string]interface{}, error) {
+ resize := map[string]interface{}{
+ "flavorRef": opts.FlavorRef,
+ }
+
+ return map[string]interface{}{"resize": resize}, nil
+}
+
+// Resize instructs the provider to change the flavor of the server.
+// Note that this implies rebuilding it.
+// Unfortunately, one cannot pass rebuild parameters to the resize function.
+// When the resize completes, the server will be in RESIZE_VERIFY state.
+// While in this state, you can explore the use of the new server's configuration.
+// If you like it, call ConfirmResize() to commit the resize permanently.
+// Otherwise, call RevertResize() to restore the old configuration.
+func Resize(client *gophercloud.ServiceClient, id string, opts ResizeOptsBuilder) ActionResult {
+ var res ActionResult
+ reqBody, err := opts.ToServerResizeMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, nil)
+ return res
+}
+
+// ConfirmResize confirms a previous resize operation on a server.
+// See Resize() for more details.
+func ConfirmResize(client *gophercloud.ServiceClient, id string) ActionResult {
+ var res ActionResult
+
+ reqBody := map[string]interface{}{"confirmResize": nil}
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{201, 202, 204},
+ })
+ return res
+}
+
+// RevertResize cancels a previous resize operation on a server.
+// See Resize() for more details.
+func RevertResize(client *gophercloud.ServiceClient, id string) ActionResult {
+ var res ActionResult
+ reqBody := map[string]interface{}{"revertResize": nil}
+ _, res.Err = client.Post(actionURL(client, id), reqBody, nil, nil)
+ return res
+}
+
+// RescueOptsBuilder is an interface that allows extensions to override the
+// default structure of a Rescue request.
+type RescueOptsBuilder interface {
+ ToServerRescueMap() (map[string]interface{}, error)
+}
+
+// RescueOpts represents the configuration options used to control a Rescue
+// option.
+type RescueOpts struct {
+ // AdminPass is the desired administrative password for the instance in
+ // RESCUE mode. If it's left blank, the server will generate a password.
+ AdminPass string
+}
+
+// ToServerRescueMap formats a RescueOpts as a map that can be used as a JSON
+// request body for the Rescue request.
+func (opts RescueOpts) ToServerRescueMap() (map[string]interface{}, error) {
+ server := make(map[string]interface{})
+ if opts.AdminPass != "" {
+ server["adminPass"] = opts.AdminPass
+ }
+ return map[string]interface{}{"rescue": server}, nil
+}
+
+// Rescue instructs the provider to place the server into RESCUE mode.
+func Rescue(client *gophercloud.ServiceClient, id string, opts RescueOptsBuilder) RescueResult {
+ var result RescueResult
+
+ if id == "" {
+ result.Err = fmt.Errorf("ID is required")
+ return result
+ }
+ reqBody, err := opts.ToServerRescueMap()
+ if err != nil {
+ result.Err = err
+ return result
+ }
+
+ _, result.Err = client.Post(actionURL(client, id), reqBody, &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// ResetMetadataOptsBuilder allows extensions to add additional parameters to the
+// Reset request.
+type ResetMetadataOptsBuilder interface {
+ ToMetadataResetMap() (map[string]interface{}, error)
+}
+
+// MetadataOpts is a map that contains key-value pairs.
+type MetadataOpts map[string]string
+
+// ToMetadataResetMap assembles a body for a Reset request based on the contents of a MetadataOpts.
+func (opts MetadataOpts) ToMetadataResetMap() (map[string]interface{}, error) {
+ return map[string]interface{}{"metadata": opts}, nil
+}
+
+// ToMetadataUpdateMap assembles a body for an Update request based on the contents of a MetadataOpts.
+func (opts MetadataOpts) ToMetadataUpdateMap() (map[string]interface{}, error) {
+ return map[string]interface{}{"metadata": opts}, nil
+}
+
+// ResetMetadata will create multiple new key-value pairs for the given server ID.
+// Note: Using this operation will erase any already-existing metadata and create
+// the new metadata provided. To keep any already-existing metadata, use the
+// UpdateMetadatas or UpdateMetadata function.
+func ResetMetadata(client *gophercloud.ServiceClient, id string, opts ResetMetadataOptsBuilder) ResetMetadataResult {
+ var res ResetMetadataResult
+ metadata, err := opts.ToMetadataResetMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ _, res.Err = client.Put(metadataURL(client, id), metadata, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Metadata requests all the metadata for the given server ID.
+func Metadata(client *gophercloud.ServiceClient, id string) GetMetadataResult {
+ var res GetMetadataResult
+ _, res.Err = client.Get(metadataURL(client, id), &res.Body, nil)
+ return res
+}
+
+// UpdateMetadataOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type UpdateMetadataOptsBuilder interface {
+ ToMetadataUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateMetadata updates (or creates) all the metadata specified by opts for the given server ID.
+// This operation does not affect already-existing metadata that is not specified
+// by opts.
+func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts UpdateMetadataOptsBuilder) UpdateMetadataResult {
+ var res UpdateMetadataResult
+ metadata, err := opts.ToMetadataUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ _, res.Err = client.Post(metadataURL(client, id), metadata, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// MetadatumOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type MetadatumOptsBuilder interface {
+ ToMetadatumCreateMap() (map[string]interface{}, string, error)
+}
+
+// MetadatumOpts is a map of length one that contains a key-value pair.
+type MetadatumOpts map[string]string
+
+// ToMetadatumCreateMap assembles a body for a Create request based on the contents of a MetadataumOpts.
+func (opts MetadatumOpts) ToMetadatumCreateMap() (map[string]interface{}, string, error) {
+ if len(opts) != 1 {
+ return nil, "", errors.New("CreateMetadatum operation must have 1 and only 1 key-value pair.")
+ }
+ metadatum := map[string]interface{}{"meta": opts}
+ var key string
+ for k := range metadatum["meta"].(MetadatumOpts) {
+ key = k
+ }
+ return metadatum, key, nil
+}
+
+// CreateMetadatum will create or update the key-value pair with the given key for the given server ID.
+func CreateMetadatum(client *gophercloud.ServiceClient, id string, opts MetadatumOptsBuilder) CreateMetadatumResult {
+ var res CreateMetadatumResult
+ metadatum, key, err := opts.ToMetadatumCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Put(metadatumURL(client, id, key), metadatum, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Metadatum requests the key-value pair with the given key for the given server ID.
+func Metadatum(client *gophercloud.ServiceClient, id, key string) GetMetadatumResult {
+ var res GetMetadatumResult
+ _, res.Err = client.Request("GET", metadatumURL(client, id, key), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ })
+ return res
+}
+
+// DeleteMetadatum will delete the key-value pair with the given key for the given server ID.
+func DeleteMetadatum(client *gophercloud.ServiceClient, id, key string) DeleteMetadatumResult {
+ var res DeleteMetadatumResult
+ _, res.Err = client.Delete(metadatumURL(client, id, key), nil)
+ return res
+}
+
+// ListAddresses makes a request against the API to list the servers IP addresses.
+func ListAddresses(client *gophercloud.ServiceClient, id string) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return AddressPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, listAddressesURL(client, id), createPageFn)
+}
+
+// ListAddressesByNetwork makes a request against the API to list the servers IP addresses
+// for the given network.
+func ListAddressesByNetwork(client *gophercloud.ServiceClient, id, network string) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return NetworkAddressPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, listAddressesByNetworkURL(client, id, network), createPageFn)
+}
+
+type CreateImageOpts struct {
+ // Name [required] of the image/snapshot
+ Name string
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the created image.
+ Metadata map[string]string
+}
+
+type CreateImageOptsBuilder interface {
+ ToServerCreateImageMap() (map[string]interface{}, error)
+}
+
+// ToServerCreateImageMap formats a CreateImageOpts structure into a request body.
+func (opts CreateImageOpts) ToServerCreateImageMap() (map[string]interface{}, error) {
+ var err error
+ img := make(map[string]interface{})
+ if opts.Name == "" {
+ return nil, fmt.Errorf("Cannot create a server image without a name")
+ }
+ img["name"] = opts.Name
+ if opts.Metadata != nil {
+ img["metadata"] = opts.Metadata
+ }
+ createImage := make(map[string]interface{})
+ createImage["createImage"] = img
+ return createImage, err
+}
+
+// CreateImage makes a request against the nova API to schedule an image to be created of the server
+func CreateImage(client *gophercloud.ServiceClient, serverId string, opts CreateImageOptsBuilder) CreateImageResult {
+ var res CreateImageResult
+ reqBody, err := opts.ToServerCreateImageMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ response, err := client.Post(actionURL(client, serverId), reqBody, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ res.Err = err
+ res.Header = response.Header
+ return res
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ serverCount := 0
+ serverID := ""
+ if name == "" {
+ return "", fmt.Errorf("A server name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ serverList, err := ExtractServers(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range serverList {
+ if s.Name == name {
+ serverCount++
+ serverID = s.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch serverCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find server: %s", name)
+ case 1:
+ return serverID, nil
+ default:
+ return "", fmt.Errorf("Found %d servers matching %s", serverCount, name)
+ }
+}
diff --git a/openstack/compute/v2/servers/requests_test.go b/openstack/compute/v2/servers/requests_test.go
new file mode 100644
index 0000000..88cb54d
--- /dev/null
+++ b/openstack/compute/v2/servers/requests_test.go
@@ -0,0 +1,373 @@
+package servers
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListServers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerListSuccessfully(t)
+
+ pages := 0
+ err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractServers(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 servers, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, ServerHerp, actual[0])
+ th.CheckDeepEquals(t, ServerDerp, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllServers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerListSuccessfully(t)
+
+ allPages, err := List(client.ServiceClient(), ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := ExtractServers(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ServerHerp, actual[0])
+ th.CheckDeepEquals(t, ServerDerp, actual[1])
+}
+
+func TestCreateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationSuccessfully(t, SingleServerBody)
+
+ actual, err := Create(client.ServiceClient(), CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerDeletionSuccessfully(t)
+
+ res := Delete(client.ServiceClient(), "asdfasdfasdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerGetSuccessfully(t)
+
+ client := client.ServiceClient()
+ actual, err := Get(client, "1234asdf").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestUpdateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerUpdateSuccessfully(t)
+
+ client := client.ServiceClient()
+ actual, err := Update(client, "1234asdf", UpdateOpts{Name: "new-name"}).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestChangeServerAdminPassword(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAdminPasswordChangeSuccessfully(t)
+
+ res := ChangeAdminPassword(client.ServiceClient(), "1234asdf", "new-password")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRebootServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRebootSuccessfully(t)
+
+ res := Reboot(client.ServiceClient(), "1234asdf", SoftReboot)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRebuildServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRebuildSuccessfully(t, SingleServerBody)
+
+ opts := RebuildOpts{
+ Name: "new-name",
+ AdminPass: "swordfish",
+ ImageID: "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ AccessIPv4: "1.2.3.4",
+ }
+
+ actual, err := Rebuild(client.ServiceClient(), "1234asdf", opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestResizeServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "resize": { "flavorRef": "2" } }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+
+ res := Resize(client.ServiceClient(), "1234asdf", ResizeOpts{FlavorRef: "2"})
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestConfirmResize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "confirmResize": null }`)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := ConfirmResize(client.ServiceClient(), "1234asdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRevertResize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "revertResize": null }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+
+ res := RevertResize(client.ServiceClient(), "1234asdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRescue(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleServerRescueSuccessfully(t)
+
+ res := Rescue(client.ServiceClient(), "1234asdf", RescueOpts{
+ AdminPass: "1234567890",
+ })
+ th.AssertNoErr(t, res.Err)
+ adminPass, _ := res.Extract()
+ th.AssertEquals(t, "1234567890", adminPass)
+}
+
+func TestGetMetadatum(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadatumGetSuccessfully(t)
+
+ expected := map[string]string{"foo": "bar"}
+ actual, err := Metadatum(client.ServiceClient(), "1234asdf", "foo").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestCreateMetadatum(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadatumCreateSuccessfully(t)
+
+ expected := map[string]string{"foo": "bar"}
+ actual, err := CreateMetadatum(client.ServiceClient(), "1234asdf", MetadatumOpts{"foo": "bar"}).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteMetadatum(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadatumDeleteSuccessfully(t)
+
+ err := DeleteMetadatum(client.ServiceClient(), "1234asdf", "foo").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGetMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadataGetSuccessfully(t)
+
+ expected := map[string]string{"foo": "bar", "this": "that"}
+ actual, err := Metadata(client.ServiceClient(), "1234asdf").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestResetMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadataResetSuccessfully(t)
+
+ expected := map[string]string{"foo": "bar", "this": "that"}
+ actual, err := ResetMetadata(client.ServiceClient(), "1234asdf", MetadataOpts{
+ "foo": "bar",
+ "this": "that",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleMetadataUpdateSuccessfully(t)
+
+ expected := map[string]string{"foo": "baz", "this": "those"}
+ actual, err := UpdateMetadata(client.ServiceClient(), "1234asdf", MetadataOpts{
+ "foo": "baz",
+ "this": "those",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListAddresses(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAddressListSuccessfully(t)
+
+ expected := ListAddressesExpected
+ pages := 0
+ err := ListAddresses(client.ServiceClient(), "asdfasdfasdf").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractAddresses(page)
+ th.AssertNoErr(t, err)
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 networks, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, pages)
+}
+
+func TestListAddressesByNetwork(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleNetworkAddressListSuccessfully(t)
+
+ expected := ListNetworkAddressesExpected
+ pages := 0
+ err := ListAddressesByNetwork(client.ServiceClient(), "asdfasdfasdf", "public").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractNetworkAddresses(page)
+ th.AssertNoErr(t, err)
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 addresses, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, pages)
+}
+
+func TestCreateServerImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateServerImageSuccessfully(t)
+
+ _, err := CreateImage(client.ServiceClient(), "serverimage", CreateImageOpts{Name: "test"}).ExtractImageID()
+ th.AssertNoErr(t, err)
+}
+
+func TestMarshalPersonality(t *testing.T) {
+ name := "/etc/test"
+ contents := []byte("asdfasdf")
+
+ personality := Personality{
+ &File{
+ Path: name,
+ Contents: contents,
+ },
+ }
+
+ data, err := json.Marshal(personality)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var actual []map[string]string
+ err = json.Unmarshal(data, &actual)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(actual) != 1 {
+ t.Fatal("expected personality length 1")
+ }
+
+ if actual[0]["path"] != name {
+ t.Fatal("file path incorrect")
+ }
+
+ if actual[0]["contents"] != base64.StdEncoding.EncodeToString(contents) {
+ t.Fatal("file contents incorrect")
+ }
+}
diff --git a/openstack/compute/v2/servers/results.go b/openstack/compute/v2/servers/results.go
new file mode 100644
index 0000000..f278709
--- /dev/null
+++ b/openstack/compute/v2/servers/results.go
@@ -0,0 +1,372 @@
+package servers
+
+import (
+ "reflect"
+ "fmt"
+ "path"
+ "net/url"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type serverResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any serverResult as a Server, if possible.
+func (r serverResult) Extract() (*Server, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Server Server `mapstructure:"server"`
+ }
+
+ config := &mapstructure.DecoderConfig{
+ DecodeHook: toMapFromString,
+ Result: &response,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return nil, err
+ }
+
+ err = decoder.Decode(r.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ return &response.Server, nil
+}
+
+// CreateResult temporarily contains the response from a Create call.
+type CreateResult struct {
+ serverResult
+}
+
+// GetResult temporarily contains the response from a Get call.
+type GetResult struct {
+ serverResult
+}
+
+// UpdateResult temporarily contains the response from an Update call.
+type UpdateResult struct {
+ serverResult
+}
+
+// DeleteResult temporarily contains the response from a Delete call.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// RebuildResult temporarily contains the response from a Rebuild call.
+type RebuildResult struct {
+ serverResult
+}
+
+// ActionResult represents the result of server action operations, like reboot
+type ActionResult struct {
+ gophercloud.ErrResult
+}
+
+// RescueResult represents the result of a server rescue operation
+type RescueResult struct {
+ ActionResult
+}
+
+// CreateImageResult represents the result of an image creation operation
+type CreateImageResult struct {
+ gophercloud.Result
+}
+
+// ExtractImageID gets the ID of the newly created server image from the header
+func (res CreateImageResult) ExtractImageID() (string, error) {
+ if res.Err != nil {
+ return "", res.Err
+ }
+ // Get the image id from the header
+ u, err := url.ParseRequestURI(res.Header.Get("Location"))
+ if err != nil {
+ return "", fmt.Errorf("Failed to parse the image id: %s", err.Error())
+ }
+ imageId := path.Base(u.Path)
+ if imageId == "." || imageId == "/" {
+ return "", fmt.Errorf("Failed to parse the ID of newly created image: %s", u)
+ }
+ return imageId, nil
+}
+
+// Extract interprets any RescueResult as an AdminPass, if possible.
+func (r RescueResult) Extract() (string, error) {
+ if r.Err != nil {
+ return "", r.Err
+ }
+
+ var response struct {
+ AdminPass string `mapstructure:"adminPass"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return response.AdminPass, err
+}
+
+// Server exposes only the standard OpenStack fields corresponding to a given server on the user's account.
+type Server struct {
+ // ID uniquely identifies this server amongst all other servers, including those not accessible to the current tenant.
+ ID string
+
+ // TenantID identifies the tenant owning this server resource.
+ TenantID string `mapstructure:"tenant_id"`
+
+ // UserID uniquely identifies the user account owning the tenant.
+ UserID string `mapstructure:"user_id"`
+
+ // Name contains the human-readable name for the server.
+ Name string
+
+ // Updated and Created contain ISO-8601 timestamps of when the state of the server last changed, and when it was created.
+ Updated string
+ Created string
+
+ HostID string
+
+ // Status contains the current operational status of the server, such as IN_PROGRESS or ACTIVE.
+ Status string
+
+ // Progress ranges from 0..100.
+ // A request made against the server completes only once Progress reaches 100.
+ Progress int
+
+ // AccessIPv4 and AccessIPv6 contain the IP addresses of the server, suitable for remote access for administration.
+ AccessIPv4, AccessIPv6 string
+
+ // Image refers to a JSON object, which itself indicates the OS image used to deploy the server.
+ Image map[string]interface{}
+
+ // Flavor refers to a JSON object, which itself indicates the hardware configuration of the deployed server.
+ Flavor map[string]interface{}
+
+ // Addresses includes a list of all IP addresses assigned to the server, keyed by pool.
+ Addresses map[string]interface{}
+
+ // Metadata includes a list of all user-specified key-value pairs attached to the server.
+ Metadata map[string]interface{}
+
+ // Links includes HTTP references to the itself, useful for passing along to other APIs that might want a server reference.
+ Links []interface{}
+
+ // KeyName indicates which public key was injected into the server on launch.
+ KeyName string `json:"key_name" mapstructure:"key_name"`
+
+ // AdminPass will generally be empty (""). However, it will contain the administrative password chosen when provisioning a new server without a set AdminPass setting in the first place.
+ // Note that this is the ONLY time this field will be valid.
+ AdminPass string `json:"adminPass" mapstructure:"adminPass"`
+
+ // SecurityGroups includes the security groups that this instance has applied to it
+ SecurityGroups []map[string]interface{} `json:"security_groups" mapstructure:"security_groups"`
+}
+
+// ServerPage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractServers call.
+type ServerPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (page ServerPage) IsEmpty() (bool, error) {
+ servers, err := ExtractServers(page)
+ if err != nil {
+ return true, err
+ }
+ return len(servers) == 0, nil
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page ServerPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"servers_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractServers interprets the results of a single page from a List() call, producing a slice of Server entities.
+func ExtractServers(page pagination.Page) ([]Server, error) {
+ casted := page.(ServerPage).Body
+
+ var response struct {
+ Servers []Server `mapstructure:"servers"`
+ }
+
+ config := &mapstructure.DecoderConfig{
+ DecodeHook: toMapFromString,
+ Result: &response,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return nil, err
+ }
+
+ err = decoder.Decode(casted)
+
+ return response.Servers, err
+}
+
+// MetadataResult contains the result of a call for (potentially) multiple key-value pairs.
+type MetadataResult struct {
+ gophercloud.Result
+}
+
+// GetMetadataResult temporarily contains the response from a metadata Get call.
+type GetMetadataResult struct {
+ MetadataResult
+}
+
+// ResetMetadataResult temporarily contains the response from a metadata Reset call.
+type ResetMetadataResult struct {
+ MetadataResult
+}
+
+// UpdateMetadataResult temporarily contains the response from a metadata Update call.
+type UpdateMetadataResult struct {
+ MetadataResult
+}
+
+// MetadatumResult contains the result of a call for individual a single key-value pair.
+type MetadatumResult struct {
+ gophercloud.Result
+}
+
+// GetMetadatumResult temporarily contains the response from a metadatum Get call.
+type GetMetadatumResult struct {
+ MetadatumResult
+}
+
+// CreateMetadatumResult temporarily contains the response from a metadatum Create call.
+type CreateMetadatumResult struct {
+ MetadatumResult
+}
+
+// DeleteMetadatumResult temporarily contains the response from a metadatum Delete call.
+type DeleteMetadatumResult struct {
+ gophercloud.ErrResult
+}
+
+// Extract interprets any MetadataResult as a Metadata, if possible.
+func (r MetadataResult) Extract() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Metadata map[string]string `mapstructure:"metadata"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return response.Metadata, err
+}
+
+// Extract interprets any MetadatumResult as a Metadatum, if possible.
+func (r MetadatumResult) Extract() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Metadatum map[string]string `mapstructure:"meta"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return response.Metadatum, err
+}
+
+func toMapFromString(from reflect.Kind, to reflect.Kind, data interface{}) (interface{}, error) {
+ if (from == reflect.String) && (to == reflect.Map) {
+ return map[string]interface{}{}, nil
+ }
+ return data, nil
+}
+
+// Address represents an IP address.
+type Address struct {
+ Version int `mapstructure:"version"`
+ Address string `mapstructure:"addr"`
+}
+
+// AddressPage abstracts the raw results of making a ListAddresses() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned
+// to the client, you may only safely access the data provided through the ExtractAddresses call.
+type AddressPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if an AddressPage contains no networks.
+func (r AddressPage) IsEmpty() (bool, error) {
+ addresses, err := ExtractAddresses(r)
+ if err != nil {
+ return true, err
+ }
+ return len(addresses) == 0, nil
+}
+
+// ExtractAddresses interprets the results of a single page from a ListAddresses() call,
+// producing a map of addresses.
+func ExtractAddresses(page pagination.Page) (map[string][]Address, error) {
+ casted := page.(AddressPage).Body
+
+ var response struct {
+ Addresses map[string][]Address `mapstructure:"addresses"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return response.Addresses, err
+}
+
+// NetworkAddressPage abstracts the raw results of making a ListAddressesByNetwork() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned
+// to the client, you may only safely access the data provided through the ExtractAddresses call.
+type NetworkAddressPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a NetworkAddressPage contains no addresses.
+func (r NetworkAddressPage) IsEmpty() (bool, error) {
+ addresses, err := ExtractNetworkAddresses(r)
+ if err != nil {
+ return true, err
+ }
+ return len(addresses) == 0, nil
+}
+
+// ExtractNetworkAddresses interprets the results of a single page from a ListAddressesByNetwork() call,
+// producing a slice of addresses.
+func ExtractNetworkAddresses(page pagination.Page) ([]Address, error) {
+ casted := page.(NetworkAddressPage).Body
+
+ var response map[string][]Address
+ err := mapstructure.Decode(casted, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ var key string
+ for k := range response {
+ key = k
+ }
+
+ return response[key], err
+}
diff --git a/openstack/compute/v2/servers/urls.go b/openstack/compute/v2/servers/urls.go
new file mode 100644
index 0000000..8998354
--- /dev/null
+++ b/openstack/compute/v2/servers/urls.go
@@ -0,0 +1,47 @@
+package servers
+
+import "github.com/rackspace/gophercloud"
+
+func createURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("servers")
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return createURL(client)
+}
+
+func listDetailURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("servers", "detail")
+}
+
+func deleteURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id)
+}
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+ return deleteURL(client, id)
+}
+
+func updateURL(client *gophercloud.ServiceClient, id string) string {
+ return deleteURL(client, id)
+}
+
+func actionURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id, "action")
+}
+
+func metadatumURL(client *gophercloud.ServiceClient, id, key string) string {
+ return client.ServiceURL("servers", id, "metadata", key)
+}
+
+func metadataURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id, "metadata")
+}
+
+func listAddressesURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id, "ips")
+}
+
+func listAddressesByNetworkURL(client *gophercloud.ServiceClient, id, network string) string {
+ return client.ServiceURL("servers", id, "ips", network)
+}
diff --git a/openstack/compute/v2/servers/urls_test.go b/openstack/compute/v2/servers/urls_test.go
new file mode 100644
index 0000000..17a1d28
--- /dev/null
+++ b/openstack/compute/v2/servers/urls_test.go
@@ -0,0 +1,68 @@
+package servers
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "servers"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "servers"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestListDetailURL(t *testing.T) {
+ actual := listDetailURL(endpointClient())
+ expected := endpoint + "servers/detail"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "servers/foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "servers/foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "servers/foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestActionURL(t *testing.T) {
+ actual := actionURL(endpointClient(), "foo")
+ expected := endpoint + "servers/foo/action"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestMetadatumURL(t *testing.T) {
+ actual := metadatumURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "servers/foo/metadata/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestMetadataURL(t *testing.T) {
+ actual := metadataURL(endpointClient(), "foo")
+ expected := endpoint + "servers/foo/metadata"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/servers/util.go b/openstack/compute/v2/servers/util.go
new file mode 100644
index 0000000..e6baf74
--- /dev/null
+++ b/openstack/compute/v2/servers/util.go
@@ -0,0 +1,20 @@
+package servers
+
+import "github.com/rackspace/gophercloud"
+
+// WaitForStatus will continually poll a server until it successfully transitions to a specified
+// status. It will do this for at most the number of seconds specified.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := Get(c, id).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if current.Status == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/openstack/db/v1/configurations/doc.go b/openstack/db/v1/configurations/doc.go
new file mode 100644
index 0000000..45b9cfb
--- /dev/null
+++ b/openstack/db/v1/configurations/doc.go
@@ -0,0 +1,11 @@
+// Package configurations provides information and interaction with the
+// configuration API resource in the Rackspace Database service.
+//
+// A configuration group is a collection of key/value pairs which define how a
+// particular database operates. These key/value pairs are specific to each
+// datastore type and serve like settings. Some directives are capable of being
+// applied dynamically, while other directives require a server restart to take
+// effect. The configuration group can be applied to an instance at creation or
+// applied to an existing instance to modify the behavior of the running
+// datastore on the instance.
+package configurations
diff --git a/openstack/db/v1/configurations/fixtures.go b/openstack/db/v1/configurations/fixtures.go
new file mode 100644
index 0000000..ae65416
--- /dev/null
+++ b/openstack/db/v1/configurations/fixtures.go
@@ -0,0 +1,157 @@
+package configurations
+
+import (
+ "fmt"
+ "time"
+)
+
+var (
+ timestamp = "2015-11-12T14:22:42Z"
+ timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var singleConfigJSON = `
+{
+ "created": "` + timestamp + `",
+ "datastore_name": "mysql",
+ "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+ "datastore_version_name": "5.6",
+ "description": "example_description",
+ "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ "name": "example-configuration-name",
+ "updated": "` + timestamp + `"
+}
+`
+
+var singleConfigWithValuesJSON = `
+{
+ "created": "` + timestamp + `",
+ "datastore_name": "mysql",
+ "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+ "datastore_version_name": "5.6",
+ "description": "example description",
+ "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ "instance_count": 0,
+ "name": "example-configuration-name",
+ "updated": "` + timestamp + `",
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120
+ }
+}
+`
+
+var (
+ ListConfigsJSON = fmt.Sprintf(`{"configurations": [%s]}`, singleConfigJSON)
+ GetConfigJSON = fmt.Sprintf(`{"configuration": %s}`, singleConfigJSON)
+ CreateConfigJSON = fmt.Sprintf(`{"configuration": %s}`, singleConfigWithValuesJSON)
+)
+
+var CreateReq = `
+{
+ "configuration": {
+ "datastore": {
+ "type": "a00000a0-00a0-0a00-00a0-000a000000aa",
+ "version": "b00000b0-00b0-0b00-00b0-000b000000bb"
+ },
+ "description": "example description",
+ "name": "example-configuration-name",
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120
+ }
+ }
+}
+`
+
+var UpdateReq = `
+{
+ "configuration": {
+ "values": {
+ "connect_timeout": 300
+ }
+ }
+}
+`
+
+var ListInstancesJSON = `
+{
+ "instances": [
+ {
+ "id": "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ "name": "json_rack_instance"
+ }
+ ]
+}
+`
+
+var ListParamsJSON = `
+{
+ "configuration-parameters": [
+ {
+ "max": 1,
+ "min": 0,
+ "name": "innodb_file_per_table",
+ "restart_required": true,
+ "type": "integer"
+ },
+ {
+ "max": 4294967296,
+ "min": 0,
+ "name": "key_buffer_size",
+ "restart_required": false,
+ "type": "integer"
+ },
+ {
+ "max": 65535,
+ "min": 2,
+ "name": "connect_timeout",
+ "restart_required": false,
+ "type": "integer"
+ },
+ {
+ "max": 4294967296,
+ "min": 0,
+ "name": "join_buffer_size",
+ "restart_required": false,
+ "type": "integer"
+ }
+ ]
+}
+`
+
+var GetParamJSON = `
+{
+ "max": 1,
+ "min": 0,
+ "name": "innodb_file_per_table",
+ "restart_required": true,
+ "type": "integer"
+}
+`
+
+var ExampleConfig = Config{
+ Created: timeVal,
+ DatastoreName: "mysql",
+ DatastoreVersionID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ DatastoreVersionName: "5.6",
+ Description: "example_description",
+ ID: "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ Name: "example-configuration-name",
+ Updated: timeVal,
+}
+
+var ExampleConfigWithValues = Config{
+ Created: timeVal,
+ DatastoreName: "mysql",
+ DatastoreVersionID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ DatastoreVersionName: "5.6",
+ Description: "example description",
+ ID: "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ Name: "example-configuration-name",
+ Updated: timeVal,
+ Values: map[string]interface{}{
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120,
+ },
+}
diff --git a/openstack/db/v1/configurations/requests.go b/openstack/db/v1/configurations/requests.go
new file mode 100644
index 0000000..83c7102
--- /dev/null
+++ b/openstack/db/v1/configurations/requests.go
@@ -0,0 +1,287 @@
+package configurations
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all of the available configurations.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return ConfigPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, baseURL(client), pageFn)
+}
+
+// CreateOptsBuilder is a top-level interface which renders a JSON map.
+type CreateOptsBuilder interface {
+ ToConfigCreateMap() (map[string]interface{}, error)
+}
+
+// DatastoreOpts is the primary options struct for creating and modifying
+// how configuration resources are associated with datastores.
+type DatastoreOpts struct {
+ // [OPTIONAL] The type of datastore. Defaults to "MySQL".
+ Type string
+
+ // [OPTIONAL] The specific version of a datastore. Defaults to "5.6".
+ Version string
+}
+
+// ToMap renders a JSON map for a datastore setting.
+func (opts DatastoreOpts) ToMap() (map[string]string, error) {
+ datastore := map[string]string{}
+
+ if opts.Type != "" {
+ datastore["type"] = opts.Type
+ }
+
+ if opts.Version != "" {
+ datastore["version"] = opts.Version
+ }
+
+ return datastore, nil
+}
+
+// CreateOpts is the struct responsible for configuring new configurations.
+type CreateOpts struct {
+ // [REQUIRED] The configuration group name
+ Name string
+
+ // [REQUIRED] A map of user-defined configuration settings that will define
+ // how each associated datastore works. Each key/value pair is specific to a
+ // datastore type.
+ Values map[string]interface{}
+
+ // [OPTIONAL] Associates the configuration group with a particular datastore.
+ Datastore *DatastoreOpts
+
+ // [OPTIONAL] A human-readable explanation for the group.
+ Description string
+}
+
+// ToConfigCreateMap casts a CreateOpts struct into a JSON map.
+func (opts CreateOpts) ToConfigCreateMap() (map[string]interface{}, error) {
+ if opts.Name == "" {
+ return nil, errors.New("Name is a required field")
+ }
+ if len(opts.Values) == 0 {
+ return nil, errors.New("Values must be a populated map")
+ }
+
+ config := map[string]interface{}{
+ "name": opts.Name,
+ "values": opts.Values,
+ }
+
+ if opts.Datastore != nil {
+ ds, err := opts.Datastore.ToMap()
+ if err != nil {
+ return config, err
+ }
+ config["datastore"] = ds
+ }
+
+ if opts.Description != "" {
+ config["description"] = opts.Description
+ }
+
+ return map[string]interface{}{"configuration": config}, nil
+}
+
+// Create will create a new configuration group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToConfigCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("POST", baseURL(client), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONBody: &reqBody,
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
+
+// Get will retrieve the details for a specified configuration group.
+func Get(client *gophercloud.ServiceClient, configID string) GetResult {
+ var res GetResult
+
+ _, res.Err = client.Request("GET", resourceURL(client, configID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
+
+// UpdateOptsBuilder is the top-level interface for casting update options into
+// JSON maps.
+type UpdateOptsBuilder interface {
+ ToConfigUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the struct responsible for modifying existing configurations.
+type UpdateOpts struct {
+ // [OPTIONAL] The configuration group name
+ Name string
+
+ // [OPTIONAL] A map of user-defined configuration settings that will define
+ // how each associated datastore works. Each key/value pair is specific to a
+ // datastore type.
+ Values map[string]interface{}
+
+ // [OPTIONAL] Associates the configuration group with a particular datastore.
+ Datastore *DatastoreOpts
+
+ // [OPTIONAL] A human-readable explanation for the group.
+ Description string
+}
+
+// ToConfigUpdateMap will cast an UpdateOpts struct into a JSON map.
+func (opts UpdateOpts) ToConfigUpdateMap() (map[string]interface{}, error) {
+ config := map[string]interface{}{}
+
+ if opts.Name != "" {
+ config["name"] = opts.Name
+ }
+
+ if opts.Description != "" {
+ config["description"] = opts.Description
+ }
+
+ if opts.Datastore != nil {
+ ds, err := opts.Datastore.ToMap()
+ if err != nil {
+ return config, err
+ }
+ config["datastore"] = ds
+ }
+
+ if len(opts.Values) > 0 {
+ config["values"] = opts.Values
+ }
+
+ return map[string]interface{}{"configuration": config}, nil
+}
+
+// Update will modify an existing configuration group by performing a merge
+// between new and existing values. If the key already exists, the new value
+// will overwrite. All other keys will remain unaffected.
+func Update(client *gophercloud.ServiceClient, configID string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToConfigUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("PATCH", resourceURL(client, configID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONBody: &reqBody,
+ })
+
+ return res
+}
+
+// Replace will modify an existing configuration group by overwriting the
+// entire parameter group with the new values provided. Any existing keys not
+// included in UpdateOptsBuilder will be deleted.
+func Replace(client *gophercloud.ServiceClient, configID string, opts UpdateOptsBuilder) ReplaceResult {
+ var res ReplaceResult
+
+ reqBody, err := opts.ToConfigUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("PUT", resourceURL(client, configID), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ JSONBody: &reqBody,
+ })
+
+ return res
+}
+
+// Delete will permanently delete a configuration group. Please note that
+// config groups cannot be deleted whilst still attached to running instances -
+// you must detach and then delete them.
+func Delete(client *gophercloud.ServiceClient, configID string) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = client.Request("DELETE", resourceURL(client, configID), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// ListInstances will list all the instances associated with a particular
+// configuration group.
+func ListInstances(client *gophercloud.ServiceClient, configID string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return instances.InstancePage{pagination.LinkedPageBase{PageResult: r}}
+ }
+ return pagination.NewPager(client, instancesURL(client, configID), pageFn)
+}
+
+// ListDatastoreParams will list all the available and supported parameters
+// that can be used for a particular datastore ID and a particular version.
+// For example, if you are wondering how you can configure a MySQL 5.6 instance,
+// you can use this operation (you will need to retrieve the MySQL datastore ID
+// by using the datastores API).
+func ListDatastoreParams(client *gophercloud.ServiceClient, datastoreID, versionID string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return ParamPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, listDSParamsURL(client, datastoreID, versionID), pageFn)
+}
+
+// GetDatastoreParam will retrieve information about a specific configuration
+// parameter. For example, you can use this operation to understand more about
+// "innodb_file_per_table" configuration param for MySQL datastores. You will
+// need the param's ID first, which can be attained by using the ListDatastoreParams
+// operation.
+func GetDatastoreParam(client *gophercloud.ServiceClient, datastoreID, versionID, paramID string) ParamResult {
+ var res ParamResult
+
+ _, res.Err = client.Request("GET", getDSParamURL(client, datastoreID, versionID, paramID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
+
+// ListGlobalParams is similar to ListDatastoreParams but does not require a
+// DatastoreID.
+func ListGlobalParams(client *gophercloud.ServiceClient, versionID string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return ParamPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, listGlobalParamsURL(client, versionID), pageFn)
+}
+
+// GetGlobalParam is similar to GetDatastoreParam but does not require a
+// DatastoreID.
+func GetGlobalParam(client *gophercloud.ServiceClient, versionID, paramID string) ParamResult {
+ var res ParamResult
+
+ _, res.Err = client.Request("GET", getGlobalParamURL(client, versionID, paramID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
diff --git a/openstack/db/v1/configurations/requests_test.go b/openstack/db/v1/configurations/requests_test.go
new file mode 100644
index 0000000..db66f29
--- /dev/null
+++ b/openstack/db/v1/configurations/requests_test.go
@@ -0,0 +1,236 @@
+package configurations
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ configID = "{configID}"
+ _baseURL = "/configurations"
+ resURL = _baseURL + "/" + configID
+
+ dsID = "{datastoreID}"
+ versionID = "{versionID}"
+ paramID = "{paramID}"
+ dsParamListURL = "/datastores/" + dsID + "/versions/" + versionID + "/parameters"
+ dsParamGetURL = "/datastores/" + dsID + "/versions/" + versionID + "/parameters/" + paramID
+ globalParamListURL = "/datastores/versions/" + versionID + "/parameters"
+ globalParamGetURL = "/datastores/versions/" + versionID + "/parameters/" + paramID
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _baseURL, "GET", "", ListConfigsJSON, 200)
+
+ count := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractConfigs(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Config{ExampleConfig}
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "GET", "", GetConfigJSON, 200)
+
+ config, err := Get(fake.ServiceClient(), configID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &ExampleConfig, config)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _baseURL, "POST", CreateReq, CreateConfigJSON, 200)
+
+ opts := CreateOpts{
+ Datastore: &DatastoreOpts{
+ Type: "a00000a0-00a0-0a00-00a0-000a000000aa",
+ Version: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ },
+ Description: "example description",
+ Name: "example-configuration-name",
+ Values: map[string]interface{}{
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120,
+ },
+ }
+
+ config, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &ExampleConfigWithValues, config)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PATCH", UpdateReq, "", 200)
+
+ opts := UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := Update(fake.ServiceClient(), configID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestReplace(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PUT", UpdateReq, "", 202)
+
+ opts := UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := Replace(fake.ServiceClient(), configID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+
+ err := Delete(fake.ServiceClient(), configID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListInstances(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL+"/instances", "GET", "", ListInstancesJSON, 200)
+
+ expectedInstance := instances.Instance{
+ ID: "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ Name: "json_rack_instance",
+ }
+
+ pages := 0
+ err := ListInstances(fake.ServiceClient(), configID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := instances.ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.AssertDeepEquals(t, actual, []instances.Instance{expectedInstance})
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestListDSParams(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, dsParamListURL, "GET", "", ListParamsJSON, 200)
+
+ pages := 0
+ err := ListDatastoreParams(fake.ServiceClient(), dsID, versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Param{
+ Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+ }
+
+ th.AssertDeepEquals(t, actual, expected)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetDSParam(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, dsParamGetURL, "GET", "", GetParamJSON, 200)
+
+ param, err := GetDatastoreParam(fake.ServiceClient(), dsID, versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Param{
+ Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+ }
+
+ th.AssertDeepEquals(t, expected, param)
+}
+
+func TestListGlobalParams(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, globalParamListURL, "GET", "", ListParamsJSON, 200)
+
+ pages := 0
+ err := ListGlobalParams(fake.ServiceClient(), versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Param{
+ Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+ }
+
+ th.AssertDeepEquals(t, actual, expected)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetGlobalParam(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, globalParamGetURL, "GET", "", GetParamJSON, 200)
+
+ param, err := GetGlobalParam(fake.ServiceClient(), versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Param{
+ Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+ }
+
+ th.AssertDeepEquals(t, expected, param)
+}
diff --git a/openstack/db/v1/configurations/results.go b/openstack/db/v1/configurations/results.go
new file mode 100644
index 0000000..d0d1d6e
--- /dev/null
+++ b/openstack/db/v1/configurations/results.go
@@ -0,0 +1,197 @@
+package configurations
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Config represents a configuration group API resource.
+type Config struct {
+ Created time.Time `mapstructure:"-"`
+ Updated time.Time `mapstructure:"-"`
+ DatastoreName string `mapstructure:"datastore_name"`
+ DatastoreVersionID string `mapstructure:"datastore_version_id"`
+ DatastoreVersionName string `mapstructure:"datastore_version_name"`
+ Description string
+ ID string
+ Name string
+ Values map[string]interface{}
+}
+
+// ConfigPage contains a page of Config resources in a paginated collection.
+type ConfigPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a ConfigPage is empty.
+func (r ConfigPage) IsEmpty() (bool, error) {
+ is, err := ExtractConfigs(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractConfigs will retrieve a slice of Config structs from a page.
+func ExtractConfigs(page pagination.Page) ([]Config, error) {
+ casted := page.(ConfigPage).Body
+
+ var resp struct {
+ Configs []Config `mapstructure:"configurations" json:"configurations"`
+ }
+
+ if err := mapstructure.Decode(casted, &resp); err != nil {
+ return nil, err
+ }
+
+ var vals []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ vals = casted.(map[string]interface{})["configurations"].([]interface{})
+ case map[string][]interface{}:
+ vals = casted.(map[string][]interface{})["configurations"]
+ default:
+ return resp.Configs, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, v := range vals {
+ val := v.(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Configs, err
+ }
+ resp.Configs[i].Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Configs, err
+ }
+ resp.Configs[i].Updated = updatedTime
+ }
+ }
+
+ return resp.Configs, nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will retrieve a Config resource from an operation result.
+func (r commonResult) Extract() (*Config, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Config Config `mapstructure:"configuration"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ val := r.Body.(map[string]interface{})["configuration"].(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Config, err
+ }
+ response.Config.Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Config, err
+ }
+ response.Config.Updated = updatedTime
+ }
+
+ return &response.Config, err
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ commonResult
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an Update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// ReplaceResult represents the result of a Replace operation.
+type ReplaceResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Param represents a configuration parameter API resource.
+type Param struct {
+ Max int
+ Min int
+ Name string
+ RestartRequired bool `mapstructure:"restart_required" json:"restart_required"`
+ Type string
+}
+
+// ParamPage contains a page of Param resources in a paginated collection.
+type ParamPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a ParamPage is empty.
+func (r ParamPage) IsEmpty() (bool, error) {
+ is, err := ExtractParams(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractParams will retrieve a slice of Param structs from a page.
+func ExtractParams(page pagination.Page) ([]Param, error) {
+ casted := page.(ParamPage).Body
+
+ var resp struct {
+ Params []Param `mapstructure:"configuration-parameters" json:"configuration-parameters"`
+ }
+
+ err := mapstructure.Decode(casted, &resp)
+ return resp.Params, err
+}
+
+// ParamResult represents the result of an operation which retrieves details
+// about a particular configuration param.
+type ParamResult struct {
+ gophercloud.Result
+}
+
+// Extract will retrieve a param from an operation result.
+func (r ParamResult) Extract() (*Param, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var param Param
+
+ err := mapstructure.Decode(r.Body, ¶m)
+ return ¶m, err
+}
diff --git a/openstack/db/v1/configurations/urls.go b/openstack/db/v1/configurations/urls.go
new file mode 100644
index 0000000..abea961
--- /dev/null
+++ b/openstack/db/v1/configurations/urls.go
@@ -0,0 +1,31 @@
+package configurations
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("configurations")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, configID string) string {
+ return c.ServiceURL("configurations", configID)
+}
+
+func instancesURL(c *gophercloud.ServiceClient, configID string) string {
+ return c.ServiceURL("configurations", configID, "instances")
+}
+
+func listDSParamsURL(c *gophercloud.ServiceClient, datastoreID, versionID string) string {
+ return c.ServiceURL("datastores", datastoreID, "versions", versionID, "parameters")
+}
+
+func getDSParamURL(c *gophercloud.ServiceClient, datastoreID, versionID, paramID string) string {
+ return c.ServiceURL("datastores", datastoreID, "versions", versionID, "parameters", paramID)
+}
+
+func listGlobalParamsURL(c *gophercloud.ServiceClient, versionID string) string {
+ return c.ServiceURL("datastores", "versions", versionID, "parameters")
+}
+
+func getGlobalParamURL(c *gophercloud.ServiceClient, versionID, paramID string) string {
+ return c.ServiceURL("datastores", "versions", versionID, "parameters", paramID)
+}
diff --git a/openstack/db/v1/databases/doc.go b/openstack/db/v1/databases/doc.go
new file mode 100644
index 0000000..15275fe
--- /dev/null
+++ b/openstack/db/v1/databases/doc.go
@@ -0,0 +1,6 @@
+// Package flavors provides information and interaction with the database API
+// resource in the OpenStack Database service.
+//
+// A database, when referred to here, refers to the database engine running on
+// an instance.
+package databases
diff --git a/openstack/db/v1/databases/fixtures.go b/openstack/db/v1/databases/fixtures.go
new file mode 100644
index 0000000..3e67721
--- /dev/null
+++ b/openstack/db/v1/databases/fixtures.go
@@ -0,0 +1,61 @@
+package databases
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ instanceID = "{instanceID}"
+ resURL = "/instances/" + instanceID + "/databases"
+)
+
+var createDBsReq = `
+{
+ "databases": [
+ {
+ "character_set": "utf8",
+ "collate": "utf8_general_ci",
+ "name": "testingdb"
+ },
+ {
+ "name": "sampledb"
+ }
+ ]
+}
+`
+
+var listDBsResp = `
+{
+ "databases": [
+ {
+ "name": "anotherexampledb"
+ },
+ {
+ "name": "exampledb"
+ },
+ {
+ "name": "nextround"
+ },
+ {
+ "name": "sampledb"
+ },
+ {
+ "name": "testingdb"
+ }
+ ]
+}
+`
+
+func HandleCreate(t *testing.T) {
+ fixture.SetupHandler(t, resURL, "POST", createDBsReq, "", 202)
+}
+
+func HandleList(t *testing.T) {
+ fixture.SetupHandler(t, resURL, "GET", "", listDBsResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+ fixture.SetupHandler(t, resURL+"/{dbName}", "DELETE", "", "", 202)
+}
diff --git a/openstack/db/v1/databases/requests.go b/openstack/db/v1/databases/requests.go
new file mode 100644
index 0000000..f1eb5d9
--- /dev/null
+++ b/openstack/db/v1/databases/requests.go
@@ -0,0 +1,115 @@
+package databases
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder builds create options
+type CreateOptsBuilder interface {
+ ToDBCreateMap() (map[string]interface{}, error)
+}
+
+// DatabaseOpts is the struct responsible for configuring a database; often in
+// the context of an instance.
+type CreateOpts struct {
+ // [REQUIRED] Specifies the name of the database. Valid names can be composed
+ // of the following characters: letters (either case); numbers; these
+ // characters '@', '?', '#', ' ' but NEVER beginning a name string; '_' is
+ // permitted anywhere. Prohibited characters that are forbidden include:
+ // single quotes, double quotes, back quotes, semicolons, commas, backslashes,
+ // and forward slashes.
+ Name string
+
+ // [OPTIONAL] Set of symbols and encodings. The default character set is
+ // "utf8". See http://dev.mysql.com/doc/refman/5.1/en/charset-mysql.html for
+ // supported character sets.
+ CharSet string
+
+ // [OPTIONAL] Set of rules for comparing characters in a character set. The
+ // default value for collate is "utf8_general_ci". See
+ // http://dev.mysql.com/doc/refman/5.1/en/charset-mysql.html for supported
+ // collations.
+ Collate string
+}
+
+// ToMap is a helper function to convert individual DB create opt structures
+// into sub-maps.
+func (opts CreateOpts) ToMap() (map[string]string, error) {
+ if opts.Name == "" {
+ return nil, fmt.Errorf("Name is a required field")
+ }
+ if len(opts.Name) > 64 {
+ return nil, fmt.Errorf("Name must be less than 64 chars long")
+ }
+
+ db := map[string]string{"name": opts.Name}
+
+ if opts.CharSet != "" {
+ db["character_set"] = opts.CharSet
+ }
+ if opts.Collate != "" {
+ db["collate"] = opts.Collate
+ }
+ return db, nil
+}
+
+// BatchCreateOpts allows for multiple databases to created and modified.
+type BatchCreateOpts []CreateOpts
+
+// ToDBCreateMap renders a JSON map for creating DBs.
+func (opts BatchCreateOpts) ToDBCreateMap() (map[string]interface{}, error) {
+ dbs := make([]map[string]string, len(opts))
+ for i, db := range opts {
+ dbMap, err := db.ToMap()
+ if err != nil {
+ return nil, err
+ }
+ dbs[i] = dbMap
+ }
+ return map[string]interface{}{"databases": dbs}, nil
+}
+
+// Create will create a new database within the specified instance. If the
+// specified instance does not exist, a 404 error will be returned.
+func Create(client *gophercloud.ServiceClient, instanceID string, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToDBCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("POST", baseURL(client, instanceID), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// List will list all of the databases for a specified instance. Note: this
+// operation will only return user-defined databases; it will exclude system
+// databases like "mysql", "information_schema", "lost+found" etc.
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return DBPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, baseURL(client, instanceID), createPageFn)
+}
+
+// Delete will permanently delete the database within a specified instance.
+// All contained data inside the database will also be permanently deleted.
+func Delete(client *gophercloud.ServiceClient, instanceID, dbName string) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = client.Request("DELETE", dbURL(client, instanceID, dbName), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/openstack/db/v1/databases/requests_test.go b/openstack/db/v1/databases/requests_test.go
new file mode 100644
index 0000000..8a1b297
--- /dev/null
+++ b/openstack/db/v1/databases/requests_test.go
@@ -0,0 +1,66 @@
+package databases
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := BatchCreateOpts{
+ CreateOpts{Name: "testingdb", CharSet: "utf8", Collate: "utf8_general_ci"},
+ CreateOpts{Name: "sampledb"},
+ }
+
+ res := Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ expectedDBs := []Database{
+ Database{Name: "anotherexampledb"},
+ Database{Name: "exampledb"},
+ Database{Name: "nextround"},
+ Database{Name: "sampledb"},
+ Database{Name: "testingdb"},
+ }
+
+ pages := 0
+ err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractDBs(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, expectedDBs, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDelete(t)
+
+ err := Delete(fake.ServiceClient(), instanceID, "{dbName}").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/db/v1/databases/results.go b/openstack/db/v1/databases/results.go
new file mode 100644
index 0000000..7d4b6ae
--- /dev/null
+++ b/openstack/db/v1/databases/results.go
@@ -0,0 +1,72 @@
+package databases
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Database represents a Database API resource.
+type Database struct {
+ // Specifies the name of the MySQL database.
+ Name string
+
+ // Set of symbols and encodings. The default character set is utf8.
+ CharSet string
+
+ // Set of rules for comparing characters in a character set. The default
+ // value for collate is utf8_general_ci.
+ Collate string
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// DBPage represents a single page of a paginated DB collection.
+type DBPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page DBPage) IsEmpty() (bool, error) {
+ dbs, err := ExtractDBs(page)
+ if err != nil {
+ return true, err
+ }
+ return len(dbs) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page DBPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"databases_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractDBs will convert a generic pagination struct into a more
+// relevant slice of DB structs.
+func ExtractDBs(page pagination.Page) ([]Database, error) {
+ casted := page.(DBPage).Body
+
+ var response struct {
+ Databases []Database `mapstructure:"databases"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Databases, err
+}
diff --git a/openstack/db/v1/databases/urls.go b/openstack/db/v1/databases/urls.go
new file mode 100644
index 0000000..027ca58
--- /dev/null
+++ b/openstack/db/v1/databases/urls.go
@@ -0,0 +1,11 @@
+package databases
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+ return c.ServiceURL("instances", instanceID, "databases")
+}
+
+func dbURL(c *gophercloud.ServiceClient, instanceID, dbName string) string {
+ return c.ServiceURL("instances", instanceID, "databases", dbName)
+}
diff --git a/openstack/db/v1/datastores/doc.go b/openstack/db/v1/datastores/doc.go
new file mode 100644
index 0000000..ae14026
--- /dev/null
+++ b/openstack/db/v1/datastores/doc.go
@@ -0,0 +1,3 @@
+// Package datastores provides information and interaction with the datastore
+// API resource in the Rackspace Database service.
+package datastores
diff --git a/openstack/db/v1/datastores/fixtures.go b/openstack/db/v1/datastores/fixtures.go
new file mode 100644
index 0000000..fd767cd
--- /dev/null
+++ b/openstack/db/v1/datastores/fixtures.go
@@ -0,0 +1,100 @@
+package datastores
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const version1JSON = `
+{
+ "id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+ "links": [
+ {
+ "href": "https://10.240.28.38:8779/v1.0/1234/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb",
+ "rel": "self"
+ },
+ {
+ "href": "https://10.240.28.38:8779/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "5.1"
+}
+`
+
+const version2JSON = `
+{
+ "id": "c00000b0-00c0-0c00-00c0-000b000000cc",
+ "links": [
+ {
+ "href": "https://10.240.28.38:8779/v1.0/1234/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc",
+ "rel": "self"
+ },
+ {
+ "href": "https://10.240.28.38:8779/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "5.2"
+}
+`
+
+var versionsJSON = fmt.Sprintf(`"versions": [%s, %s]`, version1JSON, version2JSON)
+
+var singleDSJSON = fmt.Sprintf(`
+{
+ "default_version": "c00000b0-00c0-0c00-00c0-000b000000cc",
+ "id": "10000000-0000-0000-0000-000000000001",
+ "links": [
+ {
+ "href": "https://10.240.28.38:8779/v1.0/1234/datastores/10000000-0000-0000-0000-000000000001",
+ "rel": "self"
+ },
+ {
+ "href": "https://10.240.28.38:8779/datastores/10000000-0000-0000-0000-000000000001",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "mysql",
+ %s
+}
+`, versionsJSON)
+
+var (
+ ListDSResp = fmt.Sprintf(`{"datastores":[%s]}`, singleDSJSON)
+ GetDSResp = fmt.Sprintf(`{"datastore":%s}`, singleDSJSON)
+ ListVersionsResp = fmt.Sprintf(`{%s}`, versionsJSON)
+ GetVersionResp = fmt.Sprintf(`{"version":%s}`, version1JSON)
+)
+
+var ExampleVersion1 = Version{
+ ID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+ gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+ },
+ Name: "5.1",
+}
+
+var exampleVersion2 = Version{
+ ID: "c00000b0-00c0-0c00-00c0-000b000000cc",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+ gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+ },
+ Name: "5.2",
+}
+
+var ExampleVersions = []Version{ExampleVersion1, exampleVersion2}
+
+var ExampleDatastore = Datastore{
+ DefaultVersion: "c00000b0-00c0-0c00-00c0-000b000000cc",
+ ID: "10000000-0000-0000-0000-000000000001",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/10000000-0000-0000-0000-000000000001"},
+ gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/10000000-0000-0000-0000-000000000001"},
+ },
+ Name: "mysql",
+ Versions: ExampleVersions,
+}
diff --git a/openstack/db/v1/datastores/requests.go b/openstack/db/v1/datastores/requests.go
new file mode 100644
index 0000000..9e147ab
--- /dev/null
+++ b/openstack/db/v1/datastores/requests.go
@@ -0,0 +1,47 @@
+package datastores
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all available datastore types that instances can use.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return DatastorePage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, baseURL(client), pageFn)
+}
+
+// Get will retrieve the details of a specified datastore type.
+func Get(client *gophercloud.ServiceClient, datastoreID string) GetResult {
+ var res GetResult
+
+ _, res.Err = client.Request("GET", resourceURL(client, datastoreID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
+
+// ListVersions will list all of the available versions for a specified
+// datastore type.
+func ListVersions(client *gophercloud.ServiceClient, datastoreID string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return VersionPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, versionsURL(client, datastoreID), pageFn)
+}
+
+// GetVersion will retrieve the details of a specified datastore version.
+func GetVersion(client *gophercloud.ServiceClient, datastoreID, versionID string) GetVersionResult {
+ var res GetVersionResult
+
+ _, res.Err = client.Request("GET", versionURL(client, datastoreID, versionID), gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ JSONResponse: &res.Body,
+ })
+
+ return res
+}
diff --git a/openstack/db/v1/datastores/requests_test.go b/openstack/db/v1/datastores/requests_test.go
new file mode 100644
index 0000000..b4ce871
--- /dev/null
+++ b/openstack/db/v1/datastores/requests_test.go
@@ -0,0 +1,78 @@
+package datastores
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores", "GET", "", ListDSResp, 200)
+
+ pages := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractDatastores(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []Datastore{ExampleDatastore}, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}", "GET", "", GetDSResp, 200)
+
+ ds, err := Get(fake.ServiceClient(), "{dsID}").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &ExampleDatastore, ds)
+}
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}/versions", "GET", "", ListVersionsResp, 200)
+
+ pages := 0
+
+ err := ListVersions(fake.ServiceClient(), "{dsID}").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractVersions(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, ExampleVersions, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetVersion(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}/versions/{versionID}", "GET", "", GetVersionResp, 200)
+
+ ds, err := GetVersion(fake.ServiceClient(), "{dsID}", "{versionID}").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &ExampleVersion1, ds)
+}
diff --git a/openstack/db/v1/datastores/results.go b/openstack/db/v1/datastores/results.go
new file mode 100644
index 0000000..a86a3cc
--- /dev/null
+++ b/openstack/db/v1/datastores/results.go
@@ -0,0 +1,123 @@
+package datastores
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Version represents a version API resource. Multiple versions belong to a Datastore.
+type Version struct {
+ ID string
+ Links []gophercloud.Link
+ Name string
+}
+
+// Datastore represents a Datastore API resource.
+type Datastore struct {
+ DefaultVersion string `json:"default_version" mapstructure:"default_version"`
+ ID string
+ Links []gophercloud.Link
+ Name string
+ Versions []Version
+}
+
+// DatastorePartial is a meta structure which is used in various API responses.
+// It is a lightweight and truncated version of a full Datastore resource,
+// offering details of the Version, Type and VersionID only.
+type DatastorePartial struct {
+ Version string
+ Type string
+ VersionID string `json:"version_id" mapstructure:"version_id"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// GetVersionResult represents the result of getting a version.
+type GetVersionResult struct {
+ gophercloud.Result
+}
+
+// DatastorePage represents a page of datastore resources.
+type DatastorePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a Datastore collection is empty.
+func (r DatastorePage) IsEmpty() (bool, error) {
+ is, err := ExtractDatastores(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractDatastores retrieves a slice of datastore structs from a paginated
+// collection.
+func ExtractDatastores(page pagination.Page) ([]Datastore, error) {
+ casted := page.(DatastorePage).Body
+
+ var resp struct {
+ Datastores []Datastore `mapstructure:"datastores" json:"datastores"`
+ }
+
+ err := mapstructure.Decode(casted, &resp)
+ return resp.Datastores, err
+}
+
+// Extract retrieves a single Datastore struct from an operation result.
+func (r GetResult) Extract() (*Datastore, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Datastore Datastore `mapstructure:"datastore"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return &response.Datastore, err
+}
+
+// DatastorePage represents a page of version resources.
+type VersionPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a collection of version resources is empty.
+func (r VersionPage) IsEmpty() (bool, error) {
+ is, err := ExtractVersions(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractVersions retrieves a slice of versions from a paginated collection.
+func ExtractVersions(page pagination.Page) ([]Version, error) {
+ casted := page.(VersionPage).Body
+
+ var resp struct {
+ Versions []Version `mapstructure:"versions" json:"versions"`
+ }
+
+ err := mapstructure.Decode(casted, &resp)
+ return resp.Versions, err
+}
+
+// Extract retrieves a single Version struct from an operation result.
+func (r GetVersionResult) Extract() (*Version, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Version Version `mapstructure:"version"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return &response.Version, err
+}
diff --git a/openstack/db/v1/datastores/urls.go b/openstack/db/v1/datastores/urls.go
new file mode 100644
index 0000000..c4d5248
--- /dev/null
+++ b/openstack/db/v1/datastores/urls.go
@@ -0,0 +1,19 @@
+package datastores
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("datastores")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, dsID string) string {
+ return c.ServiceURL("datastores", dsID)
+}
+
+func versionsURL(c *gophercloud.ServiceClient, dsID string) string {
+ return c.ServiceURL("datastores", dsID, "versions")
+}
+
+func versionURL(c *gophercloud.ServiceClient, dsID, versionID string) string {
+ return c.ServiceURL("datastores", dsID, "versions", versionID)
+}
diff --git a/openstack/db/v1/flavors/doc.go b/openstack/db/v1/flavors/doc.go
new file mode 100644
index 0000000..4d281d5
--- /dev/null
+++ b/openstack/db/v1/flavors/doc.go
@@ -0,0 +1,7 @@
+// Package flavors provides information and interaction with the flavor API
+// resource in the OpenStack Database service.
+//
+// A flavor is an available hardware configuration for a database instance.
+// Each flavor has a unique combination of disk space, memory capacity and
+// priority for CPU time.
+package flavors
diff --git a/openstack/db/v1/flavors/fixtures.go b/openstack/db/v1/flavors/fixtures.go
new file mode 100644
index 0000000..f0016bc
--- /dev/null
+++ b/openstack/db/v1/flavors/fixtures.go
@@ -0,0 +1,50 @@
+package flavors
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+const flavor = `
+{
+ "id": %d,
+ "links": [
+ {
+ "href": "https://openstack.example.com/v1.0/1234/flavors/%d",
+ "rel": "self"
+ },
+ {
+ "href": "https://openstack.example.com/flavors/%d",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "%s",
+ "ram": %d
+}
+`
+
+var (
+ flavorID = "{flavorID}"
+ _baseURL = "/flavors"
+ resURL = "/flavors/" + flavorID
+)
+
+var (
+ flavor1 = fmt.Sprintf(flavor, 1, 1, 1, "m1.tiny", 512)
+ flavor2 = fmt.Sprintf(flavor, 2, 2, 2, "m1.small", 1024)
+ flavor3 = fmt.Sprintf(flavor, 3, 3, 3, "m1.medium", 2048)
+ flavor4 = fmt.Sprintf(flavor, 4, 4, 4, "m1.large", 4096)
+
+ listFlavorsResp = fmt.Sprintf(`{"flavors":[%s, %s, %s, %s]}`, flavor1, flavor2, flavor3, flavor4)
+ getFlavorResp = fmt.Sprintf(`{"flavor": %s}`, flavor1)
+)
+
+func HandleList(t *testing.T) {
+ fixture.SetupHandler(t, _baseURL, "GET", "", listFlavorsResp, 200)
+}
+
+func HandleGet(t *testing.T) {
+ fixture.SetupHandler(t, resURL, "GET", "", getFlavorResp, 200)
+}
diff --git a/openstack/db/v1/flavors/requests.go b/openstack/db/v1/flavors/requests.go
new file mode 100644
index 0000000..fa34446
--- /dev/null
+++ b/openstack/db/v1/flavors/requests.go
@@ -0,0 +1,29 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all available hardware flavors that an instance can use. The
+// operation is identical to the one supported by the Nova API, but without the
+// "disk" property.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, listURL(client), createPage)
+}
+
+// Get will retrieve information for a specified hardware flavor.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var gr GetResult
+
+ _, gr.Err = client.Request("GET", getURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &gr.Body,
+ OkCodes: []int{200},
+ })
+
+ return gr
+}
diff --git a/openstack/db/v1/flavors/requests_test.go b/openstack/db/v1/flavors/requests_test.go
new file mode 100644
index 0000000..88b5871
--- /dev/null
+++ b/openstack/db/v1/flavors/requests_test.go
@@ -0,0 +1,91 @@
+package flavors
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListFlavors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ pages := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Flavor{
+ Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/1", Rel: "bookmark"},
+ },
+ },
+ Flavor{
+ ID: "2",
+ Name: "m1.small",
+ RAM: 1024,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/2", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/2", Rel: "bookmark"},
+ },
+ },
+ Flavor{
+ ID: "3",
+ Name: "m1.medium",
+ RAM: 2048,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/3", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/3", Rel: "bookmark"},
+ },
+ },
+ Flavor{
+ ID: "4",
+ Name: "m1.large",
+ RAM: 4096,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/4", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/4", Rel: "bookmark"},
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetFlavor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGet(t)
+
+ actual, err := Get(fake.ServiceClient(), flavorID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/db/v1/flavors/results.go b/openstack/db/v1/flavors/results.go
new file mode 100644
index 0000000..2cee010
--- /dev/null
+++ b/openstack/db/v1/flavors/results.go
@@ -0,0 +1,92 @@
+package flavors
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract provides access to the individual Flavor returned by the Get function.
+func (gr GetResult) Extract() (*Flavor, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+
+ var result struct {
+ Flavor Flavor `mapstructure:"flavor"`
+ }
+
+ decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
+ WeaklyTypedInput: true,
+ Result: &result,
+ })
+
+ err = decoder.Decode(gr.Body)
+ return &result.Flavor, err
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+ // The flavor's unique identifier.
+ ID string `mapstructure:"id"`
+
+ // The RAM capacity for the flavor.
+ RAM int `mapstructure:"ram"`
+
+ // The Name field provides a human-readable moniker for the flavor.
+ Name string `mapstructure:"name"`
+
+ // Links to access the flavor.
+ Links []gophercloud.Link
+}
+
+// FlavorPage contains a single page of the response from a List call.
+type FlavorPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty determines if a page contains any results.
+func (p FlavorPage) IsEmpty() (bool, error) {
+ flavors, err := ExtractFlavors(p)
+ if err != nil {
+ return true, err
+ }
+ return len(flavors) == 0, nil
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (p FlavorPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"flavors_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(page pagination.Page) ([]Flavor, error) {
+ casted := page.(FlavorPage).Body
+ var container struct {
+ Flavors []Flavor `mapstructure:"flavors"`
+ }
+
+ decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
+ WeaklyTypedInput: true,
+ Result: &container,
+ })
+
+ err = decoder.Decode(casted)
+
+ return container.Flavors, err
+}
diff --git a/openstack/db/v1/flavors/urls.go b/openstack/db/v1/flavors/urls.go
new file mode 100644
index 0000000..80da11f
--- /dev/null
+++ b/openstack/db/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/rackspace/gophercloud"
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("flavors", id)
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("flavors")
+}
diff --git a/openstack/db/v1/instances/doc.go b/openstack/db/v1/instances/doc.go
new file mode 100644
index 0000000..dc5c90f
--- /dev/null
+++ b/openstack/db/v1/instances/doc.go
@@ -0,0 +1,7 @@
+// Package instances provides information and interaction with the instance API
+// resource in the OpenStack Database service.
+//
+// A database instance is an isolated database environment with compute and
+// storage resources in a single tenant environment on a shared physical host
+// machine.
+package instances
diff --git a/openstack/db/v1/instances/fixtures.go b/openstack/db/v1/instances/fixtures.go
new file mode 100644
index 0000000..af7b185
--- /dev/null
+++ b/openstack/db/v1/instances/fixtures.go
@@ -0,0 +1,169 @@
+package instances
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ timestamp = "2015-11-12T14:22:42Z"
+ timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var instance = `
+{
+ "created": "` + timestamp + `",
+ "datastore": {
+ "type": "mysql",
+ "version": "5.6"
+ },
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "https://my-openstack.com/v1.0/1234/flavors/1",
+ "rel": "self"
+ },
+ {
+ "href": "https://my-openstack.com/v1.0/1234/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "https://my-openstack.com/v1.0/1234/instances/1",
+ "rel": "self"
+ }
+ ],
+ "hostname": "e09ad9a3f73309469cf1f43d11e79549caf9acf2.my-openstack.com",
+ "id": "{instanceID}",
+ "name": "json_rack_instance",
+ "status": "BUILD",
+ "updated": "` + timestamp + `",
+ "volume": {
+ "size": 2
+ }
+}
+`
+
+var createReq = `
+{
+ "instance": {
+ "databases": [
+ {
+ "character_set": "utf8",
+ "collate": "utf8_general_ci",
+ "name": "sampledb"
+ },
+ {
+ "name": "nextround"
+ }
+ ],
+ "flavorRef": "1",
+ "name": "json_rack_instance",
+ "users": [
+ {
+ "databases": [
+ {
+ "name": "sampledb"
+ }
+ ],
+ "name": "demouser",
+ "password": "demopassword"
+ }
+ ],
+ "volume": {
+ "size": 2
+ }
+ }
+}
+`
+
+var (
+ instanceID = "{instanceID}"
+ rootURL = "/instances"
+ resURL = rootURL + "/" + instanceID
+ uRootURL = resURL + "/root"
+ aURL = resURL + "/action"
+)
+
+var (
+ restartReq = `{"restart": {}}`
+ resizeReq = `{"resize": {"flavorRef": "2"}}`
+ resizeVolReq = `{"resize": {"volume": {"size": 4}}}`
+)
+
+var (
+ createResp = fmt.Sprintf(`{"instance": %s}`, instance)
+ listInstancesResp = fmt.Sprintf(`{"instances":[%s]}`, instance)
+ getInstanceResp = createResp
+ enableUserResp = `{"user":{"name":"root","password":"secretsecret"}}`
+ isUserEnabledResp = `{"rootEnabled":true}`
+)
+
+var expectedInstance = Instance{
+ Created: timeVal,
+ Updated: timeVal,
+ Flavor: flavors.Flavor{
+ ID: "1",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/flavors/1", Rel: "self"},
+ gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/flavors/1", Rel: "bookmark"},
+ },
+ },
+ Hostname: "e09ad9a3f73309469cf1f43d11e79549caf9acf2.my-openstack.com",
+ ID: instanceID,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/instances/1", Rel: "self"},
+ },
+ Name: "json_rack_instance",
+ Status: "BUILD",
+ Volume: Volume{Size: 2},
+ Datastore: datastores.DatastorePartial{
+ Type: "mysql",
+ Version: "5.6",
+ },
+}
+
+func HandleCreate(t *testing.T) {
+ fixture.SetupHandler(t, rootURL, "POST", createReq, createResp, 200)
+}
+
+func HandleList(t *testing.T) {
+ fixture.SetupHandler(t, rootURL, "GET", "", listInstancesResp, 200)
+}
+
+func HandleGet(t *testing.T) {
+ fixture.SetupHandler(t, resURL, "GET", "", getInstanceResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+ fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+}
+
+func HandleEnableRoot(t *testing.T) {
+ fixture.SetupHandler(t, uRootURL, "POST", "", enableUserResp, 200)
+}
+
+func HandleIsRootEnabled(t *testing.T) {
+ fixture.SetupHandler(t, uRootURL, "GET", "", isUserEnabledResp, 200)
+}
+
+func HandleRestart(t *testing.T) {
+ fixture.SetupHandler(t, aURL, "POST", restartReq, "", 202)
+}
+
+func HandleResize(t *testing.T) {
+ fixture.SetupHandler(t, aURL, "POST", resizeReq, "", 202)
+}
+
+func HandleResizeVol(t *testing.T) {
+ fixture.SetupHandler(t, aURL, "POST", resizeVolReq, "", 202)
+}
diff --git a/openstack/db/v1/instances/requests.go b/openstack/db/v1/instances/requests.go
new file mode 100644
index 0000000..f4a63b8
--- /dev/null
+++ b/openstack/db/v1/instances/requests.go
@@ -0,0 +1,238 @@
+package instances
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the top-level interface for create options.
+type CreateOptsBuilder interface {
+ ToInstanceCreateMap() (map[string]interface{}, error)
+}
+
+// DatastoreOpts represents the configuration for how an instance stores data.
+type DatastoreOpts struct {
+ Version string
+ Type string
+}
+
+func (opts DatastoreOpts) ToMap() (map[string]string, error) {
+ return map[string]string{
+ "version": opts.Version,
+ "type": opts.Type,
+ }, nil
+}
+
+// CreateOpts is the struct responsible for configuring a new database instance.
+type CreateOpts struct {
+ // Either the integer UUID (in string form) of the flavor, or its URI
+ // reference as specified in the response from the List() call. Required.
+ FlavorRef string
+
+ // Specifies the volume size in gigabytes (GB). The value must be between 1
+ // and 300. Required.
+ Size int
+
+ // Name of the instance to create. The length of the name is limited to
+ // 255 characters and any characters are permitted. Optional.
+ Name string
+
+ // A slice of database information options.
+ Databases db.CreateOptsBuilder
+
+ // A slice of user information options.
+ Users users.CreateOptsBuilder
+
+ // Options to configure the type of datastore the instance will use. This is
+ // optional, and if excluded will default to MySQL.
+ Datastore *DatastoreOpts
+}
+
+// ToInstanceCreateMap will render a JSON map.
+func (opts CreateOpts) ToInstanceCreateMap() (map[string]interface{}, error) {
+ if opts.Size > 300 || opts.Size < 1 {
+ return nil, fmt.Errorf("Size (GB) must be between 1-300")
+ }
+ if opts.FlavorRef == "" {
+ return nil, fmt.Errorf("FlavorRef is a required field")
+ }
+
+ instance := map[string]interface{}{
+ "volume": map[string]int{"size": opts.Size},
+ "flavorRef": opts.FlavorRef,
+ }
+
+ if opts.Name != "" {
+ instance["name"] = opts.Name
+ }
+ if opts.Databases != nil {
+ dbs, err := opts.Databases.ToDBCreateMap()
+ if err != nil {
+ return nil, err
+ }
+ instance["databases"] = dbs["databases"]
+ }
+ if opts.Users != nil {
+ users, err := opts.Users.ToUserCreateMap()
+ if err != nil {
+ return nil, err
+ }
+ instance["users"] = users["users"]
+ }
+
+ return map[string]interface{}{"instance": instance}, nil
+}
+
+// Create asynchronously provisions a new database instance. It requires the
+// user to specify a flavor and a volume size. The API service then provisions
+// the instance with the requested flavor and sets up a volume of the specified
+// size, which is the storage for the database instance.
+//
+// Although this call only allows the creation of 1 instance per request, you
+// can create an instance with multiple databases and users. The default
+// binding for a MySQL instance is port 3306.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToInstanceCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("POST", baseURL(client), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// List retrieves the status and information for all database instances.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return InstancePage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, baseURL(client), createPageFn)
+}
+
+// Get retrieves the status and information for a specified database instance.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+
+ _, res.Err = client.Request("GET", resourceURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// Delete permanently destroys the database instance.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = client.Request("DELETE", resourceURL(client, id), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// EnableRootUser enables the login from any host for the root user and
+// provides the user with a generated root password.
+func EnableRootUser(client *gophercloud.ServiceClient, id string) UserRootResult {
+ var res UserRootResult
+
+ _, res.Err = client.Request("POST", userRootURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// IsRootEnabled checks an instance to see if root access is enabled. It returns
+// True if root user is enabled for the specified database instance or False
+// otherwise.
+func IsRootEnabled(client *gophercloud.ServiceClient, id string) (bool, error) {
+ var res gophercloud.Result
+
+ _, err := client.Request("GET", userRootURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res.Body.(map[string]interface{})["rootEnabled"] == true, err
+}
+
+// Restart will restart only the MySQL Instance. Restarting MySQL will
+// erase any dynamic configuration settings that you have made within MySQL.
+// The MySQL service will be unavailable until the instance restarts.
+func Restart(client *gophercloud.ServiceClient, id string) ActionResult {
+ var res ActionResult
+
+ _, res.Err = client.Request("POST", actionURL(client, id), gophercloud.RequestOpts{
+ JSONBody: map[string]interface{}{"restart": struct{}{}},
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// Resize changes the memory size of the instance, assuming a valid
+// flavorRef is provided. It will also restart the MySQL service.
+func Resize(client *gophercloud.ServiceClient, id, flavorRef string) ActionResult {
+ var res ActionResult
+
+ type resize struct {
+ FlavorRef string `json:"flavorRef"`
+ }
+
+ type req struct {
+ Resize resize `json:"resize"`
+ }
+
+ reqBody := req{Resize: resize{FlavorRef: flavorRef}}
+
+ _, res.Err = client.Request("POST", actionURL(client, id), gophercloud.RequestOpts{
+ JSONBody: reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// ResizeVolume will resize the attached volume for an instance. It supports
+// only increasing the volume size and does not support decreasing the size.
+// The volume size is in gigabytes (GB) and must be an integer.
+func ResizeVolume(client *gophercloud.ServiceClient, id string, size int) ActionResult {
+ var res ActionResult
+
+ type volume struct {
+ Size int `json:"size"`
+ }
+
+ type resize struct {
+ Volume volume `json:"volume"`
+ }
+
+ type req struct {
+ Resize resize `json:"resize"`
+ }
+
+ reqBody := req{Resize: resize{Volume: volume{Size: size}}}
+
+ _, res.Err = client.Request("POST", actionURL(client, id), gophercloud.RequestOpts{
+ JSONBody: reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/openstack/db/v1/instances/requests_test.go b/openstack/db/v1/instances/requests_test.go
new file mode 100644
index 0000000..3cc2b70
--- /dev/null
+++ b/openstack/db/v1/instances/requests_test.go
@@ -0,0 +1,133 @@
+package instances
+
+import (
+ "testing"
+
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := CreateOpts{
+ Name: "json_rack_instance",
+ FlavorRef: "1",
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{CharSet: "utf8", Collate: "utf8_general_ci", Name: "sampledb"},
+ db.CreateOpts{Name: "nextround"},
+ },
+ Users: users.BatchCreateOpts{
+ users.CreateOpts{
+ Name: "demouser",
+ Password: "demopassword",
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "sampledb"},
+ },
+ },
+ },
+ Size: 2,
+ }
+
+ instance, err := Create(fake.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &expectedInstance, instance)
+}
+
+func TestInstanceList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ pages := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []Instance{expectedInstance}, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetInstance(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGet(t)
+
+ instance, err := Get(fake.ServiceClient(), instanceID).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &expectedInstance, instance)
+}
+
+func TestDeleteInstance(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDelete(t)
+
+ res := Delete(fake.ServiceClient(), instanceID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestEnableRootUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleEnableRoot(t)
+
+ expected := &users.User{Name: "root", Password: "secretsecret"}
+ user, err := EnableRootUser(fake.ServiceClient(), instanceID).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestIsRootEnabled(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleIsRootEnabled(t)
+
+ isEnabled, err := IsRootEnabled(fake.ServiceClient(), instanceID)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, isEnabled)
+}
+
+func TestRestart(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRestart(t)
+
+ res := Restart(fake.ServiceClient(), instanceID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleResize(t)
+
+ res := Resize(fake.ServiceClient(), instanceID, "2")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResizeVolume(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleResizeVol(t)
+
+ res := ResizeVolume(fake.ServiceClient(), instanceID, 4)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/instances/results.go b/openstack/db/v1/instances/results.go
new file mode 100644
index 0000000..95aed16
--- /dev/null
+++ b/openstack/db/v1/instances/results.go
@@ -0,0 +1,213 @@
+package instances
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Volume represents information about an attached volume for a database instance.
+type Volume struct {
+ // The size in GB of the volume
+ Size int
+
+ Used float64
+}
+
+// Instance represents a remote MySQL instance.
+type Instance struct {
+ // Indicates the datetime that the instance was created
+ Created time.Time `mapstructure:"-"`
+
+ // Indicates the most recent datetime that the instance was updated.
+ Updated time.Time `mapstructure:"-"`
+
+ // Indicates the hardware flavor the instance uses.
+ Flavor flavors.Flavor
+
+ // A DNS-resolvable hostname associated with the database instance (rather
+ // than an IPv4 address). Since the hostname always resolves to the correct
+ // IP address of the database instance, this relieves the user from the task
+ // of maintaining the mapping. Note that although the IP address may likely
+ // change on resizing, migrating, and so forth, the hostname always resolves
+ // to the correct database instance.
+ Hostname string
+
+ // Indicates the unique identifier for the instance resource.
+ ID string
+
+ // Exposes various links that reference the instance resource.
+ Links []gophercloud.Link
+
+ // The human-readable name of the instance.
+ Name string
+
+ // The build status of the instance.
+ Status string
+
+ // Information about the attached volume of the instance.
+ Volume Volume
+
+ // Indicates how the instance stores data.
+ Datastore datastores.DatastorePartial
+}
+
+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
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Extract will extract an Instance from various result structs.
+func (r commonResult) Extract() (*Instance, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Instance Instance `mapstructure:"instance"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ val := r.Body.(map[string]interface{})["instance"].(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Instance, err
+ }
+ response.Instance.Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Instance, err
+ }
+ response.Instance.Updated = updatedTime
+ }
+
+ return &response.Instance, err
+}
+
+// InstancePage represents a single page of a paginated instance collection.
+type InstancePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page InstancePage) IsEmpty() (bool, error) {
+ instances, err := ExtractInstances(page)
+ if err != nil {
+ return true, err
+ }
+ return len(instances) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page InstancePage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"instances_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractInstances will convert a generic pagination struct into a more
+// relevant slice of Instance structs.
+func ExtractInstances(page pagination.Page) ([]Instance, error) {
+ casted := page.(InstancePage).Body
+
+ var resp struct {
+ Instances []Instance `mapstructure:"instances"`
+ }
+
+ if err := mapstructure.Decode(casted, &resp); err != nil {
+ return nil, err
+ }
+
+ var vals []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ vals = casted.(map[string]interface{})["instances"].([]interface{})
+ case map[string][]interface{}:
+ vals = casted.(map[string][]interface{})["instances"]
+ default:
+ return resp.Instances, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, v := range vals {
+ val := v.(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Instances, err
+ }
+ resp.Instances[i].Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Instances, err
+ }
+ resp.Instances[i].Updated = updatedTime
+ }
+ }
+
+ return resp.Instances, nil
+}
+
+// UserRootResult represents the result of an operation to enable the root user.
+type UserRootResult struct {
+ gophercloud.Result
+}
+
+// Extract will extract root user information from a UserRootResult.
+func (r UserRootResult) Extract() (*users.User, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ User users.User `mapstructure:"user"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.User, err
+}
+
+// ActionResult represents the result of action requests, such as: restarting
+// an instance service, resizing its memory allocation, and resizing its
+// attached volume size.
+type ActionResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/db/v1/instances/urls.go b/openstack/db/v1/instances/urls.go
new file mode 100644
index 0000000..28c0bec
--- /dev/null
+++ b/openstack/db/v1/instances/urls.go
@@ -0,0 +1,19 @@
+package instances
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("instances")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id)
+}
+
+func userRootURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id, "root")
+}
+
+func actionURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id, "action")
+}
diff --git a/openstack/db/v1/users/doc.go b/openstack/db/v1/users/doc.go
new file mode 100644
index 0000000..cf07832
--- /dev/null
+++ b/openstack/db/v1/users/doc.go
@@ -0,0 +1,3 @@
+// Package users provides information and interaction with the user API
+// resource in the OpenStack Database service.
+package users
diff --git a/openstack/db/v1/users/fixtures.go b/openstack/db/v1/users/fixtures.go
new file mode 100644
index 0000000..516b335
--- /dev/null
+++ b/openstack/db/v1/users/fixtures.go
@@ -0,0 +1,37 @@
+package users
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+const user1 = `
+{"databases": [{"name": "databaseA"}],"name": "dbuser3"%s}
+`
+
+const user2 = `
+{"databases": [{"name": "databaseB"},{"name": "databaseC"}],"name": "dbuser4"%s}
+`
+
+var (
+ instanceID = "{instanceID}"
+ _rootURL = "/instances/" + instanceID + "/users"
+ pUser1 = fmt.Sprintf(user1, `,"password":"secretsecret"`)
+ pUser2 = fmt.Sprintf(user2, `,"password":"secretsecret"`)
+ createReq = fmt.Sprintf(`{"users":[%s, %s]}`, pUser1, pUser2)
+ listResp = fmt.Sprintf(`{"users":[%s, %s]}`, fmt.Sprintf(user1, ""), fmt.Sprintf(user2, ""))
+)
+
+func HandleCreate(t *testing.T) {
+ fixture.SetupHandler(t, _rootURL, "POST", createReq, "", 202)
+}
+
+func HandleList(t *testing.T) {
+ fixture.SetupHandler(t, _rootURL, "GET", "", listResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+ fixture.SetupHandler(t, _rootURL+"/{userName}", "DELETE", "", "", 202)
+}
diff --git a/openstack/db/v1/users/requests.go b/openstack/db/v1/users/requests.go
new file mode 100644
index 0000000..7533fc4
--- /dev/null
+++ b/openstack/db/v1/users/requests.go
@@ -0,0 +1,132 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the top-level interface for creating JSON maps.
+type CreateOptsBuilder interface {
+ ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the struct responsible for configuring a new user; often in the
+// context of an instance.
+type CreateOpts struct {
+ // [REQUIRED] Specifies a name for the user. Valid names can be composed
+ // of the following characters: letters (either case); numbers; these
+ // characters '@', '?', '#', ' ' but NEVER beginning a name string; '_' is
+ // permitted anywhere. Prohibited characters that are forbidden include:
+ // single quotes, double quotes, back quotes, semicolons, commas, backslashes,
+ // and forward slashes. Spaces at the front or end of a user name are also
+ // not permitted.
+ Name string
+
+ // [REQUIRED] Specifies a password for the user.
+ Password string
+
+ // [OPTIONAL] An array of databases that this user will connect to. The
+ // "name" field is the only requirement for each option.
+ Databases db.BatchCreateOpts
+
+ // [OPTIONAL] Specifies the host from which a user is allowed to connect to
+ // the database. Possible values are a string containing an IPv4 address or
+ // "%" to allow connecting from any host. Optional; the default is "%".
+ Host string
+}
+
+// ToMap is a convenience function for creating sub-maps for individual users.
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+
+ if opts.Name == "root" {
+ return nil, errors.New("root is a reserved user name and cannot be used")
+ }
+ if opts.Name == "" {
+ return nil, errors.New("Name is a required field")
+ }
+ if opts.Password == "" {
+ return nil, errors.New("Password is a required field")
+ }
+
+ user := map[string]interface{}{
+ "name": opts.Name,
+ "password": opts.Password,
+ }
+
+ if opts.Host != "" {
+ user["host"] = opts.Host
+ }
+
+ dbs := make([]map[string]string, len(opts.Databases))
+ for i, db := range opts.Databases {
+ dbs[i] = map[string]string{"name": db.Name}
+ }
+
+ if len(dbs) > 0 {
+ user["databases"] = dbs
+ }
+
+ return user, nil
+}
+
+// BatchCreateOpts allows multiple users to be created at once.
+type BatchCreateOpts []CreateOpts
+
+// ToUserCreateMap will generate a JSON map.
+func (opts BatchCreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+ users := make([]map[string]interface{}, len(opts))
+ for i, opt := range opts {
+ user, err := opt.ToMap()
+ if err != nil {
+ return nil, err
+ }
+ users[i] = user
+ }
+ return map[string]interface{}{"users": users}, nil
+}
+
+// Create asynchronously provisions a new user for the specified database
+// instance based on the configuration defined in CreateOpts. If databases are
+// assigned for a particular user, the user will be granted all privileges
+// for those specified databases. "root" is a reserved name and cannot be used.
+func Create(client *gophercloud.ServiceClient, instanceID string, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToUserCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("POST", baseURL(client, instanceID), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// List will list all the users associated with a specified database instance,
+// along with their associated databases. This operation will not return any
+// system users or administrators for a database.
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, baseURL(client, instanceID), createPageFn)
+}
+
+// Delete will permanently delete a user from a specified database instance.
+func Delete(client *gophercloud.ServiceClient, instanceID, userName string) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = client.Request("DELETE", userURL(client, instanceID, userName), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/openstack/db/v1/users/requests_test.go b/openstack/db/v1/users/requests_test.go
new file mode 100644
index 0000000..5711f63
--- /dev/null
+++ b/openstack/db/v1/users/requests_test.go
@@ -0,0 +1,84 @@
+package users
+
+import (
+ "testing"
+
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := BatchCreateOpts{
+ CreateOpts{
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "databaseA"},
+ },
+ Name: "dbuser3",
+ Password: "secretsecret",
+ },
+ CreateOpts{
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "databaseB"},
+ db.CreateOpts{Name: "databaseC"},
+ },
+ Name: "dbuser4",
+ Password: "secretsecret",
+ },
+ }
+
+ res := Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUserList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ expectedUsers := []User{
+ User{
+ Databases: []db.Database{
+ db.Database{Name: "databaseA"},
+ },
+ Name: "dbuser3",
+ },
+ User{
+ Databases: []db.Database{
+ db.Database{Name: "databaseB"},
+ db.Database{Name: "databaseC"},
+ },
+ Name: "dbuser4",
+ },
+ }
+
+ pages := 0
+ err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractUsers(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, expectedUsers, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDelete(t)
+
+ res := Delete(fake.ServiceClient(), instanceID, "{userName}")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/users/results.go b/openstack/db/v1/users/results.go
new file mode 100644
index 0000000..217ddd8
--- /dev/null
+++ b/openstack/db/v1/users/results.go
@@ -0,0 +1,73 @@
+package users
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// User represents a database user
+type User struct {
+ // The user name
+ Name string
+
+ // The user password
+ Password string
+
+ // The databases associated with this user
+ Databases []db.Database
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// UserPage represents a single page of a paginated user collection.
+type UserPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page UserPage) IsEmpty() (bool, error) {
+ users, err := ExtractUsers(page)
+ if err != nil {
+ return true, err
+ }
+ return len(users) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page UserPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"users_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractUsers will convert a generic pagination struct into a more
+// relevant slice of User structs.
+func ExtractUsers(page pagination.Page) ([]User, error) {
+ casted := page.(UserPage).Body
+
+ var response struct {
+ Users []User `mapstructure:"users"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+
+ return response.Users, err
+}
diff --git a/openstack/db/v1/users/urls.go b/openstack/db/v1/users/urls.go
new file mode 100644
index 0000000..2a3cacd
--- /dev/null
+++ b/openstack/db/v1/users/urls.go
@@ -0,0 +1,11 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+ return c.ServiceURL("instances", instanceID, "users")
+}
+
+func userURL(c *gophercloud.ServiceClient, instanceID, userName string) string {
+ return c.ServiceURL("instances", instanceID, "users", userName)
+}
diff --git a/openstack/endpoint_location.go b/openstack/endpoint_location.go
new file mode 100644
index 0000000..29d02c4
--- /dev/null
+++ b/openstack/endpoint_location.go
@@ -0,0 +1,91 @@
+package openstack
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+)
+
+// V2EndpointURL discovers the endpoint URL for a specific service from a ServiceCatalog acquired
+// during the v2 identity service. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V2EndpointURL(catalog *tokens2.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
+ // Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
+ var endpoints = make([]tokens2.Endpoint, 0, 1)
+ for _, entry := range catalog.Entries {
+ if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+ for _, endpoint := range entry.Endpoints {
+ if opts.Region == "" || endpoint.Region == opts.Region {
+ endpoints = append(endpoints, endpoint)
+ }
+ }
+ }
+ }
+
+ // Report an error if the options were ambiguous.
+ if len(endpoints) > 1 {
+ return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+ }
+
+ // Extract the appropriate URL from the matching Endpoint.
+ for _, endpoint := range endpoints {
+ switch opts.Availability {
+ case gophercloud.AvailabilityPublic:
+ return gophercloud.NormalizeURL(endpoint.PublicURL), nil
+ case gophercloud.AvailabilityInternal:
+ return gophercloud.NormalizeURL(endpoint.InternalURL), nil
+ case gophercloud.AvailabilityAdmin:
+ return gophercloud.NormalizeURL(endpoint.AdminURL), nil
+ default:
+ return "", fmt.Errorf("Unexpected availability in endpoint query: %s", opts.Availability)
+ }
+ }
+
+ // Report an error if there were no matching endpoints.
+ return "", gophercloud.ErrEndpointNotFound
+}
+
+// V3EndpointURL discovers the endpoint URL for a specific service from a Catalog acquired
+// during the v3 identity service. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V3EndpointURL(catalog *tokens3.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
+ // Extract Endpoints from the catalog entries that match the requested Type, Interface,
+ // Name if provided, and Region if provided.
+ var endpoints = make([]tokens3.Endpoint, 0, 1)
+ for _, entry := range catalog.Entries {
+ if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+ for _, endpoint := range entry.Endpoints {
+ if opts.Availability != gophercloud.AvailabilityAdmin &&
+ opts.Availability != gophercloud.AvailabilityPublic &&
+ opts.Availability != gophercloud.AvailabilityInternal {
+ return "", fmt.Errorf("Unexpected availability in endpoint query: %s", opts.Availability)
+ }
+ if (opts.Availability == gophercloud.Availability(endpoint.Interface)) &&
+ (opts.Region == "" || endpoint.Region == opts.Region) {
+ endpoints = append(endpoints, endpoint)
+ }
+ }
+ }
+ }
+
+ // Report an error if the options were ambiguous.
+ if len(endpoints) > 1 {
+ return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+ }
+
+ // Extract the URL from the matching Endpoint.
+ for _, endpoint := range endpoints {
+ return gophercloud.NormalizeURL(endpoint.URL), nil
+ }
+
+ // Report an error if there were no matching endpoints.
+ return "", gophercloud.ErrEndpointNotFound
+}
diff --git a/openstack/endpoint_location_test.go b/openstack/endpoint_location_test.go
new file mode 100644
index 0000000..8e65918
--- /dev/null
+++ b/openstack/endpoint_location_test.go
@@ -0,0 +1,228 @@
+package openstack
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+// Service catalog fixtures take too much vertical space!
+var catalog2 = tokens2.ServiceCatalog{
+ Entries: []tokens2.CatalogEntry{
+ tokens2.CatalogEntry{
+ Type: "same",
+ Name: "same",
+ Endpoints: []tokens2.Endpoint{
+ tokens2.Endpoint{
+ Region: "same",
+ PublicURL: "https://public.correct.com/",
+ InternalURL: "https://internal.correct.com/",
+ AdminURL: "https://admin.correct.com/",
+ },
+ tokens2.Endpoint{
+ Region: "different",
+ PublicURL: "https://badregion.com/",
+ },
+ },
+ },
+ tokens2.CatalogEntry{
+ Type: "same",
+ Name: "different",
+ Endpoints: []tokens2.Endpoint{
+ tokens2.Endpoint{
+ Region: "same",
+ PublicURL: "https://badname.com/",
+ },
+ tokens2.Endpoint{
+ Region: "different",
+ PublicURL: "https://badname.com/+badregion",
+ },
+ },
+ },
+ tokens2.CatalogEntry{
+ Type: "different",
+ Name: "different",
+ Endpoints: []tokens2.Endpoint{
+ tokens2.Endpoint{
+ Region: "same",
+ PublicURL: "https://badtype.com/+badname",
+ },
+ tokens2.Endpoint{
+ Region: "different",
+ PublicURL: "https://badtype.com/+badregion+badname",
+ },
+ },
+ },
+ },
+}
+
+func TestV2EndpointExact(t *testing.T) {
+ expectedURLs := map[gophercloud.Availability]string{
+ gophercloud.AvailabilityPublic: "https://public.correct.com/",
+ gophercloud.AvailabilityAdmin: "https://admin.correct.com/",
+ gophercloud.AvailabilityInternal: "https://internal.correct.com/",
+ }
+
+ for availability, expected := range expectedURLs {
+ actual, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+ Type: "same",
+ Name: "same",
+ Region: "same",
+ Availability: availability,
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, expected, actual)
+ }
+}
+
+func TestV2EndpointNone(t *testing.T) {
+ _, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+ Type: "nope",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ th.CheckEquals(t, gophercloud.ErrEndpointNotFound, err)
+}
+
+func TestV2EndpointMultiple(t *testing.T) {
+ _, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+ Type: "same",
+ Region: "same",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ if !strings.HasPrefix(err.Error(), "Discovered 2 matching endpoints:") {
+ t.Errorf("Received unexpected error: %v", err)
+ }
+}
+
+func TestV2EndpointBadAvailability(t *testing.T) {
+ _, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+ Type: "same",
+ Name: "same",
+ Region: "same",
+ Availability: "wat",
+ })
+ th.CheckEquals(t, "Unexpected availability in endpoint query: wat", err.Error())
+}
+
+var catalog3 = tokens3.ServiceCatalog{
+ Entries: []tokens3.CatalogEntry{
+ tokens3.CatalogEntry{
+ Type: "same",
+ Name: "same",
+ Endpoints: []tokens3.Endpoint{
+ tokens3.Endpoint{
+ ID: "1",
+ Region: "same",
+ Interface: "public",
+ URL: "https://public.correct.com/",
+ },
+ tokens3.Endpoint{
+ ID: "2",
+ Region: "same",
+ Interface: "admin",
+ URL: "https://admin.correct.com/",
+ },
+ tokens3.Endpoint{
+ ID: "3",
+ Region: "same",
+ Interface: "internal",
+ URL: "https://internal.correct.com/",
+ },
+ tokens3.Endpoint{
+ ID: "4",
+ Region: "different",
+ Interface: "public",
+ URL: "https://badregion.com/",
+ },
+ },
+ },
+ tokens3.CatalogEntry{
+ Type: "same",
+ Name: "different",
+ Endpoints: []tokens3.Endpoint{
+ tokens3.Endpoint{
+ ID: "5",
+ Region: "same",
+ Interface: "public",
+ URL: "https://badname.com/",
+ },
+ tokens3.Endpoint{
+ ID: "6",
+ Region: "different",
+ Interface: "public",
+ URL: "https://badname.com/+badregion",
+ },
+ },
+ },
+ tokens3.CatalogEntry{
+ Type: "different",
+ Name: "different",
+ Endpoints: []tokens3.Endpoint{
+ tokens3.Endpoint{
+ ID: "7",
+ Region: "same",
+ Interface: "public",
+ URL: "https://badtype.com/+badname",
+ },
+ tokens3.Endpoint{
+ ID: "8",
+ Region: "different",
+ Interface: "public",
+ URL: "https://badtype.com/+badregion+badname",
+ },
+ },
+ },
+ },
+}
+
+func TestV3EndpointExact(t *testing.T) {
+ expectedURLs := map[gophercloud.Availability]string{
+ gophercloud.AvailabilityPublic: "https://public.correct.com/",
+ gophercloud.AvailabilityAdmin: "https://admin.correct.com/",
+ gophercloud.AvailabilityInternal: "https://internal.correct.com/",
+ }
+
+ for availability, expected := range expectedURLs {
+ actual, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+ Type: "same",
+ Name: "same",
+ Region: "same",
+ Availability: availability,
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, expected, actual)
+ }
+}
+
+func TestV3EndpointNone(t *testing.T) {
+ _, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+ Type: "nope",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ th.CheckEquals(t, gophercloud.ErrEndpointNotFound, err)
+}
+
+func TestV3EndpointMultiple(t *testing.T) {
+ _, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+ Type: "same",
+ Region: "same",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ if !strings.HasPrefix(err.Error(), "Discovered 2 matching endpoints:") {
+ t.Errorf("Received unexpected error: %v", err)
+ }
+}
+
+func TestV3EndpointBadAvailability(t *testing.T) {
+ _, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+ Type: "same",
+ Name: "same",
+ Region: "same",
+ Availability: "wat",
+ })
+ th.CheckEquals(t, "Unexpected availability in endpoint query: wat", err.Error())
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/docs.go b/openstack/identity/v2/extensions/admin/roles/docs.go
new file mode 100644
index 0000000..8954178
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/docs.go
@@ -0,0 +1,16 @@
+// Package roles provides functionality to interact with and control roles on
+// the API.
+//
+// A role represents a personality that a user can assume when performing a
+// specific set of operations. If a role includes a set of rights and
+// privileges, a user assuming that role inherits those rights and privileges.
+//
+// When a token is generated, the list of roles that user can assume is returned
+// back to them. Services that are being called by that user determine how they
+// interpret the set of roles a user has and to which operations or resources
+// each role grants access.
+//
+// It is up to individual services such as Compute or Image to assign meaning
+// to these roles. As far as the Identity service is concerned, a role is an
+// arbitrary name assigned by the user.
+package roles
diff --git a/openstack/identity/v2/extensions/admin/roles/fixtures.go b/openstack/identity/v2/extensions/admin/roles/fixtures.go
new file mode 100644
index 0000000..8256f0f
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/fixtures.go
@@ -0,0 +1,48 @@
+package roles
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/OS-KSADM/roles", 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, `
+{
+ "roles": [
+ {
+ "id": "123",
+ "name": "compute:admin",
+ "description": "Nova Administrator"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func MockAddUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+func MockDeleteUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests.go b/openstack/identity/v2/extensions/admin/roles/requests.go
new file mode 100644
index 0000000..9a33314
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests.go
@@ -0,0 +1,33 @@
+package roles
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List is the operation responsible for listing all available global roles
+// that a user can adopt.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, rootURL(client), createPage)
+}
+
+// AddUserRole is the operation responsible for assigning a particular role to
+// a user. This is confined to the scope of the user's tenant - so the tenant
+// ID is a required argument.
+func AddUserRole(client *gophercloud.ServiceClient, tenantID, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+ _, result.Err = client.Put(userRoleURL(client, tenantID, userID, roleID), nil, nil, nil)
+ return result
+}
+
+// DeleteUserRole is the operation responsible for deleting a particular role
+// from a user. This is confined to the scope of the user's tenant - so the
+// tenant ID is a required argument.
+func DeleteUserRole(client *gophercloud.ServiceClient, tenantID, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+ _, result.Err = client.Delete(userRoleURL(client, tenantID, userID, roleID), nil)
+ return result
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests_test.go b/openstack/identity/v2/extensions/admin/roles/requests_test.go
new file mode 100644
index 0000000..7bfeea4
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests_test.go
@@ -0,0 +1,64 @@
+package roles
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListRoleResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRoles(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []Role{
+ Role{
+ ID: "123",
+ Name: "compute:admin",
+ Description: "Nova Administrator",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestAddUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAddUserRoleResponse(t)
+
+ err := AddUserRole(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteUserRoleResponse(t)
+
+ err := DeleteUserRole(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/results.go b/openstack/identity/v2/extensions/admin/roles/results.go
new file mode 100644
index 0000000..ebb3aa5
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/results.go
@@ -0,0 +1,53 @@
+package roles
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Role represents an API role resource.
+type Role struct {
+ // The unique ID for the role.
+ ID string
+
+ // The human-readable name of the role.
+ Name string
+
+ // The description of the role.
+ Description string
+
+ // The associated service for this role.
+ ServiceID string
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractRoles returns a slice of roles contained in a single page of results.
+func ExtractRoles(page pagination.Page) ([]Role, error) {
+ casted := page.(RolePage).Body
+ var response struct {
+ Roles []Role `mapstructure:"roles"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Roles, err
+}
+
+// UserRoleResult represents the result of either an AddUserRole or
+// a DeleteUserRole operation.
+type UserRoleResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/urls.go b/openstack/identity/v2/extensions/admin/roles/urls.go
new file mode 100644
index 0000000..61b3155
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/urls.go
@@ -0,0 +1,21 @@
+package roles
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ ExtPath = "OS-KSADM"
+ RolePath = "roles"
+ UserPath = "users"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(ExtPath, RolePath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(ExtPath, RolePath)
+}
+
+func userRoleURL(c *gophercloud.ServiceClient, tenantID, userID, roleID string) string {
+ return c.ServiceURL("tenants", tenantID, UserPath, userID, RolePath, ExtPath, roleID)
+}
diff --git a/openstack/identity/v2/extensions/delegate.go b/openstack/identity/v2/extensions/delegate.go
new file mode 100644
index 0000000..fd6e80e
--- /dev/null
+++ b/openstack/identity/v2/extensions/delegate.go
@@ -0,0 +1,52 @@
+package extensions
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtensionPage is a single page of Extension results.
+type ExtensionPage struct {
+ common.ExtensionPage
+}
+
+// IsEmpty returns true if the current page contains at least one Extension.
+func (page ExtensionPage) IsEmpty() (bool, error) {
+ is, err := ExtractExtensions(page)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of Extension structs.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+ // Identity v2 adds an intermediate "values" object.
+
+ var resp struct {
+ Extensions struct {
+ Values []common.Extension `mapstructure:"values"`
+ } `mapstructure:"extensions"`
+ }
+
+ err := mapstructure.Decode(page.(ExtensionPage).Body, &resp)
+ return resp.Extensions.Values, err
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+ return common.Get(c, alias)
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return common.List(c).WithPageCreator(func(r pagination.PageResult) pagination.Page {
+ return ExtensionPage{
+ ExtensionPage: common.ExtensionPage{SinglePageBase: pagination.SinglePageBase(r)},
+ }
+ })
+}
diff --git a/openstack/identity/v2/extensions/delegate_test.go b/openstack/identity/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..504118a
--- /dev/null
+++ b/openstack/identity/v2/extensions/delegate_test.go
@@ -0,0 +1,38 @@
+package extensions
+
+import (
+ "testing"
+
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListExtensionsSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, common.ExpectedExtensions, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ common.HandleGetExtensionSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, common.SingleExtension, actual)
+}
diff --git a/openstack/identity/v2/extensions/doc.go b/openstack/identity/v2/extensions/doc.go
new file mode 100644
index 0000000..791e4e3
--- /dev/null
+++ b/openstack/identity/v2/extensions/doc.go
@@ -0,0 +1,3 @@
+// Package extensions provides information and interaction with the
+// different extensions available for the OpenStack Identity service.
+package extensions
diff --git a/openstack/identity/v2/extensions/fixtures.go b/openstack/identity/v2/extensions/fixtures.go
new file mode 100644
index 0000000..96cb7d2
--- /dev/null
+++ b/openstack/identity/v2/extensions/fixtures.go
@@ -0,0 +1,60 @@
+// +build fixtures
+
+package extensions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single Extension result. It differs from the delegated implementation
+// by the introduction of an intermediate "values" member.
+const ListOutput = `
+{
+ "extensions": {
+ "values": [
+ {
+ "updated": "2013-01-20T00:00:00-00:00",
+ "name": "Neutron Service Type Management",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ "alias": "service-type",
+ "description": "API for retrieving service providers for Neutron advanced services"
+ }
+ ]
+ }
+}
+`
+
+// HandleListExtensionsSuccessfully creates an HTTP handler that returns ListOutput for a List
+// call.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ fmt.Fprintf(w, `
+{
+ "extensions": {
+ "values": [
+ {
+ "updated": "2013-01-20T00:00:00-00:00",
+ "name": "Neutron Service Type Management",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ "alias": "service-type",
+ "description": "API for retrieving service providers for Neutron advanced services"
+ }
+ ]
+ }
+}
+ `)
+ })
+
+}
diff --git a/openstack/identity/v2/tenants/doc.go b/openstack/identity/v2/tenants/doc.go
new file mode 100644
index 0000000..0c2d49d
--- /dev/null
+++ b/openstack/identity/v2/tenants/doc.go
@@ -0,0 +1,7 @@
+// Package tenants provides information and interaction with the
+// tenants API resource for the OpenStack Identity service.
+//
+// See http://developer.openstack.org/api-ref-identity-v2.html#identity-auth-v2
+// and http://developer.openstack.org/api-ref-identity-v2.html#admin-tenants
+// for more information.
+package tenants
diff --git a/openstack/identity/v2/tenants/fixtures.go b/openstack/identity/v2/tenants/fixtures.go
new file mode 100644
index 0000000..7f044ac
--- /dev/null
+++ b/openstack/identity/v2/tenants/fixtures.go
@@ -0,0 +1,65 @@
+// +build fixtures
+
+package tenants
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Tenant results.
+const ListOutput = `
+{
+ "tenants": [
+ {
+ "id": "1234",
+ "name": "Red Team",
+ "description": "The team that is red",
+ "enabled": true
+ },
+ {
+ "id": "9876",
+ "name": "Blue Team",
+ "description": "The team that is blue",
+ "enabled": false
+ }
+ ]
+}
+`
+
+// RedTeam is a Tenant fixture.
+var RedTeam = Tenant{
+ ID: "1234",
+ Name: "Red Team",
+ Description: "The team that is red",
+ Enabled: true,
+}
+
+// BlueTeam is a Tenant fixture.
+var BlueTeam = Tenant{
+ ID: "9876",
+ Name: "Blue Team",
+ Description: "The team that is blue",
+ Enabled: false,
+}
+
+// ExpectedTenantSlice is the slice of tenants expected to be returned from ListOutput.
+var ExpectedTenantSlice = []Tenant{RedTeam, BlueTeam}
+
+// HandleListTenantsSuccessfully creates an HTTP handler at `/tenants` on the test handler mux that
+// responds with a list of two tenants.
+func HandleListTenantsSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/tenants", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, ListOutput)
+ })
+}
diff --git a/openstack/identity/v2/tenants/requests.go b/openstack/identity/v2/tenants/requests.go
new file mode 100644
index 0000000..5a359f5
--- /dev/null
+++ b/openstack/identity/v2/tenants/requests.go
@@ -0,0 +1,33 @@
+package tenants
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts filters the Tenants that are returned by the List call.
+type ListOpts struct {
+ // Marker is the ID of the last Tenant on the previous page.
+ Marker string `q:"marker"`
+
+ // Limit specifies the page size.
+ Limit int `q:"limit"`
+}
+
+// List enumerates the Tenants to which the current token has access.
+func List(client *gophercloud.ServiceClient, opts *ListOpts) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return TenantPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ url := listURL(client)
+ if opts != nil {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += q.String()
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
diff --git a/openstack/identity/v2/tenants/requests_test.go b/openstack/identity/v2/tenants/requests_test.go
new file mode 100644
index 0000000..e8f172d
--- /dev/null
+++ b/openstack/identity/v2/tenants/requests_test.go
@@ -0,0 +1,29 @@
+package tenants
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListTenants(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListTenantsSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+
+ actual, err := ExtractTenants(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ExpectedTenantSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
diff --git a/openstack/identity/v2/tenants/results.go b/openstack/identity/v2/tenants/results.go
new file mode 100644
index 0000000..c1220c3
--- /dev/null
+++ b/openstack/identity/v2/tenants/results.go
@@ -0,0 +1,62 @@
+package tenants
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Tenant is a grouping of users in the identity service.
+type Tenant struct {
+ // ID is a unique identifier for this tenant.
+ ID string `mapstructure:"id"`
+
+ // Name is a friendlier user-facing name for this tenant.
+ Name string `mapstructure:"name"`
+
+ // Description is a human-readable explanation of this Tenant's purpose.
+ Description string `mapstructure:"description"`
+
+ // Enabled indicates whether or not a tenant is active.
+ Enabled bool `mapstructure:"enabled"`
+}
+
+// TenantPage is a single page of Tenant results.
+type TenantPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page TenantPage) IsEmpty() (bool, error) {
+ tenants, err := ExtractTenants(page)
+ if err != nil {
+ return false, err
+ }
+ return len(tenants) == 0, nil
+}
+
+// NextPageURL extracts the "next" link from the tenants_links section of the result.
+func (page TenantPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"tenants_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractTenants returns a slice of Tenants contained in a single page of results.
+func ExtractTenants(page pagination.Page) ([]Tenant, error) {
+ casted := page.(TenantPage).Body
+ var response struct {
+ Tenants []Tenant `mapstructure:"tenants"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Tenants, err
+}
diff --git a/openstack/identity/v2/tenants/urls.go b/openstack/identity/v2/tenants/urls.go
new file mode 100644
index 0000000..1dd6ce0
--- /dev/null
+++ b/openstack/identity/v2/tenants/urls.go
@@ -0,0 +1,7 @@
+package tenants
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("tenants")
+}
diff --git a/openstack/identity/v2/tokens/doc.go b/openstack/identity/v2/tokens/doc.go
new file mode 100644
index 0000000..31cacc5
--- /dev/null
+++ b/openstack/identity/v2/tokens/doc.go
@@ -0,0 +1,5 @@
+// Package tokens provides information and interaction with the token API
+// resource for the OpenStack Identity service.
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v2.html#identity-auth-v2
+package tokens
diff --git a/openstack/identity/v2/tokens/errors.go b/openstack/identity/v2/tokens/errors.go
new file mode 100644
index 0000000..3dfdc08
--- /dev/null
+++ b/openstack/identity/v2/tokens/errors.go
@@ -0,0 +1,30 @@
+package tokens
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ // ErrUserIDProvided is returned if you attempt to authenticate with a UserID.
+ ErrUserIDProvided = unacceptedAttributeErr("UserID")
+
+ // ErrAPIKeyProvided is returned if you attempt to authenticate with an APIKey.
+ ErrAPIKeyProvided = unacceptedAttributeErr("APIKey")
+
+ // ErrDomainIDProvided is returned if you attempt to authenticate with a DomainID.
+ ErrDomainIDProvided = unacceptedAttributeErr("DomainID")
+
+ // ErrDomainNameProvided is returned if you attempt to authenticate with a DomainName.
+ ErrDomainNameProvided = unacceptedAttributeErr("DomainName")
+
+ // ErrUsernameRequired is returned if you attempt to authenticate without a Username.
+ ErrUsernameRequired = errors.New("You must supply a Username in your AuthOptions.")
+
+ // ErrPasswordRequired is returned if you don't provide a password.
+ ErrPasswordRequired = errors.New("Please supply a Password in your AuthOptions.")
+)
+
+func unacceptedAttributeErr(attribute string) error {
+ return fmt.Errorf("The base Identity V2 API does not accept authentication by %s", attribute)
+}
diff --git a/openstack/identity/v2/tokens/fixtures.go b/openstack/identity/v2/tokens/fixtures.go
new file mode 100644
index 0000000..6245259
--- /dev/null
+++ b/openstack/identity/v2/tokens/fixtures.go
@@ -0,0 +1,195 @@
+// +build fixtures
+
+package tokens
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ th "github.com/rackspace/gophercloud/testhelper"
+ thclient "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ExpectedToken is the token that should be parsed from TokenCreationResponse.
+var ExpectedToken = &Token{
+ ID: "aaaabbbbccccdddd",
+ ExpiresAt: time.Date(2014, time.January, 31, 15, 30, 58, 0, time.UTC),
+ Tenant: tenants.Tenant{
+ ID: "fc394f2ab2df4114bde39905f800dc57",
+ Name: "test",
+ Description: "There are many tenants. This one is yours.",
+ Enabled: true,
+ },
+}
+
+// ExpectedServiceCatalog is the service catalog that should be parsed from TokenCreationResponse.
+var ExpectedServiceCatalog = &ServiceCatalog{
+ Entries: []CatalogEntry{
+ CatalogEntry{
+ Name: "inscrutablewalrus",
+ Type: "something",
+ Endpoints: []Endpoint{
+ Endpoint{
+ PublicURL: "http://something0:1234/v2/",
+ Region: "region0",
+ },
+ Endpoint{
+ PublicURL: "http://something1:1234/v2/",
+ Region: "region1",
+ },
+ },
+ },
+ CatalogEntry{
+ Name: "arbitrarypenguin",
+ Type: "else",
+ Endpoints: []Endpoint{
+ Endpoint{
+ PublicURL: "http://else0:4321/v3/",
+ Region: "region0",
+ },
+ },
+ },
+ },
+}
+
+// ExpectedUser is the token that should be parsed from TokenGetResponse.
+var ExpectedUser = &User{
+ ID: "a530fefc3d594c4ba2693a4ecd6be74e",
+ Name: "apiserver",
+ Roles: []Role{{"member"}, {"service"}},
+ UserName: "apiserver",
+}
+
+// TokenCreationResponse is a JSON response that contains ExpectedToken and ExpectedServiceCatalog.
+const TokenCreationResponse = `
+{
+ "access": {
+ "token": {
+ "issued_at": "2014-01-30T15:30:58.000000Z",
+ "expires": "2014-01-31T15:30:58Z",
+ "id": "aaaabbbbccccdddd",
+ "tenant": {
+ "description": "There are many tenants. This one is yours.",
+ "enabled": true,
+ "id": "fc394f2ab2df4114bde39905f800dc57",
+ "name": "test"
+ }
+ },
+ "serviceCatalog": [
+ {
+ "endpoints": [
+ {
+ "publicURL": "http://something0:1234/v2/",
+ "region": "region0"
+ },
+ {
+ "publicURL": "http://something1:1234/v2/",
+ "region": "region1"
+ }
+ ],
+ "type": "something",
+ "name": "inscrutablewalrus"
+ },
+ {
+ "endpoints": [
+ {
+ "publicURL": "http://else0:4321/v3/",
+ "region": "region0"
+ }
+ ],
+ "type": "else",
+ "name": "arbitrarypenguin"
+ }
+ ]
+ }
+}
+`
+
+// TokenGetResponse is a JSON response that contains ExpectedToken and ExpectedUser.
+const TokenGetResponse = `
+{
+ "access": {
+ "token": {
+ "issued_at": "2014-01-30T15:30:58.000000Z",
+ "expires": "2014-01-31T15:30:58Z",
+ "id": "aaaabbbbccccdddd",
+ "tenant": {
+ "description": "There are many tenants. This one is yours.",
+ "enabled": true,
+ "id": "fc394f2ab2df4114bde39905f800dc57",
+ "name": "test"
+ }
+ },
+ "serviceCatalog": [],
+ "user": {
+ "id": "a530fefc3d594c4ba2693a4ecd6be74e",
+ "name": "apiserver",
+ "roles": [
+ {
+ "name": "member"
+ },
+ {
+ "name": "service"
+ }
+ ],
+ "roles_links": [],
+ "username": "apiserver"
+ }
+ }
+}`
+
+// HandleTokenPost expects a POST against a /tokens handler, ensures that the request body has been
+// constructed properly given certain auth options, and returns the result.
+func HandleTokenPost(t *testing.T, requestJSON string) {
+ th.Mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ if requestJSON != "" {
+ th.TestJSONRequest(t, r, requestJSON)
+ }
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, TokenCreationResponse)
+ })
+}
+
+// HandleTokenGet expects a Get against a /tokens handler, ensures that the request body has been
+// constructed properly given certain auth options, and returns the result.
+func HandleTokenGet(t *testing.T, token string) {
+ th.Mux.HandleFunc("/tokens/"+token, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestHeader(t, r, "X-Auth-Token", thclient.TokenID)
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, TokenGetResponse)
+ })
+}
+
+// IsSuccessful ensures that a CreateResult was successful and contains the correct token and
+// service catalog.
+func IsSuccessful(t *testing.T, result CreateResult) {
+ token, err := result.ExtractToken()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedToken, token)
+
+ serviceCatalog, err := result.ExtractServiceCatalog()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedServiceCatalog, serviceCatalog)
+}
+
+// GetIsSuccessful ensures that a GetResult was successful and contains the correct token and
+// User Info.
+func GetIsSuccessful(t *testing.T, result GetResult) {
+ token, err := result.ExtractToken()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedToken, token)
+
+ user, err := result.ExtractUser()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedUser, user)
+}
diff --git a/openstack/identity/v2/tokens/requests.go b/openstack/identity/v2/tokens/requests.go
new file mode 100644
index 0000000..1f51438
--- /dev/null
+++ b/openstack/identity/v2/tokens/requests.go
@@ -0,0 +1,99 @@
+package tokens
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// AuthOptionsBuilder describes any argument that may be passed to the Create call.
+type AuthOptionsBuilder interface {
+
+ // ToTokenCreateMap assembles the Create request body, returning an error if parameters are
+ // missing or inconsistent.
+ ToTokenCreateMap() (map[string]interface{}, error)
+}
+
+// AuthOptions wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+// interface.
+type AuthOptions struct {
+ gophercloud.AuthOptions
+}
+
+// WrapOptions embeds a root AuthOptions struct in a package-specific one.
+func WrapOptions(original gophercloud.AuthOptions) AuthOptions {
+ return AuthOptions{AuthOptions: original}
+}
+
+// ToTokenCreateMap converts AuthOptions into nested maps that can be serialized into a JSON
+// request.
+func (auth AuthOptions) ToTokenCreateMap() (map[string]interface{}, error) {
+ // Error out if an unsupported auth option is present.
+ if auth.UserID != "" {
+ return nil, ErrUserIDProvided
+ }
+ if auth.APIKey != "" {
+ return nil, ErrAPIKeyProvided
+ }
+ if auth.DomainID != "" {
+ return nil, ErrDomainIDProvided
+ }
+ if auth.DomainName != "" {
+ return nil, ErrDomainNameProvided
+ }
+
+ // Populate the request map.
+ authMap := make(map[string]interface{})
+
+ if auth.Username != "" {
+ if auth.Password != "" {
+ authMap["passwordCredentials"] = map[string]interface{}{
+ "username": auth.Username,
+ "password": auth.Password,
+ }
+ } else {
+ return nil, ErrPasswordRequired
+ }
+ } else if auth.TokenID != "" {
+ authMap["token"] = map[string]interface{}{
+ "id": auth.TokenID,
+ }
+ } else {
+ return nil, fmt.Errorf("You must provide either username/password or tenantID/token values.")
+ }
+
+ if auth.TenantID != "" {
+ authMap["tenantId"] = auth.TenantID
+ }
+ if auth.TenantName != "" {
+ authMap["tenantName"] = auth.TenantName
+ }
+
+ return map[string]interface{}{"auth": authMap}, nil
+}
+
+// Create authenticates to the identity service and attempts to acquire a Token.
+// If successful, the CreateResult
+// Generally, rather than interact with this call directly, end users should call openstack.AuthenticatedClient(),
+// which abstracts all of the gory details about navigating service catalogs and such.
+func Create(client *gophercloud.ServiceClient, auth AuthOptionsBuilder) CreateResult {
+ request, err := auth.ToTokenCreateMap()
+ if err != nil {
+ return CreateResult{gophercloud.Result{Err: err}}
+ }
+
+ var result CreateResult
+ _, result.Err = client.Post(CreateURL(client), request, &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ })
+ return result
+}
+
+// Validates and retrieves information for user's token.
+func Get(client *gophercloud.ServiceClient, token string) GetResult {
+ var result GetResult
+ _, result.Err = client.Get(GetURL(client, token), &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ })
+ return result
+}
diff --git a/openstack/identity/v2/tokens/requests_test.go b/openstack/identity/v2/tokens/requests_test.go
new file mode 100644
index 0000000..f1ec339
--- /dev/null
+++ b/openstack/identity/v2/tokens/requests_test.go
@@ -0,0 +1,152 @@
+package tokens
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) CreateResult {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenPost(t, requestJSON)
+
+ return Create(client.ServiceClient(), AuthOptions{options})
+}
+
+func tokenPostErr(t *testing.T, options gophercloud.AuthOptions, expectedErr error) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenPost(t, "")
+
+ actualErr := Create(client.ServiceClient(), AuthOptions{options}).Err
+ th.CheckDeepEquals(t, expectedErr, actualErr)
+}
+
+func TestCreateWithPassword(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "swordfish",
+ }
+
+ IsSuccessful(t, tokenPost(t, options, `
+ {
+ "auth": {
+ "passwordCredentials": {
+ "username": "me",
+ "password": "swordfish"
+ }
+ }
+ }
+ `))
+}
+
+func TestCreateTokenWithTenantID(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "opensesame",
+ TenantID: "fc394f2ab2df4114bde39905f800dc57",
+ }
+
+ IsSuccessful(t, tokenPost(t, options, `
+ {
+ "auth": {
+ "tenantId": "fc394f2ab2df4114bde39905f800dc57",
+ "passwordCredentials": {
+ "username": "me",
+ "password": "opensesame"
+ }
+ }
+ }
+ `))
+}
+
+func TestCreateTokenWithTenantName(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "opensesame",
+ TenantName: "demo",
+ }
+
+ IsSuccessful(t, tokenPost(t, options, `
+ {
+ "auth": {
+ "tenantName": "demo",
+ "passwordCredentials": {
+ "username": "me",
+ "password": "opensesame"
+ }
+ }
+ }
+ `))
+}
+
+func TestProhibitUserID(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ UserID: "1234",
+ Password: "thing",
+ }
+
+ tokenPostErr(t, options, ErrUserIDProvided)
+}
+
+func TestProhibitAPIKey(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "thing",
+ APIKey: "123412341234",
+ }
+
+ tokenPostErr(t, options, ErrAPIKeyProvided)
+}
+
+func TestProhibitDomainID(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "thing",
+ DomainID: "1234",
+ }
+
+ tokenPostErr(t, options, ErrDomainIDProvided)
+}
+
+func TestProhibitDomainName(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "thing",
+ DomainName: "wat",
+ }
+
+ tokenPostErr(t, options, ErrDomainNameProvided)
+}
+
+func TestRequireUsername(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "thing",
+ }
+
+ tokenPostErr(t, options, fmt.Errorf("You must provide either username/password or tenantID/token values."))
+}
+
+func TestRequirePassword(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ }
+
+ tokenPostErr(t, options, ErrPasswordRequired)
+}
+
+func tokenGet(t *testing.T, tokenId string) GetResult {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenGet(t, tokenId)
+ return Get(client.ServiceClient(), tokenId)
+}
+
+func TestGetWithToken(t *testing.T) {
+ GetIsSuccessful(t, tokenGet(t, "db22caf43c934e6c829087c41ff8d8d6"))
+}
diff --git a/openstack/identity/v2/tokens/results.go b/openstack/identity/v2/tokens/results.go
new file mode 100644
index 0000000..67c577b
--- /dev/null
+++ b/openstack/identity/v2/tokens/results.go
@@ -0,0 +1,170 @@
+package tokens
+
+import (
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+)
+
+// Token provides only the most basic information related to an authentication token.
+type Token struct {
+ // ID provides the primary means of identifying a user to the OpenStack API.
+ // OpenStack defines this field as an opaque value, so do not depend on its content.
+ // It is safe, however, to compare for equality.
+ ID string
+
+ // ExpiresAt provides a timestamp in ISO 8601 format, indicating when the authentication token becomes invalid.
+ // After this point in time, future API requests made using this authentication token will respond with errors.
+ // Either the caller will need to reauthenticate manually, or more preferably, the caller should exploit automatic re-authentication.
+ // See the AuthOptions structure for more details.
+ ExpiresAt time.Time
+
+ // Tenant provides information about the tenant to which this token grants access.
+ Tenant tenants.Tenant
+}
+
+// Authorization need user info which can get from token authentication's response
+type Role struct {
+ Name string `mapstructure:"name"`
+}
+type User struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ UserName string `mapstructure:"username"`
+ Roles []Role `mapstructure:"roles"`
+}
+
+// Endpoint represents a single API endpoint offered by a service.
+// It provides the public and internal URLs, if supported, along with a region specifier, again if provided.
+// The significance of the Region field will depend upon your provider.
+//
+// In addition, the interface offered by the service will have version information associated with it
+// through the VersionId, VersionInfo, and VersionList fields, if provided or supported.
+//
+// In all cases, fields which aren't supported by the provider and service combined will assume a zero-value ("").
+type Endpoint struct {
+ TenantID string `mapstructure:"tenantId"`
+ PublicURL string `mapstructure:"publicURL"`
+ InternalURL string `mapstructure:"internalURL"`
+ AdminURL string `mapstructure:"adminURL"`
+ Region string `mapstructure:"region"`
+ VersionID string `mapstructure:"versionId"`
+ VersionInfo string `mapstructure:"versionInfo"`
+ VersionList string `mapstructure:"versionList"`
+}
+
+// CatalogEntry provides a type-safe interface to an Identity API V2 service catalog listing.
+// Each class of service, such as cloud DNS or block storage services, will have a single
+// CatalogEntry representing it.
+//
+// Note: when looking for the desired service, try, whenever possible, to key off the type field.
+// Otherwise, you'll tie the representation of the service to a specific provider.
+type CatalogEntry struct {
+ // Name will contain the provider-specified name for the service.
+ Name string `mapstructure:"name"`
+
+ // Type will contain a type string if OpenStack defines a type for the service.
+ // Otherwise, for provider-specific services, the provider may assign their own type strings.
+ Type string `mapstructure:"type"`
+
+ // Endpoints will let the caller iterate over all the different endpoints that may exist for
+ // the service.
+ Endpoints []Endpoint `mapstructure:"endpoints"`
+}
+
+// ServiceCatalog provides a view into the service catalog from a previous, successful authentication.
+type ServiceCatalog struct {
+ Entries []CatalogEntry
+}
+
+// CreateResult defers the interpretation of a created token.
+// Use ExtractToken() to interpret it as a Token, or ExtractServiceCatalog() to interpret it as a service catalog.
+type CreateResult struct {
+ gophercloud.Result
+}
+
+// GetResult is the deferred response from a Get call, which is the same with a Created token.
+// Use ExtractUser() to interpret it as a User.
+type GetResult struct {
+ CreateResult
+}
+
+// ExtractToken returns the just-created Token from a CreateResult.
+func (result CreateResult) ExtractToken() (*Token, error) {
+ if result.Err != nil {
+ return nil, result.Err
+ }
+
+ var response struct {
+ Access struct {
+ Token struct {
+ Expires string `mapstructure:"expires"`
+ ID string `mapstructure:"id"`
+ Tenant tenants.Tenant `mapstructure:"tenant"`
+ } `mapstructure:"token"`
+ } `mapstructure:"access"`
+ }
+
+ err := mapstructure.Decode(result.Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ expiresTs, err := time.Parse(gophercloud.RFC3339Milli, response.Access.Token.Expires)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Token{
+ ID: response.Access.Token.ID,
+ ExpiresAt: expiresTs,
+ Tenant: response.Access.Token.Tenant,
+ }, nil
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (result CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+ if result.Err != nil {
+ return nil, result.Err
+ }
+
+ var response struct {
+ Access struct {
+ Entries []CatalogEntry `mapstructure:"serviceCatalog"`
+ } `mapstructure:"access"`
+ }
+
+ err := mapstructure.Decode(result.Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ServiceCatalog{Entries: response.Access.Entries}, nil
+}
+
+// createErr quickly packs an error in a CreateResult.
+func createErr(err error) CreateResult {
+ return CreateResult{gophercloud.Result{Err: err}}
+}
+
+// ExtractUser returns the User from a GetResult.
+func (result GetResult) ExtractUser() (*User, error) {
+ if result.Err != nil {
+ return nil, result.Err
+ }
+
+ var response struct {
+ Access struct {
+ User User `mapstructure:"user"`
+ } `mapstructure:"access"`
+ }
+
+ err := mapstructure.Decode(result.Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return &response.Access.User, nil
+}
diff --git a/openstack/identity/v2/tokens/urls.go b/openstack/identity/v2/tokens/urls.go
new file mode 100644
index 0000000..ee13932
--- /dev/null
+++ b/openstack/identity/v2/tokens/urls.go
@@ -0,0 +1,13 @@
+package tokens
+
+import "github.com/rackspace/gophercloud"
+
+// CreateURL generates the URL used to create new Tokens.
+func CreateURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("tokens")
+}
+
+// GetURL generates the URL used to Validate Tokens.
+func GetURL(client *gophercloud.ServiceClient, token string) string {
+ return client.ServiceURL("tokens", token)
+}
diff --git a/openstack/identity/v2/users/doc.go b/openstack/identity/v2/users/doc.go
new file mode 100644
index 0000000..82abcb9
--- /dev/null
+++ b/openstack/identity/v2/users/doc.go
@@ -0,0 +1 @@
+package users
diff --git a/openstack/identity/v2/users/fixtures.go b/openstack/identity/v2/users/fixtures.go
new file mode 100644
index 0000000..8941868
--- /dev/null
+++ b/openstack/identity/v2/users/fixtures.go
@@ -0,0 +1,163 @@
+package users
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", 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, `
+{
+ "users":[
+ {
+ "id": "u1000",
+ "name": "John Smith",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true,
+ "tenant_id": "12345"
+ },
+ {
+ "id": "u1001",
+ "name": "Jane Smith",
+ "username": "jqsmith",
+ "email": "jane.smith@example.org",
+ "enabled": true,
+ "tenant_id": "12345"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", 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, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockGetUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/new_user", 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, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockUpdateUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", 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, `
+{
+ "user": {
+ "name": "new_name",
+ "enabled": true,
+ "email": "new_email@foo.com"
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "name": "new_name",
+ "tenant_id": "12345",
+ "enabled": true,
+ "email": "new_email@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockDeleteUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+func mockListRolesResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/1d8b6120dcc640fda4fc9194ffc80273/users/c39e3de9be2d4c779f1dfd6abacc176d/roles", 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, `
+{
+ "roles": [
+ {
+ "id": "9fe2ff9ee4384b1894a90878d3e92bab",
+ "name": "foo_role"
+ },
+ {
+ "id": "1ea3d56793574b668e85960fbf651e13",
+ "name": "admin"
+ }
+ ]
+}
+ `)
+ })
+}
diff --git a/openstack/identity/v2/users/requests.go b/openstack/identity/v2/users/requests.go
new file mode 100644
index 0000000..88be45e
--- /dev/null
+++ b/openstack/identity/v2/users/requests.go
@@ -0,0 +1,161 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, rootURL(client), createPage)
+}
+
+// EnabledState represents whether the user is enabled or not.
+type EnabledState *bool
+
+// Useful variables to use when creating or updating users.
+var (
+ iTrue = true
+ iFalse = false
+
+ Enabled EnabledState = &iTrue
+ Disabled EnabledState = &iFalse
+)
+
+// CommonOpts are the parameters that are shared between CreateOpts and
+// UpdateOpts
+type CommonOpts struct {
+ // Either a name or username is required. When provided, the value must be
+ // unique or a 409 conflict error will be returned. If you provide a name but
+ // omit a username, the latter will be set to the former; and vice versa.
+ Name, Username string
+
+ // The ID of the tenant to which you want to assign this user.
+ TenantID string
+
+ // Indicates whether this user is enabled or not.
+ Enabled EnabledState
+
+ // The email address of this user.
+ Email string
+}
+
+// CreateOpts represents the options needed when creating new users.
+type CreateOpts CommonOpts
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+type CreateOptsBuilder interface {
+ ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// ToUserCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+ m := make(map[string]interface{})
+
+ if opts.Name == "" && opts.Username == "" {
+ return m, errors.New("Either a Name or Username must be provided")
+ }
+
+ if opts.Name != "" {
+ m["name"] = opts.Name
+ }
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.TenantID != "" {
+ m["tenant_id"] = opts.TenantID
+ }
+
+ return map[string]interface{}{"user": m}, nil
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToUserCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(rootURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+
+ return res
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+ _, result.Err = client.Get(ResourceURL(client, id), &result.Body, nil)
+ return result
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToUserUpdateMap() map[string]interface{}
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts CommonOpts
+
+// ToUserUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToUserUpdateMap() map[string]interface{} {
+ m := make(map[string]interface{})
+
+ if opts.Name != "" {
+ m["name"] = opts.Name
+ }
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.TenantID != "" {
+ m["tenant_id"] = opts.TenantID
+ }
+
+ return map[string]interface{}{"user": m}
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+ reqBody := opts.ToUserUpdateMap()
+ _, result.Err = client.Put(ResourceURL(client, id), reqBody, &result.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return result
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var result DeleteResult
+ _, result.Err = client.Delete(ResourceURL(client, id), nil)
+ return result
+}
+
+func ListRoles(client *gophercloud.ServiceClient, tenantID, userID string) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, listRolesURL(client, tenantID, userID), createPage)
+}
diff --git a/openstack/identity/v2/users/requests_test.go b/openstack/identity/v2/users/requests_test.go
new file mode 100644
index 0000000..04f8371
--- /dev/null
+++ b/openstack/identity/v2/users/requests_test.go
@@ -0,0 +1,165 @@
+package users
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListUserResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractUsers(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []User{
+ User{
+ ID: "u1000",
+ Name: "John Smith",
+ Username: "jqsmith",
+ Email: "john.smith@example.org",
+ Enabled: true,
+ TenantID: "12345",
+ },
+ User{
+ ID: "u1001",
+ Name: "Jane Smith",
+ Username: "jqsmith",
+ Email: "jane.smith@example.org",
+ Enabled: true,
+ TenantID: "12345",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateUserResponse(t)
+
+ opts := CreateOpts{
+ Name: "new_user",
+ TenantID: "12345",
+ Enabled: Disabled,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := Create(client.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_user",
+ ID: "c39e3de9be2d4c779f1dfd6abacc176d",
+ Email: "new_user@foo.com",
+ Enabled: false,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestGetUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetUserResponse(t)
+
+ user, err := Get(client.ServiceClient(), "new_user").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_user",
+ ID: "c39e3de9be2d4c779f1dfd6abacc176d",
+ Email: "new_user@foo.com",
+ Enabled: false,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestUpdateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateUserResponse(t)
+
+ id := "c39e3de9be2d4c779f1dfd6abacc176d"
+ opts := UpdateOpts{
+ Name: "new_name",
+ Enabled: Enabled,
+ Email: "new_email@foo.com",
+ }
+
+ user, err := Update(client.ServiceClient(), id, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_name",
+ ID: id,
+ Email: "new_email@foo.com",
+ Enabled: true,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestDeleteUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteUserResponse(t)
+
+ res := Delete(client.ServiceClient(), "c39e3de9be2d4c779f1dfd6abacc176d")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestListingUserRoles(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListRolesResponse(t)
+
+ tenantID := "1d8b6120dcc640fda4fc9194ffc80273"
+ userID := "c39e3de9be2d4c779f1dfd6abacc176d"
+
+ err := ListRoles(client.ServiceClient(), tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+ actual, err := ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Role{
+ Role{ID: "9fe2ff9ee4384b1894a90878d3e92bab", Name: "foo_role"},
+ Role{ID: "1ea3d56793574b668e85960fbf651e13", Name: "admin"},
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/users/results.go b/openstack/identity/v2/users/results.go
new file mode 100644
index 0000000..f531d5d
--- /dev/null
+++ b/openstack/identity/v2/users/results.go
@@ -0,0 +1,128 @@
+package users
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// User represents a user resource that exists on the API.
+type User struct {
+ // The UUID for this user.
+ ID string
+
+ // The human name for this user.
+ Name string
+
+ // The username for this user.
+ Username string
+
+ // Indicates whether the user is enabled (true) or disabled (false).
+ Enabled bool
+
+ // The email address for this user.
+ Email string
+
+ // The ID of the tenant to which this user belongs.
+ TenantID string `mapstructure:"tenant_id"`
+}
+
+// Role assigns specific responsibilities to users, allowing them to accomplish
+// certain API operations whilst scoped to a service.
+type Role struct {
+ // UUID of the role
+ ID string
+
+ // Name of the role
+ Name string
+}
+
+// UserPage is a single page of a User collection.
+type UserPage struct {
+ pagination.SinglePageBase
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page UserPage) IsEmpty() (bool, error) {
+ users, err := ExtractUsers(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractUsers returns a slice of Tenants contained in a single page of results.
+func ExtractUsers(page pagination.Page) ([]User, error) {
+ casted := page.(UserPage).Body
+ var response struct {
+ Users []User `mapstructure:"users"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Users, err
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractRoles returns a slice of Roles contained in a single page of results.
+func ExtractRoles(page pagination.Page) ([]Role, error) {
+ casted := page.(RolePage).Body
+ var response struct {
+ Roles []Role `mapstructure:"roles"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Roles, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as a User, if possible.
+func (r commonResult) Extract() (*User, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ User User `mapstructure:"user"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.User, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a Delete operation
+type DeleteResult struct {
+ commonResult
+}
diff --git a/openstack/identity/v2/users/urls.go b/openstack/identity/v2/users/urls.go
new file mode 100644
index 0000000..7ec4385
--- /dev/null
+++ b/openstack/identity/v2/users/urls.go
@@ -0,0 +1,21 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ tenantPath = "tenants"
+ userPath = "users"
+ rolePath = "roles"
+)
+
+func ResourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(userPath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(userPath)
+}
+
+func listRolesURL(c *gophercloud.ServiceClient, tenantID, userID string) string {
+ return c.ServiceURL(tenantPath, tenantID, userPath, userID, rolePath)
+}
diff --git a/openstack/identity/v3/endpoints/doc.go b/openstack/identity/v3/endpoints/doc.go
new file mode 100644
index 0000000..8516394
--- /dev/null
+++ b/openstack/identity/v3/endpoints/doc.go
@@ -0,0 +1,6 @@
+// Package endpoints provides information and interaction with the service
+// endpoints API resource in the OpenStack Identity service.
+//
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v3.html#endpoints-v3
+package endpoints
diff --git a/openstack/identity/v3/endpoints/errors.go b/openstack/identity/v3/endpoints/errors.go
new file mode 100644
index 0000000..854957f
--- /dev/null
+++ b/openstack/identity/v3/endpoints/errors.go
@@ -0,0 +1,21 @@
+package endpoints
+
+import "fmt"
+
+func requiredAttribute(attribute string) error {
+ return fmt.Errorf("You must specify %s for this endpoint.", attribute)
+}
+
+var (
+ // ErrAvailabilityRequired is reported if an Endpoint is created without an Availability.
+ ErrAvailabilityRequired = requiredAttribute("an availability")
+
+ // ErrNameRequired is reported if an Endpoint is created without a Name.
+ ErrNameRequired = requiredAttribute("a name")
+
+ // ErrURLRequired is reported if an Endpoint is created without a URL.
+ ErrURLRequired = requiredAttribute("a URL")
+
+ // ErrServiceIDRequired is reported if an Endpoint is created without a ServiceID.
+ ErrServiceIDRequired = requiredAttribute("a serviceID")
+)
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
new file mode 100644
index 0000000..99a495d
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -0,0 +1,123 @@
+package endpoints
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// EndpointOpts contains the subset of Endpoint attributes that should be used to create or update an Endpoint.
+type EndpointOpts struct {
+ Availability gophercloud.Availability
+ Name string
+ Region string
+ URL string
+ ServiceID string
+}
+
+// Create inserts a new Endpoint into the service catalog.
+// Within EndpointOpts, Region may be omitted by being left as "", but all other fields are required.
+func Create(client *gophercloud.ServiceClient, opts EndpointOpts) CreateResult {
+ // Redefined so that Region can be re-typed as a *string, which can be omitted from the JSON output.
+ type endpoint struct {
+ Interface string `json:"interface"`
+ Name string `json:"name"`
+ Region *string `json:"region,omitempty"`
+ URL string `json:"url"`
+ ServiceID string `json:"service_id"`
+ }
+
+ type request struct {
+ Endpoint endpoint `json:"endpoint"`
+ }
+
+ // Ensure that EndpointOpts is fully populated.
+ if opts.Availability == "" {
+ return createErr(ErrAvailabilityRequired)
+ }
+ if opts.Name == "" {
+ return createErr(ErrNameRequired)
+ }
+ if opts.URL == "" {
+ return createErr(ErrURLRequired)
+ }
+ if opts.ServiceID == "" {
+ return createErr(ErrServiceIDRequired)
+ }
+
+ // Populate the request body.
+ reqBody := request{
+ Endpoint: endpoint{
+ Interface: string(opts.Availability),
+ Name: opts.Name,
+ URL: opts.URL,
+ ServiceID: opts.ServiceID,
+ },
+ }
+ reqBody.Endpoint.Region = gophercloud.MaybeString(opts.Region)
+
+ var result CreateResult
+ _, result.Err = client.Post(listURL(client), reqBody, &result.Body, nil)
+ return result
+}
+
+// ListOpts allows finer control over the endpoints returned by a List call.
+// All fields are optional.
+type ListOpts struct {
+ Availability gophercloud.Availability `q:"interface"`
+ ServiceID string `q:"service_id"`
+ Page int `q:"page"`
+ PerPage int `q:"per_page"`
+}
+
+// List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
+func List(client *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ u := listURL(client)
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u += q.String()
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return EndpointPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, u, createPage)
+}
+
+// Update changes an existing endpoint with new data.
+// All fields are optional in the provided EndpointOpts.
+func Update(client *gophercloud.ServiceClient, endpointID string, opts EndpointOpts) UpdateResult {
+ type endpoint struct {
+ Interface *string `json:"interface,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Region *string `json:"region,omitempty"`
+ URL *string `json:"url,omitempty"`
+ ServiceID *string `json:"service_id,omitempty"`
+ }
+
+ type request struct {
+ Endpoint endpoint `json:"endpoint"`
+ }
+
+ reqBody := request{Endpoint: endpoint{}}
+ reqBody.Endpoint.Interface = gophercloud.MaybeString(string(opts.Availability))
+ reqBody.Endpoint.Name = gophercloud.MaybeString(opts.Name)
+ reqBody.Endpoint.Region = gophercloud.MaybeString(opts.Region)
+ reqBody.Endpoint.URL = gophercloud.MaybeString(opts.URL)
+ reqBody.Endpoint.ServiceID = gophercloud.MaybeString(opts.ServiceID)
+
+ var result UpdateResult
+ _, result.Err = client.Request("PATCH", endpointURL(client, endpointID), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ JSONResponse: &result.Body,
+ OkCodes: []int{200},
+ })
+ return result
+}
+
+// Delete removes an endpoint from the service catalog.
+func Delete(client *gophercloud.ServiceClient, endpointID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(endpointURL(client, endpointID), nil)
+ return res
+}
diff --git a/openstack/identity/v3/endpoints/requests_test.go b/openstack/identity/v3/endpoints/requests_test.go
new file mode 100644
index 0000000..80687c4
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests_test.go
@@ -0,0 +1,226 @@
+package endpoints
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ testhelper.TestJSONRequest(t, r, `
+ {
+ "endpoint": {
+ "interface": "public",
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "url": "https://1.2.3.4:9000/",
+ "service_id": "asdfasdfasdfasdf"
+ }
+ }
+ `)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ {
+ "endpoint": {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ }
+ }
+ `)
+ })
+
+ actual, err := Create(client.ServiceClient(), EndpointOpts{
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ URL: "https://1.2.3.4:9000/",
+ ServiceID: "asdfasdfasdfasdf",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create an endpoint: %v", err)
+ }
+
+ expected := &Endpoint{
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+
+ if !reflect.DeepEqual(actual, expected) {
+ t.Errorf("Expected %#v, was %#v", expected, actual)
+ }
+}
+
+func TestListEndpoints(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "endpoints": [
+ {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "the-endiest-of-points",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ },
+ {
+ "id": "13",
+ "interface": "internal",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/13"
+ },
+ "name": "shhhh",
+ "region": "underground",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9001/"
+ }
+ ],
+ "links": {
+ "next": null,
+ "previous": null
+ }
+ }
+ `)
+ })
+
+ count := 0
+ List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractEndpoints(page)
+ if err != nil {
+ t.Errorf("Failed to extract endpoints: %v", err)
+ return false, err
+ }
+
+ expected := []Endpoint{
+ Endpoint{
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ },
+ Endpoint{
+ ID: "13",
+ Availability: gophercloud.AvailabilityInternal,
+ Name: "shhhh",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9001/",
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, got %#v", expected, actual)
+ }
+
+ return true, nil
+ })
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestUpdateEndpoint(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "PATCH")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ testhelper.TestJSONRequest(t, r, `
+ {
+ "endpoint": {
+ "name": "renamed",
+ "region": "somewhere-else"
+ }
+ }
+ `)
+
+ fmt.Fprintf(w, `
+ {
+ "endpoint": {
+ "id": "12",
+ "interface": "public",
+ "links": {
+ "self": "https://localhost:5000/v3/endpoints/12"
+ },
+ "name": "renamed",
+ "region": "somewhere-else",
+ "service_id": "asdfasdfasdfasdf",
+ "url": "https://1.2.3.4:9000/"
+ }
+ }
+ `)
+ })
+
+ actual, err := Update(client.ServiceClient(), "12", EndpointOpts{
+ Name: "renamed",
+ Region: "somewhere-else",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Update: %v", err)
+ }
+
+ expected := &Endpoint{
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "renamed",
+ Region: "somewhere-else",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, was %#v", expected, actual)
+ }
+}
+
+func TestDeleteEndpoint(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/endpoints/34", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "DELETE")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(client.ServiceClient(), "34")
+ testhelper.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
new file mode 100644
index 0000000..1281122
--- /dev/null
+++ b/openstack/identity/v3/endpoints/results.go
@@ -0,0 +1,82 @@
+package endpoints
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult, CreateResult or UpdateResult as a concrete Endpoint.
+// An error is returned if the original call or the extraction failed.
+func (r commonResult) Extract() (*Endpoint, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Endpoint `json:"endpoint"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return &res.Endpoint, err
+}
+
+// CreateResult is the deferred result of a Create call.
+type CreateResult struct {
+ commonResult
+}
+
+// createErr quickly wraps an error in a CreateResult.
+func createErr(err error) CreateResult {
+ return CreateResult{commonResult{gophercloud.Result{Err: err}}}
+}
+
+// UpdateResult is the deferred result of an Update call.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult is the deferred result of an Delete call.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Endpoint describes the entry point for another service's API.
+type Endpoint struct {
+ ID string `mapstructure:"id" json:"id"`
+ Availability gophercloud.Availability `mapstructure:"interface" json:"interface"`
+ Name string `mapstructure:"name" json:"name"`
+ Region string `mapstructure:"region" json:"region"`
+ ServiceID string `mapstructure:"service_id" json:"service_id"`
+ URL string `mapstructure:"url" json:"url"`
+}
+
+// EndpointPage is a single page of Endpoint results.
+type EndpointPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if no Endpoints were returned.
+func (p EndpointPage) IsEmpty() (bool, error) {
+ es, err := ExtractEndpoints(p)
+ if err != nil {
+ return true, err
+ }
+ return len(es) == 0, nil
+}
+
+// ExtractEndpoints extracts an Endpoint slice from a Page.
+func ExtractEndpoints(page pagination.Page) ([]Endpoint, error) {
+ var response struct {
+ Endpoints []Endpoint `mapstructure:"endpoints"`
+ }
+
+ err := mapstructure.Decode(page.(EndpointPage).Body, &response)
+
+ return response.Endpoints, err
+}
diff --git a/openstack/identity/v3/endpoints/urls.go b/openstack/identity/v3/endpoints/urls.go
new file mode 100644
index 0000000..547d7b1
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls.go
@@ -0,0 +1,11 @@
+package endpoints
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("endpoints")
+}
+
+func endpointURL(client *gophercloud.ServiceClient, endpointID string) string {
+ return client.ServiceURL("endpoints", endpointID)
+}
diff --git a/openstack/identity/v3/endpoints/urls_test.go b/openstack/identity/v3/endpoints/urls_test.go
new file mode 100644
index 0000000..0b183b7
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls_test.go
@@ -0,0 +1,23 @@
+package endpoints
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func TestGetListURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := listURL(&client)
+ if url != "http://localhost:5000/v3/endpoints" {
+ t.Errorf("Unexpected list URL generated: [%s]", url)
+ }
+}
+
+func TestGetEndpointURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := endpointURL(&client, "1234")
+ if url != "http://localhost:5000/v3/endpoints/1234" {
+ t.Errorf("Unexpected service URL generated: [%s]", url)
+ }
+}
diff --git a/openstack/identity/v3/roles/doc.go b/openstack/identity/v3/roles/doc.go
new file mode 100644
index 0000000..bdbc674
--- /dev/null
+++ b/openstack/identity/v3/roles/doc.go
@@ -0,0 +1,3 @@
+// Package roles provides information and interaction with the roles API
+// resource for the OpenStack Identity service.
+package roles
diff --git a/openstack/identity/v3/roles/requests.go b/openstack/identity/v3/roles/requests.go
new file mode 100644
index 0000000..d95c1e5
--- /dev/null
+++ b/openstack/identity/v3/roles/requests.go
@@ -0,0 +1,50 @@
+package roles
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListAssignmentsOptsBuilder allows extensions to add additional parameters to
+// the ListAssignments request.
+type ListAssignmentsOptsBuilder interface {
+ ToRolesListAssignmentsQuery() (string, error)
+}
+
+// ListAssignmentsOpts allows you to query the ListAssignments method.
+// Specify one of or a combination of GroupId, RoleId, ScopeDomainId, ScopeProjectId,
+// and/or UserId to search for roles assigned to corresponding entities.
+// Effective lists effective assignments at the user, project, and domain level,
+// allowing for the effects of group membership.
+type ListAssignmentsOpts struct {
+ GroupId string `q:"group.id"`
+ RoleId string `q:"role.id"`
+ ScopeDomainId string `q:"scope.domain.id"`
+ ScopeProjectId string `q:"scope.project.id"`
+ UserId string `q:"user.id"`
+ Effective bool `q:"effective"`
+}
+
+// ToRolesListAssignmentsQuery formats a ListAssignmentsOpts into a query string.
+func (opts ListAssignmentsOpts) ToRolesListAssignmentsQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListAssignments enumerates the roles assigned to a specified resource.
+func ListAssignments(client *gophercloud.ServiceClient, opts ListAssignmentsOptsBuilder) pagination.Pager {
+ url := listAssignmentsURL(client)
+ query, err := opts.ToRolesListAssignmentsQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return RoleAssignmentsPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, url, createPage)
+}
diff --git a/openstack/identity/v3/roles/requests_test.go b/openstack/identity/v3/roles/requests_test.go
new file mode 100644
index 0000000..d62dbff
--- /dev/null
+++ b/openstack/identity/v3/roles/requests_test.go
@@ -0,0 +1,104 @@
+package roles
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListSinglePage(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/role_assignments", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "role_assignments": [
+ {
+ "links": {
+ "assignment": "http://identity:35357/v3/domains/161718/users/313233/roles/123456"
+ },
+ "role": {
+ "id": "123456"
+ },
+ "scope": {
+ "domain": {
+ "id": "161718"
+ }
+ },
+ "user": {
+ "id": "313233"
+ }
+ },
+ {
+ "links": {
+ "assignment": "http://identity:35357/v3/projects/456789/groups/101112/roles/123456",
+ "membership": "http://identity:35357/v3/groups/101112/users/313233"
+ },
+ "role": {
+ "id": "123456"
+ },
+ "scope": {
+ "project": {
+ "id": "456789"
+ }
+ },
+ "user": {
+ "id": "313233"
+ }
+ }
+ ],
+ "links": {
+ "self": "http://identity:35357/v3/role_assignments?effective",
+ "previous": null,
+ "next": null
+ }
+ }
+ `)
+ })
+
+ count := 0
+ err := ListAssignments(client.ServiceClient(), ListAssignmentsOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRoleAssignments(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []RoleAssignment{
+ RoleAssignment{
+ Role: Role{ID: "123456"},
+ Scope: Scope{Domain: Domain{ID: "161718"}},
+ User: User{ID: "313233"},
+ Group: Group{},
+ },
+ RoleAssignment{
+ Role: Role{ID: "123456"},
+ Scope: Scope{Project: Project{ID: "456789"}},
+ User: User{ID: "313233"},
+ Group: Group{},
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, got %#v", expected, actual)
+ }
+
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("Unexpected error while paging: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
diff --git a/openstack/identity/v3/roles/results.go b/openstack/identity/v3/roles/results.go
new file mode 100644
index 0000000..d25abd2
--- /dev/null
+++ b/openstack/identity/v3/roles/results.go
@@ -0,0 +1,81 @@
+package roles
+
+import (
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// RoleAssignment is the result of a role assignments query.
+type RoleAssignment struct {
+ Role Role `json:"role,omitempty"`
+ Scope Scope `json:"scope,omitempty"`
+ User User `json:"user,omitempty"`
+ Group Group `json:"group,omitempty"`
+}
+
+type Role struct {
+ ID string `json:"id,omitempty"`
+}
+
+type Scope struct {
+ Domain Domain `json:"domain,omitempty"`
+ Project Project `json:"domain,omitempty"`
+}
+
+type Domain struct {
+ ID string `json:"id,omitempty"`
+}
+
+type Project struct {
+ ID string `json:"id,omitempty"`
+}
+
+type User struct {
+ ID string `json:"id,omitempty"`
+}
+
+type Group struct {
+ ID string `json:"id,omitempty"`
+}
+
+// RoleAssignmentsPage is a single page of RoleAssignments results.
+type RoleAssignmentsPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (p RoleAssignmentsPage) IsEmpty() (bool, error) {
+ roleAssignments, err := ExtractRoleAssignments(p)
+ if err != nil {
+ return true, err
+ }
+ return len(roleAssignments) == 0, nil
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page RoleAssignmentsPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links struct {
+ Next string `mapstructure:"next"`
+ } `mapstructure:"links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return r.Links.Next, nil
+}
+
+// ExtractRoleAssignments extracts a slice of RoleAssignments from a Collection acquired from List.
+func ExtractRoleAssignments(page pagination.Page) ([]RoleAssignment, error) {
+ var response struct {
+ RoleAssignments []RoleAssignment `mapstructure:"role_assignments"`
+ }
+
+ err := mapstructure.Decode(page.(RoleAssignmentsPage).Body, &response)
+ return response.RoleAssignments, err
+}
diff --git a/openstack/identity/v3/roles/urls.go b/openstack/identity/v3/roles/urls.go
new file mode 100644
index 0000000..b009340
--- /dev/null
+++ b/openstack/identity/v3/roles/urls.go
@@ -0,0 +1,7 @@
+package roles
+
+import "github.com/rackspace/gophercloud"
+
+func listAssignmentsURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("role_assignments")
+}
diff --git a/openstack/identity/v3/roles/urls_test.go b/openstack/identity/v3/roles/urls_test.go
new file mode 100644
index 0000000..04679da
--- /dev/null
+++ b/openstack/identity/v3/roles/urls_test.go
@@ -0,0 +1,15 @@
+package roles
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func TestListAssignmentsURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := listAssignmentsURL(&client)
+ if url != "http://localhost:5000/v3/role_assignments" {
+ t.Errorf("Unexpected list URL generated: [%s]", url)
+ }
+}
diff --git a/openstack/identity/v3/services/doc.go b/openstack/identity/v3/services/doc.go
new file mode 100644
index 0000000..fa56411
--- /dev/null
+++ b/openstack/identity/v3/services/doc.go
@@ -0,0 +1,3 @@
+// Package services provides information and interaction with the services API
+// resource for the OpenStack Identity service.
+package services
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
new file mode 100644
index 0000000..3ee924f
--- /dev/null
+++ b/openstack/identity/v3/services/requests.go
@@ -0,0 +1,77 @@
+package services
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type response struct {
+ Service Service `json:"service"`
+}
+
+// Create adds a new service of the requested type to the catalog.
+func Create(client *gophercloud.ServiceClient, serviceType string) CreateResult {
+ type request struct {
+ Type string `json:"type"`
+ }
+
+ req := request{Type: serviceType}
+
+ var result CreateResult
+ _, result.Err = client.Post(listURL(client), req, &result.Body, nil)
+ return result
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+ ServiceType string `q:"type"`
+ PerPage int `q:"perPage"`
+ Page int `q:"page"`
+}
+
+// List enumerates the services available to a specific user.
+func List(client *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ u := listURL(client)
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u += q.String()
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return ServicePage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, u, createPage)
+}
+
+// Get returns additional information about a service, given its ID.
+func Get(client *gophercloud.ServiceClient, serviceID string) GetResult {
+ var result GetResult
+ _, result.Err = client.Get(serviceURL(client, serviceID), &result.Body, nil)
+ return result
+}
+
+// Update changes the service type of an existing service.
+func Update(client *gophercloud.ServiceClient, serviceID string, serviceType string) UpdateResult {
+ type request struct {
+ Type string `json:"type"`
+ }
+
+ req := request{Type: serviceType}
+
+ var result UpdateResult
+ _, result.Err = client.Request("PATCH", serviceURL(client, serviceID), gophercloud.RequestOpts{
+ JSONBody: &req,
+ JSONResponse: &result.Body,
+ OkCodes: []int{200},
+ })
+ return result
+}
+
+// Delete removes an existing service.
+// It either deletes all associated endpoints, or fails until all endpoints are deleted.
+func Delete(client *gophercloud.ServiceClient, serviceID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = client.Delete(serviceURL(client, serviceID), nil)
+ return res
+}
diff --git a/openstack/identity/v3/services/requests_test.go b/openstack/identity/v3/services/requests_test.go
new file mode 100644
index 0000000..42f05d3
--- /dev/null
+++ b/openstack/identity/v3/services/requests_test.go
@@ -0,0 +1,209 @@
+package services
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ testhelper.TestJSONRequest(t, r, `{ "type": "compute" }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{
+ "service": {
+ "description": "Here's your service",
+ "id": "1234",
+ "name": "InscrutableOpenStackProjectName",
+ "type": "compute"
+ }
+ }`)
+ })
+
+ result, err := Create(client.ServiceClient(), "compute").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Create: %v", err)
+ }
+
+ if result.Description == nil || *result.Description != "Here's your service" {
+ t.Errorf("Service description was unexpected [%s]", *result.Description)
+ }
+ if result.ID != "1234" {
+ t.Errorf("Service ID was unexpected [%s]", result.ID)
+ }
+ if result.Name != "InscrutableOpenStackProjectName" {
+ t.Errorf("Service name was unexpected [%s]", result.Name)
+ }
+ if result.Type != "compute" {
+ t.Errorf("Service type was unexpected [%s]", result.Type)
+ }
+}
+
+func TestListSinglePage(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "links": {
+ "next": null,
+ "previous": null
+ },
+ "services": [
+ {
+ "description": "Service One",
+ "id": "1234",
+ "name": "service-one",
+ "type": "identity"
+ },
+ {
+ "description": "Service Two",
+ "id": "9876",
+ "name": "service-two",
+ "type": "compute"
+ }
+ ]
+ }
+ `)
+ })
+
+ count := 0
+ err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractServices(page)
+ if err != nil {
+ return false, err
+ }
+
+ desc0 := "Service One"
+ desc1 := "Service Two"
+ expected := []Service{
+ Service{
+ Description: &desc0,
+ ID: "1234",
+ Name: "service-one",
+ Type: "identity",
+ },
+ Service{
+ Description: &desc1,
+ ID: "9876",
+ Name: "service-two",
+ Type: "compute",
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, got %#v", expected, actual)
+ }
+
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("Unexpected error while paging: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGetSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "service": {
+ "description": "Service One",
+ "id": "12345",
+ "name": "service-one",
+ "type": "identity"
+ }
+ }
+ `)
+ })
+
+ result, err := Get(client.ServiceClient(), "12345").Extract()
+ if err != nil {
+ t.Fatalf("Error fetching service information: %v", err)
+ }
+
+ if result.ID != "12345" {
+ t.Errorf("Unexpected service ID: %s", result.ID)
+ }
+ if *result.Description != "Service One" {
+ t.Errorf("Unexpected service description: [%s]", *result.Description)
+ }
+ if result.Name != "service-one" {
+ t.Errorf("Unexpected service name: [%s]", result.Name)
+ }
+ if result.Type != "identity" {
+ t.Errorf("Unexpected service type: [%s]", result.Type)
+ }
+}
+
+func TestUpdateSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "PATCH")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ testhelper.TestJSONRequest(t, r, `{ "type": "lasermagic" }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "service": {
+ "id": "12345",
+ "type": "lasermagic"
+ }
+ }
+ `)
+ })
+
+ result, err := Update(client.ServiceClient(), "12345", "lasermagic").Extract()
+ if err != nil {
+ t.Fatalf("Unable to update service: %v", err)
+ }
+
+ if result.ID != "12345" {
+ t.Fatalf("Expected ID 12345, was %s", result.ID)
+ }
+}
+
+func TestDeleteSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "DELETE")
+ testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(client.ServiceClient(), "12345")
+ testhelper.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
new file mode 100644
index 0000000..1d0d141
--- /dev/null
+++ b/openstack/identity/v3/services/results.go
@@ -0,0 +1,80 @@
+package services
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult, CreateResult or UpdateResult as a concrete Service.
+// An error is returned if the original call or the extraction failed.
+func (r commonResult) Extract() (*Service, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Service `json:"service"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return &res.Service, err
+}
+
+// CreateResult is the deferred result of a Create call.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult is the deferred result of a Get call.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult is the deferred result of an Update call.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult is the deferred result of an Delete call.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Service is the result of a list or information query.
+type Service struct {
+ Description *string `json:"description,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+// ServicePage is a single page of Service results.
+type ServicePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (p ServicePage) IsEmpty() (bool, error) {
+ services, err := ExtractServices(p)
+ if err != nil {
+ return true, err
+ }
+ return len(services) == 0, nil
+}
+
+// ExtractServices extracts a slice of Services from a Collection acquired from List.
+func ExtractServices(page pagination.Page) ([]Service, error) {
+ var response struct {
+ Services []Service `mapstructure:"services"`
+ }
+
+ err := mapstructure.Decode(page.(ServicePage).Body, &response)
+ return response.Services, err
+}
diff --git a/openstack/identity/v3/services/urls.go b/openstack/identity/v3/services/urls.go
new file mode 100644
index 0000000..85443a4
--- /dev/null
+++ b/openstack/identity/v3/services/urls.go
@@ -0,0 +1,11 @@
+package services
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("services")
+}
+
+func serviceURL(client *gophercloud.ServiceClient, serviceID string) string {
+ return client.ServiceURL("services", serviceID)
+}
diff --git a/openstack/identity/v3/services/urls_test.go b/openstack/identity/v3/services/urls_test.go
new file mode 100644
index 0000000..5a31b32
--- /dev/null
+++ b/openstack/identity/v3/services/urls_test.go
@@ -0,0 +1,23 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func TestListURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := listURL(&client)
+ if url != "http://localhost:5000/v3/services" {
+ t.Errorf("Unexpected list URL generated: [%s]", url)
+ }
+}
+
+func TestServiceURL(t *testing.T) {
+ client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+ url := serviceURL(&client, "1234")
+ if url != "http://localhost:5000/v3/services/1234" {
+ t.Errorf("Unexpected service URL generated: [%s]", url)
+ }
+}
diff --git a/openstack/identity/v3/tokens/doc.go b/openstack/identity/v3/tokens/doc.go
new file mode 100644
index 0000000..76ff5f4
--- /dev/null
+++ b/openstack/identity/v3/tokens/doc.go
@@ -0,0 +1,6 @@
+// Package tokens provides information and interaction with the token API
+// resource for the OpenStack Identity service.
+//
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v3.html#tokens-v3
+package tokens
diff --git a/openstack/identity/v3/tokens/errors.go b/openstack/identity/v3/tokens/errors.go
new file mode 100644
index 0000000..4476109
--- /dev/null
+++ b/openstack/identity/v3/tokens/errors.go
@@ -0,0 +1,72 @@
+package tokens
+
+import (
+ "errors"
+ "fmt"
+)
+
+func unacceptedAttributeErr(attribute string) error {
+ return fmt.Errorf("The base Identity V3 API does not accept authentication by %s", attribute)
+}
+
+func redundantWithTokenErr(attribute string) error {
+ return fmt.Errorf("%s may not be provided when authenticating with a TokenID", attribute)
+}
+
+func redundantWithUserID(attribute string) error {
+ return fmt.Errorf("%s may not be provided when authenticating with a UserID", attribute)
+}
+
+var (
+ // ErrAPIKeyProvided indicates that an APIKey was provided but can't be used.
+ ErrAPIKeyProvided = unacceptedAttributeErr("APIKey")
+
+ // ErrTenantIDProvided indicates that a TenantID was provided but can't be used.
+ ErrTenantIDProvided = unacceptedAttributeErr("TenantID")
+
+ // ErrTenantNameProvided indicates that a TenantName was provided but can't be used.
+ ErrTenantNameProvided = unacceptedAttributeErr("TenantName")
+
+ // ErrUsernameWithToken indicates that a Username was provided, but token authentication is being used instead.
+ ErrUsernameWithToken = redundantWithTokenErr("Username")
+
+ // ErrUserIDWithToken indicates that a UserID was provided, but token authentication is being used instead.
+ ErrUserIDWithToken = redundantWithTokenErr("UserID")
+
+ // ErrDomainIDWithToken indicates that a DomainID was provided, but token authentication is being used instead.
+ ErrDomainIDWithToken = redundantWithTokenErr("DomainID")
+
+ // ErrDomainNameWithToken indicates that a DomainName was provided, but token authentication is being used instead.s
+ ErrDomainNameWithToken = redundantWithTokenErr("DomainName")
+
+ // ErrUsernameOrUserID indicates that neither username nor userID are specified, or both are at once.
+ ErrUsernameOrUserID = errors.New("Exactly one of Username and UserID must be provided for password authentication")
+
+ // ErrDomainIDWithUserID indicates that a DomainID was provided, but unnecessary because a UserID is being used.
+ ErrDomainIDWithUserID = redundantWithUserID("DomainID")
+
+ // ErrDomainNameWithUserID indicates that a DomainName was provided, but unnecessary because a UserID is being used.
+ ErrDomainNameWithUserID = redundantWithUserID("DomainName")
+
+ // ErrDomainIDOrDomainName indicates that a username was provided, but no domain to scope it.
+ // It may also indicate that both a DomainID and a DomainName were provided at once.
+ ErrDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName to authenticate by Username")
+
+ // ErrMissingPassword indicates that no password was provided and no token is available.
+ ErrMissingPassword = errors.New("You must provide a password to authenticate")
+
+ // ErrScopeDomainIDOrDomainName indicates that a domain ID or Name was required in a Scope, but not present.
+ ErrScopeDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName in a Scope with ProjectName")
+
+ // ErrScopeProjectIDOrProjectName indicates that both a ProjectID and a ProjectName were provided in a Scope.
+ ErrScopeProjectIDOrProjectName = errors.New("You must provide at most one of ProjectID or ProjectName in a Scope")
+
+ // ErrScopeProjectIDAlone indicates that a ProjectID was provided with other constraints in a Scope.
+ ErrScopeProjectIDAlone = errors.New("ProjectID must be supplied alone in a Scope")
+
+ // ErrScopeDomainName indicates that a DomainName was provided alone in a Scope.
+ ErrScopeDomainName = errors.New("DomainName must be supplied with a ProjectName or ProjectID in a Scope.")
+
+ // ErrScopeEmpty indicates that no credentials were provided in a Scope.
+ ErrScopeEmpty = errors.New("You must provide either a Project or Domain in a Scope")
+)
diff --git a/openstack/identity/v3/tokens/requests.go b/openstack/identity/v3/tokens/requests.go
new file mode 100644
index 0000000..d449ca3
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests.go
@@ -0,0 +1,281 @@
+package tokens
+
+import (
+ "net/http"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// Scope allows a created token to be limited to a specific domain or project.
+type Scope struct {
+ ProjectID string
+ ProjectName string
+ DomainID string
+ DomainName string
+}
+
+func subjectTokenHeaders(c *gophercloud.ServiceClient, subjectToken string) map[string]string {
+ h := c.AuthenticatedHeaders()
+ h["X-Subject-Token"] = subjectToken
+ return h
+}
+
+// Create authenticates and either generates a new token, or changes the Scope of an existing token.
+func Create(c *gophercloud.ServiceClient, options gophercloud.AuthOptions, scope *Scope) CreateResult {
+ type domainReq struct {
+ ID *string `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ }
+
+ type projectReq struct {
+ Domain *domainReq `json:"domain,omitempty"`
+ Name *string `json:"name,omitempty"`
+ ID *string `json:"id,omitempty"`
+ }
+
+ type userReq struct {
+ ID *string `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Password string `json:"password"`
+ Domain *domainReq `json:"domain,omitempty"`
+ }
+
+ type passwordReq struct {
+ User userReq `json:"user"`
+ }
+
+ type tokenReq struct {
+ ID string `json:"id"`
+ }
+
+ type identityReq struct {
+ Methods []string `json:"methods"`
+ Password *passwordReq `json:"password,omitempty"`
+ Token *tokenReq `json:"token,omitempty"`
+ }
+
+ type scopeReq struct {
+ Domain *domainReq `json:"domain,omitempty"`
+ Project *projectReq `json:"project,omitempty"`
+ }
+
+ type authReq struct {
+ Identity identityReq `json:"identity"`
+ Scope *scopeReq `json:"scope,omitempty"`
+ }
+
+ type request struct {
+ Auth authReq `json:"auth"`
+ }
+
+ // Populate the request structure based on the provided arguments. Create and return an error
+ // if insufficient or incompatible information is present.
+ var req request
+
+ // Test first for unrecognized arguments.
+ if options.APIKey != "" {
+ return createErr(ErrAPIKeyProvided)
+ }
+ if options.TenantID != "" {
+ return createErr(ErrTenantIDProvided)
+ }
+ if options.TenantName != "" {
+ return createErr(ErrTenantNameProvided)
+ }
+
+ if options.Password == "" {
+ if c.TokenID != "" {
+ // Because we aren't using password authentication, it's an error to also provide any of the user-based authentication
+ // parameters.
+ if options.Username != "" {
+ return createErr(ErrUsernameWithToken)
+ }
+ if options.UserID != "" {
+ return createErr(ErrUserIDWithToken)
+ }
+ if options.DomainID != "" {
+ return createErr(ErrDomainIDWithToken)
+ }
+ if options.DomainName != "" {
+ return createErr(ErrDomainNameWithToken)
+ }
+
+ // Configure the request for Token authentication.
+ req.Auth.Identity.Methods = []string{"token"}
+ req.Auth.Identity.Token = &tokenReq{
+ ID: c.TokenID,
+ }
+ } else {
+ // If no password or token ID are available, authentication can't continue.
+ return createErr(ErrMissingPassword)
+ }
+ } else {
+ // Password authentication.
+ req.Auth.Identity.Methods = []string{"password"}
+
+ // At least one of Username and UserID must be specified.
+ if options.Username == "" && options.UserID == "" {
+ return createErr(ErrUsernameOrUserID)
+ }
+
+ if options.Username != "" {
+ // If Username is provided, UserID may not be provided.
+ if options.UserID != "" {
+ return createErr(ErrUsernameOrUserID)
+ }
+
+ // Either DomainID or DomainName must also be specified.
+ if options.DomainID == "" && options.DomainName == "" {
+ return createErr(ErrDomainIDOrDomainName)
+ }
+
+ if options.DomainID != "" {
+ if options.DomainName != "" {
+ return createErr(ErrDomainIDOrDomainName)
+ }
+
+ // Configure the request for Username and Password authentication with a DomainID.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &options.Username,
+ Password: options.Password,
+ Domain: &domainReq{ID: &options.DomainID},
+ },
+ }
+ }
+
+ if options.DomainName != "" {
+ // Configure the request for Username and Password authentication with a DomainName.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &options.Username,
+ Password: options.Password,
+ Domain: &domainReq{Name: &options.DomainName},
+ },
+ }
+ }
+ }
+
+ if options.UserID != "" {
+ // If UserID is specified, neither DomainID nor DomainName may be.
+ if options.DomainID != "" {
+ return createErr(ErrDomainIDWithUserID)
+ }
+ if options.DomainName != "" {
+ return createErr(ErrDomainNameWithUserID)
+ }
+
+ // Configure the request for UserID and Password authentication.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{ID: &options.UserID, Password: options.Password},
+ }
+ }
+ }
+
+ // Add a "scope" element if a Scope has been provided.
+ if scope != nil {
+ if scope.ProjectName != "" {
+ // ProjectName provided: either DomainID or DomainName must also be supplied.
+ // ProjectID may not be supplied.
+ if scope.DomainID == "" && scope.DomainName == "" {
+ return createErr(ErrScopeDomainIDOrDomainName)
+ }
+ if scope.ProjectID != "" {
+ return createErr(ErrScopeProjectIDOrProjectName)
+ }
+
+ if scope.DomainID != "" {
+ // ProjectName + DomainID
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{
+ Name: &scope.ProjectName,
+ Domain: &domainReq{ID: &scope.DomainID},
+ },
+ }
+ }
+
+ if scope.DomainName != "" {
+ // ProjectName + DomainName
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{
+ Name: &scope.ProjectName,
+ Domain: &domainReq{Name: &scope.DomainName},
+ },
+ }
+ }
+ } else if scope.ProjectID != "" {
+ // ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+ if scope.DomainID != "" {
+ return createErr(ErrScopeProjectIDAlone)
+ }
+ if scope.DomainName != "" {
+ return createErr(ErrScopeProjectIDAlone)
+ }
+
+ // ProjectID
+ req.Auth.Scope = &scopeReq{
+ Project: &projectReq{ID: &scope.ProjectID},
+ }
+ } else if scope.DomainID != "" {
+ // DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+ if scope.DomainName != "" {
+ return createErr(ErrScopeDomainIDOrDomainName)
+ }
+
+ // DomainID
+ req.Auth.Scope = &scopeReq{
+ Domain: &domainReq{ID: &scope.DomainID},
+ }
+ } else if scope.DomainName != "" {
+ return createErr(ErrScopeDomainName)
+ } else {
+ return createErr(ErrScopeEmpty)
+ }
+ }
+
+ var result CreateResult
+ var response *http.Response
+ response, result.Err = c.Post(tokenURL(c), req, &result.Body, nil)
+ if result.Err != nil {
+ return result
+ }
+ result.Header = response.Header
+ return result
+}
+
+// Get validates and retrieves information about another token.
+func Get(c *gophercloud.ServiceClient, token string) GetResult {
+ var result GetResult
+ var response *http.Response
+ response, result.Err = c.Get(tokenURL(c), &result.Body, &gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{200, 203},
+ })
+ if result.Err != nil {
+ return result
+ }
+ result.Header = response.Header
+ return result
+}
+
+// Validate determines if a specified token is valid or not.
+func Validate(c *gophercloud.ServiceClient, token string) (bool, error) {
+ response, err := c.Request("HEAD", tokenURL(c), gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{204, 404},
+ })
+ if err != nil {
+ return false, err
+ }
+
+ return response.StatusCode == 204, nil
+}
+
+// Revoke immediately makes specified token invalid.
+func Revoke(c *gophercloud.ServiceClient, token string) RevokeResult {
+ var res RevokeResult
+ _, res.Err = c.Delete(tokenURL(c), &gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ })
+ return res
+}
diff --git a/openstack/identity/v3/tokens/requests_test.go b/openstack/identity/v3/tokens/requests_test.go
new file mode 100644
index 0000000..2b26e4a
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests_test.go
@@ -0,0 +1,514 @@
+package tokens
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// authTokenPost verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func authTokenPost(t *testing.T, options gophercloud.AuthOptions, scope *Scope, requestJSON string) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "POST")
+ testhelper.TestHeader(t, r, "Content-Type", "application/json")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestJSONRequest(t, r, requestJSON)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{
+ "token": {
+ "expires_at": "2014-10-02T13:45:00.000000Z"
+ }
+ }`)
+ })
+
+ _, err := Create(&client, options, scope).Extract()
+ if err != nil {
+ t.Errorf("Create returned an error: %v", err)
+ }
+}
+
+func authTokenPostErr(t *testing.T, options gophercloud.AuthOptions, scope *Scope, includeToken bool, expectedErr error) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{},
+ Endpoint: testhelper.Endpoint(),
+ }
+ if includeToken {
+ client.TokenID = "abcdef123456"
+ }
+
+ _, err := Create(&client, options, scope).Extract()
+ if err == nil {
+ t.Errorf("Create did NOT return an error")
+ }
+ if err != expectedErr {
+ t.Errorf("Create returned an unexpected error: wanted %v, got %v", expectedErr, err)
+ }
+}
+
+func TestCreateUserIDAndPassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{UserID: "me", Password: "squirrel!"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": { "id": "me", "password": "squirrel!" }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateUsernameDomainIDPassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{Username: "fakey", Password: "notpassword", DomainID: "abc123"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "domain": {
+ "id": "abc123"
+ },
+ "name": "fakey",
+ "password": "notpassword"
+ }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateUsernameDomainNamePassword(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{Username: "frank", Password: "swordfish", DomainName: "spork.net"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "domain": {
+ "name": "spork.net"
+ },
+ "name": "frank",
+ "password": "swordfish"
+ }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateTokenID(t *testing.T) {
+ authTokenPost(t, gophercloud.AuthOptions{}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["token"],
+ "token": {
+ "id": "12345abcdef"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectID: "123456"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "id": "123456"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateDomainIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{DomainID: "1000"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "domain": {
+ "id": "1000"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectNameAndDomainIDScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectName: "world-domination", DomainID: "1000"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "domain": {
+ "id": "1000"
+ },
+ "name": "world-domination"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectNameAndDomainNameScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &Scope{ProjectName: "world-domination", DomainName: "evil-plans"}
+ authTokenPost(t, options, scope, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": {
+ "id": "fenris",
+ "password": "g0t0h311"
+ }
+ }
+ },
+ "scope": {
+ "project": {
+ "domain": {
+ "name": "evil-plans"
+ },
+ "name": "world-domination"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateExtractsTokenFromResponse(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{},
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("X-Subject-Token", "aaa111")
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{
+ "token": {
+ "expires_at": "2014-10-02T13:45:00.000000Z"
+ }
+ }`)
+ })
+
+ options := gophercloud.AuthOptions{UserID: "me", Password: "shhh"}
+ token, err := Create(&client, options, nil).Extract()
+ if err != nil {
+ t.Fatalf("Create returned an error: %v", err)
+ }
+
+ if token.ID != "aaa111" {
+ t.Errorf("Expected token to be aaa111, but was %s", token.ID)
+ }
+}
+
+func TestCreateFailureEmptyAuth(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{}, nil, false, ErrMissingPassword)
+}
+
+func TestCreateFailureAPIKey(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{APIKey: "something"}, nil, false, ErrAPIKeyProvided)
+}
+
+func TestCreateFailureTenantID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{TenantID: "something"}, nil, false, ErrTenantIDProvided)
+}
+
+func TestCreateFailureTenantName(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{TenantName: "something"}, nil, false, ErrTenantNameProvided)
+}
+
+func TestCreateFailureTokenIDUsername(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{Username: "something"}, nil, true, ErrUsernameWithToken)
+}
+
+func TestCreateFailureTokenIDUserID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{UserID: "something"}, nil, true, ErrUserIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainID(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{DomainID: "something"}, nil, true, ErrDomainIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainName(t *testing.T) {
+ authTokenPostErr(t, gophercloud.AuthOptions{DomainName: "something"}, nil, true, ErrDomainNameWithToken)
+}
+
+func TestCreateFailureMissingUser(t *testing.T) {
+ options := gophercloud.AuthOptions{Password: "supersecure"}
+ authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureBothUser(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "oops",
+ UserID: "redundancy",
+ }
+ authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureMissingDomain(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "notuniqueenough",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureBothDomain(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "supersecure",
+ Username: "someone",
+ DomainID: "hurf",
+ DomainName: "durf",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureUserIDDomainID(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ UserID: "100",
+ Password: "stuff",
+ DomainID: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainIDWithUserID)
+}
+
+func TestCreateFailureUserIDDomainName(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ UserID: "100",
+ Password: "sssh",
+ DomainName: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, ErrDomainNameWithUserID)
+}
+
+func TestCreateFailureScopeProjectNameAlone(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectName: "notenough"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeProjectNameAndID(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectName: "whoops", ProjectID: "toomuch", DomainID: "1234"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDOrProjectName)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainID(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectID: "toomuch", DomainID: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainNAme(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{ProjectID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeDomainIDAndDomainName(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{DomainID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeDomainNameAlone(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{DomainName: "notenough"}
+ authTokenPostErr(t, options, scope, false, ErrScopeDomainName)
+}
+
+func TestCreateFailureEmptyScope(t *testing.T) {
+ options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &Scope{}
+ authTokenPostErr(t, options, scope, false, ErrScopeEmpty)
+}
+
+func TestGetRequest(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, "GET")
+ testhelper.TestHeader(t, r, "Content-Type", "")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+ testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ { "token": { "expires_at": "2014-08-29T13:10:01.000000Z" } }
+ `)
+ })
+
+ token, err := Get(&client, "abcdef12345").Extract()
+ if err != nil {
+ t.Errorf("Info returned an error: %v", err)
+ }
+
+ expected, _ := time.Parse(time.UnixDate, "Fri Aug 29 13:10:01 UTC 2014")
+ if token.ExpiresAt != expected {
+ t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), token.ExpiresAt.Format(time.UnixDate))
+ }
+}
+
+func prepareAuthTokenHandler(t *testing.T, expectedMethod string, status int) gophercloud.ServiceClient {
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{
+ TokenID: "12345abcdef",
+ },
+ Endpoint: testhelper.Endpoint(),
+ }
+
+ testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+ testhelper.TestMethod(t, r, expectedMethod)
+ testhelper.TestHeader(t, r, "Content-Type", "")
+ testhelper.TestHeader(t, r, "Accept", "application/json")
+ testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+ testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+ w.WriteHeader(status)
+ })
+
+ return client
+}
+
+func TestValidateRequestSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusNoContent)
+
+ ok, err := Validate(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Unexpected error from Validate: %v", err)
+ }
+
+ if !ok {
+ t.Errorf("Validate returned false for a valid token")
+ }
+}
+
+func TestValidateRequestFailure(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusNotFound)
+
+ ok, err := Validate(&client, "abcdef12345")
+ if err != nil {
+ t.Errorf("Unexpected error from Validate: %v", err)
+ }
+
+ if ok {
+ t.Errorf("Validate returned true for an invalid token")
+ }
+}
+
+func TestValidateRequestError(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "HEAD", http.StatusUnauthorized)
+
+ _, err := Validate(&client, "abcdef12345")
+ if err == nil {
+ t.Errorf("Missing expected error from Validate")
+ }
+}
+
+func TestRevokeRequestSuccessful(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "DELETE", http.StatusNoContent)
+
+ res := Revoke(&client, "abcdef12345")
+ testhelper.AssertNoErr(t, res.Err)
+}
+
+func TestRevokeRequestError(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ client := prepareAuthTokenHandler(t, "DELETE", http.StatusNotFound)
+
+ res := Revoke(&client, "abcdef12345")
+ if res.Err == nil {
+ t.Errorf("Missing expected error from Revoke")
+ }
+}
diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go
new file mode 100644
index 0000000..d134f7d
--- /dev/null
+++ b/openstack/identity/v3/tokens/results.go
@@ -0,0 +1,139 @@
+package tokens
+
+import (
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+)
+
+// Endpoint represents a single API endpoint offered by a service.
+// It matches either a public, internal or admin URL.
+// If supported, it contains a region specifier, again if provided.
+// The significance of the Region field will depend upon your provider.
+type Endpoint struct {
+ ID string `mapstructure:"id"`
+ Region string `mapstructure:"region"`
+ Interface string `mapstructure:"interface"`
+ URL string `mapstructure:"url"`
+}
+
+// CatalogEntry provides a type-safe interface to an Identity API V3 service catalog listing.
+// Each class of service, such as cloud DNS or block storage services, could have multiple
+// CatalogEntry representing it (one by interface type, e.g public, admin or internal).
+//
+// Note: when looking for the desired service, try, whenever possible, to key off the type field.
+// Otherwise, you'll tie the representation of the service to a specific provider.
+type CatalogEntry struct {
+
+ // Service ID
+ ID string `mapstructure:"id"`
+
+ // Name will contain the provider-specified name for the service.
+ Name string `mapstructure:"name"`
+
+ // Type will contain a type string if OpenStack defines a type for the service.
+ // Otherwise, for provider-specific services, the provider may assign their own type strings.
+ Type string `mapstructure:"type"`
+
+ // Endpoints will let the caller iterate over all the different endpoints that may exist for
+ // the service.
+ Endpoints []Endpoint `mapstructure:"endpoints"`
+}
+
+// ServiceCatalog provides a view into the service catalog from a previous, successful authentication.
+type ServiceCatalog struct {
+ Entries []CatalogEntry
+}
+
+// commonResult is the deferred result of a Create or a Get call.
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a shortcut for ExtractToken.
+// This function is deprecated and still present for backward compatibility.
+func (r commonResult) Extract() (*Token, error) {
+ return r.ExtractToken()
+}
+
+// ExtractToken interprets a commonResult as a Token.
+func (r commonResult) ExtractToken() (*Token, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Token struct {
+ ExpiresAt string `mapstructure:"expires_at"`
+ } `mapstructure:"token"`
+ }
+
+ var token Token
+
+ // Parse the token itself from the stored headers.
+ token.ID = r.Header.Get("X-Subject-Token")
+
+ err := mapstructure.Decode(r.Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ // Attempt to parse the timestamp.
+ token.ExpiresAt, err = time.Parse(gophercloud.RFC3339Milli, response.Token.ExpiresAt)
+
+ return &token, err
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (result CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+ if result.Err != nil {
+ return nil, result.Err
+ }
+
+ var response struct {
+ Token struct {
+ Entries []CatalogEntry `mapstructure:"catalog"`
+ } `mapstructure:"token"`
+ }
+
+ err := mapstructure.Decode(result.Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ServiceCatalog{Entries: response.Token.Entries}, nil
+}
+
+// CreateResult defers the interpretation of a created token.
+// Use ExtractToken() to interpret it as a Token, or ExtractServiceCatalog() to interpret it as a service catalog.
+type CreateResult struct {
+ commonResult
+}
+
+// createErr quickly creates a CreateResult that reports an error.
+func createErr(err error) CreateResult {
+ return CreateResult{
+ commonResult: commonResult{Result: gophercloud.Result{Err: err}},
+ }
+}
+
+// GetResult is the deferred response from a Get call.
+type GetResult struct {
+ commonResult
+}
+
+// RevokeResult is the deferred response from a Revoke call.
+type RevokeResult struct {
+ commonResult
+}
+
+// Token is a string that grants a user access to a controlled set of services in an OpenStack provider.
+// Each Token is valid for a set length of time.
+type Token struct {
+ // ID is the issued token.
+ ID string
+
+ // ExpiresAt is the timestamp at which this token will no longer be accepted.
+ ExpiresAt time.Time
+}
diff --git a/openstack/identity/v3/tokens/urls.go b/openstack/identity/v3/tokens/urls.go
new file mode 100644
index 0000000..360b60a
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls.go
@@ -0,0 +1,7 @@
+package tokens
+
+import "github.com/rackspace/gophercloud"
+
+func tokenURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("auth", "tokens")
+}
diff --git a/openstack/identity/v3/tokens/urls_test.go b/openstack/identity/v3/tokens/urls_test.go
new file mode 100644
index 0000000..549c398
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls_test.go
@@ -0,0 +1,21 @@
+package tokens
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTokenURL(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{Endpoint: testhelper.Endpoint()}
+
+ expected := testhelper.Endpoint() + "auth/tokens"
+ actual := tokenURL(&client)
+ if actual != expected {
+ t.Errorf("Expected URL %s, but was %s", expected, actual)
+ }
+}
diff --git a/openstack/networking/v2/apiversions/doc.go b/openstack/networking/v2/apiversions/doc.go
new file mode 100644
index 0000000..0208ee2
--- /dev/null
+++ b/openstack/networking/v2/apiversions/doc.go
@@ -0,0 +1,4 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Neutron service. This functionality is not
+// restricted to this particular version.
+package apiversions
diff --git a/openstack/networking/v2/apiversions/errors.go b/openstack/networking/v2/apiversions/errors.go
new file mode 100644
index 0000000..76bdb14
--- /dev/null
+++ b/openstack/networking/v2/apiversions/errors.go
@@ -0,0 +1 @@
+package apiversions
diff --git a/openstack/networking/v2/apiversions/requests.go b/openstack/networking/v2/apiversions/requests.go
new file mode 100644
index 0000000..9fb6de1
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests.go
@@ -0,0 +1,21 @@
+package apiversions
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListVersions lists all the Neutron API versions available to end-users
+func ListVersions(c *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(c, apiVersionsURL(c), func(r pagination.PageResult) pagination.Page {
+ return APIVersionPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// ListVersionResources lists all of the different API resources for a particular
+// API versions. Typical resources for Neutron might be: networks, subnets, etc.
+func ListVersionResources(c *gophercloud.ServiceClient, v string) pagination.Pager {
+ return pagination.NewPager(c, apiInfoURL(c, v), func(r pagination.PageResult) pagination.Page {
+ return APIVersionResourcePage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/networking/v2/apiversions/requests_test.go b/openstack/networking/v2/apiversions/requests_test.go
new file mode 100644
index 0000000..d35af9f
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests_test.go
@@ -0,0 +1,182 @@
+package apiversions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", 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, `
+{
+ "versions": [
+ {
+ "status": "CURRENT",
+ "id": "v2.0",
+ "links": [
+ {
+ "href": "http://23.253.228.211:9696/v2.0",
+ "rel": "self"
+ }
+ ]
+ }
+ ]
+}`)
+ })
+
+ count := 0
+
+ ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractAPIVersions(page)
+ if err != nil {
+ t.Errorf("Failed to extract API versions: %v", err)
+ return false, err
+ }
+
+ expected := []APIVersion{
+ APIVersion{
+ Status: "CURRENT",
+ ID: "v2.0",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := ExtractAPIVersions(page); err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+ return true, nil
+ })
+}
+
+func TestAPIInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/", 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, `
+{
+ "resources": [
+ {
+ "links": [
+ {
+ "href": "http://23.253.228.211:9696/v2.0/subnets",
+ "rel": "self"
+ }
+ ],
+ "name": "subnet",
+ "collection": "subnets"
+ },
+ {
+ "links": [
+ {
+ "href": "http://23.253.228.211:9696/v2.0/networks",
+ "rel": "self"
+ }
+ ],
+ "name": "network",
+ "collection": "networks"
+ },
+ {
+ "links": [
+ {
+ "href": "http://23.253.228.211:9696/v2.0/ports",
+ "rel": "self"
+ }
+ ],
+ "name": "port",
+ "collection": "ports"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVersionResources(page)
+ if err != nil {
+ t.Errorf("Failed to extract version resources: %v", err)
+ return false, err
+ }
+
+ expected := []APIVersionResource{
+ APIVersionResource{
+ Name: "subnet",
+ Collection: "subnets",
+ },
+ APIVersionResource{
+ Name: "network",
+ Collection: "networks",
+ },
+ APIVersionResource{
+ Name: "port",
+ Collection: "ports",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersionResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := ExtractVersionResources(page); err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+ return true, nil
+ })
+}
diff --git a/openstack/networking/v2/apiversions/results.go b/openstack/networking/v2/apiversions/results.go
new file mode 100644
index 0000000..9715934
--- /dev/null
+++ b/openstack/networking/v2/apiversions/results.go
@@ -0,0 +1,77 @@
+package apiversions
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Neutron. It contains the status of
+// the API, and its unique ID.
+type APIVersion struct {
+ Status string `mapstructure:"status" json:"status"`
+ ID string `mapstructure:"id" json:"id"`
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+ is, err := ExtractAPIVersions(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(page pagination.Page) ([]APIVersion, error) {
+ var resp struct {
+ Versions []APIVersion `mapstructure:"versions"`
+ }
+
+ err := mapstructure.Decode(page.(APIVersionPage).Body, &resp)
+
+ return resp.Versions, err
+}
+
+// APIVersionResource represents a generic API resource. It contains the name
+// of the resource and its plural collection name.
+type APIVersionResource struct {
+ Name string `mapstructure:"name" json:"name"`
+ Collection string `mapstructure:"collection" json:"collection"`
+}
+
+// APIVersionResourcePage is a concrete type which embeds the common
+// SinglePageBase struct, and is used when traversing API versions collections.
+type APIVersionResourcePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty is a concrete function which indicates whether an
+// APIVersionResourcePage is empty or not.
+func (r APIVersionResourcePage) IsEmpty() (bool, error) {
+ is, err := ExtractVersionResources(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractVersionResources accepts a Page struct, specifically a
+// APIVersionResourcePage struct, and extracts the elements into a slice of
+// APIVersionResource structs. In other words, the collection is mapped into
+// a relevant slice.
+func ExtractVersionResources(page pagination.Page) ([]APIVersionResource, error) {
+ var resp struct {
+ APIVersionResources []APIVersionResource `mapstructure:"resources"`
+ }
+
+ err := mapstructure.Decode(page.(APIVersionResourcePage).Body, &resp)
+
+ return resp.APIVersionResources, err
+}
diff --git a/openstack/networking/v2/apiversions/urls.go b/openstack/networking/v2/apiversions/urls.go
new file mode 100644
index 0000000..58aa2b6
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+func apiVersionsURL(c *gophercloud.ServiceClient) string {
+ return c.Endpoint
+}
+
+func apiInfoURL(c *gophercloud.ServiceClient, version string) string {
+ return c.Endpoint + strings.TrimRight(version, "/") + "/"
+}
diff --git a/openstack/networking/v2/apiversions/urls_test.go b/openstack/networking/v2/apiversions/urls_test.go
new file mode 100644
index 0000000..7dd069c
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls_test.go
@@ -0,0 +1,26 @@
+package apiversions
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestAPIVersionsURL(t *testing.T) {
+ actual := apiVersionsURL(endpointClient())
+ expected := endpoint
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestAPIInfoURL(t *testing.T) {
+ actual := apiInfoURL(endpointClient(), "v2.0")
+ expected := endpoint + "v2.0/"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/common/common_tests.go b/openstack/networking/v2/common/common_tests.go
new file mode 100644
index 0000000..4160351
--- /dev/null
+++ b/openstack/networking/v2/common/common_tests.go
@@ -0,0 +1,14 @@
+package common
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const TokenID = client.TokenID
+
+func ServiceClient() *gophercloud.ServiceClient {
+ sc := client.ServiceClient()
+ sc.ResourceBase = sc.Endpoint + "v2.0/"
+ return sc
+}
diff --git a/openstack/networking/v2/extensions/delegate.go b/openstack/networking/v2/extensions/delegate.go
new file mode 100644
index 0000000..d08e1fd
--- /dev/null
+++ b/openstack/networking/v2/extensions/delegate.go
@@ -0,0 +1,41 @@
+package extensions
+
+import (
+ "github.com/rackspace/gophercloud"
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Extension is a single OpenStack extension.
+type Extension struct {
+ common.Extension
+}
+
+// GetResult wraps a GetResult from common.
+type GetResult struct {
+ common.GetResult
+}
+
+// ExtractExtensions interprets a Page as a slice of Extensions.
+func ExtractExtensions(page pagination.Page) ([]Extension, error) {
+ inner, err := common.ExtractExtensions(page)
+ if err != nil {
+ return nil, err
+ }
+ outer := make([]Extension, len(inner))
+ for index, ext := range inner {
+ outer[index] = Extension{ext}
+ }
+ return outer, nil
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) GetResult {
+ return GetResult{common.Get(c, alias)}
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return common.List(c)
+}
diff --git a/openstack/networking/v2/extensions/delegate_test.go b/openstack/networking/v2/extensions/delegate_test.go
new file mode 100755
index 0000000..3d2ac78
--- /dev/null
+++ b/openstack/networking/v2/extensions/delegate_test.go
@@ -0,0 +1,105 @@
+package extensions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/extensions", 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")
+
+ fmt.Fprintf(w, `
+{
+ "extensions": [
+ {
+ "updated": "2013-01-20T00:00:00-00:00",
+ "name": "Neutron Service Type Management",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ "alias": "service-type",
+ "description": "API for retrieving service providers for Neutron advanced services"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractExtensions(page)
+ if err != nil {
+ t.Errorf("Failed to extract extensions: %v", err)
+ }
+
+ expected := []Extension{
+ Extension{
+ common.Extension{
+ Updated: "2013-01-20T00:00:00-00:00",
+ Name: "Neutron Service Type Management",
+ Links: []interface{}{},
+ Namespace: "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+ Alias: "service-type",
+ Description: "API for retrieving service providers for Neutron advanced services",
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/extensions/agent", 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, `
+{
+ "extension": {
+ "updated": "2013-02-03T10:00:00-00:00",
+ "name": "agent",
+ "links": [],
+ "namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+ "alias": "agent",
+ "description": "The agent management extension."
+ }
+}
+ `)
+ })
+
+ ext, err := Get(fake.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, ext.Updated, "2013-02-03T10:00:00-00:00")
+ th.AssertEquals(t, ext.Name, "agent")
+ th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/agent/api/v2.0")
+ th.AssertEquals(t, ext.Alias, "agent")
+ th.AssertEquals(t, ext.Description, "The agent management extension.")
+}
diff --git a/openstack/networking/v2/extensions/external/doc.go b/openstack/networking/v2/extensions/external/doc.go
new file mode 100755
index 0000000..dad3a84
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/doc.go
@@ -0,0 +1,3 @@
+// Package external provides information and interaction with the external
+// extension for the OpenStack Networking service.
+package external
diff --git a/openstack/networking/v2/extensions/external/requests.go b/openstack/networking/v2/extensions/external/requests.go
new file mode 100644
index 0000000..097ae37
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/requests.go
@@ -0,0 +1,69 @@
+package external
+
+import (
+ "time"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+// CreateOpts is the structure used when creating new external network
+// resources. It embeds networks.CreateOpts and so inherits all of its required
+// and optional fields, with the addition of the External field.
+type CreateOpts struct {
+ Parent networks.CreateOpts
+ External bool
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (o CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+
+ // DO NOT REMOVE. Though this line seemingly does nothing of value, it is a
+ // splint to prevent the unit test from failing on Go Tip. We suspect it is a
+ // compiler issue that will hopefully be worked out prior to our next release.
+ // Again, for all the unit tests to pass, this line is necessary and sufficient
+ // at the moment. We should reassess after the Go 1.5 release to determine
+ // if this line is still needed.
+ time.Sleep(0 * time.Millisecond)
+
+ outer, err := o.Parent.ToNetworkCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ outer["network"].(map[string]interface{})["router:external"] = o.External
+
+ return outer, nil
+}
+
+// UpdateOpts is the structure used when updating existing external network
+// resources. It embeds networks.UpdateOpts and so inherits all of its required
+// and optional fields, with the addition of the External field.
+type UpdateOpts struct {
+ Parent networks.UpdateOpts
+ External bool
+}
+
+// ToNetworkUpdateMap casts an UpdateOpts struct to a map.
+func (o UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+ outer, err := o.Parent.ToNetworkUpdateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ outer["network"].(map[string]interface{})["router:external"] = o.External
+
+ return outer, nil
+}
diff --git a/openstack/networking/v2/extensions/external/results.go b/openstack/networking/v2/extensions/external/results.go
new file mode 100644
index 0000000..54dbf4b
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/results.go
@@ -0,0 +1,81 @@
+package external
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// NetworkExternal represents a decorated form of a Network with based on the
+// "external-net" extension.
+type NetworkExternal struct {
+ // UUID for the network
+ ID string `mapstructure:"id" json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `mapstructure:"name" json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+
+ // Indicates whether network is currently operational. Possible values include
+ // `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+ Status string `mapstructure:"status" json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `mapstructure:"subnets" json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `mapstructure:"shared" json:"shared"`
+
+ // Specifies whether the network is an external network or not.
+ External bool `mapstructure:"router:external" json:"router:external"`
+}
+
+func commonExtract(e error, response interface{}) (*NetworkExternal, error) {
+ if e != nil {
+ return nil, e
+ }
+
+ var res struct {
+ Network *NetworkExternal `json:"network"`
+ }
+
+ err := mapstructure.Decode(response, &res)
+
+ return res.Network, err
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExternal, error) {
+ return commonExtract(r.Err, r.Body)
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExternal, error) {
+ return commonExtract(r.Err, r.Body)
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExternal, error) {
+ return commonExtract(r.Err, r.Body)
+}
+
+// ExtractList accepts a Page struct, specifically a NetworkPage struct, and
+// extracts the elements into a slice of NetworkExternal structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractList(page pagination.Page) ([]NetworkExternal, error) {
+ var resp struct {
+ Networks []NetworkExternal `mapstructure:"networks" json:"networks"`
+ }
+
+ err := mapstructure.Decode(page.(networks.NetworkPage).Body, &resp)
+
+ return resp.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/external/results_test.go b/openstack/networking/v2/extensions/external/results_test.go
new file mode 100644
index 0000000..916cd2c
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/results_test.go
@@ -0,0 +1,254 @@
+package external
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks", 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, `
+{
+ "networks": [
+ {
+ "admin_state_up": true,
+ "id": "0f38d5ad-10a6-428f-a5fc-825cfe0f1970",
+ "name": "net1",
+ "router:external": false,
+ "shared": false,
+ "status": "ACTIVE",
+ "subnets": [
+ "25778974-48a8-46e7-8998-9dc8c70d2f06"
+ ],
+ "tenant_id": "b575417a6c444a6eb5cc3a58eb4f714a"
+ },
+ {
+ "admin_state_up": true,
+ "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+ "name": "ext_net",
+ "router:external": true,
+ "shared": false,
+ "status": "ACTIVE",
+ "subnets": [
+ "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+ ],
+ "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ networks.List(fake.ServiceClient(), networks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractList(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []NetworkExternal{
+ NetworkExternal{
+ Status: "ACTIVE",
+ Subnets: []string{"25778974-48a8-46e7-8998-9dc8c70d2f06"},
+ Name: "net1",
+ AdminStateUp: true,
+ TenantID: "b575417a6c444a6eb5cc3a58eb4f714a",
+ Shared: false,
+ ID: "0f38d5ad-10a6-428f-a5fc-825cfe0f1970",
+ External: false,
+ },
+ NetworkExternal{
+ Status: "ACTIVE",
+ Subnets: []string{"2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"},
+ Name: "ext_net",
+ AdminStateUp: true,
+ TenantID: "5eb8995cf717462c9df8d1edfa498010",
+ Shared: false,
+ ID: "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+ External: true,
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "network": {
+ "admin_state_up": true,
+ "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+ "name": "ext_net",
+ "router:external": true,
+ "shared": false,
+ "status": "ACTIVE",
+ "subnets": [
+ "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+ ],
+ "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+ }
+}
+ `)
+ })
+
+ res := networks.Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ n, err := ExtractGet(res)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, n.External)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "admin_state_up": true,
+ "name": "ext_net",
+ "router:external": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "admin_state_up": true,
+ "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+ "name": "ext_net",
+ "router:external": true,
+ "shared": false,
+ "status": "ACTIVE",
+ "subnets": [
+ "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+ ],
+ "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{networks.CreateOpts{Name: "ext_net", AdminStateUp: Up}, true}
+ res := networks.Create(fake.ServiceClient(), options)
+
+ n, err := ExtractCreate(res)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, n.External)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "router:external": true,
+ "name": "new_name"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "admin_state_up": true,
+ "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+ "name": "new_name",
+ "router:external": true,
+ "shared": false,
+ "status": "ACTIVE",
+ "subnets": [
+ "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+ ],
+ "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{networks.UpdateOpts{Name: "new_name"}, true}
+ res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+ n, err := ExtractUpdate(res)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, n.External)
+}
+
+func TestExtractFnsReturnsErrWhenResultContainsErr(t *testing.T) {
+ gr := networks.GetResult{}
+ gr.Err = errors.New("")
+
+ if _, err := ExtractGet(gr); err == nil {
+ t.Fatalf("Expected error, got one")
+ }
+
+ ur := networks.UpdateResult{}
+ ur.Err = errors.New("")
+
+ if _, err := ExtractUpdate(ur); err == nil {
+ t.Fatalf("Expected error, got one")
+ }
+
+ cr := networks.CreateResult{}
+ cr.Err = errors.New("")
+
+ if _, err := ExtractCreate(cr); err == nil {
+ t.Fatalf("Expected error, got one")
+ }
+}
diff --git a/openstack/networking/v2/extensions/fwaas/doc.go b/openstack/networking/v2/extensions/fwaas/doc.go
new file mode 100644
index 0000000..3ec450a
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/doc.go
@@ -0,0 +1,3 @@
+// Package fwaas provides information and interaction with the Firewall
+// as a Service extension for the OpenStack Networking service.
+package fwaas
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/errors.go b/openstack/networking/v2/extensions/fwaas/firewalls/errors.go
new file mode 100644
index 0000000..dd92bb2
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/errors.go
@@ -0,0 +1,11 @@
+package firewalls
+
+import "fmt"
+
+func err(str string) error {
+ return fmt.Errorf("%s", str)
+}
+
+var (
+ errPolicyRequired = err("A policy ID is required")
+)
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/requests.go b/openstack/networking/v2/extensions/fwaas/firewalls/requests.go
new file mode 100644
index 0000000..12d587f
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/requests.go
@@ -0,0 +1,216 @@
+package firewalls
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Shared gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Yes` and `No` enums.
+type Shared *bool
+
+// Convenience vars for AdminStateUp and Shared values.
+var (
+ iTrue = true
+ iFalse = false
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+ Yes Shared = &iTrue
+ No Shared = &iFalse
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToFirewallListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the firewall attributes you want to see returned. SortKey allows you to sort
+// by a particular firewall attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ TenantID string `q:"tenant_id"`
+ Name string `q:"name"`
+ Description string `q:"description"`
+ AdminStateUp bool `q:"admin_state_up"`
+ Shared bool `q:"shared"`
+ PolicyID string `q:"firewall_policy_id"`
+ ID string `q:"id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToFirewallListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToFirewallListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewalls. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewalls that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+
+ if opts != nil {
+ query, err := opts.ToFirewallListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return FirewallPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToFirewallCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall.
+type CreateOpts struct {
+ // Only required if the caller has an admin role and wants to create a firewall
+ // for another tenant.
+ TenantID string
+ Name string
+ Description string
+ AdminStateUp *bool
+ Shared *bool
+ PolicyID string
+}
+
+// ToFirewallCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToFirewallCreateMap() (map[string]interface{}, error) {
+ if opts.PolicyID == "" {
+ return nil, errPolicyRequired
+ }
+
+ f := make(map[string]interface{})
+
+ if opts.TenantID != "" {
+ f["tenant_id"] = opts.TenantID
+ }
+ if opts.Name != "" {
+ f["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ f["description"] = opts.Description
+ }
+ if opts.Shared != nil {
+ f["shared"] = *opts.Shared
+ }
+ if opts.AdminStateUp != nil {
+ f["admin_state_up"] = *opts.AdminStateUp
+ }
+ if opts.PolicyID != "" {
+ f["firewall_policy_id"] = opts.PolicyID
+ }
+
+ return map[string]interface{}{"firewall": f}, nil
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToFirewallCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular firewall based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToFirewallUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall.
+type UpdateOpts struct {
+ // Name of the firewall.
+ Name string
+ Description string
+ AdminStateUp *bool
+ Shared *bool
+ PolicyID string
+}
+
+// ToFirewallUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToFirewallUpdateMap() (map[string]interface{}, error) {
+ f := make(map[string]interface{})
+
+ if opts.Name != "" {
+ f["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ f["description"] = opts.Description
+ }
+ if opts.Shared != nil {
+ f["shared"] = *opts.Shared
+ }
+ if opts.AdminStateUp != nil {
+ f["admin_state_up"] = *opts.AdminStateUp
+ }
+ if opts.PolicyID != "" {
+ f["firewall_policy_id"] = opts.PolicyID
+ }
+
+ return map[string]interface{}{"firewall": f}, nil
+}
+
+// Update allows firewalls to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToFirewallUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Delete will permanently delete a particular firewall based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go b/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go
new file mode 100644
index 0000000..19d32c5
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go
@@ -0,0 +1,246 @@
+package firewalls
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewalls", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewalls", 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, `
+{
+ "firewalls":[
+ {
+ "status": "ACTIVE",
+ "name": "fw1",
+ "admin_state_up": false,
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+ "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+ "description": "OpenStack firewall 1"
+ },
+ {
+ "status": "PENDING_UPDATE",
+ "name": "fw2",
+ "admin_state_up": true,
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e299",
+ "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f99",
+ "description": "OpenStack firewall 2"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractFirewalls(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []Firewall{
+ Firewall{
+ Status: "ACTIVE",
+ Name: "fw1",
+ AdminStateUp: false,
+ TenantID: "b4eedccc6fb74fa8a7ad6b08382b852b",
+ PolicyID: "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+ ID: "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+ Description: "OpenStack firewall 1",
+ },
+ Firewall{
+ Status: "PENDING_UPDATE",
+ Name: "fw2",
+ AdminStateUp: true,
+ TenantID: "b4eedccc6fb74fa8a7ad6b08382b852b",
+ PolicyID: "34be8c83-4d42-4dca-a74e-b77fffb8e299",
+ ID: "fb5b5315-64f6-4ea3-8e58-981cc37c6f99",
+ Description: "OpenStack firewall 2",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewalls", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall":{
+ "name": "fw",
+ "description": "OpenStack firewall",
+ "admin_state_up": true,
+ "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "firewall":{
+ "status": "PENDING_CREATE",
+ "name": "fw",
+ "description": "OpenStack firewall",
+ "admin_state_up": true,
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ TenantID: "b4eedccc6fb74fa8a7ad6b08382b852b",
+ Name: "fw",
+ Description: "OpenStack firewall",
+ AdminStateUp: Up,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewalls/fb5b5315-64f6-4ea3-8e58-981cc37c6f61", 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, `
+{
+ "firewall": {
+ "status": "ACTIVE",
+ "name": "fw",
+ "admin_state_up": true,
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+ "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+ "description": "OpenStack firewall"
+ }
+}
+ `)
+ })
+
+ fw, err := Get(fake.ServiceClient(), "fb5b5315-64f6-4ea3-8e58-981cc37c6f61").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "ACTIVE", fw.Status)
+ th.AssertEquals(t, "fw", fw.Name)
+ th.AssertEquals(t, "OpenStack firewall", fw.Description)
+ th.AssertEquals(t, true, fw.AdminStateUp)
+ th.AssertEquals(t, "34be8c83-4d42-4dca-a74e-b77fffb8e28a", fw.PolicyID)
+ th.AssertEquals(t, "fb5b5315-64f6-4ea3-8e58-981cc37c6f61", fw.ID)
+ th.AssertEquals(t, "b4eedccc6fb74fa8a7ad6b08382b852b", fw.TenantID)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewalls/ea5b5315-64f6-4ea3-8e58-981cc37c6576", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall":{
+ "name": "fw",
+ "description": "updated fw",
+ "admin_state_up":false,
+ "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "firewall": {
+ "status": "ACTIVE",
+ "name": "fw",
+ "admin_state_up": false,
+ "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ "id": "ea5b5315-64f6-4ea3-8e58-981cc37c6576",
+ "description": "OpenStack firewall"
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{
+ Name: "fw",
+ Description: "updated fw",
+ AdminStateUp: Down,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+
+ _, err := Update(fake.ServiceClient(), "ea5b5315-64f6-4ea3-8e58-981cc37c6576", options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewalls/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/results.go b/openstack/networking/v2/extensions/fwaas/firewalls/results.go
new file mode 100644
index 0000000..a8c76ee
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/results.go
@@ -0,0 +1,101 @@
+package firewalls
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type Firewall struct {
+ ID string `json:"id" mapstructure:"id"`
+ Name string `json:"name" mapstructure:"name"`
+ Description string `json:"description" mapstructure:"description"`
+ AdminStateUp bool `json:"admin_state_up" mapstructure:"admin_state_up"`
+ Status string `json:"status" mapstructure:"status"`
+ PolicyID string `json:"firewall_policy_id" mapstructure:"firewall_policy_id"`
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall.
+func (r commonResult) Extract() (*Firewall, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Firewall *Firewall `json:"firewall"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Firewall, err
+}
+
+// FirewallPage is the page returned by a pager when traversing over a
+// collection of firewalls.
+type FirewallPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewalls has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p FirewallPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"firewalls_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a FirewallPage struct is empty.
+func (p FirewallPage) IsEmpty() (bool, error) {
+ is, err := ExtractFirewalls(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractFirewalls accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractFirewalls(page pagination.Page) ([]Firewall, error) {
+ var resp struct {
+ Firewalls []Firewall `mapstructure:"firewalls" json:"firewalls"`
+ }
+
+ err := mapstructure.Decode(page.(FirewallPage).Body, &resp)
+
+ return resp.Firewalls, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/urls.go b/openstack/networking/v2/extensions/fwaas/firewalls/urls.go
new file mode 100644
index 0000000..4dde530
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/urls.go
@@ -0,0 +1,16 @@
+package firewalls
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "fw"
+ resourcePath = "firewalls"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/requests.go b/openstack/networking/v2/extensions/fwaas/policies/requests.go
new file mode 100644
index 0000000..fe07d9a
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/requests.go
@@ -0,0 +1,243 @@
+package policies
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Binary gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Yes` and `No` enums
+type Binary *bool
+
+// Convenience vars for Audited and Shared values.
+var (
+ iTrue = true
+ iFalse = false
+ Yes Binary = &iTrue
+ No Binary = &iFalse
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToPolicyListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the firewall policy attributes you want to see returned. SortKey allows you
+// to sort by a particular firewall policy attribute. SortDir sets the direction,
+// and is either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ TenantID string `q:"tenant_id"`
+ Name string `q:"name"`
+ Description string `q:"description"`
+ Shared bool `q:"shared"`
+ Audited bool `q:"audited"`
+ ID string `q:"id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToPolicyListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPolicyListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewall policies. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewall policies that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+
+ if opts != nil {
+ query, err := opts.ToPolicyListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return PolicyPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToPolicyCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall policy.
+type CreateOpts struct {
+ // Only required if the caller has an admin role and wants to create a firewall policy
+ // for another tenant.
+ TenantID string
+ Name string
+ Description string
+ Shared *bool
+ Audited *bool
+ Rules []string
+}
+
+// ToPolicyCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPolicyCreateMap() (map[string]interface{}, error) {
+ p := make(map[string]interface{})
+
+ if opts.TenantID != "" {
+ p["tenant_id"] = opts.TenantID
+ }
+ if opts.Name != "" {
+ p["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ p["description"] = opts.Description
+ }
+ if opts.Shared != nil {
+ p["shared"] = *opts.Shared
+ }
+ if opts.Audited != nil {
+ p["audited"] = *opts.Audited
+ }
+ if opts.Rules != nil {
+ p["firewall_rules"] = opts.Rules
+ }
+
+ return map[string]interface{}{"firewall_policy": p}, nil
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall policy
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToPolicyCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular firewall policy based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToPolicyUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall policy.
+type UpdateOpts struct {
+ // Name of the firewall policy.
+ Name string
+ Description string
+ Shared *bool
+ Audited *bool
+ Rules []string
+}
+
+// ToPolicyUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToPolicyUpdateMap() (map[string]interface{}, error) {
+ p := make(map[string]interface{})
+
+ if opts.Name != "" {
+ p["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ p["description"] = opts.Description
+ }
+ if opts.Shared != nil {
+ p["shared"] = *opts.Shared
+ }
+ if opts.Audited != nil {
+ p["audited"] = *opts.Audited
+ }
+ if opts.Rules != nil {
+ p["firewall_rules"] = opts.Rules
+ }
+
+ return map[string]interface{}{"firewall_policy": p}, nil
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToPolicyUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Delete will permanently delete a particular firewall policy based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
+
+func InsertRule(c *gophercloud.ServiceClient, policyID, ruleID, beforeID, afterID string) error {
+ type request struct {
+ RuleId string `json:"firewall_rule_id"`
+ Before string `json:"insert_before,omitempty"`
+ After string `json:"insert_after,omitempty"`
+ }
+
+ reqBody := request{
+ RuleId: ruleID,
+ Before: beforeID,
+ After: afterID,
+ }
+
+ // Send request to API
+ var res commonResult
+ _, res.Err = c.Put(insertURL(c, policyID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res.Err
+}
+
+func RemoveRule(c *gophercloud.ServiceClient, policyID, ruleID string) error {
+ type request struct {
+ RuleId string `json:"firewall_rule_id"`
+ }
+
+ reqBody := request{
+ RuleId: ruleID,
+ }
+
+ // Send request to API
+ var res commonResult
+ _, res.Err = c.Put(removeURL(c, policyID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res.Err
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/requests_test.go b/openstack/networking/v2/extensions/fwaas/policies/requests_test.go
new file mode 100644
index 0000000..b9d7865
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/requests_test.go
@@ -0,0 +1,279 @@
+package policies
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewall_policies", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_policies", 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, `
+{
+ "firewall_policies": [
+ {
+ "name": "policy1",
+ "firewall_rules": [
+ "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+ "c9e77ca0-1bc8-497d-904d-948107873dc6"
+ ],
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": true,
+ "shared": false,
+ "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+ "description": "Firewall policy 1"
+ },
+ {
+ "name": "policy2",
+ "firewall_rules": [
+ "03d2a6ad-633f-431a-8463-4370d06a22c8"
+ ],
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": false,
+ "shared": true,
+ "id": "c854fab5-bdaf-4a86-9359-78de93e5df01",
+ "description": "Firewall policy 2"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPolicies(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []Policy{
+ Policy{
+ Name: "policy1",
+ Rules: []string{
+ "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+ "c9e77ca0-1bc8-497d-904d-948107873dc6",
+ },
+ TenantID: "9145d91459d248b1b02fdaca97c6a75d",
+ Audited: true,
+ Shared: false,
+ ID: "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+ Description: "Firewall policy 1",
+ },
+ Policy{
+ Name: "policy2",
+ Rules: []string{
+ "03d2a6ad-633f-431a-8463-4370d06a22c8",
+ },
+ TenantID: "9145d91459d248b1b02fdaca97c6a75d",
+ Audited: false,
+ Shared: true,
+ ID: "c854fab5-bdaf-4a86-9359-78de93e5df01",
+ Description: "Firewall policy 2",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_policies", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall_policy":{
+ "name": "policy",
+ "firewall_rules": [
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+ ],
+ "description": "Firewall policy",
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": true,
+ "shared": false
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "firewall_policy":{
+ "name": "policy",
+ "firewall_rules": [
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+ ],
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": false,
+ "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+ "description": "Firewall policy"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ TenantID: "9145d91459d248b1b02fdaca97c6a75d",
+ Name: "policy",
+ Description: "Firewall policy",
+ Shared: No,
+ Audited: Yes,
+ Rules: []string{
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32",
+ },
+ }
+
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_policies/bcab5315-64f6-4ea3-8e58-981cc37c6f61", 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, `
+{
+ "firewall_policy":{
+ "name": "www",
+ "firewall_rules": [
+ "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+ "c9e77ca0-1bc8-497d-904d-948107873dc6",
+ "03d2a6ad-633f-431a-8463-4370d06a22c8"
+ ],
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": false,
+ "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+ "description": "Firewall policy web"
+ }
+}
+ `)
+ })
+
+ policy, err := Get(fake.ServiceClient(), "bcab5315-64f6-4ea3-8e58-981cc37c6f61").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "www", policy.Name)
+ th.AssertEquals(t, "f2b08c1e-aa81-4668-8ae1-1401bcb0576c", policy.ID)
+ th.AssertEquals(t, "Firewall policy web", policy.Description)
+ th.AssertEquals(t, 3, len(policy.Rules))
+ th.AssertEquals(t, "75452b36-268e-4e75-aaf4-f0e7ed50bc97", policy.Rules[0])
+ th.AssertEquals(t, "c9e77ca0-1bc8-497d-904d-948107873dc6", policy.Rules[1])
+ th.AssertEquals(t, "03d2a6ad-633f-431a-8463-4370d06a22c8", policy.Rules[2])
+ th.AssertEquals(t, "9145d91459d248b1b02fdaca97c6a75d", policy.TenantID)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_policies/f2b08c1e-aa81-4668-8ae1-1401bcb0576c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall_policy":{
+ "name": "policy",
+ "firewall_rules": [
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+ ],
+ "description": "Firewall policy"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "firewall_policy":{
+ "name": "policy",
+ "firewall_rules": [
+ "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+ "c9e77ca0-1bc8-497d-904d-948107873dc6",
+ "03d2a6ad-633f-431a-8463-4370d06a22c8"
+ ],
+ "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+ "audited": false,
+ "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+ "description": "Firewall policy"
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{
+ Name: "policy",
+ Description: "Firewall policy",
+ Rules: []string{
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32",
+ },
+ }
+
+ _, err := Update(fake.ServiceClient(), "f2b08c1e-aa81-4668-8ae1-1401bcb0576c", options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_policies/4ec89077-d057-4a2b-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/results.go b/openstack/networking/v2/extensions/fwaas/policies/results.go
new file mode 100644
index 0000000..a9a0c35
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/results.go
@@ -0,0 +1,101 @@
+package policies
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type Policy struct {
+ ID string `json:"id" mapstructure:"id"`
+ Name string `json:"name" mapstructure:"name"`
+ Description string `json:"description" mapstructure:"description"`
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+ Audited bool `json:"audited" mapstructure:"audited"`
+ Shared bool `json:"shared" mapstructure:"shared"`
+ Rules []string `json:"firewall_rules,omitempty" mapstructure:"firewall_rules"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall policy.
+func (r commonResult) Extract() (*Policy, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Policy *Policy `json:"firewall_policy" mapstructure:"firewall_policy"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Policy, err
+}
+
+// PolicyPage is the page returned by a pager when traversing over a
+// collection of firewall policies.
+type PolicyPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewall policies has
+// reached the end of a page and the pager seeks to traverse over a new one.
+// In order to do this, it needs to construct the next page's URL.
+func (p PolicyPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"firewall_policies_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a PolicyPage struct is empty.
+func (p PolicyPage) IsEmpty() (bool, error) {
+ is, err := ExtractPolicies(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractPolicies accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPolicies(page pagination.Page) ([]Policy, error) {
+ var resp struct {
+ Policies []Policy `mapstructure:"firewall_policies" json:"firewall_policies"`
+ }
+
+ err := mapstructure.Decode(page.(PolicyPage).Body, &resp)
+
+ return resp.Policies, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/urls.go b/openstack/networking/v2/extensions/fwaas/policies/urls.go
new file mode 100644
index 0000000..27ea9ae
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/urls.go
@@ -0,0 +1,26 @@
+package policies
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "fw"
+ resourcePath = "firewall_policies"
+ insertPath = "insert_rule"
+ removePath = "remove_rule"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
+
+func insertURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id, insertPath)
+}
+
+func removeURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id, removePath)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/errors.go b/openstack/networking/v2/extensions/fwaas/rules/errors.go
new file mode 100644
index 0000000..0b29d39
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/errors.go
@@ -0,0 +1,12 @@
+package rules
+
+import "fmt"
+
+func err(str string) error {
+ return fmt.Errorf("%s", str)
+}
+
+var (
+ errProtocolRequired = err("A protocol is required (tcp, udp, icmp or any)")
+ errActionRequired = err("An action is required (allow or deny)")
+)
diff --git a/openstack/networking/v2/extensions/fwaas/rules/requests.go b/openstack/networking/v2/extensions/fwaas/rules/requests.go
new file mode 100644
index 0000000..57a0e8b
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/requests.go
@@ -0,0 +1,285 @@
+package rules
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Binary gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Yes` and `No` enums
+type Binary *bool
+
+// Convenience vars for Enabled and Shared values.
+var (
+ iTrue = true
+ iFalse = false
+ Yes Binary = &iTrue
+ No Binary = &iFalse
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToRuleListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the Firewall rule attributes you want to see returned. SortKey allows you to
+// sort by a particular firewall rule attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ TenantID string `q:"tenant_id"`
+ Name string `q:"name"`
+ Description string `q:"description"`
+ Protocol string `q:"protocol"`
+ Action string `q:"action"`
+ IPVersion int `q:"ip_version"`
+ SourceIPAddress string `q:"source_ip_address"`
+ DestinationIPAddress string `q:"destination_ip_address"`
+ SourcePort string `q:"source_port"`
+ DestinationPort string `q:"destination_port"`
+ Enabled bool `q:"enabled"`
+ ID string `q:"id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToRuleListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToRuleListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewall rules. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewall rules that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+
+ if opts != nil {
+ query, err := opts.ToRuleListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return RulePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall rule.
+type CreateOpts struct {
+ // Mandatory for create
+ Protocol string
+ Action string
+ // Optional
+ TenantID string
+ Name string
+ Description string
+ IPVersion int
+ SourceIPAddress string
+ DestinationIPAddress string
+ SourcePort string
+ DestinationPort string
+ Shared *bool
+ Enabled *bool
+}
+
+// ToRuleCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+ if opts.Protocol == "" {
+ return nil, errProtocolRequired
+ }
+
+ if opts.Action == "" {
+ return nil, errActionRequired
+ }
+
+ r := make(map[string]interface{})
+
+ r["protocol"] = opts.Protocol
+ r["action"] = opts.Action
+
+ if opts.TenantID != "" {
+ r["tenant_id"] = opts.TenantID
+ }
+ if opts.Name != "" {
+ r["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ r["description"] = opts.Description
+ }
+ if opts.IPVersion != 0 {
+ r["ip_version"] = opts.IPVersion
+ }
+ if opts.SourceIPAddress != "" {
+ r["source_ip_address"] = opts.SourceIPAddress
+ }
+ if opts.DestinationIPAddress != "" {
+ r["destination_ip_address"] = opts.DestinationIPAddress
+ }
+ if opts.SourcePort != "" {
+ r["source_port"] = opts.SourcePort
+ }
+ if opts.DestinationPort != "" {
+ r["destination_port"] = opts.DestinationPort
+ }
+ if opts.Shared != nil {
+ r["shared"] = *opts.Shared
+ }
+ if opts.Enabled != nil {
+ r["enabled"] = *opts.Enabled
+ }
+
+ return map[string]interface{}{"firewall_rule": r}, nil
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall rule
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToRuleCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular firewall rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToRuleUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall rule.
+// Optional
+type UpdateOpts struct {
+ Protocol string
+ Action string
+ Name string
+ Description string
+ IPVersion int
+ SourceIPAddress *string
+ DestinationIPAddress *string
+ SourcePort *string
+ DestinationPort *string
+ Shared *bool
+ Enabled *bool
+}
+
+// ToRuleUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToRuleUpdateMap() (map[string]interface{}, error) {
+ r := make(map[string]interface{})
+
+ if opts.Protocol != "" {
+ r["protocol"] = opts.Protocol
+ }
+ if opts.Action != "" {
+ r["action"] = opts.Action
+ }
+ if opts.Name != "" {
+ r["name"] = opts.Name
+ }
+ if opts.Description != "" {
+ r["description"] = opts.Description
+ }
+ if opts.IPVersion != 0 {
+ r["ip_version"] = opts.IPVersion
+ }
+ if opts.SourceIPAddress != nil {
+ s := *opts.SourceIPAddress
+ if s == "" {
+ r["source_ip_address"] = nil
+ } else {
+ r["source_ip_address"] = s
+ }
+ }
+ if opts.DestinationIPAddress != nil {
+ s := *opts.DestinationIPAddress
+ if s == "" {
+ r["destination_ip_address"] = nil
+ } else {
+ r["destination_ip_address"] = s
+ }
+ }
+ if opts.SourcePort != nil {
+ s := *opts.SourcePort
+ if s == "" {
+ r["source_port"] = nil
+ } else {
+ r["source_port"] = s
+ }
+ }
+ if opts.DestinationPort != nil {
+ s := *opts.DestinationPort
+ if s == "" {
+ r["destination_port"] = nil
+ } else {
+ r["destination_port"] = s
+ }
+ }
+ if opts.Shared != nil {
+ r["shared"] = *opts.Shared
+ }
+ if opts.Enabled != nil {
+ r["enabled"] = *opts.Enabled
+ }
+
+ return map[string]interface{}{"firewall_rule": r}, nil
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToRuleUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a particular firewall rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/requests_test.go b/openstack/networking/v2/extensions/fwaas/rules/requests_test.go
new file mode 100644
index 0000000..36f89fa
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/requests_test.go
@@ -0,0 +1,328 @@
+package rules
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewall_rules", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_rules", 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, `
+{
+ "firewall_rules": [
+ {
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "source_port": null,
+ "source_ip_address": null,
+ "destination_ip_address": "192.168.1.0/24",
+ "firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+ "position": 2,
+ "destination_port": "22",
+ "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+ "name": "ssh_form_any",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": true,
+ "action": "allow",
+ "ip_version": 4,
+ "shared": false
+ },
+ {
+ "protocol": "udp",
+ "description": "udp rule",
+ "source_port": null,
+ "source_ip_address": null,
+ "destination_ip_address": null,
+ "firewall_policy_id": "98d7fb51-698c-4123-87e8-f1eee6b5ab7e",
+ "position": 1,
+ "destination_port": null,
+ "id": "ab7bd950-6c56-4f5e-a307-45967078f890",
+ "name": "deny_all_udp",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": true,
+ "action": "deny",
+ "ip_version": 4,
+ "shared": false
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []Rule{
+ Rule{
+ Protocol: "tcp",
+ Description: "ssh rule",
+ SourcePort: "",
+ SourceIPAddress: "",
+ DestinationIPAddress: "192.168.1.0/24",
+ PolicyID: "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+ Position: 2,
+ DestinationPort: "22",
+ ID: "f03bd950-6c56-4f5e-a307-45967078f507",
+ Name: "ssh_form_any",
+ TenantID: "80cf934d6ffb4ef5b244f1c512ad1e61",
+ Enabled: true,
+ Action: "allow",
+ IPVersion: 4,
+ Shared: false,
+ },
+ Rule{
+ Protocol: "udp",
+ Description: "udp rule",
+ SourcePort: "",
+ SourceIPAddress: "",
+ DestinationIPAddress: "",
+ PolicyID: "98d7fb51-698c-4123-87e8-f1eee6b5ab7e",
+ Position: 1,
+ DestinationPort: "",
+ ID: "ab7bd950-6c56-4f5e-a307-45967078f890",
+ Name: "deny_all_udp",
+ TenantID: "80cf934d6ffb4ef5b244f1c512ad1e61",
+ Enabled: true,
+ Action: "deny",
+ IPVersion: 4,
+ Shared: false,
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_rules", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall_rule": {
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "destination_ip_address": "192.168.1.0/24",
+ "destination_port": "22",
+ "name": "ssh_form_any",
+ "action": "allow",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "firewall_rule":{
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "source_port": null,
+ "source_ip_address": null,
+ "destination_ip_address": "192.168.1.0/24",
+ "firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+ "position": 2,
+ "destination_port": "22",
+ "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+ "name": "ssh_form_any",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": true,
+ "action": "allow",
+ "ip_version": 4,
+ "shared": false
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ TenantID: "80cf934d6ffb4ef5b244f1c512ad1e61",
+ Protocol: "tcp",
+ Description: "ssh rule",
+ DestinationIPAddress: "192.168.1.0/24",
+ DestinationPort: "22",
+ Name: "ssh_form_any",
+ Action: "allow",
+ }
+
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_rules/f03bd950-6c56-4f5e-a307-45967078f507", 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, `
+{
+ "firewall_rule":{
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "source_port": null,
+ "source_ip_address": null,
+ "destination_ip_address": "192.168.1.0/24",
+ "firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+ "position": 2,
+ "destination_port": "22",
+ "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+ "name": "ssh_form_any",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": true,
+ "action": "allow",
+ "ip_version": 4,
+ "shared": false
+ }
+}
+ `)
+ })
+
+ rule, err := Get(fake.ServiceClient(), "f03bd950-6c56-4f5e-a307-45967078f507").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "tcp", rule.Protocol)
+ th.AssertEquals(t, "ssh rule", rule.Description)
+ th.AssertEquals(t, "192.168.1.0/24", rule.DestinationIPAddress)
+ th.AssertEquals(t, "e2a5fb51-698c-4898-87e8-f1eee6b50919", rule.PolicyID)
+ th.AssertEquals(t, 2, rule.Position)
+ th.AssertEquals(t, "22", rule.DestinationPort)
+ th.AssertEquals(t, "f03bd950-6c56-4f5e-a307-45967078f507", rule.ID)
+ th.AssertEquals(t, "ssh_form_any", rule.Name)
+ th.AssertEquals(t, "80cf934d6ffb4ef5b244f1c512ad1e61", rule.TenantID)
+ th.AssertEquals(t, true, rule.Enabled)
+ th.AssertEquals(t, "allow", rule.Action)
+ th.AssertEquals(t, 4, rule.IPVersion)
+ th.AssertEquals(t, false, rule.Shared)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_rules/f03bd950-6c56-4f5e-a307-45967078f507", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "firewall_rule":{
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "destination_ip_address": "192.168.1.0/24",
+ "destination_port": "22",
+ "source_ip_address": null,
+ "source_port": null,
+ "name": "ssh_form_any",
+ "action": "allow",
+ "enabled": false
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "firewall_rule":{
+ "protocol": "tcp",
+ "description": "ssh rule",
+ "source_port": null,
+ "source_ip_address": null,
+ "destination_ip_address": "192.168.1.0/24",
+ "firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+ "position": 2,
+ "destination_port": "22",
+ "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+ "name": "ssh_form_any",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": false,
+ "action": "allow",
+ "ip_version": 4,
+ "shared": false
+ }
+}
+ `)
+ })
+
+ destinationIPAddress := "192.168.1.0/24"
+ destinationPort := "22"
+ empty := ""
+
+ options := UpdateOpts{
+ Protocol: "tcp",
+ Description: "ssh rule",
+ DestinationIPAddress: &destinationIPAddress,
+ DestinationPort: &destinationPort,
+ Name: "ssh_form_any",
+ SourceIPAddress: &empty,
+ SourcePort: &empty,
+ Action: "allow",
+ Enabled: No,
+ }
+
+ _, err := Update(fake.ServiceClient(), "f03bd950-6c56-4f5e-a307-45967078f507", options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/fw/firewall_rules/4ec89077-d057-4a2b-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/results.go b/openstack/networking/v2/extensions/fwaas/rules/results.go
new file mode 100644
index 0000000..d772024
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/results.go
@@ -0,0 +1,110 @@
+package rules
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Rule represents a firewall rule
+type Rule struct {
+ ID string `json:"id" mapstructure:"id"`
+ Name string `json:"name,omitempty" mapstructure:"name"`
+ Description string `json:"description,omitempty" mapstructure:"description"`
+ Protocol string `json:"protocol" mapstructure:"protocol"`
+ Action string `json:"action" mapstructure:"action"`
+ IPVersion int `json:"ip_version,omitempty" mapstructure:"ip_version"`
+ SourceIPAddress string `json:"source_ip_address,omitempty" mapstructure:"source_ip_address"`
+ DestinationIPAddress string `json:"destination_ip_address,omitempty" mapstructure:"destination_ip_address"`
+ SourcePort string `json:"source_port,omitempty" mapstructure:"source_port"`
+ DestinationPort string `json:"destination_port,omitempty" mapstructure:"destination_port"`
+ Shared bool `json:"shared,omitempty" mapstructure:"shared"`
+ Enabled bool `json:"enabled,omitempty" mapstructure:"enabled"`
+ PolicyID string `json:"firewall_policy_id" mapstructure:"firewall_policy_id"`
+ Position int `json:"position" mapstructure:"position"`
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+}
+
+// RulePage is the page returned by a pager when traversing over a
+// collection of firewall rules.
+type RulePage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewall rules has
+// reached the end of a page and the pager seeks to traverse over a new one.
+// In order to do this, it needs to construct the next page's URL.
+func (p RulePage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"firewall_rules_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a RulePage struct is empty.
+func (p RulePage) IsEmpty() (bool, error) {
+ is, err := ExtractRules(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractRules accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRules(page pagination.Page) ([]Rule, error) {
+ var resp struct {
+ Rules []Rule `mapstructure:"firewall_rules" json:"firewall_rules"`
+ }
+
+ err := mapstructure.Decode(page.(RulePage).Body, &resp)
+
+ return resp.Rules, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall rule.
+func (r commonResult) Extract() (*Rule, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Rule *Rule `json:"firewall_rule" mapstructure:"firewall_rule"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Rule, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/urls.go b/openstack/networking/v2/extensions/fwaas/rules/urls.go
new file mode 100644
index 0000000..20b0879
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/urls.go
@@ -0,0 +1,16 @@
+package rules
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "fw"
+ resourcePath = "firewall_rules"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/layer3/doc.go b/openstack/networking/v2/extensions/layer3/doc.go
new file mode 100644
index 0000000..d533458
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/doc.go
@@ -0,0 +1,5 @@
+// Package layer3 provides access to the Layer-3 networking extension for the
+// OpenStack Neutron service. This extension allows API users to route packets
+// between subnets, forward packets from internal networks to external ones,
+// and access instances from external networks through floating IPs.
+package layer3
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests.go b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
new file mode 100644
index 0000000..29f752a
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
@@ -0,0 +1,168 @@
+package floatingips
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ ID string `q:"id"`
+ FloatingNetworkID string `q:"floating_network_id"`
+ PortID string `q:"port_id"`
+ FixedIP string `q:"fixed_ip_address"`
+ FloatingIP string `q:"floating_ip_address"`
+ TenantID string `q:"tenant_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// floating IP resources. It accepts a ListOpts struct, which allows you to
+// filter and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return FloatingIPPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOpts contains all the values needed to create a new floating IP
+// resource. The only required fields are FloatingNetworkID and PortID which
+// refer to the external network and internal port respectively.
+type CreateOpts struct {
+ FloatingNetworkID string
+ FloatingIP string
+ PortID string
+ FixedIP string
+ TenantID string
+}
+
+var (
+ errFloatingNetworkIDRequired = fmt.Errorf("A NetworkID is required")
+)
+
+// Create accepts a CreateOpts struct and uses the values provided to create a
+// new floating IP resource. You can create floating IPs on external networks
+// only. If you provide a FloatingNetworkID which refers to a network that is
+// not external (i.e. its `router:external' attribute is False), the operation
+// will fail and return a 400 error.
+//
+// If you do not specify a FloatingIP address value, the operation will
+// automatically allocate an available address for the new resource. If you do
+// choose to specify one, it must fall within the subnet range for the external
+// network - otherwise the operation returns a 400 error. If the FloatingIP
+// address is already in use, the operation returns a 409 error code.
+//
+// You can associate the new resource with an internal port by using the PortID
+// field. If you specify a PortID that is not valid, the operation will fail and
+// return 404 error code.
+//
+// You must also configure an IP address for the port associated with the PortID
+// you have provided - this is what the FixedIP refers to: an IP fixed to a port.
+// Because a port might be associated with multiple IP addresses, you can use
+// the FixedIP field to associate a particular IP address rather than have the
+// API assume for you. If you specify an IP address that is not valid, the
+// operation will fail and return a 400 error code. If the PortID and FixedIP
+// are already associated with another resource, the operation will fail and
+// returns a 409 error code.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ var res CreateResult
+
+ // Validate
+ if opts.FloatingNetworkID == "" {
+ res.Err = errFloatingNetworkIDRequired
+ return res
+ }
+
+ // Define structures
+ type floatingIP struct {
+ FloatingNetworkID string `json:"floating_network_id"`
+ FloatingIP string `json:"floating_ip_address,omitempty"`
+ PortID string `json:"port_id,omitempty"`
+ FixedIP string `json:"fixed_ip_address,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ }
+ type request struct {
+ FloatingIP floatingIP `json:"floatingip"`
+ }
+
+ // Populate request body
+ reqBody := request{FloatingIP: floatingIP{
+ FloatingNetworkID: opts.FloatingNetworkID,
+ FloatingIP: opts.FloatingIP,
+ PortID: opts.PortID,
+ FixedIP: opts.FixedIP,
+ TenantID: opts.TenantID,
+ }}
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular floating IP resource based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains the values used when updating a floating IP resource. The
+// only value that can be updated is which internal port the floating IP is
+// linked to. To associate the floating IP with a new internal port, provide its
+// ID. To disassociate the floating IP from all ports, provide an empty string.
+type UpdateOpts struct {
+ PortID string
+}
+
+// Update allows floating IP resources to be updated. Currently, the only way to
+// "update" a floating IP is to associate it with a new internal port, or
+// disassociated it from all ports. See UpdateOpts for instructions of how to
+// do this.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ type floatingIP struct {
+ PortID *string `json:"port_id"`
+ }
+
+ type request struct {
+ FloatingIP floatingIP `json:"floatingip"`
+ }
+
+ var portID *string
+ if opts.PortID == "" {
+ portID = nil
+ } else {
+ portID = &opts.PortID
+ }
+
+ reqBody := request{FloatingIP: floatingIP{PortID: portID}}
+
+ // Send request to API
+ var res UpdateResult
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a particular floating IP resource. Please
+// ensure this is what you want - you can also disassociate the IP from existing
+// internal ports.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go b/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go
new file mode 100644
index 0000000..d914a79
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go
@@ -0,0 +1,355 @@
+package floatingips
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips", 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, `
+{
+ "floatingips": [
+ {
+ "floating_network_id": "6d67c30a-ddb4-49a1-bec3-a65b286b4170",
+ "router_id": null,
+ "fixed_ip_address": null,
+ "floating_ip_address": "192.0.0.4",
+ "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+ "status": "DOWN",
+ "port_id": null,
+ "id": "2f95fd2b-9f6a-4e8e-9e9a-2cbe286cbf9e"
+ },
+ {
+ "floating_network_id": "90f742b1-6d17-487b-ba95-71881dbc0b64",
+ "router_id": "0a24cb83-faf5-4d7f-b723-3144ed8a2167",
+ "fixed_ip_address": "192.0.0.2",
+ "floating_ip_address": "10.0.0.3",
+ "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+ "status": "DOWN",
+ "port_id": "74a342ce-8e07-4e91-880c-9f834b68fa25",
+ "id": "ada25a95-f321-4f59-b0e0-f3a970dd3d63"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractFloatingIPs(page)
+ if err != nil {
+ t.Errorf("Failed to extract floating IPs: %v", err)
+ return false, err
+ }
+
+ expected := []FloatingIP{
+ FloatingIP{
+ FloatingNetworkID: "6d67c30a-ddb4-49a1-bec3-a65b286b4170",
+ FixedIP: "",
+ FloatingIP: "192.0.0.4",
+ TenantID: "017d8de156df4177889f31a9bd6edc00",
+ Status: "DOWN",
+ PortID: "",
+ ID: "2f95fd2b-9f6a-4e8e-9e9a-2cbe286cbf9e",
+ },
+ FloatingIP{
+ FloatingNetworkID: "90f742b1-6d17-487b-ba95-71881dbc0b64",
+ FixedIP: "192.0.0.2",
+ FloatingIP: "10.0.0.3",
+ TenantID: "017d8de156df4177889f31a9bd6edc00",
+ Status: "DOWN",
+ PortID: "74a342ce-8e07-4e91-880c-9f834b68fa25",
+ ID: "ada25a95-f321-4f59-b0e0-f3a970dd3d63",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestInvalidNextPageURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{"floatingips": [{}], "floatingips_links": {}}`)
+ })
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ ExtractFloatingIPs(page)
+ return true, nil
+ })
+}
+
+func TestRequiredFieldsForCreate(t *testing.T) {
+ res1 := Create(fake.ServiceClient(), CreateOpts{FloatingNetworkID: ""})
+ if res1.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res2 := Create(fake.ServiceClient(), CreateOpts{FloatingNetworkID: "foo", PortID: ""})
+ if res2.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "floatingip": {
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+ "port_id": "ce705c24-c1ef-408a-bda3-7bbd946164ab"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "floatingip": {
+ "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+ "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+ "fixed_ip_address": "10.0.0.3",
+ "floating_ip_address": "",
+ "port_id": "ce705c24-c1ef-408a-bda3-7bbd946164ab",
+ "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+ PortID: "ce705c24-c1ef-408a-bda3-7bbd946164ab",
+ }
+
+ ip, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+ th.AssertEquals(t, "4969c491a3c74ee4af974e6d800c62de", ip.TenantID)
+ th.AssertEquals(t, "376da547-b977-4cfe-9cba-275c80debf57", ip.FloatingNetworkID)
+ th.AssertEquals(t, "", ip.FloatingIP)
+ th.AssertEquals(t, "ce705c24-c1ef-408a-bda3-7bbd946164ab", ip.PortID)
+ th.AssertEquals(t, "10.0.0.3", ip.FixedIP)
+}
+
+func TestCreateEmptyPort(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "floatingip": {
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57"
+ }
+ }
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+ {
+ "floatingip": {
+ "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+ "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+ "fixed_ip_address": "10.0.0.3",
+ "floating_ip_address": "",
+ "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+ }
+ }
+ `)
+ })
+
+ options := CreateOpts{
+ FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+ }
+
+ ip, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+ th.AssertEquals(t, "4969c491a3c74ee4af974e6d800c62de", ip.TenantID)
+ th.AssertEquals(t, "376da547-b977-4cfe-9cba-275c80debf57", ip.FloatingNetworkID)
+ th.AssertEquals(t, "", ip.FloatingIP)
+ th.AssertEquals(t, "", ip.PortID)
+ th.AssertEquals(t, "10.0.0.3", ip.FixedIP)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", 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, `
+{
+ "floatingip": {
+ "floating_network_id": "90f742b1-6d17-487b-ba95-71881dbc0b64",
+ "fixed_ip_address": "192.0.0.2",
+ "floating_ip_address": "10.0.0.3",
+ "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+ "status": "DOWN",
+ "port_id": "74a342ce-8e07-4e91-880c-9f834b68fa25",
+ "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+ }
+}
+ `)
+ })
+
+ ip, err := Get(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "90f742b1-6d17-487b-ba95-71881dbc0b64", ip.FloatingNetworkID)
+ th.AssertEquals(t, "10.0.0.3", ip.FloatingIP)
+ th.AssertEquals(t, "74a342ce-8e07-4e91-880c-9f834b68fa25", ip.PortID)
+ th.AssertEquals(t, "192.0.0.2", ip.FixedIP)
+ th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", ip.TenantID)
+ th.AssertEquals(t, "DOWN", ip.Status)
+ th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+}
+
+func TestAssociate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "floatingip": {
+ "port_id": "423abc8d-2991-4a55-ba98-2aaea84cc72e"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "floatingip": {
+ "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+ "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+ "fixed_ip_address": null,
+ "floating_ip_address": "172.24.4.228",
+ "port_id": "423abc8d-2991-4a55-ba98-2aaea84cc72e",
+ "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+ }
+}
+ `)
+ })
+
+ ip, err := Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", UpdateOpts{PortID: "423abc8d-2991-4a55-ba98-2aaea84cc72e"}).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, "423abc8d-2991-4a55-ba98-2aaea84cc72e", ip.PortID)
+}
+
+func TestDisassociate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "floatingip": {
+ "port_id": null
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "floatingip": {
+ "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+ "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+ "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+ "fixed_ip_address": null,
+ "floating_ip_address": "172.24.4.228",
+ "port_id": null,
+ "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+ }
+}
+ `)
+ })
+
+ ip, err := Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", UpdateOpts{}).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, "", ip.FixedIP)
+ th.AssertDeepEquals(t, "", ip.PortID)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/results.go b/openstack/networking/v2/extensions/layer3/floatingips/results.go
new file mode 100644
index 0000000..a1c7afe
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/results.go
@@ -0,0 +1,127 @@
+package floatingips
+
+import (
+ "fmt"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// FloatingIP represents a floating IP resource. A floating IP is an external
+// IP address that is mapped to an internal port and, optionally, a specific
+// IP address on a private network. In other words, it enables access to an
+// instance on a private network from an external network. For this reason,
+// floating IPs can only be defined on networks where the `router:external'
+// attribute (provided by the external network extension) is set to True.
+type FloatingIP struct {
+ // Unique identifier for the floating IP instance.
+ ID string `json:"id" mapstructure:"id"`
+
+ // UUID of the external network where the floating IP is to be created.
+ FloatingNetworkID string `json:"floating_network_id" mapstructure:"floating_network_id"`
+
+ // Address of the floating IP on the external network.
+ FloatingIP string `json:"floating_ip_address" mapstructure:"floating_ip_address"`
+
+ // UUID of the port on an internal network that is associated with the floating IP.
+ PortID string `json:"port_id" mapstructure:"port_id"`
+
+ // The specific IP address of the internal port which should be associated
+ // with the floating IP.
+ FixedIP string `json:"fixed_ip_address" mapstructure:"fixed_ip_address"`
+
+ // Owner of the floating IP. Only admin users can specify a tenant identifier
+ // other than its own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+
+ // The condition of the API resource.
+ Status string `json:"status" mapstructure:"status"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract a result and extracts a FloatingIP resource.
+func (r commonResult) Extract() (*FloatingIP, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ FloatingIP *FloatingIP `json:"floatingip"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+ if err != nil {
+ return nil, fmt.Errorf("Error decoding Neutron floating IP: %v", err)
+ }
+
+ return res.FloatingIP, nil
+}
+
+// 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
+}
+
+// DeleteResult represents the result of an update operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// FloatingIPPage is the page returned by a pager when traversing over a
+// collection of floating IPs.
+type FloatingIPPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of floating IPs has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p FloatingIPPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"floatingips_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (p FloatingIPPage) IsEmpty() (bool, error) {
+ is, err := ExtractFloatingIPs(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractFloatingIPs accepts a Page struct, specifically a FloatingIPPage struct,
+// and extracts the elements into a slice of FloatingIP structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractFloatingIPs(page pagination.Page) ([]FloatingIP, error) {
+ var resp struct {
+ FloatingIPs []FloatingIP `mapstructure:"floatingips" json:"floatingips"`
+ }
+
+ err := mapstructure.Decode(page.(FloatingIPPage).Body, &resp)
+
+ return resp.FloatingIPs, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/urls.go b/openstack/networking/v2/extensions/layer3/floatingips/urls.go
new file mode 100644
index 0000000..355f20d
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/urls.go
@@ -0,0 +1,13 @@
+package floatingips
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "floatingips"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/requests.go b/openstack/networking/v2/extensions/layer3/routers/requests.go
new file mode 100644
index 0000000..8b6e73d
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/requests.go
@@ -0,0 +1,230 @@
+package routers
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ ID string `q:"id"`
+ Name string `q:"name"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ Status string `q:"status"`
+ TenantID string `q:"tenant_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return RouterPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOpts contains all the values needed to create a new router. There are
+// no required values.
+type CreateOpts struct {
+ Name string
+ AdminStateUp *bool
+ TenantID string
+ GatewayInfo *GatewayInfo
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// logical router. When it is created, the router does not have an internal
+// interface - it is not associated to any subnet.
+//
+// You can optionally specify an external gateway for a router using the
+// GatewayInfo struct. The external gateway for the router must be plugged into
+// an external network (it is external if its `router:external' field is set to
+// true).
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ type router struct {
+ Name *string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ TenantID *string `json:"tenant_id,omitempty"`
+ GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"`
+ }
+
+ type request struct {
+ Router router `json:"router"`
+ }
+
+ reqBody := request{Router: router{
+ Name: gophercloud.MaybeString(opts.Name),
+ AdminStateUp: opts.AdminStateUp,
+ TenantID: gophercloud.MaybeString(opts.TenantID),
+ }}
+
+ if opts.GatewayInfo != nil {
+ reqBody.Router.GatewayInfo = opts.GatewayInfo
+ }
+
+ var res CreateResult
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular router based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains the values used when updating a router.
+type UpdateOpts struct {
+ Name string
+ AdminStateUp *bool
+ GatewayInfo *GatewayInfo
+ Routes []Route
+}
+
+// Update allows routers to be updated. You can update the name, administrative
+// state, and the external gateway. For more information about how to set the
+// external gateway for a router, see Create. This operation does not enable
+// the update of router interfaces. To do this, use the AddInterface and
+// RemoveInterface functions.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ type router struct {
+ Name *string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"`
+ Routes []Route `json:"routes"`
+ }
+
+ type request struct {
+ Router router `json:"router"`
+ }
+
+ reqBody := request{Router: router{
+ Name: gophercloud.MaybeString(opts.Name),
+ AdminStateUp: opts.AdminStateUp,
+ }}
+
+ if opts.GatewayInfo != nil {
+ reqBody.Router.GatewayInfo = opts.GatewayInfo
+ }
+
+ if opts.Routes != nil {
+ reqBody.Router.Routes = opts.Routes
+ }
+
+ // Send request to API
+ var res UpdateResult
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a particular router based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
+
+var errInvalidInterfaceOpts = errors.New("When adding a router interface you must provide either a subnet ID or a port ID")
+
+// InterfaceOpts allow you to work with operations that either add or remote
+// an internal interface from a router.
+type InterfaceOpts struct {
+ SubnetID string
+ PortID string
+}
+
+// AddInterface attaches a subnet to an internal router interface. You must
+// specify either a SubnetID or PortID in the request body. If you specify both,
+// the operation will fail and an error will be returned.
+//
+// If you specify a SubnetID, the gateway IP address for that particular subnet
+// is used to create the router interface. Alternatively, if you specify a
+// PortID, the IP address associated with the port is used to create the router
+// interface.
+//
+// If you reference a port that is associated with multiple IP addresses, or
+// if the port is associated with zero IP addresses, the operation will fail and
+// a 400 Bad Request error will be returned.
+//
+// If you reference a port already in use, the operation will fail and a 409
+// Conflict error will be returned.
+//
+// The PortID that is returned after using Extract() on the result of this
+// operation can either be the same PortID passed in or, on the other hand, the
+// identifier of a new port created by this operation. After the operation
+// completes, the device ID of the port is set to the router ID, and the
+// device owner attribute is set to `network:router_interface'.
+func AddInterface(c *gophercloud.ServiceClient, id string, opts InterfaceOpts) InterfaceResult {
+ var res InterfaceResult
+
+ // Validate
+ if (opts.SubnetID == "" && opts.PortID == "") || (opts.SubnetID != "" && opts.PortID != "") {
+ res.Err = errInvalidInterfaceOpts
+ return res
+ }
+
+ type request struct {
+ SubnetID string `json:"subnet_id,omitempty"`
+ PortID string `json:"port_id,omitempty"`
+ }
+
+ body := request{SubnetID: opts.SubnetID, PortID: opts.PortID}
+
+ _, res.Err = c.Put(addInterfaceURL(c, id), body, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// RemoveInterface removes an internal router interface, which detaches a
+// subnet from the router. You must specify either a SubnetID or PortID, since
+// these values are used to identify the router interface to remove.
+//
+// Unlike AddInterface, you can also specify both a SubnetID and PortID. If you
+// choose to specify both, the subnet ID must correspond to the subnet ID of
+// the first IP address on the port specified by the port ID. Otherwise, the
+// operation will fail and return a 409 Conflict error.
+//
+// If the router, subnet or port which are referenced do not exist or are not
+// visible to you, the operation will fail and a 404 Not Found error will be
+// returned. After this operation completes, the port connecting the router
+// with the subnet is removed from the subnet for the network.
+func RemoveInterface(c *gophercloud.ServiceClient, id string, opts InterfaceOpts) InterfaceResult {
+ var res InterfaceResult
+
+ type request struct {
+ SubnetID string `json:"subnet_id,omitempty"`
+ PortID string `json:"port_id,omitempty"`
+ }
+
+ body := request{SubnetID: opts.SubnetID, PortID: opts.PortID}
+
+ _, res.Err = c.Put(removeInterfaceURL(c, id), body, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/requests_test.go b/openstack/networking/v2/extensions/layer3/routers/requests_test.go
new file mode 100644
index 0000000..1981733
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/requests_test.go
@@ -0,0 +1,405 @@
+package routers
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/routers", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers", 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, `
+{
+ "routers": [
+ {
+ "status": "ACTIVE",
+ "external_gateway_info": null,
+ "name": "second_routers",
+ "admin_state_up": true,
+ "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+ "id": "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b"
+ },
+ {
+ "status": "ACTIVE",
+ "external_gateway_info": {
+ "network_id": "3c5bcddd-6af9-4e6b-9c3e-c153e521cab8"
+ },
+ "name": "router1",
+ "admin_state_up": true,
+ "tenant_id": "33a40233088643acb66ff6eb0ebea679",
+ "id": "a9254bdb-2613-4a13-ac4c-adc581fba50d"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRouters(page)
+ if err != nil {
+ t.Errorf("Failed to extract routers: %v", err)
+ return false, err
+ }
+
+ expected := []Router{
+ Router{
+ Status: "ACTIVE",
+ GatewayInfo: GatewayInfo{NetworkID: ""},
+ AdminStateUp: true,
+ Name: "second_routers",
+ ID: "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b",
+ TenantID: "6b96ff0cb17a4b859e1e575d221683d3",
+ },
+ Router{
+ Status: "ACTIVE",
+ GatewayInfo: GatewayInfo{NetworkID: "3c5bcddd-6af9-4e6b-9c3e-c153e521cab8"},
+ AdminStateUp: true,
+ Name: "router1",
+ ID: "a9254bdb-2613-4a13-ac4c-adc581fba50d",
+ TenantID: "33a40233088643acb66ff6eb0ebea679",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "router":{
+ "name": "foo_router",
+ "admin_state_up": false,
+ "external_gateway_info":{
+ "network_id":"8ca37218-28ff-41cb-9b10-039601ea7e6b"
+ }
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "router": {
+ "status": "ACTIVE",
+ "external_gateway_info": {
+ "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+ },
+ "name": "foo_router",
+ "admin_state_up": false,
+ "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e"
+ }
+}
+ `)
+ })
+
+ asu := false
+ gwi := GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+
+ options := CreateOpts{
+ Name: "foo_router",
+ AdminStateUp: &asu,
+ GatewayInfo: &gwi,
+ }
+ r, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "foo_router", r.Name)
+ th.AssertEquals(t, false, r.AdminStateUp)
+ th.AssertDeepEquals(t, GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}, r.GatewayInfo)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/a07eea83-7710-4860-931b-5fe220fae533", 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, `
+{
+ "router": {
+ "status": "ACTIVE",
+ "external_gateway_info": {
+ "network_id": "85d76829-6415-48ff-9c63-5c5ca8c61ac6"
+ },
+ "routes": [
+ {
+ "nexthop": "10.1.0.10",
+ "destination": "40.0.1.0/24"
+ }
+ ],
+ "name": "router1",
+ "admin_state_up": true,
+ "tenant_id": "d6554fe62e2f41efbb6e026fad5c1542",
+ "id": "a07eea83-7710-4860-931b-5fe220fae533"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "a07eea83-7710-4860-931b-5fe220fae533").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.GatewayInfo, GatewayInfo{NetworkID: "85d76829-6415-48ff-9c63-5c5ca8c61ac6"})
+ th.AssertEquals(t, n.Name, "router1")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.TenantID, "d6554fe62e2f41efbb6e026fad5c1542")
+ th.AssertEquals(t, n.ID, "a07eea83-7710-4860-931b-5fe220fae533")
+ th.AssertDeepEquals(t, n.Routes, []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}})
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "router": {
+ "name": "new_name",
+ "external_gateway_info": {
+ "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+ },
+ "routes": [
+ {
+ "nexthop": "10.1.0.10",
+ "destination": "40.0.1.0/24"
+ }
+ ]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "router": {
+ "status": "ACTIVE",
+ "external_gateway_info": {
+ "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+ },
+ "name": "new_name",
+ "admin_state_up": true,
+ "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+ "routes": [
+ {
+ "nexthop": "10.1.0.10",
+ "destination": "40.0.1.0/24"
+ }
+ ]
+ }
+}
+ `)
+ })
+
+ gwi := GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+ r := []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}}
+ options := UpdateOpts{Name: "new_name", GatewayInfo: &gwi, Routes: r}
+
+ n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Name, "new_name")
+ th.AssertDeepEquals(t, n.GatewayInfo, GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"})
+ th.AssertDeepEquals(t, n.Routes, []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}})
+}
+
+func TestAllRoutesRemoved(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "router": {
+ "routes": []
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "router": {
+ "status": "ACTIVE",
+ "external_gateway_info": {
+ "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+ },
+ "name": "name",
+ "admin_state_up": true,
+ "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+ "routes": []
+ }
+}
+ `)
+ })
+
+ r := []Route{}
+ options := UpdateOpts{Routes: r}
+
+ n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, n.Routes, []Route{})
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestAddInterface(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c/add_router_interface", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet_id": "a2f1f29d-571b-4533-907f-5803ab96ead1"
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "subnet_id": "0d32a837-8069-4ec3-84c4-3eef3e10b188",
+ "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+ "port_id": "3f990102-4485-4df1-97a0-2c35bdb85b31",
+ "id": "9a83fa11-8da5-436e-9afe-3d3ac5ce7770"
+}
+`)
+ })
+
+ opts := InterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+ res, err := AddInterface(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "0d32a837-8069-4ec3-84c4-3eef3e10b188", res.SubnetID)
+ th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", res.TenantID)
+ th.AssertEquals(t, "3f990102-4485-4df1-97a0-2c35bdb85b31", res.PortID)
+ th.AssertEquals(t, "9a83fa11-8da5-436e-9afe-3d3ac5ce7770", res.ID)
+}
+
+func TestAddInterfaceRequiredOpts(t *testing.T) {
+ _, err := AddInterface(fake.ServiceClient(), "foo", InterfaceOpts{}).Extract()
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ _, err = AddInterface(fake.ServiceClient(), "foo", InterfaceOpts{SubnetID: "bar", PortID: "baz"}).Extract()
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestRemoveInterface(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c/remove_router_interface", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet_id": "a2f1f29d-571b-4533-907f-5803ab96ead1"
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "subnet_id": "0d32a837-8069-4ec3-84c4-3eef3e10b188",
+ "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+ "port_id": "3f990102-4485-4df1-97a0-2c35bdb85b31",
+ "id": "9a83fa11-8da5-436e-9afe-3d3ac5ce7770"
+}
+`)
+ })
+
+ opts := InterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+ res, err := RemoveInterface(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "0d32a837-8069-4ec3-84c4-3eef3e10b188", res.SubnetID)
+ th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", res.TenantID)
+ th.AssertEquals(t, "3f990102-4485-4df1-97a0-2c35bdb85b31", res.PortID)
+ th.AssertEquals(t, "9a83fa11-8da5-436e-9afe-3d3ac5ce7770", res.ID)
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/results.go b/openstack/networking/v2/extensions/layer3/routers/results.go
new file mode 100644
index 0000000..5e297ab
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/results.go
@@ -0,0 +1,168 @@
+package routers
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// GatewayInfo represents the information of an external gateway for any
+// particular network router.
+type GatewayInfo struct {
+ NetworkID string `json:"network_id" mapstructure:"network_id"`
+}
+
+type Route struct {
+ NextHop string `mapstructure:"nexthop" json:"nexthop"`
+ DestinationCIDR string `mapstructure:"destination" json:"destination"`
+}
+
+// Router represents a Neutron router. A router is a logical entity that
+// forwards packets across internal subnets and NATs (network address
+// translation) them on external networks through an appropriate gateway.
+//
+// A router has an interface for each subnet with which it is associated. By
+// default, the IP address of such interface is the subnet's gateway IP. Also,
+// whenever a router is associated with a subnet, a port for that router
+// interface is added to the subnet's network.
+type Router struct {
+ // Indicates whether or not a router is currently operational.
+ Status string `json:"status" mapstructure:"status"`
+
+ // Information on external gateway for the router.
+ GatewayInfo GatewayInfo `json:"external_gateway_info" mapstructure:"external_gateway_info"`
+
+ // Administrative state of the router.
+ AdminStateUp bool `json:"admin_state_up" mapstructure:"admin_state_up"`
+
+ // Human readable name for the router. Does not have to be unique.
+ Name string `json:"name" mapstructure:"name"`
+
+ // Unique identifier for the router.
+ ID string `json:"id" mapstructure:"id"`
+
+ // Owner of the router. Only admin users can specify a tenant identifier
+ // other than its own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+
+ Routes []Route `json:"routes" mapstructure:"routes"`
+}
+
+// RouterPage is the page returned by a pager when traversing over a
+// collection of routers.
+type RouterPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of routers has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p RouterPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"routers_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a RouterPage struct is empty.
+func (p RouterPage) IsEmpty() (bool, error) {
+ is, err := ExtractRouters(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractRouters accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRouters(page pagination.Page) ([]Router, error) {
+ var resp struct {
+ Routers []Router `mapstructure:"routers" json:"routers"`
+ }
+
+ err := mapstructure.Decode(page.(RouterPage).Body, &resp)
+
+ return resp.Routers, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Router, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Router *Router `json:"router"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Router, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// InterfaceInfo represents information about a particular router interface. As
+// mentioned above, in order for a router to forward to a subnet, it needs an
+// interface.
+type InterfaceInfo struct {
+ // The ID of the subnet which this interface is associated with.
+ SubnetID string `json:"subnet_id" mapstructure:"subnet_id"`
+
+ // The ID of the port that is a part of the subnet.
+ PortID string `json:"port_id" mapstructure:"port_id"`
+
+ // The UUID of the interface.
+ ID string `json:"id" mapstructure:"id"`
+
+ // Owner of the interface.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+}
+
+// InterfaceResult represents the result of interface operations, such as
+// AddInterface() and RemoveInterface().
+type InterfaceResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts an information struct.
+func (r InterfaceResult) Extract() (*InterfaceInfo, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res *InterfaceInfo
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/urls.go b/openstack/networking/v2/extensions/layer3/routers/urls.go
new file mode 100644
index 0000000..bc22c2a
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/urls.go
@@ -0,0 +1,21 @@
+package routers
+
+import "github.com/rackspace/gophercloud"
+
+const resourcePath = "routers"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id)
+}
+
+func addInterfaceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id, "add_router_interface")
+}
+
+func removeInterfaceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(resourcePath, id, "remove_router_interface")
+}
diff --git a/openstack/networking/v2/extensions/lbaas/doc.go b/openstack/networking/v2/extensions/lbaas/doc.go
new file mode 100644
index 0000000..bc1fc28
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/doc.go
@@ -0,0 +1,3 @@
+// Package lbaas provides information and interaction with the Load Balancer
+// as a Service extension for the OpenStack Networking service.
+package lbaas
diff --git a/openstack/networking/v2/extensions/lbaas/members/requests.go b/openstack/networking/v2/extensions/lbaas/members/requests.go
new file mode 100644
index 0000000..848938f
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/requests.go
@@ -0,0 +1,123 @@
+package members
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Status string `q:"status"`
+ Weight int `q:"weight"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ TenantID string `q:"tenant_id"`
+ PoolID string `q:"pool_id"`
+ Address string `q:"address"`
+ ProtocolPort int `q:"protocol_port"`
+ ID string `q:"id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// pools. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those pools that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return MemberPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOpts contains all the values needed to create a new pool member.
+type CreateOpts struct {
+ // Only required if the caller has an admin role and wants to create a pool
+ // for another tenant.
+ TenantID string
+
+ // Required. The IP address of the member.
+ Address string
+
+ // Required. The port on which the application is hosted.
+ ProtocolPort int
+
+ // Required. The pool to which this member will belong.
+ PoolID string
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// load balancer pool member.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ type member struct {
+ TenantID string `json:"tenant_id,omitempty"`
+ ProtocolPort int `json:"protocol_port"`
+ Address string `json:"address"`
+ PoolID string `json:"pool_id"`
+ }
+ type request struct {
+ Member member `json:"member"`
+ }
+
+ reqBody := request{Member: member{
+ Address: opts.Address,
+ TenantID: opts.TenantID,
+ ProtocolPort: opts.ProtocolPort,
+ PoolID: opts.PoolID,
+ }}
+
+ var res CreateResult
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular pool member based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains the values used when updating a pool member.
+type UpdateOpts struct {
+ // The administrative state of the member, which is up (true) or down (false).
+ AdminStateUp bool
+}
+
+// Update allows members to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ type member struct {
+ AdminStateUp bool `json:"admin_state_up"`
+ }
+ type request struct {
+ Member member `json:"member"`
+ }
+
+ reqBody := request{Member: member{AdminStateUp: opts.AdminStateUp}}
+
+ // Send request to API
+ var res UpdateResult
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return res
+}
+
+// Delete will permanently delete a particular member based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/requests_test.go b/openstack/networking/v2/extensions/lbaas/members/requests_test.go
new file mode 100644
index 0000000..dc1ece3
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/requests_test.go
@@ -0,0 +1,243 @@
+package members
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/lb/members", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/members", 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, `
+{
+ "members":[
+ {
+ "status":"ACTIVE",
+ "weight":1,
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "pool_id":"72741b06-df4d-4715-b142-276b6bce75ab",
+ "address":"10.0.0.4",
+ "protocol_port":80,
+ "id":"701b531b-111a-4f21-ad85-4795b7b12af6"
+ },
+ {
+ "status":"ACTIVE",
+ "weight":1,
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "pool_id":"72741b06-df4d-4715-b142-276b6bce75ab",
+ "address":"10.0.0.3",
+ "protocol_port":80,
+ "id":"beb53b4d-230b-4abd-8118-575b8fa006ef"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractMembers(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []Member{
+ Member{
+ Status: "ACTIVE",
+ Weight: 1,
+ AdminStateUp: true,
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ PoolID: "72741b06-df4d-4715-b142-276b6bce75ab",
+ Address: "10.0.0.4",
+ ProtocolPort: 80,
+ ID: "701b531b-111a-4f21-ad85-4795b7b12af6",
+ },
+ Member{
+ Status: "ACTIVE",
+ Weight: 1,
+ AdminStateUp: true,
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ PoolID: "72741b06-df4d-4715-b142-276b6bce75ab",
+ Address: "10.0.0.3",
+ ProtocolPort: 80,
+ ID: "beb53b4d-230b-4abd-8118-575b8fa006ef",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/members", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "member": {
+ "tenant_id": "453105b9-1754-413f-aab1-55f1af620750",
+ "pool_id": "foo",
+ "address": "192.0.2.14",
+ "protocol_port":8080
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "member": {
+ "id": "975592ca-e308-48ad-8298-731935ee9f45",
+ "address": "192.0.2.14",
+ "protocol_port": 8080,
+ "tenant_id": "453105b9-1754-413f-aab1-55f1af620750",
+ "admin_state_up":true,
+ "weight": 1,
+ "status": "DOWN"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ TenantID: "453105b9-1754-413f-aab1-55f1af620750",
+ Address: "192.0.2.14",
+ ProtocolPort: 8080,
+ PoolID: "foo",
+ }
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/members/975592ca-e308-48ad-8298-731935ee9f45", 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, `
+{
+ "member":{
+ "id":"975592ca-e308-48ad-8298-731935ee9f45",
+ "address":"192.0.2.14",
+ "protocol_port":8080,
+ "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+ "admin_state_up":true,
+ "weight":1,
+ "status":"DOWN"
+ }
+}
+ `)
+ })
+
+ m, err := Get(fake.ServiceClient(), "975592ca-e308-48ad-8298-731935ee9f45").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "975592ca-e308-48ad-8298-731935ee9f45", m.ID)
+ th.AssertEquals(t, "192.0.2.14", m.Address)
+ th.AssertEquals(t, 8080, m.ProtocolPort)
+ th.AssertEquals(t, "453105b9-1754-413f-aab1-55f1af620750", m.TenantID)
+ th.AssertEquals(t, true, m.AdminStateUp)
+ th.AssertEquals(t, 1, m.Weight)
+ th.AssertEquals(t, "DOWN", m.Status)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/members/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "member":{
+ "admin_state_up":false
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "member":{
+ "status":"PENDING_UPDATE",
+ "protocol_port":8080,
+ "weight":1,
+ "admin_state_up":false,
+ "tenant_id":"4fd44f30292945e481c7b8a0c8908869",
+ "pool_id":"7803631d-f181-4500-b3a2-1b68ba2a75fd",
+ "address":"10.0.0.5",
+ "status_description":null,
+ "id":"48a471ea-64f1-4eb6-9be7-dae6bbe40a0f"
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{AdminStateUp: false}
+
+ _, err := Update(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/members/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/results.go b/openstack/networking/v2/extensions/lbaas/members/results.go
new file mode 100644
index 0000000..3cad339
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/results.go
@@ -0,0 +1,122 @@
+package members
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Member represents the application running on a backend server.
+type Member struct {
+ // The status of the member. Indicates whether the member is operational.
+ Status string
+
+ // Weight of member.
+ Weight int
+
+ // The administrative state of the member, which is up (true) or down (false).
+ AdminStateUp bool `json:"admin_state_up" mapstructure:"admin_state_up"`
+
+ // Owner of the member. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+
+ // The pool to which the member belongs.
+ PoolID string `json:"pool_id" mapstructure:"pool_id"`
+
+ // The IP address of the member.
+ Address string
+
+ // The port on which the application is hosted.
+ ProtocolPort int `json:"protocol_port" mapstructure:"protocol_port"`
+
+ // The unique ID for the member.
+ ID string
+}
+
+// MemberPage is the page returned by a pager when traversing over a
+// collection of pool members.
+type MemberPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of members has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p MemberPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"members_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a MemberPage struct is empty.
+func (p MemberPage) IsEmpty() (bool, error) {
+ is, err := ExtractMembers(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractMembers accepts a Page struct, specifically a MemberPage struct,
+// and extracts the elements into a slice of Member structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractMembers(page pagination.Page) ([]Member, error) {
+ var resp struct {
+ Members []Member `mapstructure:"members" json:"members"`
+ }
+
+ err := mapstructure.Decode(page.(MemberPage).Body, &resp)
+ if err != nil {
+ return nil, err
+ }
+
+ return resp.Members, nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Member, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Member *Member `json:"member"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Member, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/urls.go b/openstack/networking/v2/extensions/lbaas/members/urls.go
new file mode 100644
index 0000000..94b57e4
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/urls.go
@@ -0,0 +1,16 @@
+package members
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "lb"
+ resourcePath = "members"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/requests.go b/openstack/networking/v2/extensions/lbaas/monitors/requests.go
new file mode 100644
index 0000000..71b21ef
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/requests.go
@@ -0,0 +1,265 @@
+package monitors
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ ID string `q:"id"`
+ TenantID string `q:"tenant_id"`
+ Type string `q:"type"`
+ Delay int `q:"delay"`
+ Timeout int `q:"timeout"`
+ MaxRetries int `q:"max_retries"`
+ HTTPMethod string `q:"http_method"`
+ URLPath string `q:"url_path"`
+ ExpectedCodes string `q:"expected_codes"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ Status string `q:"status"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return MonitorPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Constants that represent approved monitoring types.
+const (
+ TypePING = "PING"
+ TypeTCP = "TCP"
+ TypeHTTP = "HTTP"
+ TypeHTTPS = "HTTPS"
+)
+
+var (
+ errValidTypeRequired = fmt.Errorf("A valid Type is required. Supported values are PING, TCP, HTTP and HTTPS")
+ errDelayRequired = fmt.Errorf("Delay is required")
+ errTimeoutRequired = fmt.Errorf("Timeout is required")
+ errMaxRetriesRequired = fmt.Errorf("MaxRetries is required")
+ errURLPathRequired = fmt.Errorf("URL path is required")
+ errExpectedCodesRequired = fmt.Errorf("ExpectedCodes is required")
+ errDelayMustGETimeout = fmt.Errorf("Delay must be greater than or equal to timeout")
+)
+
+// CreateOpts contains all the values needed to create a new health monitor.
+type CreateOpts struct {
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string
+
+ // Required. The type of probe, which is PING, TCP, HTTP, or HTTPS, that is
+ // sent by the load balancer to verify the member state.
+ Type string
+
+ // Required. The time, in seconds, between sending probes to members.
+ Delay int
+
+ // Required. Maximum number of seconds for a monitor to wait for a ping reply
+ // before it times out. The value must be less than the delay value.
+ Timeout int
+
+ // Required. Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int
+
+ // Required for HTTP(S) types. URI path that will be accessed if monitor type
+ // is HTTP or HTTPS.
+ URLPath string
+
+ // Required for HTTP(S) types. The HTTP method used for requests by the
+ // monitor. If this attribute is not specified, it defaults to "GET".
+ HTTPMethod string
+
+ // Required for HTTP(S) types. Expected HTTP codes for a passing HTTP(S)
+ // monitor. You can either specify a single status like "200", or a range
+ // like "200-202".
+ ExpectedCodes string
+
+ AdminStateUp *bool
+}
+
+// Create is an operation which provisions a new health monitor. There are
+// different types of monitor you can provision: PING, TCP or HTTP(S). Below
+// are examples of how to create each one.
+//
+// Here is an example config struct to use when creating a PING or TCP monitor:
+//
+// CreateOpts{Type: TypePING, Delay: 20, Timeout: 10, MaxRetries: 3}
+// CreateOpts{Type: TypeTCP, Delay: 20, Timeout: 10, MaxRetries: 3}
+//
+// Here is an example config struct to use when creating a HTTP(S) monitor:
+//
+// CreateOpts{Type: TypeHTTP, Delay: 20, Timeout: 10, MaxRetries: 3,
+// HttpMethod: "HEAD", ExpectedCodes: "200"}
+//
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ var res CreateResult
+
+ // Validate inputs
+ allowed := map[string]bool{TypeHTTP: true, TypeHTTPS: true, TypeTCP: true, TypePING: true}
+ if opts.Type == "" || allowed[opts.Type] == false {
+ res.Err = errValidTypeRequired
+ }
+ if opts.Delay == 0 {
+ res.Err = errDelayRequired
+ }
+ if opts.Timeout == 0 {
+ res.Err = errTimeoutRequired
+ }
+ if opts.MaxRetries == 0 {
+ res.Err = errMaxRetriesRequired
+ }
+ if opts.Type == TypeHTTP || opts.Type == TypeHTTPS {
+ if opts.URLPath == "" {
+ res.Err = errURLPathRequired
+ }
+ if opts.ExpectedCodes == "" {
+ res.Err = errExpectedCodesRequired
+ }
+ }
+ if opts.Delay < opts.Timeout {
+ res.Err = errDelayMustGETimeout
+ }
+ if res.Err != nil {
+ return res
+ }
+
+ type monitor struct {
+ Type string `json:"type"`
+ Delay int `json:"delay"`
+ Timeout int `json:"timeout"`
+ MaxRetries int `json:"max_retries"`
+ TenantID *string `json:"tenant_id,omitempty"`
+ URLPath *string `json:"url_path,omitempty"`
+ ExpectedCodes *string `json:"expected_codes,omitempty"`
+ HTTPMethod *string `json:"http_method,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ }
+
+ type request struct {
+ Monitor monitor `json:"health_monitor"`
+ }
+
+ reqBody := request{Monitor: monitor{
+ Type: opts.Type,
+ Delay: opts.Delay,
+ Timeout: opts.Timeout,
+ MaxRetries: opts.MaxRetries,
+ TenantID: gophercloud.MaybeString(opts.TenantID),
+ URLPath: gophercloud.MaybeString(opts.URLPath),
+ ExpectedCodes: gophercloud.MaybeString(opts.ExpectedCodes),
+ HTTPMethod: gophercloud.MaybeString(opts.HTTPMethod),
+ AdminStateUp: opts.AdminStateUp,
+ }}
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular health monitor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains all the values needed to update an existing virtual IP.
+// Attributes not listed here but appear in CreateOpts are immutable and cannot
+// be updated.
+type UpdateOpts struct {
+ // Required. The time, in seconds, between sending probes to members.
+ Delay int
+
+ // Required. Maximum number of seconds for a monitor to wait for a ping reply
+ // before it times out. The value must be less than the delay value.
+ Timeout int
+
+ // Required. Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int
+
+ // Required for HTTP(S) types. URI path that will be accessed if monitor type
+ // is HTTP or HTTPS.
+ URLPath string
+
+ // Required for HTTP(S) types. The HTTP method used for requests by the
+ // monitor. If this attribute is not specified, it defaults to "GET".
+ HTTPMethod string
+
+ // Required for HTTP(S) types. Expected HTTP codes for a passing HTTP(S)
+ // monitor. You can either specify a single status like "200", or a range
+ // like "200-202".
+ ExpectedCodes string
+
+ AdminStateUp *bool
+}
+
+// Update is an operation which modifies the attributes of the specified monitor.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ var res UpdateResult
+
+ if opts.Delay > 0 && opts.Timeout > 0 && opts.Delay < opts.Timeout {
+ res.Err = errDelayMustGETimeout
+ }
+
+ type monitor struct {
+ Delay int `json:"delay"`
+ Timeout int `json:"timeout"`
+ MaxRetries int `json:"max_retries"`
+ URLPath *string `json:"url_path,omitempty"`
+ ExpectedCodes *string `json:"expected_codes,omitempty"`
+ HTTPMethod *string `json:"http_method,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ }
+
+ type request struct {
+ Monitor monitor `json:"health_monitor"`
+ }
+
+ reqBody := request{Monitor: monitor{
+ Delay: opts.Delay,
+ Timeout: opts.Timeout,
+ MaxRetries: opts.MaxRetries,
+ URLPath: gophercloud.MaybeString(opts.URLPath),
+ ExpectedCodes: gophercloud.MaybeString(opts.ExpectedCodes),
+ HTTPMethod: gophercloud.MaybeString(opts.HTTPMethod),
+ AdminStateUp: opts.AdminStateUp,
+ }}
+
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a particular monitor based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go b/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go
new file mode 100644
index 0000000..79a99bf
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go
@@ -0,0 +1,312 @@
+package monitors
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.AssertEquals(t, th.Endpoint()+"v2.0/lb/health_monitors", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/health_monitors", 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, `
+{
+ "health_monitors":[
+ {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":10,
+ "max_retries":1,
+ "timeout":1,
+ "type":"PING",
+ "id":"466c8345-28d8-4f84-a246-e04380b0461d"
+ },
+ {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":5,
+ "expected_codes":"200",
+ "max_retries":2,
+ "http_method":"GET",
+ "timeout":2,
+ "url_path":"/",
+ "type":"HTTP",
+ "id":"5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractMonitors(page)
+ if err != nil {
+ t.Errorf("Failed to extract monitors: %v", err)
+ return false, err
+ }
+
+ expected := []Monitor{
+ Monitor{
+ AdminStateUp: true,
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 10,
+ MaxRetries: 1,
+ Timeout: 1,
+ Type: "PING",
+ ID: "466c8345-28d8-4f84-a246-e04380b0461d",
+ },
+ Monitor{
+ AdminStateUp: true,
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 5,
+ ExpectedCodes: "200",
+ MaxRetries: 2,
+ Timeout: 2,
+ URLPath: "/",
+ Type: "HTTP",
+ HTTPMethod: "GET",
+ ID: "5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestDelayMustBeGreaterOrEqualThanTimeout(t *testing.T) {
+ _, err := Create(fake.ServiceClient(), CreateOpts{
+ Type: "HTTP",
+ Delay: 1,
+ Timeout: 10,
+ MaxRetries: 5,
+ URLPath: "/check",
+ ExpectedCodes: "200-299",
+ }).Extract()
+
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ _, err = Update(fake.ServiceClient(), "453105b9-1754-413f-aab1-55f1af620750", UpdateOpts{
+ Delay: 1,
+ Timeout: 10,
+ }).Extract()
+
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/health_monitors", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "health_monitor":{
+ "type":"HTTP",
+ "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+ "delay":20,
+ "timeout":10,
+ "max_retries":5,
+ "url_path":"/check",
+ "expected_codes":"200-299"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "health_monitor":{
+ "id":"f3eeab00-8367-4524-b662-55e64d4cacb5",
+ "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+ "type":"HTTP",
+ "delay":20,
+ "timeout":10,
+ "max_retries":5,
+ "http_method":"GET",
+ "url_path":"/check",
+ "expected_codes":"200-299",
+ "admin_state_up":true,
+ "status":"ACTIVE"
+ }
+}
+ `)
+ })
+
+ _, err := Create(fake.ServiceClient(), CreateOpts{
+ Type: "HTTP",
+ TenantID: "453105b9-1754-413f-aab1-55f1af620750",
+ Delay: 20,
+ Timeout: 10,
+ MaxRetries: 5,
+ URLPath: "/check",
+ ExpectedCodes: "200-299",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Type: TypeHTTP})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/health_monitors/f3eeab00-8367-4524-b662-55e64d4cacb5", 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, `
+{
+ "health_monitor":{
+ "id":"f3eeab00-8367-4524-b662-55e64d4cacb5",
+ "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+ "type":"HTTP",
+ "delay":20,
+ "timeout":10,
+ "max_retries":5,
+ "http_method":"GET",
+ "url_path":"/check",
+ "expected_codes":"200-299",
+ "admin_state_up":true,
+ "status":"ACTIVE"
+ }
+}
+ `)
+ })
+
+ hm, err := Get(fake.ServiceClient(), "f3eeab00-8367-4524-b662-55e64d4cacb5").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "f3eeab00-8367-4524-b662-55e64d4cacb5", hm.ID)
+ th.AssertEquals(t, "453105b9-1754-413f-aab1-55f1af620750", hm.TenantID)
+ th.AssertEquals(t, "HTTP", hm.Type)
+ th.AssertEquals(t, 20, hm.Delay)
+ th.AssertEquals(t, 10, hm.Timeout)
+ th.AssertEquals(t, 5, hm.MaxRetries)
+ th.AssertEquals(t, "GET", hm.HTTPMethod)
+ th.AssertEquals(t, "/check", hm.URLPath)
+ th.AssertEquals(t, "200-299", hm.ExpectedCodes)
+ th.AssertEquals(t, true, hm.AdminStateUp)
+ th.AssertEquals(t, "ACTIVE", hm.Status)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/health_monitors/b05e44b5-81f9-4551-b474-711a722698f7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "health_monitor":{
+ "delay": 3,
+ "timeout": 20,
+ "max_retries": 10,
+ "url_path": "/another_check",
+ "expected_codes": "301"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "health_monitor": {
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "delay": 3,
+ "max_retries": 10,
+ "http_method": "GET",
+ "timeout": 20,
+ "pools": [
+ {
+ "status": "PENDING_CREATE",
+ "status_description": null,
+ "pool_id": "6e55751f-6ad4-4e53-b8d4-02e442cd21df"
+ }
+ ],
+ "type": "PING",
+ "id": "b05e44b5-81f9-4551-b474-711a722698f7"
+ }
+}
+ `)
+ })
+
+ _, err := Update(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7", UpdateOpts{
+ Delay: 3,
+ Timeout: 20,
+ MaxRetries: 10,
+ URLPath: "/another_check",
+ ExpectedCodes: "301",
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/health_monitors/b05e44b5-81f9-4551-b474-711a722698f7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/results.go b/openstack/networking/v2/extensions/lbaas/monitors/results.go
new file mode 100644
index 0000000..d595abd
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/results.go
@@ -0,0 +1,147 @@
+package monitors
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Monitor represents a load balancer health monitor. A health monitor is used
+// to determine whether or not back-end members of the VIP's pool are usable
+// for processing a request. A pool can have several health monitors associated
+// with it. There are different types of health monitors supported:
+//
+// PING: used to ping the members using ICMP.
+// TCP: used to connect to the members using TCP.
+// HTTP: used to send an HTTP request to the member.
+// HTTPS: used to send a secure HTTP request to the member.
+//
+// When a pool has several monitors associated with it, each member of the pool
+// is monitored by all these monitors. If any monitor declares the member as
+// unhealthy, then the member status is changed to INACTIVE and the member
+// won't participate in its pool's load balancing. In other words, ALL monitors
+// must declare the member to be healthy for it to stay ACTIVE.
+type Monitor struct {
+ // The unique ID for the VIP.
+ ID string
+
+ // Owner of the VIP. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+
+ // The type of probe sent by the load balancer to verify the member state,
+ // which is PING, TCP, HTTP, or HTTPS.
+ Type string
+
+ // The time, in seconds, between sending probes to members.
+ Delay int
+
+ // The maximum number of seconds for a monitor to wait for a connection to be
+ // established before it times out. This value must be less than the delay value.
+ Timeout int
+
+ // Number of allowed connection failures before changing the status of the
+ // member to INACTIVE. A valid value is from 1 to 10.
+ MaxRetries int `json:"max_retries" mapstructure:"max_retries"`
+
+ // The HTTP method that the monitor uses for requests.
+ HTTPMethod string `json:"http_method" mapstructure:"http_method"`
+
+ // The HTTP path of the request sent by the monitor to test the health of a
+ // member. Must be a string beginning with a forward slash (/).
+ URLPath string `json:"url_path" mapstructure:"url_path"`
+
+ // Expected HTTP codes for a passing HTTP(S) monitor.
+ ExpectedCodes string `json:"expected_codes" mapstructure:"expected_codes"`
+
+ // The administrative state of the health monitor, which is up (true) or down (false).
+ AdminStateUp bool `json:"admin_state_up" mapstructure:"admin_state_up"`
+
+ // The status of the health monitor. Indicates whether the health monitor is
+ // operational.
+ Status string
+}
+
+// MonitorPage is the page returned by a pager when traversing over a
+// collection of health monitors.
+type MonitorPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of monitors has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p MonitorPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"health_monitors_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (p MonitorPage) IsEmpty() (bool, error) {
+ is, err := ExtractMonitors(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractMonitors accepts a Page struct, specifically a MonitorPage struct,
+// and extracts the elements into a slice of Monitor structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractMonitors(page pagination.Page) ([]Monitor, error) {
+ var resp struct {
+ Monitors []Monitor `mapstructure:"health_monitors" json:"health_monitors"`
+ }
+
+ err := mapstructure.Decode(page.(MonitorPage).Body, &resp)
+
+ return resp.Monitors, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a monitor.
+func (r commonResult) Extract() (*Monitor, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Monitor *Monitor `json:"health_monitor" mapstructure:"health_monitor"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Monitor, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/urls.go b/openstack/networking/v2/extensions/lbaas/monitors/urls.go
new file mode 100644
index 0000000..46e84bb
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/urls.go
@@ -0,0 +1,16 @@
+package monitors
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "lb"
+ resourcePath = "health_monitors"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/requests.go b/openstack/networking/v2/extensions/lbaas/pools/requests.go
new file mode 100644
index 0000000..2bb0acc
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/requests.go
@@ -0,0 +1,181 @@
+package pools
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Status string `q:"status"`
+ LBMethod string `q:"lb_method"`
+ Protocol string `q:"protocol"`
+ SubnetID string `q:"subnet_id"`
+ TenantID string `q:"tenant_id"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ Name string `q:"name"`
+ ID string `q:"id"`
+ VIPID string `q:"vip_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// pools. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those pools that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return PoolPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Supported attributes for create/update operations.
+const (
+ LBMethodRoundRobin = "ROUND_ROBIN"
+ LBMethodLeastConnections = "LEAST_CONNECTIONS"
+
+ ProtocolTCP = "TCP"
+ ProtocolHTTP = "HTTP"
+ ProtocolHTTPS = "HTTPS"
+)
+
+// CreateOpts contains all the values needed to create a new pool.
+type CreateOpts struct {
+ // Only required if the caller has an admin role and wants to create a pool
+ // for another tenant.
+ TenantID string
+
+ // Required. Name of the pool.
+ Name string
+
+ // Required. The protocol used by the pool members, you can use either
+ // ProtocolTCP, ProtocolHTTP, or ProtocolHTTPS.
+ Protocol string
+
+ // The network on which the members of the pool will be located. Only members
+ // that are on this network can be added to the pool.
+ SubnetID string
+
+ // The algorithm used to distribute load between the members of the pool. The
+ // current specification supports LBMethodRoundRobin and
+ // LBMethodLeastConnections as valid values for this attribute.
+ LBMethod string
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// load balancer pool.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ type pool struct {
+ Name string `json:"name"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Protocol string `json:"protocol"`
+ SubnetID string `json:"subnet_id"`
+ LBMethod string `json:"lb_method"`
+ }
+ type request struct {
+ Pool pool `json:"pool"`
+ }
+
+ reqBody := request{Pool: pool{
+ Name: opts.Name,
+ TenantID: opts.TenantID,
+ Protocol: opts.Protocol,
+ SubnetID: opts.SubnetID,
+ LBMethod: opts.LBMethod,
+ }}
+
+ var res CreateResult
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular pool based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains the values used when updating a pool.
+type UpdateOpts struct {
+ // Required. Name of the pool.
+ Name string
+
+ // The algorithm used to distribute load between the members of the pool. The
+ // current specification supports LBMethodRoundRobin and
+ // LBMethodLeastConnections as valid values for this attribute.
+ LBMethod string
+}
+
+// Update allows pools to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ type pool struct {
+ Name string `json:"name,"`
+ LBMethod string `json:"lb_method"`
+ }
+ type request struct {
+ Pool pool `json:"pool"`
+ }
+
+ reqBody := request{Pool: pool{
+ Name: opts.Name,
+ LBMethod: opts.LBMethod,
+ }}
+
+ // Send request to API
+ var res UpdateResult
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Delete will permanently delete a particular pool based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
+
+// AssociateMonitor will associate a health monitor with a particular pool.
+// Once associated, the health monitor will start monitoring the members of the
+// pool and will deactivate these members if they are deemed unhealthy. A
+// member can be deactivated (status set to INACTIVE) if any of health monitors
+// finds it unhealthy.
+func AssociateMonitor(c *gophercloud.ServiceClient, poolID, monitorID string) AssociateResult {
+ type hm struct {
+ ID string `json:"id"`
+ }
+ type request struct {
+ Monitor hm `json:"health_monitor"`
+ }
+
+ reqBody := request{hm{ID: monitorID}}
+
+ var res AssociateResult
+ _, res.Err = c.Post(associateURL(c, poolID), reqBody, &res.Body, nil)
+ return res
+}
+
+// DisassociateMonitor will disassociate a health monitor with a particular
+// pool. When dissociation is successful, the health monitor will no longer
+// check for the health of the members of the pool.
+func DisassociateMonitor(c *gophercloud.ServiceClient, poolID, monitorID string) AssociateResult {
+ var res AssociateResult
+ _, res.Err = c.Delete(disassociateURL(c, poolID, monitorID), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/requests_test.go b/openstack/networking/v2/extensions/lbaas/pools/requests_test.go
new file mode 100644
index 0000000..3b5c7c7
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/requests_test.go
@@ -0,0 +1,318 @@
+package pools
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/lb/pools", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools", 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, `
+{
+ "pools":[
+ {
+ "status":"ACTIVE",
+ "lb_method":"ROUND_ROBIN",
+ "protocol":"HTTP",
+ "description":"",
+ "health_monitors":[
+ "466c8345-28d8-4f84-a246-e04380b0461d",
+ "5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+ ],
+ "members":[
+ "701b531b-111a-4f21-ad85-4795b7b12af6",
+ "beb53b4d-230b-4abd-8118-575b8fa006ef"
+ ],
+ "status_description": null,
+ "id":"72741b06-df4d-4715-b142-276b6bce75ab",
+ "vip_id":"4ec89087-d057-4e2c-911f-60a3b47ee304",
+ "name":"app_pool",
+ "admin_state_up":true,
+ "subnet_id":"8032909d-47a1-4715-90af-5153ffe39861",
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "health_monitors_status": [],
+ "provider": "haproxy"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPools(page)
+ if err != nil {
+ t.Errorf("Failed to extract pools: %v", err)
+ return false, err
+ }
+
+ expected := []Pool{
+ Pool{
+ Status: "ACTIVE",
+ LBMethod: "ROUND_ROBIN",
+ Protocol: "HTTP",
+ Description: "",
+ MonitorIDs: []string{
+ "466c8345-28d8-4f84-a246-e04380b0461d",
+ "5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+ },
+ SubnetID: "8032909d-47a1-4715-90af-5153ffe39861",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ AdminStateUp: true,
+ Name: "app_pool",
+ MemberIDs: []string{
+ "701b531b-111a-4f21-ad85-4795b7b12af6",
+ "beb53b4d-230b-4abd-8118-575b8fa006ef",
+ },
+ ID: "72741b06-df4d-4715-b142-276b6bce75ab",
+ VIPID: "4ec89087-d057-4e2c-911f-60a3b47ee304",
+ Provider: "haproxy",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "pool": {
+ "lb_method": "ROUND_ROBIN",
+ "protocol": "HTTP",
+ "name": "Example pool",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "pool": {
+ "status": "PENDING_CREATE",
+ "lb_method": "ROUND_ROBIN",
+ "protocol": "HTTP",
+ "description": "",
+ "health_monitors": [],
+ "members": [],
+ "status_description": null,
+ "id": "69055154-f603-4a28-8951-7cc2d9e54a9a",
+ "vip_id": null,
+ "name": "Example pool",
+ "admin_state_up": true,
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "health_monitors_status": []
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{
+ LBMethod: LBMethodRoundRobin,
+ Protocol: "HTTP",
+ Name: "Example pool",
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ }
+ p, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "PENDING_CREATE", p.Status)
+ th.AssertEquals(t, "ROUND_ROBIN", p.LBMethod)
+ th.AssertEquals(t, "HTTP", p.Protocol)
+ th.AssertEquals(t, "", p.Description)
+ th.AssertDeepEquals(t, []string{}, p.MonitorIDs)
+ th.AssertDeepEquals(t, []string{}, p.MemberIDs)
+ th.AssertEquals(t, "69055154-f603-4a28-8951-7cc2d9e54a9a", p.ID)
+ th.AssertEquals(t, "Example pool", p.Name)
+ th.AssertEquals(t, "1981f108-3c48-48d2-b908-30f7d28532c9", p.SubnetID)
+ th.AssertEquals(t, "2ffc6e22aae24e4795f87155d24c896f", p.TenantID)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", 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, `
+{
+ "pool":{
+ "id":"332abe93-f488-41ba-870b-2ac66be7f853",
+ "tenant_id":"19eaa775-cf5d-49bc-902e-2f85f668d995",
+ "name":"Example pool",
+ "description":"",
+ "protocol":"tcp",
+ "lb_algorithm":"ROUND_ROBIN",
+ "session_persistence":{
+ },
+ "healthmonitor_id":null,
+ "members":[
+ ],
+ "admin_state_up":true,
+ "status":"ACTIVE"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.ID, "332abe93-f488-41ba-870b-2ac66be7f853")
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "pool":{
+ "name":"SuperPool",
+ "lb_method": "LEAST_CONNECTIONS"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "pool":{
+ "status":"PENDING_UPDATE",
+ "lb_method":"LEAST_CONNECTIONS",
+ "protocol":"TCP",
+ "description":"",
+ "health_monitors":[
+
+ ],
+ "subnet_id":"8032909d-47a1-4715-90af-5153ffe39861",
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "admin_state_up":true,
+ "name":"SuperPool",
+ "members":[
+
+ ],
+ "id":"61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ "vip_id":null
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{Name: "SuperPool", LBMethod: LBMethodLeastConnections}
+
+ n, err := Update(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "SuperPool", n.Name)
+ th.AssertDeepEquals(t, "LEAST_CONNECTIONS", n.LBMethod)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestAssociateHealthMonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853/health_monitors", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "health_monitor":{
+ "id":"b624decf-d5d3-4c66-9a3d-f047e7786181"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{}`)
+ })
+
+ _, err := AssociateMonitor(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", "b624decf-d5d3-4c66-9a3d-f047e7786181").Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisassociateHealthMonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853/health_monitors/b624decf-d5d3-4c66-9a3d-f047e7786181", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := DisassociateMonitor(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", "b624decf-d5d3-4c66-9a3d-f047e7786181")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/results.go b/openstack/networking/v2/extensions/lbaas/pools/results.go
new file mode 100644
index 0000000..07ec85e
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/results.go
@@ -0,0 +1,146 @@
+package pools
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Pool represents a logical set of devices, such as web servers, that you
+// group together to receive and process traffic. The load balancing function
+// chooses a member of the pool according to the configured load balancing
+// method to handle the new requests or connections received on the VIP address.
+// There is only one pool per virtual IP.
+type Pool struct {
+ // The status of the pool. Indicates whether the pool is operational.
+ Status string
+
+ // The load-balancer algorithm, which is round-robin, least-connections, and
+ // so on. This value, which must be supported, is dependent on the provider.
+ // Round-robin must be supported.
+ LBMethod string `json:"lb_method" mapstructure:"lb_method"`
+
+ // The protocol of the pool, which is TCP, HTTP, or HTTPS.
+ Protocol string
+
+ // Description for the pool.
+ Description string
+
+ // The IDs of associated monitors which check the health of the pool members.
+ MonitorIDs []string `json:"health_monitors" mapstructure:"health_monitors"`
+
+ // The network on which the members of the pool will be located. Only members
+ // that are on this network can be added to the pool.
+ SubnetID string `json:"subnet_id" mapstructure:"subnet_id"`
+
+ // Owner of the pool. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+
+ // The administrative state of the pool, which is up (true) or down (false).
+ AdminStateUp bool `json:"admin_state_up" mapstructure:"admin_state_up"`
+
+ // Pool name. Does not have to be unique.
+ Name string
+
+ // List of member IDs that belong to the pool.
+ MemberIDs []string `json:"members" mapstructure:"members"`
+
+ // The unique ID for the pool.
+ ID string
+
+ // The ID of the virtual IP associated with this pool
+ VIPID string `json:"vip_id" mapstructure:"vip_id"`
+
+ // The provider
+ Provider string
+}
+
+// PoolPage is the page returned by a pager when traversing over a
+// collection of pools.
+type PoolPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of pools has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p PoolPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"pools_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (p PoolPage) IsEmpty() (bool, error) {
+ is, err := ExtractPools(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractPools accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPools(page pagination.Page) ([]Pool, error) {
+ var resp struct {
+ Pools []Pool `mapstructure:"pools" json:"pools"`
+ }
+
+ err := mapstructure.Decode(page.(PoolPage).Body, &resp)
+
+ return resp.Pools, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Pool, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Pool *Pool `json:"pool"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Pool, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// AssociateResult represents the result of an association operation.
+type AssociateResult struct {
+ commonResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/urls.go b/openstack/networking/v2/extensions/lbaas/pools/urls.go
new file mode 100644
index 0000000..6cd15b0
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/urls.go
@@ -0,0 +1,25 @@
+package pools
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "lb"
+ resourcePath = "pools"
+ monitorPath = "health_monitors"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
+
+func associateURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id, monitorPath)
+}
+
+func disassociateURL(c *gophercloud.ServiceClient, poolID, monitorID string) string {
+ return c.ServiceURL(rootPath, resourcePath, poolID, monitorPath, monitorID)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/requests.go b/openstack/networking/v2/extensions/lbaas/vips/requests.go
new file mode 100644
index 0000000..6216f87
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/requests.go
@@ -0,0 +1,256 @@
+package vips
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ ID string `q:"id"`
+ Name string `q:"name"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ Status string `q:"status"`
+ TenantID string `q:"tenant_id"`
+ SubnetID string `q:"subnet_id"`
+ Address string `q:"address"`
+ PortID string `q:"port_id"`
+ Protocol string `q:"protocol"`
+ ProtocolPort int `q:"protocol_port"`
+ ConnectionLimit int `q:"connection_limit"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return VIPPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+var (
+ errNameRequired = fmt.Errorf("Name is required")
+ errSubnetIDRequried = fmt.Errorf("SubnetID is required")
+ errProtocolRequired = fmt.Errorf("Protocol is required")
+ errProtocolPortRequired = fmt.Errorf("Protocol port is required")
+ errPoolIDRequired = fmt.Errorf("PoolID is required")
+)
+
+// CreateOpts contains all the values needed to create a new virtual IP.
+type CreateOpts struct {
+ // Required. Human-readable name for the VIP. Does not have to be unique.
+ Name string
+
+ // Required. The network on which to allocate the VIP's address. A tenant can
+ // only create VIPs on networks authorized by policy (e.g. networks that
+ // belong to them or networks that are shared).
+ SubnetID string
+
+ // Required. The protocol - can either be TCP, HTTP or HTTPS.
+ Protocol string
+
+ // Required. The port on which to listen for client traffic.
+ ProtocolPort int
+
+ // Required. The ID of the pool with which the VIP is associated.
+ PoolID string
+
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string
+
+ // Optional. The IP address of the VIP.
+ Address string
+
+ // Optional. Human-readable description for the VIP.
+ Description string
+
+ // Optional. Omit this field to prevent session persistence.
+ Persistence *SessionPersistence
+
+ // Optional. The maximum number of connections allowed for the VIP.
+ ConnLimit *int
+
+ // Optional. The administrative state of the VIP. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool
+}
+
+// Create is an operation which provisions a new virtual IP based on the
+// configuration defined in the CreateOpts struct. Once the request is
+// validated and progress has started on the provisioning process, a
+// CreateResult will be returned.
+//
+// Please note that the PoolID should refer to a pool that is not already
+// associated with another vip. If the pool is already used by another vip,
+// then the operation will fail with a 409 Conflict error will be returned.
+//
+// Users with an admin role can create VIPs on behalf of other tenants by
+// specifying a TenantID attribute different than their own.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ var res CreateResult
+
+ // Validate required opts
+ if opts.Name == "" {
+ res.Err = errNameRequired
+ return res
+ }
+ if opts.SubnetID == "" {
+ res.Err = errSubnetIDRequried
+ return res
+ }
+ if opts.Protocol == "" {
+ res.Err = errProtocolRequired
+ return res
+ }
+ if opts.ProtocolPort == 0 {
+ res.Err = errProtocolPortRequired
+ return res
+ }
+ if opts.PoolID == "" {
+ res.Err = errPoolIDRequired
+ return res
+ }
+
+ type vip struct {
+ Name string `json:"name"`
+ SubnetID string `json:"subnet_id"`
+ Protocol string `json:"protocol"`
+ ProtocolPort int `json:"protocol_port"`
+ PoolID string `json:"pool_id"`
+ Description *string `json:"description,omitempty"`
+ TenantID *string `json:"tenant_id,omitempty"`
+ Address *string `json:"address,omitempty"`
+ Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ }
+
+ type request struct {
+ VirtualIP vip `json:"vip"`
+ }
+
+ reqBody := request{VirtualIP: vip{
+ Name: opts.Name,
+ SubnetID: opts.SubnetID,
+ Protocol: opts.Protocol,
+ ProtocolPort: opts.ProtocolPort,
+ PoolID: opts.PoolID,
+ Description: gophercloud.MaybeString(opts.Description),
+ TenantID: gophercloud.MaybeString(opts.TenantID),
+ Address: gophercloud.MaybeString(opts.Address),
+ ConnLimit: opts.ConnLimit,
+ AdminStateUp: opts.AdminStateUp,
+ }}
+
+ if opts.Persistence != nil {
+ reqBody.VirtualIP.Persistence = opts.Persistence
+ }
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular virtual IP based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// UpdateOpts contains all the values needed to update an existing virtual IP.
+// Attributes not listed here but appear in CreateOpts are immutable and cannot
+// be updated.
+type UpdateOpts struct {
+ // Human-readable name for the VIP. Does not have to be unique.
+ Name string
+
+ // Required. The ID of the pool with which the VIP is associated.
+ PoolID string
+
+ // Optional. Human-readable description for the VIP.
+ Description string
+
+ // Optional. Omit this field to prevent session persistence.
+ Persistence *SessionPersistence
+
+ // Optional. The maximum number of connections allowed for the VIP.
+ ConnLimit *int
+
+ // Optional. The administrative state of the VIP. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool
+}
+
+// Update is an operation which modifies the attributes of the specified VIP.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) UpdateResult {
+ type vip struct {
+ Name string `json:"name,omitempty"`
+ PoolID string `json:"pool_id,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ }
+
+ type request struct {
+ VirtualIP vip `json:"vip"`
+ }
+
+ reqBody := request{VirtualIP: vip{
+ Name: opts.Name,
+ PoolID: opts.PoolID,
+ Description: gophercloud.MaybeString(opts.Description),
+ ConnLimit: opts.ConnLimit,
+ AdminStateUp: opts.AdminStateUp,
+ }}
+
+ if opts.Persistence != nil {
+ reqBody.VirtualIP.Persistence = opts.Persistence
+ }
+
+ var res UpdateResult
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a particular virtual IP based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/requests_test.go b/openstack/networking/v2/extensions/lbaas/vips/requests_test.go
new file mode 100644
index 0000000..430f1a1
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/requests_test.go
@@ -0,0 +1,336 @@
+package vips
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/lb/vips", rootURL(fake.ServiceClient()))
+ th.AssertEquals(t, th.Endpoint()+"v2.0/lb/vips/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/vips", 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, `
+{
+ "vips":[
+ {
+ "id": "db902c0c-d5ff-4753-b465-668ad9656918",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "web_vip",
+ "description": "lb config for the web tier",
+ "subnet_id": "96a4386a-f8c3-42ed-afce-d7954eee77b3",
+ "address" : "10.30.176.47",
+ "port_id" : "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+ "protocol": "HTTP",
+ "protocol_port": 80,
+ "pool_id" : "cfc6589d-f949-4c66-99d2-c2da56ef3764",
+ "admin_state_up": true,
+ "status": "ACTIVE"
+ },
+ {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "db_vip",
+ "description": "lb config for the db tier",
+ "subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ "address" : "10.30.176.48",
+ "port_id" : "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+ "protocol": "TCP",
+ "protocol_port": 3306,
+ "pool_id" : "41efe233-7591-43c5-9cf7-923964759f9e",
+ "session_persistence" : {"type" : "SOURCE_IP"},
+ "connection_limit" : 2000,
+ "admin_state_up": true,
+ "status": "INACTIVE"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVIPs(page)
+ if err != nil {
+ t.Errorf("Failed to extract LBs: %v", err)
+ return false, err
+ }
+
+ expected := []VirtualIP{
+ VirtualIP{
+ ID: "db902c0c-d5ff-4753-b465-668ad9656918",
+ TenantID: "310df60f-2a10-4ee5-9554-98393092194c",
+ Name: "web_vip",
+ Description: "lb config for the web tier",
+ SubnetID: "96a4386a-f8c3-42ed-afce-d7954eee77b3",
+ Address: "10.30.176.47",
+ PortID: "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+ Protocol: "HTTP",
+ ProtocolPort: 80,
+ PoolID: "cfc6589d-f949-4c66-99d2-c2da56ef3764",
+ Persistence: SessionPersistence{},
+ ConnLimit: 0,
+ AdminStateUp: true,
+ Status: "ACTIVE",
+ },
+ VirtualIP{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ TenantID: "310df60f-2a10-4ee5-9554-98393092194c",
+ Name: "db_vip",
+ Description: "lb config for the db tier",
+ SubnetID: "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ Address: "10.30.176.48",
+ PortID: "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+ Protocol: "TCP",
+ ProtocolPort: 3306,
+ PoolID: "41efe233-7591-43c5-9cf7-923964759f9e",
+ Persistence: SessionPersistence{Type: "SOURCE_IP"},
+ ConnLimit: 2000,
+ AdminStateUp: true,
+ Status: "INACTIVE",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/vips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "vip": {
+ "protocol": "HTTP",
+ "name": "NewVip",
+ "admin_state_up": true,
+ "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+ "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ "protocol_port": 80,
+ "session_persistence": {"type": "SOURCE_IP"}
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "vip": {
+ "status": "PENDING_CREATE",
+ "protocol": "HTTP",
+ "description": "",
+ "admin_state_up": true,
+ "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+ "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+ "connection_limit": -1,
+ "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ "address": "10.0.0.11",
+ "protocol_port": 80,
+ "port_id": "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5",
+ "id": "c987d2be-9a3c-4ac9-a046-e8716b1350e2",
+ "name": "NewVip"
+ }
+}
+ `)
+ })
+
+ opts := CreateOpts{
+ Protocol: "HTTP",
+ Name: "NewVip",
+ AdminStateUp: Up,
+ SubnetID: "8032909d-47a1-4715-90af-5153ffe39861",
+ PoolID: "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ ProtocolPort: 80,
+ Persistence: &SessionPersistence{Type: "SOURCE_IP"},
+ }
+
+ r, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "PENDING_CREATE", r.Status)
+ th.AssertEquals(t, "HTTP", r.Protocol)
+ th.AssertEquals(t, "", r.Description)
+ th.AssertEquals(t, true, r.AdminStateUp)
+ th.AssertEquals(t, "8032909d-47a1-4715-90af-5153ffe39861", r.SubnetID)
+ th.AssertEquals(t, "83657cfcdfe44cd5920adaf26c48ceea", r.TenantID)
+ th.AssertEquals(t, -1, r.ConnLimit)
+ th.AssertEquals(t, "61b1f87a-7a21-4ad3-9dda-7f81d249944f", r.PoolID)
+ th.AssertEquals(t, "10.0.0.11", r.Address)
+ th.AssertEquals(t, 80, r.ProtocolPort)
+ th.AssertEquals(t, "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5", r.PortID)
+ th.AssertEquals(t, "c987d2be-9a3c-4ac9-a046-e8716b1350e2", r.ID)
+ th.AssertEquals(t, "NewVip", r.Name)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Name: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar", Protocol: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar", Protocol: "bar", ProtocolPort: 80})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", 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, `
+{
+ "vip": {
+ "status": "ACTIVE",
+ "protocol": "HTTP",
+ "description": "",
+ "admin_state_up": true,
+ "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+ "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+ "connection_limit": 1000,
+ "pool_id": "72741b06-df4d-4715-b142-276b6bce75ab",
+ "session_persistence": {
+ "cookie_name": "MyAppCookie",
+ "type": "APP_COOKIE"
+ },
+ "address": "10.0.0.10",
+ "protocol_port": 80,
+ "port_id": "b5a743d6-056b-468b-862d-fb13a9aa694e",
+ "id": "4ec89087-d057-4e2c-911f-60a3b47ee304",
+ "name": "my-vip"
+ }
+}
+ `)
+ })
+
+ vip, err := Get(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "ACTIVE", vip.Status)
+ th.AssertEquals(t, "HTTP", vip.Protocol)
+ th.AssertEquals(t, "", vip.Description)
+ th.AssertEquals(t, true, vip.AdminStateUp)
+ th.AssertEquals(t, 1000, vip.ConnLimit)
+ th.AssertEquals(t, SessionPersistence{Type: "APP_COOKIE", CookieName: "MyAppCookie"}, vip.Persistence)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "vip": {
+ "connection_limit": 1000,
+ "session_persistence": {"type": "SOURCE_IP"}
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "vip": {
+ "status": "PENDING_UPDATE",
+ "protocol": "HTTP",
+ "description": "",
+ "admin_state_up": true,
+ "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+ "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+ "connection_limit": 1000,
+ "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ "address": "10.0.0.11",
+ "protocol_port": 80,
+ "port_id": "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5",
+ "id": "c987d2be-9a3c-4ac9-a046-e8716b1350e2",
+ "name": "NewVip"
+ }
+}
+ `)
+ })
+
+ i1000 := 1000
+ options := UpdateOpts{
+ ConnLimit: &i1000,
+ Persistence: &SessionPersistence{Type: "SOURCE_IP"},
+ }
+ vip, err := Update(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "PENDING_UPDATE", vip.Status)
+ th.AssertEquals(t, 1000, vip.ConnLimit)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/results.go b/openstack/networking/v2/extensions/lbaas/vips/results.go
new file mode 100644
index 0000000..e1092e7
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/results.go
@@ -0,0 +1,166 @@
+package vips
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// SessionPersistence represents the session persistence feature of the load
+// balancing service. It attempts to force connections or requests in the same
+// session to be processed by the same member as long as it is ative. Three
+// types of persistence are supported:
+//
+// SOURCE_IP: With this mode, all connections originating from the same source
+// IP address, will be handled by the same member of the pool.
+// HTTP_COOKIE: With this persistence mode, the load balancing function will
+// create a cookie on the first request from a client. Subsequent
+// requests containing the same cookie value will be handled by
+// the same member of the pool.
+// APP_COOKIE: With this persistence mode, the load balancing function will
+// rely on a cookie established by the backend application. All
+// requests carrying the same cookie value will be handled by the
+// same member of the pool.
+type SessionPersistence struct {
+ // The type of persistence mode
+ Type string `mapstructure:"type" json:"type"`
+
+ // Name of cookie if persistence mode is set appropriately
+ CookieName string `mapstructure:"cookie_name" json:"cookie_name,omitempty"`
+}
+
+// VirtualIP is the primary load balancing configuration object that specifies
+// the virtual IP address and port on which client traffic is received, as well
+// as other details such as the load balancing method to be use, protocol, etc.
+// This entity is sometimes known in LB products under the name of a "virtual
+// server", a "vserver" or a "listener".
+type VirtualIP struct {
+ // The unique ID for the VIP.
+ ID string `mapstructure:"id" json:"id"`
+
+ // Owner of the VIP. Only an admin user can specify a tenant ID other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+
+ // Human-readable name for the VIP. Does not have to be unique.
+ Name string `mapstructure:"name" json:"name"`
+
+ // Human-readable description for the VIP.
+ Description string `mapstructure:"description" json:"description"`
+
+ // The ID of the subnet on which to allocate the VIP address.
+ SubnetID string `mapstructure:"subnet_id" json:"subnet_id"`
+
+ // The IP address of the VIP.
+ Address string `mapstructure:"address" json:"address"`
+
+ // The protocol of the VIP address. A valid value is TCP, HTTP, or HTTPS.
+ Protocol string `mapstructure:"protocol" json:"protocol"`
+
+ // The port on which to listen to client traffic that is associated with the
+ // VIP address. A valid value is from 0 to 65535.
+ ProtocolPort int `mapstructure:"protocol_port" json:"protocol_port"`
+
+ // The ID of the pool with which the VIP is associated.
+ PoolID string `mapstructure:"pool_id" json:"pool_id"`
+
+ // The ID of the port which belongs to the load balancer
+ PortID string `mapstructure:"port_id" json:"port_id"`
+
+ // Indicates whether connections in the same session will be processed by the
+ // same pool member or not.
+ Persistence SessionPersistence `mapstructure:"session_persistence" json:"session_persistence"`
+
+ // The maximum number of connections allowed for the VIP. Default is -1,
+ // meaning no limit.
+ ConnLimit int `mapstructure:"connection_limit" json:"connection_limit"`
+
+ // The administrative state of the VIP. A valid value is true (UP) or false (DOWN).
+ AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+
+ // The status of the VIP. Indicates whether the VIP is operational.
+ Status string `mapstructure:"status" json:"status"`
+}
+
+// VIPPage is the page returned by a pager when traversing over a
+// collection of routers.
+type VIPPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of routers has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p VIPPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"vips_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a RouterPage struct is empty.
+func (p VIPPage) IsEmpty() (bool, error) {
+ is, err := ExtractVIPs(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractVIPs accepts a Page struct, specifically a VIPPage struct,
+// and extracts the elements into a slice of VirtualIP structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractVIPs(page pagination.Page) ([]VirtualIP, error) {
+ var resp struct {
+ VIPs []VirtualIP `mapstructure:"vips" json:"vips"`
+ }
+
+ err := mapstructure.Decode(page.(VIPPage).Body, &resp)
+
+ return resp.VIPs, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*VirtualIP, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ VirtualIP *VirtualIP `mapstructure:"vip" json:"vip"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.VirtualIP, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/urls.go b/openstack/networking/v2/extensions/lbaas/vips/urls.go
new file mode 100644
index 0000000..2b6f67e
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/urls.go
@@ -0,0 +1,16 @@
+package vips
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ rootPath = "lb"
+ resourcePath = "vips"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/provider/doc.go b/openstack/networking/v2/extensions/provider/doc.go
new file mode 100755
index 0000000..373da44
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/doc.go
@@ -0,0 +1,21 @@
+// Package provider gives access to the provider Neutron plugin, allowing
+// network extended attributes. The provider extended attributes for networks
+// enable administrative users to specify how network objects map to the
+// underlying networking infrastructure. These extended attributes also appear
+// when administrative users query networks.
+//
+// For more information about extended attributes, see the NetworkExtAttrs
+// struct. The actual semantics of these attributes depend on the technology
+// back end of the particular plug-in. See the plug-in documentation and the
+// OpenStack Cloud Administrator Guide to understand which values should be
+// specific for each of these attributes when OpenStack Networking is deployed
+// with a particular plug-in. The examples shown in this chapter refer to the
+// Open vSwitch plug-in.
+//
+// The default policy settings enable only users with administrative rights to
+// specify these parameters in requests and to see their values in responses. By
+// default, the provider network extension attributes are completely hidden from
+// regular tenants. As a rule of thumb, if these attributes are not visible in a
+// GET /networks/<network-id> operation, this implies the user submitting the
+// request is not authorized to view or manipulate provider network attributes.
+package provider
diff --git a/openstack/networking/v2/extensions/provider/results.go b/openstack/networking/v2/extensions/provider/results.go
new file mode 100755
index 0000000..f07d628
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/results.go
@@ -0,0 +1,124 @@
+package provider
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+// NetworkExtAttrs represents an extended form of a Network with additional fields.
+type NetworkExtAttrs struct {
+ // UUID for the network
+ ID string `mapstructure:"id" json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `mapstructure:"name" json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+
+ // Indicates whether network is currently operational. Possible values include
+ // `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+ Status string `mapstructure:"status" json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `mapstructure:"subnets" json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `mapstructure:"shared" json:"shared"`
+
+ // Specifies the nature of the physical network mapped to this network
+ // resource. Examples are flat, vlan, or gre.
+ NetworkType string `json:"provider:network_type" mapstructure:"provider:network_type"`
+
+ // Identifies the physical network on top of which this network object is
+ // being implemented. The OpenStack Networking API does not expose any facility
+ // for retrieving the list of available physical networks. As an example, in
+ // the Open vSwitch plug-in this is a symbolic name which is then mapped to
+ // specific bridges on each compute host through the Open vSwitch plug-in
+ // configuration file.
+ PhysicalNetwork string `json:"provider:physical_network" mapstructure:"provider:physical_network"`
+
+ // Identifies an isolated segment on the physical network; the nature of the
+ // segment depends on the segmentation model defined by network_type. For
+ // instance, if network_type is vlan, then this is a vlan identifier;
+ // otherwise, if network_type is gre, then this will be a gre key.
+ SegmentationID string `json:"provider:segmentation_id" mapstructure:"provider:segmentation_id"`
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExtAttrs, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &res)
+
+ return res.Network, err
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExtAttrs, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &res)
+
+ return res.Network, err
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExtAttrs, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+
+ err := mapstructure.WeakDecode(r.Body, &res)
+
+ return res.Network, err
+}
+
+// ExtractList accepts a Page struct, specifically a NetworkPage struct, and
+// extracts the elements into a slice of NetworkExtAttrs structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractList(page pagination.Page) ([]NetworkExtAttrs, error) {
+ var resp struct {
+ Networks []NetworkExtAttrs `mapstructure:"networks" json:"networks"`
+ }
+
+ err := mapstructure.WeakDecode(page.(networks.NetworkPage).Body, &resp)
+
+ return resp.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/provider/results_test.go b/openstack/networking/v2/extensions/provider/results_test.go
new file mode 100644
index 0000000..8081692
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/results_test.go
@@ -0,0 +1,253 @@
+package provider
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks", 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, `
+{
+ "networks": [
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "provider:segmentation_id": null,
+ "provider:physical_network": null,
+ "provider:network_type": "local"
+ },
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "08eae331-0402-425a-923c-34f7cfe39c1b"
+ ],
+ "name": "private",
+ "admin_state_up": true,
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "shared": true,
+ "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ "provider:segmentation_id": 1234567890,
+ "provider:physical_network": null,
+ "provider:network_type": "local"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ networks.List(fake.ServiceClient(), networks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractList(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []NetworkExtAttrs{
+ NetworkExtAttrs{
+ Status: "ACTIVE",
+ Subnets: []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+ Name: "private-network",
+ AdminStateUp: true,
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ Shared: true,
+ ID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ NetworkType: "local",
+ PhysicalNetwork: "",
+ SegmentationID: "",
+ },
+ NetworkExtAttrs{
+ Status: "ACTIVE",
+ Subnets: []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+ Name: "private",
+ AdminStateUp: true,
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ Shared: true,
+ ID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ NetworkType: "local",
+ PhysicalNetwork: "",
+ SegmentationID: "1234567890",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "provider:physical_network": null,
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "provider:network_type": "local",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "provider:segmentation_id": null
+ }
+}
+ `)
+ })
+
+ res := networks.Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ n, err := ExtractGet(res)
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "", n.PhysicalNetwork)
+ th.AssertEquals(t, "local", n.NetworkType)
+ th.AssertEquals(t, "", n.SegmentationID)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "provider:physical_network": null,
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "provider:network_type": "local",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "provider:segmentation_id": null
+ }
+}
+ `)
+ })
+
+ options := networks.CreateOpts{Name: "sample_network", AdminStateUp: Up}
+ res := networks.Create(fake.ServiceClient(), options)
+ n, err := ExtractCreate(res)
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "", n.PhysicalNetwork)
+ th.AssertEquals(t, "local", n.NetworkType)
+ th.AssertEquals(t, "", n.SegmentationID)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "new_network_name",
+ "admin_state_up": false,
+ "shared": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "provider:physical_network": null,
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "provider:network_type": "local",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "provider:segmentation_id": null
+ }
+}
+ `)
+ })
+
+ iTrue := true
+ options := networks.UpdateOpts{Name: "new_network_name", AdminStateUp: Down, Shared: &iTrue}
+ res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+ n, err := ExtractUpdate(res)
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "", n.PhysicalNetwork)
+ th.AssertEquals(t, "local", n.NetworkType)
+ th.AssertEquals(t, "", n.SegmentationID)
+}
diff --git a/openstack/networking/v2/extensions/security/doc.go b/openstack/networking/v2/extensions/security/doc.go
new file mode 100644
index 0000000..31f744c
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/doc.go
@@ -0,0 +1,32 @@
+// Package security contains functionality to work with security group and
+// security group rules Neutron resources.
+//
+// Security groups and security group rules allows administrators and tenants
+// the ability to specify the type of traffic and direction (ingress/egress)
+// that is allowed to pass through a port. A security group is a container for
+// security group rules.
+//
+// When a port is created in Networking it is associated with a security group.
+// If a security group is not specified the port is associated with a 'default'
+// security group. By default, this group drops all ingress traffic and allows
+// all egress. Rules can be added to this group in order to change the behaviour.
+//
+// The basic characteristics of Neutron Security Groups are:
+//
+// For ingress traffic (to an instance)
+// - Only traffic matched with security group rules are allowed.
+// - When there is no rule defined, all traffic is dropped.
+//
+// For egress traffic (from an instance)
+// - Only traffic matched with security group rules are allowed.
+// - When there is no rule defined, all egress traffic are dropped.
+// - When a new security group is created, rules to allow all egress traffic
+// is automatically added.
+//
+// "default security group" is defined for each tenant.
+// - For the default security group a rule which allows intercommunication
+// among hosts associated with the default security group is defined by default.
+// - As a result, all egress traffic and intercommunication in the default
+// group are allowed and all ingress from outside of the default group is
+// dropped by default (in the default security group).
+package security
diff --git a/openstack/networking/v2/extensions/security/groups/requests.go b/openstack/networking/v2/extensions/security/groups/requests.go
new file mode 100644
index 0000000..2712ac1
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/requests.go
@@ -0,0 +1,131 @@
+package groups
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ ID string `q:"id"`
+ Name string `q:"name"`
+ TenantID string `q:"tenant_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// security groups. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return SecGroupPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+var (
+ errNameRequired = fmt.Errorf("Name is required")
+)
+
+// CreateOpts contains all the values needed to create a new security group.
+type CreateOpts struct {
+ // Required. Human-readable name for the VIP. Does not have to be unique.
+ Name string
+
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string
+
+ // Optional. Describes the security group.
+ Description string
+}
+
+// Create is an operation which provisions a new security group with default
+// security group rules for the IPv4 and IPv6 ether types.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ var res CreateResult
+
+ // Validate required opts
+ if opts.Name == "" {
+ res.Err = errNameRequired
+ return res
+ }
+
+ type secgroup struct {
+ Name string `json:"name"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Description string `json:"description,omitempty"`
+ }
+
+ type request struct {
+ SecGroup secgroup `json:"security_group"`
+ }
+
+ reqBody := request{SecGroup: secgroup{
+ Name: opts.Name,
+ TenantID: opts.TenantID,
+ Description: opts.Description,
+ }}
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular security group based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// Delete will permanently delete a particular security group based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
+
+// IDFromName is a convenience function that returns a security group's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ securityGroupCount := 0
+ securityGroupID := ""
+ if name == "" {
+ return "", fmt.Errorf("A security group name must be provided.")
+ }
+ pager := List(client, ListOpts{})
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ securityGroupList, err := ExtractGroups(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range securityGroupList {
+ if s.Name == name {
+ securityGroupCount++
+ securityGroupID = s.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch securityGroupCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find security group: %s", name)
+ case 1:
+ return securityGroupID, nil
+ default:
+ return "", fmt.Errorf("Found %d security groups matching %s", securityGroupCount, name)
+ }
+}
diff --git a/openstack/networking/v2/extensions/security/groups/requests_test.go b/openstack/networking/v2/extensions/security/groups/requests_test.go
new file mode 100644
index 0000000..5f074c7
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/requests_test.go
@@ -0,0 +1,213 @@
+package groups
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/security-groups", rootURL(fake.ServiceClient()))
+ th.AssertEquals(t, th.Endpoint()+"v2.0/security-groups/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups", 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_groups": [
+ {
+ "description": "default",
+ "id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "name": "default",
+ "security_group_rules": [],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract secgroups: %v", err)
+ return false, err
+ }
+
+ expected := []SecGroup{
+ SecGroup{
+ Description: "default",
+ ID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ Name: "default",
+ Rules: []rules.SecGroupRule{},
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "security_group": {
+ "name": "new-webservers",
+ "description": "security group for webservers"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "security_group": {
+ "description": "security group for webservers",
+ "id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "name": "new-webservers",
+ "security_group_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "38ce2d8e-e8f1-48bd-83c2-d33cb9f50c3d",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "565b9502-12de-4ffd-91e9-68885cff6ae1",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+}
+ `)
+ })
+
+ opts := CreateOpts{Name: "new-webservers", Description: "security group for webservers"}
+ _, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups/85cc3048-abc3-43cc-89b3-377341426ac5", 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": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "name": "default",
+ "security_group_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+}
+ `)
+ })
+
+ sg, err := Get(fake.ServiceClient(), "85cc3048-abc3-43cc-89b3-377341426ac5").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "default", sg.Description)
+ th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sg.ID)
+ th.AssertEquals(t, "default", sg.Name)
+ th.AssertEquals(t, 2, len(sg.Rules))
+ th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sg.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/security/groups/results.go b/openstack/networking/v2/extensions/security/groups/results.go
new file mode 100644
index 0000000..49db261
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/results.go
@@ -0,0 +1,108 @@
+package groups
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// SecGroup represents a container for security group rules.
+type SecGroup struct {
+ // The UUID for the security group.
+ ID string
+
+ // Human-readable name for the security group. Might not be unique. Cannot be
+ // named "default" as that is automatically created for a tenant.
+ Name string
+
+ // The security group description.
+ Description string
+
+ // A slice of security group rules that dictate the permitted behaviour for
+ // traffic entering and leaving the group.
+ Rules []rules.SecGroupRule `json:"security_group_rules" mapstructure:"security_group_rules"`
+
+ // Owner of the security group. Only admin users can specify a TenantID
+ // other than their own.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+}
+
+// SecGroupPage is the page returned by a pager when traversing over a
+// collection of security groups.
+type SecGroupPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of security groups has
+// reached the end of a page and the pager seeks to traverse over a new one. In
+// order to do this, it needs to construct the next page's URL.
+func (p SecGroupPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"security_groups_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a SecGroupPage struct is empty.
+func (p SecGroupPage) IsEmpty() (bool, error) {
+ is, err := ExtractGroups(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractGroups accepts a Page struct, specifically a SecGroupPage struct,
+// and extracts the elements into a slice of SecGroup structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractGroups(page pagination.Page) ([]SecGroup, error) {
+ var resp struct {
+ SecGroups []SecGroup `mapstructure:"security_groups" json:"security_groups"`
+ }
+
+ err := mapstructure.Decode(page.(SecGroupPage).Body, &resp)
+
+ return resp.SecGroups, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a security group.
+func (r commonResult) Extract() (*SecGroup, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ SecGroup *SecGroup `mapstructure:"security_group" json:"security_group"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.SecGroup, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/security/groups/urls.go b/openstack/networking/v2/extensions/security/groups/urls.go
new file mode 100644
index 0000000..84f7324
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/urls.go
@@ -0,0 +1,13 @@
+package groups
+
+import "github.com/rackspace/gophercloud"
+
+const rootPath = "security-groups"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, id)
+}
diff --git a/openstack/networking/v2/extensions/security/rules/requests.go b/openstack/networking/v2/extensions/security/rules/requests.go
new file mode 100644
index 0000000..e06934a
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/requests.go
@@ -0,0 +1,174 @@
+package rules
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the security group attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Direction string `q:"direction"`
+ EtherType string `q:"ethertype"`
+ ID string `q:"id"`
+ PortRangeMax int `q:"port_range_max"`
+ PortRangeMin int `q:"port_range_min"`
+ Protocol string `q:"protocol"`
+ RemoteGroupID string `q:"remote_group_id"`
+ RemoteIPPrefix string `q:"remote_ip_prefix"`
+ SecGroupID string `q:"security_group_id"`
+ TenantID string `q:"tenant_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// security group rules. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+ q, err := gophercloud.BuildQueryString(&opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u := rootURL(c) + q.String()
+ return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+ return SecGroupRulePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Errors
+var (
+ errValidDirectionRequired = fmt.Errorf("A valid Direction is required")
+ errValidEtherTypeRequired = fmt.Errorf("A valid EtherType is required")
+ errSecGroupIDRequired = fmt.Errorf("A valid SecGroupID is required")
+ errValidProtocolRequired = fmt.Errorf("A valid Protocol is required")
+)
+
+// Constants useful for CreateOpts
+const (
+ DirIngress = "ingress"
+ DirEgress = "egress"
+ Ether4 = "IPv4"
+ Ether6 = "IPv6"
+ ProtocolTCP = "tcp"
+ ProtocolUDP = "udp"
+ ProtocolICMP = "icmp"
+)
+
+// CreateOpts contains all the values needed to create a new security group rule.
+type CreateOpts struct {
+ // Required. Must be either "ingress" or "egress": the direction in which the
+ // security group rule is applied.
+ Direction string
+
+ // Required. Must be "IPv4" or "IPv6", and addresses represented in CIDR must
+ // match the ingress or egress rules.
+ EtherType string
+
+ // Required. The security group ID to associate with this security group rule.
+ SecGroupID string
+
+ // Optional. The maximum port number in the range that is matched by the
+ // security group rule. The PortRangeMin attribute constrains the PortRangeMax
+ // attribute. If the protocol is ICMP, this value must be an ICMP type.
+ PortRangeMax int
+
+ // Optional. The minimum port number in the range that is matched by the
+ // security group rule. If the protocol is TCP or UDP, this value must be
+ // less than or equal to the value of the PortRangeMax attribute. If the
+ // protocol is ICMP, this value must be an ICMP type.
+ PortRangeMin int
+
+ // Optional. The protocol that is matched by the security group rule. Valid
+ // values are "tcp", "udp", "icmp" or an empty string.
+ Protocol string
+
+ // Optional. The remote group ID to be associated with this security group
+ // rule. You can specify either RemoteGroupID or RemoteIPPrefix.
+ RemoteGroupID string
+
+ // Optional. The remote IP prefix to be associated with this security group
+ // rule. You can specify either RemoteGroupID or RemoteIPPrefix. This
+ // attribute matches the specified IP prefix as the source IP address of the
+ // IP packet.
+ RemoteIPPrefix string
+
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string
+}
+
+// Create is an operation which adds a new security group rule and associates it
+// with an existing security group (whose ID is specified in CreateOpts).
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) CreateResult {
+ var res CreateResult
+
+ // Validate required opts
+ if opts.Direction != DirIngress && opts.Direction != DirEgress {
+ res.Err = errValidDirectionRequired
+ return res
+ }
+ if opts.EtherType != Ether4 && opts.EtherType != Ether6 {
+ res.Err = errValidEtherTypeRequired
+ return res
+ }
+ if opts.SecGroupID == "" {
+ res.Err = errSecGroupIDRequired
+ return res
+ }
+ if opts.Protocol != "" && opts.Protocol != ProtocolTCP && opts.Protocol != ProtocolUDP && opts.Protocol != ProtocolICMP {
+ res.Err = errValidProtocolRequired
+ return res
+ }
+
+ type secrule struct {
+ Direction string `json:"direction"`
+ EtherType string `json:"ethertype"`
+ SecGroupID string `json:"security_group_id"`
+ PortRangeMax int `json:"port_range_max,omitempty"`
+ PortRangeMin int `json:"port_range_min,omitempty"`
+ Protocol string `json:"protocol,omitempty"`
+ RemoteGroupID string `json:"remote_group_id,omitempty"`
+ RemoteIPPrefix string `json:"remote_ip_prefix,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ }
+
+ type request struct {
+ SecRule secrule `json:"security_group_rule"`
+ }
+
+ reqBody := request{SecRule: secrule{
+ Direction: opts.Direction,
+ EtherType: opts.EtherType,
+ SecGroupID: opts.SecGroupID,
+ PortRangeMax: opts.PortRangeMax,
+ PortRangeMin: opts.PortRangeMin,
+ Protocol: opts.Protocol,
+ RemoteGroupID: opts.RemoteGroupID,
+ RemoteIPPrefix: opts.RemoteIPPrefix,
+ TenantID: opts.TenantID,
+ }}
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get retrieves a particular security group rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, nil)
+ return res
+}
+
+// Delete will permanently delete a particular security group rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
diff --git a/openstack/networking/v2/extensions/security/rules/requests_test.go b/openstack/networking/v2/extensions/security/rules/requests_test.go
new file mode 100644
index 0000000..b5afef3
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/requests_test.go
@@ -0,0 +1,243 @@
+package rules
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.AssertEquals(t, th.Endpoint()+"v2.0/security-group-rules", rootURL(fake.ServiceClient()))
+ th.AssertEquals(t, th.Endpoint()+"v2.0/security-group-rules/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules", 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_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract secrules: %v", err)
+ return false, err
+ }
+
+ expected := []SecGroupRule{
+ SecGroupRule{
+ Direction: "egress",
+ EtherType: "IPv6",
+ ID: "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ PortRangeMax: 0,
+ PortRangeMin: 0,
+ Protocol: "",
+ RemoteGroupID: "",
+ RemoteIPPrefix: "",
+ SecGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ SecGroupRule{
+ Direction: "egress",
+ EtherType: "IPv4",
+ ID: "93aa42e5-80db-4581-9391-3a608bd0e448",
+ PortRangeMax: 0,
+ PortRangeMin: 0,
+ Protocol: "",
+ RemoteGroupID: "",
+ RemoteIPPrefix: "",
+ SecGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/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.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "security_group_rule": {
+ "direction": "ingress",
+ "port_range_min": 80,
+ "ethertype": "IPv4",
+ "port_range_max": 80,
+ "protocol": "tcp",
+ "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "security_group_rule": {
+ "direction": "ingress",
+ "ethertype": "IPv4",
+ "id": "2bc0accf-312e-429a-956e-e4407625eb62",
+ "port_range_max": 80,
+ "port_range_min": 80,
+ "protocol": "tcp",
+ "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "remote_ip_prefix": null,
+ "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+}
+ `)
+ })
+
+ opts := CreateOpts{
+ Direction: "ingress",
+ PortRangeMin: 80,
+ EtherType: "IPv4",
+ PortRangeMax: 80,
+ Protocol: "tcp",
+ RemoteGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a",
+ }
+ _, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), CreateOpts{Direction: "something"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: "something"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: Ether4})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: Ether4, SecGroupID: "something", Protocol: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules/3c0e45ff-adaf-4124-b083-bf390e5482ff", 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_rule": {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+}
+ `)
+ })
+
+ sr, err := Get(fake.ServiceClient(), "3c0e45ff-adaf-4124-b083-bf390e5482ff").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "egress", sr.Direction)
+ th.AssertEquals(t, "IPv6", sr.EtherType)
+ th.AssertEquals(t, "3c0e45ff-adaf-4124-b083-bf390e5482ff", sr.ID)
+ th.AssertEquals(t, 0, sr.PortRangeMax)
+ th.AssertEquals(t, 0, sr.PortRangeMin)
+ th.AssertEquals(t, "", sr.Protocol)
+ th.AssertEquals(t, "", sr.RemoteGroupID)
+ th.AssertEquals(t, "", sr.RemoteIPPrefix)
+ th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sr.SecGroupID)
+ th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sr.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/security/rules/results.go b/openstack/networking/v2/extensions/security/rules/results.go
new file mode 100644
index 0000000..6e13857
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/results.go
@@ -0,0 +1,133 @@
+package rules
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// SecGroupRule represents a rule to dictate the behaviour of incoming or
+// outgoing traffic for a particular security group.
+type SecGroupRule struct {
+ // The UUID for this security group rule.
+ ID string
+
+ // The direction in which the security group rule is applied. The only values
+ // allowed are "ingress" or "egress". For a compute instance, an ingress
+ // security group rule is applied to incoming (ingress) traffic for that
+ // instance. An egress rule is applied to traffic leaving the instance.
+ Direction string
+
+ // Must be IPv4 or IPv6, and addresses represented in CIDR must match the
+ // ingress or egress rules.
+ EtherType string `json:"ethertype" mapstructure:"ethertype"`
+
+ // The security group ID to associate with this security group rule.
+ SecGroupID string `json:"security_group_id" mapstructure:"security_group_id"`
+
+ // The minimum port number in the range that is matched by the security group
+ // rule. If the protocol is TCP or UDP, this value must be less than or equal
+ // to the value of the PortRangeMax attribute. If the protocol is ICMP, this
+ // value must be an ICMP type.
+ PortRangeMin int `json:"port_range_min" mapstructure:"port_range_min"`
+
+ // The maximum port number in the range that is matched by the security group
+ // rule. The PortRangeMin attribute constrains the PortRangeMax attribute. If
+ // the protocol is ICMP, this value must be an ICMP type.
+ PortRangeMax int `json:"port_range_max" mapstructure:"port_range_max"`
+
+ // The protocol that is matched by the security group rule. Valid values are
+ // "tcp", "udp", "icmp" or an empty string.
+ Protocol string
+
+ // The remote group ID to be associated with this security group rule. You
+ // can specify either RemoteGroupID or RemoteIPPrefix.
+ RemoteGroupID string `json:"remote_group_id" mapstructure:"remote_group_id"`
+
+ // The remote IP prefix to be associated with this security group rule. You
+ // can specify either RemoteGroupID or RemoteIPPrefix . This attribute
+ // matches the specified IP prefix as the source IP address of the IP packet.
+ RemoteIPPrefix string `json:"remote_ip_prefix" mapstructure:"remote_ip_prefix"`
+
+ // The owner of this security group rule.
+ TenantID string `json:"tenant_id" mapstructure:"tenant_id"`
+}
+
+// SecGroupRulePage is the page returned by a pager when traversing over a
+// collection of security group rules.
+type SecGroupRulePage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of security group rules has
+// reached the end of a page and the pager seeks to traverse over a new one. In
+// order to do this, it needs to construct the next page's URL.
+func (p SecGroupRulePage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"security_group_rules_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a SecGroupRulePage struct is empty.
+func (p SecGroupRulePage) IsEmpty() (bool, error) {
+ is, err := ExtractRules(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractRules accepts a Page struct, specifically a SecGroupRulePage struct,
+// and extracts the elements into a slice of SecGroupRule structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRules(page pagination.Page) ([]SecGroupRule, error) {
+ var resp struct {
+ SecGroupRules []SecGroupRule `mapstructure:"security_group_rules" json:"security_group_rules"`
+ }
+
+ err := mapstructure.Decode(page.(SecGroupRulePage).Body, &resp)
+
+ return resp.SecGroupRules, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a security rule.
+func (r commonResult) Extract() (*SecGroupRule, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ SecGroupRule *SecGroupRule `mapstructure:"security_group_rule" json:"security_group_rule"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.SecGroupRule, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/security/rules/urls.go b/openstack/networking/v2/extensions/security/rules/urls.go
new file mode 100644
index 0000000..8e2b2bb
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/urls.go
@@ -0,0 +1,13 @@
+package rules
+
+import "github.com/rackspace/gophercloud"
+
+const rootPath = "security-group-rules"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(rootPath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, id)
+}
diff --git a/openstack/networking/v2/networks/doc.go b/openstack/networking/v2/networks/doc.go
new file mode 100644
index 0000000..c87a7ce
--- /dev/null
+++ b/openstack/networking/v2/networks/doc.go
@@ -0,0 +1,9 @@
+// Package networks contains functionality for working with Neutron network
+// resources. A network is an isolated virtual layer-2 broadcast domain that is
+// typically reserved for the tenant who created it (unless you configure the
+// network to be shared). Tenants can create multiple networks until the
+// thresholds per-tenant quota is reached.
+//
+// In the v2.0 Networking API, the network is the main entity. Ports and subnets
+// are always associated with a network.
+package networks
diff --git a/openstack/networking/v2/networks/errors.go b/openstack/networking/v2/networks/errors.go
new file mode 100644
index 0000000..83c4a6a
--- /dev/null
+++ b/openstack/networking/v2/networks/errors.go
@@ -0,0 +1 @@
+package networks
diff --git a/openstack/networking/v2/networks/requests.go b/openstack/networking/v2/networks/requests.go
new file mode 100644
index 0000000..25ab7a8
--- /dev/null
+++ b/openstack/networking/v2/networks/requests.go
@@ -0,0 +1,226 @@
+package networks
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+type networkOpts struct {
+ AdminStateUp *bool
+ Name string
+ Shared *bool
+ TenantID string
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToNetworkListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the network attributes you want to see returned. SortKey allows you to sort
+// by a particular network attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Status string `q:"status"`
+ Name string `q:"name"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ TenantID string `q:"tenant_id"`
+ Shared *bool `q:"shared"`
+ ID string `q:"id"`
+ Marker string `q:"marker"`
+ Limit int `q:"limit"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToNetworkListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToNetworkListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToNetworkListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get retrieves a specific network based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToNetworkCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts networkOpts
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+ n := make(map[string]interface{})
+
+ if opts.AdminStateUp != nil {
+ n["admin_state_up"] = &opts.AdminStateUp
+ }
+ if opts.Name != "" {
+ n["name"] = opts.Name
+ }
+ if opts.Shared != nil {
+ n["shared"] = &opts.Shared
+ }
+ if opts.TenantID != "" {
+ n["tenant_id"] = opts.TenantID
+ }
+
+ return map[string]interface{}{"network": n}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. This operation does not actually require a request body, i.e. the
+// CreateOpts struct argument can be empty.
+//
+// The tenant ID that is contained in the URI is the tenant that creates the
+// network. An admin user, however, has the option of specifying another tenant
+// ID in the CreateOpts struct.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToNetworkCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToNetworkUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts networkOpts
+
+// ToNetworkUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+ n := make(map[string]interface{})
+
+ if opts.AdminStateUp != nil {
+ n["admin_state_up"] = &opts.AdminStateUp
+ }
+ if opts.Name != "" {
+ n["name"] = opts.Name
+ }
+ if opts.Shared != nil {
+ n["shared"] = &opts.Shared
+ }
+
+ return map[string]interface{}{"network": n}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing network using the
+// values provided. For more information, see the Create function.
+func Update(c *gophercloud.ServiceClient, networkID string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToNetworkUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Put(updateURL(c, networkID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+
+ return res
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, networkID), nil)
+ return res
+}
+
+// IDFromName is a convenience function that returns a network's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ networkCount := 0
+ networkID := ""
+ if name == "" {
+ return "", fmt.Errorf("A network name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ networkList, err := ExtractNetworks(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, n := range networkList {
+ if n.Name == name {
+ networkCount++
+ networkID = n.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch networkCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find network: %s", name)
+ case 1:
+ return networkID, nil
+ default:
+ return "", fmt.Errorf("Found %d networks matching %s", networkCount, name)
+ }
+}
diff --git a/openstack/networking/v2/networks/requests_test.go b/openstack/networking/v2/networks/requests_test.go
new file mode 100644
index 0000000..81eb79c
--- /dev/null
+++ b/openstack/networking/v2/networks/requests_test.go
@@ -0,0 +1,276 @@
+package networks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks", 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, `
+{
+ "networks": [
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ },
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "08eae331-0402-425a-923c-34f7cfe39c1b"
+ ],
+ "name": "private",
+ "admin_state_up": true,
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "shared": true,
+ "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324"
+ }
+ ]
+}
+ `)
+ })
+
+ client := fake.ServiceClient()
+ count := 0
+
+ List(client, ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []Network{
+ Network{
+ Status: "ACTIVE",
+ Subnets: []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+ Name: "private-network",
+ AdminStateUp: true,
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ Shared: true,
+ ID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ },
+ Network{
+ Status: "ACTIVE",
+ Subnets: []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+ Name: "private",
+ AdminStateUp: true,
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ Shared: true,
+ ID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"})
+ th.AssertEquals(t, n.Name, "private-network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertEquals(t, n.Shared, true)
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [],
+ "name": "net1",
+ "admin_state_up": true,
+ "tenant_id": "9bacb3c5d39d41a79512987f338cf177",
+ "shared": false,
+ "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+ }
+}
+ `)
+ })
+
+ iTrue := true
+ options := CreateOpts{Name: "sample_network", AdminStateUp: &iTrue}
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{})
+ th.AssertEquals(t, n.Name, "net1")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.TenantID, "9bacb3c5d39d41a79512987f338cf177")
+ th.AssertEquals(t, n.Shared, false)
+ th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestCreateWithOptionalFields(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true,
+ "shared": true,
+ "tenant_id": "12345"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{}`)
+ })
+
+ iTrue := true
+ options := CreateOpts{Name: "sample_network", AdminStateUp: &iTrue, Shared: &iTrue, TenantID: "12345"}
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "new_network_name",
+ "admin_state_up": false,
+ "shared": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [],
+ "name": "new_network_name",
+ "admin_state_up": false,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+ }
+}
+ `)
+ })
+
+ iTrue, iFalse := true, false
+ options := UpdateOpts{Name: "new_network_name", AdminStateUp: &iFalse, Shared: &iTrue}
+ n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Name, "new_network_name")
+ th.AssertEquals(t, n.AdminStateUp, false)
+ th.AssertEquals(t, n.Shared, true)
+ th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/networks/results.go b/openstack/networking/v2/networks/results.go
new file mode 100644
index 0000000..3ecedde
--- /dev/null
+++ b/openstack/networking/v2/networks/results.go
@@ -0,0 +1,116 @@
+package networks
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a network resource.
+func (r commonResult) Extract() (*Network, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *Network `json:"network"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Network, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Network represents, well, a network.
+type Network struct {
+ // UUID for the network
+ ID string `mapstructure:"id" json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `mapstructure:"name" json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+
+ // Indicates whether network is currently operational. Possible values include
+ // `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+ Status string `mapstructure:"status" json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `mapstructure:"subnets" json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `mapstructure:"shared" json:"shared"`
+}
+
+// NetworkPage is the page returned by a pager when traversing over a
+// collection of networks.
+type NetworkPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of networks has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p NetworkPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"networks_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (p NetworkPage) IsEmpty() (bool, error) {
+ is, err := ExtractNetworks(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractNetworks accepts a Page struct, specifically a NetworkPage struct,
+// and extracts the elements into a slice of Network structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractNetworks(page pagination.Page) ([]Network, error) {
+ var resp struct {
+ Networks []Network `mapstructure:"networks" json:"networks"`
+ }
+
+ err := mapstructure.Decode(page.(NetworkPage).Body, &resp)
+
+ return resp.Networks, err
+}
diff --git a/openstack/networking/v2/networks/urls.go b/openstack/networking/v2/networks/urls.go
new file mode 100644
index 0000000..a9eecc5
--- /dev/null
+++ b/openstack/networking/v2/networks/urls.go
@@ -0,0 +1,31 @@
+package networks
+
+import "github.com/rackspace/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("networks", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("networks")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/networks/urls_test.go b/openstack/networking/v2/networks/urls_test.go
new file mode 100644
index 0000000..caf77db
--- /dev/null
+++ b/openstack/networking/v2/networks/urls_test.go
@@ -0,0 +1,38 @@
+package networks
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint, ResourceBase: endpoint + "v2.0/"}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/networks/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "v2.0/networks"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "v2.0/networks"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/networks/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/ports/doc.go b/openstack/networking/v2/ports/doc.go
new file mode 100644
index 0000000..f16a4bb
--- /dev/null
+++ b/openstack/networking/v2/ports/doc.go
@@ -0,0 +1,8 @@
+// Package ports contains functionality for working with Neutron port resources.
+// A port represents a virtual switch port on a logical network switch. Virtual
+// instances attach their interfaces into ports. The logical port also defines
+// the MAC address and the IP address(es) to be assigned to the interfaces
+// plugged into them. When IP addresses are associated to a port, this also
+// implies the port is associated with a subnet, as the IP address was taken
+// from the allocation pool for a specific subnet.
+package ports
diff --git a/openstack/networking/v2/ports/errors.go b/openstack/networking/v2/ports/errors.go
new file mode 100644
index 0000000..111d977
--- /dev/null
+++ b/openstack/networking/v2/ports/errors.go
@@ -0,0 +1,11 @@
+package ports
+
+import "fmt"
+
+func err(str string) error {
+ return fmt.Errorf("%s", str)
+}
+
+var (
+ errNetworkIDRequired = err("A Network ID is required")
+)
diff --git a/openstack/networking/v2/ports/requests.go b/openstack/networking/v2/ports/requests.go
new file mode 100644
index 0000000..2caf1ca
--- /dev/null
+++ b/openstack/networking/v2/ports/requests.go
@@ -0,0 +1,260 @@
+package ports
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToPortListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the port attributes you want to see returned. SortKey allows you to sort
+// by a particular port attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Status string `q:"status"`
+ Name string `q:"name"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ NetworkID string `q:"network_id"`
+ TenantID string `q:"tenant_id"`
+ DeviceOwner string `q:"device_owner"`
+ MACAddress string `q:"mac_address"`
+ ID string `q:"id"`
+ DeviceID string `q:"device_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToPortListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPortListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// ports. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those ports that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToPortListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return PortPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToPortCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts represents the attributes used when creating a new port.
+type CreateOpts struct {
+ NetworkID string
+ Name string
+ AdminStateUp *bool
+ MACAddress string
+ FixedIPs interface{}
+ DeviceID string
+ DeviceOwner string
+ TenantID string
+ SecurityGroups []string
+}
+
+// ToPortCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPortCreateMap() (map[string]interface{}, error) {
+ p := make(map[string]interface{})
+
+ if opts.NetworkID == "" {
+ return nil, errNetworkIDRequired
+ }
+ p["network_id"] = opts.NetworkID
+
+ if opts.DeviceID != "" {
+ p["device_id"] = opts.DeviceID
+ }
+ if opts.DeviceOwner != "" {
+ p["device_owner"] = opts.DeviceOwner
+ }
+ if opts.FixedIPs != nil {
+ p["fixed_ips"] = opts.FixedIPs
+ }
+ if opts.SecurityGroups != nil {
+ p["security_groups"] = opts.SecurityGroups
+ }
+ if opts.TenantID != "" {
+ p["tenant_id"] = opts.TenantID
+ }
+ if opts.AdminStateUp != nil {
+ p["admin_state_up"] = &opts.AdminStateUp
+ }
+ if opts.Name != "" {
+ p["name"] = opts.Name
+ }
+ if opts.MACAddress != "" {
+ p["mac_address"] = opts.MACAddress
+ }
+
+ return map[string]interface{}{"port": p}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. You must remember to provide a NetworkID value.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToPortCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToPortUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represents the attributes used when updating an existing port.
+type UpdateOpts struct {
+ Name string
+ AdminStateUp *bool
+ FixedIPs interface{}
+ DeviceID string
+ DeviceOwner string
+ SecurityGroups []string
+}
+
+// ToPortUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPortUpdateMap() (map[string]interface{}, error) {
+ p := make(map[string]interface{})
+
+ if opts.DeviceID != "" {
+ p["device_id"] = opts.DeviceID
+ }
+ if opts.DeviceOwner != "" {
+ p["device_owner"] = opts.DeviceOwner
+ }
+ if opts.FixedIPs != nil {
+ p["fixed_ips"] = opts.FixedIPs
+ }
+ if opts.SecurityGroups != nil {
+ p["security_groups"] = opts.SecurityGroups
+ }
+ if opts.AdminStateUp != nil {
+ p["admin_state_up"] = &opts.AdminStateUp
+ }
+ if opts.Name != "" {
+ p["name"] = opts.Name
+ }
+
+ return map[string]interface{}{"port": p}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToPortUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(updateURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return res
+}
+
+// Delete accepts a unique ID and deletes the port associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, id), nil)
+ return res
+}
+
+// IDFromName is a convenience function that returns a port's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ portCount := 0
+ portID := ""
+ if name == "" {
+ return "", fmt.Errorf("A port name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ portList, err := ExtractPorts(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, p := range portList {
+ if p.Name == name {
+ portCount++
+ portID = p.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch portCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find port: %s", name)
+ case 1:
+ return portID, nil
+ default:
+ return "", fmt.Errorf("Found %d ports matching %s", portCount, name)
+ }
+}
diff --git a/openstack/networking/v2/ports/requests_test.go b/openstack/networking/v2/ports/requests_test.go
new file mode 100644
index 0000000..9e323ef
--- /dev/null
+++ b/openstack/networking/v2/ports/requests_test.go
@@ -0,0 +1,321 @@
+package ports
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/ports", 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, `
+{
+ "ports": [
+ {
+ "status": "ACTIVE",
+ "binding:host_id": "devstack",
+ "name": "",
+ "admin_state_up": true,
+ "network_id": "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+ "tenant_id": "",
+ "device_owner": "network:router_gateway",
+ "mac_address": "fa:16:3e:58:42:ed",
+ "fixed_ips": [
+ {
+ "subnet_id": "008ba151-0b8c-4a67-98b5-0d2b87666062",
+ "ip_address": "172.24.4.2"
+ }
+ ],
+ "id": "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+ "security_groups": [],
+ "device_id": "9ae135f4-b6e0-4dad-9e91-3c223e385824"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPorts(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []Port{
+ Port{
+ Status: "ACTIVE",
+ Name: "",
+ AdminStateUp: true,
+ NetworkID: "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+ TenantID: "",
+ DeviceOwner: "network:router_gateway",
+ MACAddress: "fa:16:3e:58:42:ed",
+ FixedIPs: []IP{
+ IP{
+ SubnetID: "008ba151-0b8c-4a67-98b5-0d2b87666062",
+ IPAddress: "172.24.4.2",
+ },
+ },
+ ID: "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+ SecurityGroups: []string{},
+ DeviceID: "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/ports/46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2", 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, `
+{
+ "port": {
+ "status": "ACTIVE",
+ "name": "",
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "7e02058126cc4950b75f9970368ba177",
+ "device_owner": "network:router_interface",
+ "mac_address": "fa:16:3e:23:fd:d7",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.1"
+ }
+ ],
+ "id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
+ "security_groups": [],
+ "device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertEquals(t, n.Name, "")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+ th.AssertEquals(t, n.TenantID, "7e02058126cc4950b75f9970368ba177")
+ th.AssertEquals(t, n.DeviceOwner, "network:router_interface")
+ th.AssertEquals(t, n.MACAddress, "fa:16:3e:23:fd:d7")
+ th.AssertDeepEquals(t, n.FixedIPs, []IP{
+ IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.1"},
+ })
+ th.AssertEquals(t, n.ID, "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2")
+ th.AssertDeepEquals(t, n.SecurityGroups, []string{})
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertEquals(t, n.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "port": {
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "name": "private-port",
+ "admin_state_up": true,
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.2"
+ }
+ ],
+ "security_groups": ["foo"]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "port": {
+ "status": "DOWN",
+ "name": "private-port",
+ "allowed_address_pairs": [],
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+ "device_owner": "",
+ "mac_address": "fa:16:3e:c9:cb:f0",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.2"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ asu := true
+ options := CreateOpts{
+ Name: "private-port",
+ AdminStateUp: &asu,
+ NetworkID: "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ FixedIPs: []IP{
+ IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ },
+ SecurityGroups: []string{"foo"},
+ }
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "DOWN")
+ th.AssertEquals(t, n.Name, "private-port")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+ th.AssertEquals(t, n.TenantID, "d6700c0c9ffa4f1cb322cd4a1f3906fa")
+ th.AssertEquals(t, n.DeviceOwner, "")
+ th.AssertEquals(t, n.MACAddress, "fa:16:3e:c9:cb:f0")
+ th.AssertDeepEquals(t, n.FixedIPs, []IP{
+ IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ })
+ th.AssertEquals(t, n.ID, "65c0ee9f-d634-4522-8954-51021b570b0d")
+ th.AssertDeepEquals(t, n.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "port": {
+ "name": "new_port_name",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.3"
+ }
+ ],
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "port": {
+ "status": "DOWN",
+ "name": "new_port_name",
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+ "device_owner": "",
+ "mac_address": "fa:16:3e:c9:cb:f0",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.3"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ options := UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []IP{
+ IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+ }
+
+ s, err := Update(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "new_port_name")
+ th.AssertDeepEquals(t, s.FixedIPs, []IP{
+ IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/ports/results.go b/openstack/networking/v2/ports/results.go
new file mode 100644
index 0000000..2511ff5
--- /dev/null
+++ b/openstack/networking/v2/ports/results.go
@@ -0,0 +1,126 @@
+package ports
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a port resource.
+func (r commonResult) Extract() (*Port, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Port *Port `json:"port"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Port, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// IP is a sub-struct that represents an individual IP.
+type IP struct {
+ SubnetID string `mapstructure:"subnet_id" json:"subnet_id"`
+ IPAddress string `mapstructure:"ip_address" json:"ip_address,omitempty"`
+}
+
+// Port represents a Neutron port. See package documentation for a top-level
+// description of what this is.
+type Port struct {
+ // UUID for the port.
+ ID string `mapstructure:"id" json:"id"`
+ // Network that this port is associated with.
+ NetworkID string `mapstructure:"network_id" json:"network_id"`
+ // Human-readable name for the port. Might not be unique.
+ Name string `mapstructure:"name" json:"name"`
+ // Administrative state of port. If false (down), port does not forward packets.
+ AdminStateUp bool `mapstructure:"admin_state_up" json:"admin_state_up"`
+ // Indicates whether network is currently operational. Possible values include
+ // `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+ Status string `mapstructure:"status" json:"status"`
+ // Mac address to use on this port.
+ MACAddress string `mapstructure:"mac_address" json:"mac_address"`
+ // Specifies IP addresses for the port thus associating the port itself with
+ // the subnets where the IP addresses are picked from
+ FixedIPs []IP `mapstructure:"fixed_ips" json:"fixed_ips"`
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+ // Identifies the entity (e.g.: dhcp agent) using this port.
+ DeviceOwner string `mapstructure:"device_owner" json:"device_owner"`
+ // Specifies the IDs of any security groups associated with a port.
+ SecurityGroups []string `mapstructure:"security_groups" json:"security_groups"`
+ // Identifies the device (e.g., virtual server) using this port.
+ DeviceID string `mapstructure:"device_id" json:"device_id"`
+}
+
+// PortPage is the page returned by a pager when traversing over a collection
+// of network ports.
+type PortPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of ports has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p PortPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"ports_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a PortPage struct is empty.
+func (p PortPage) IsEmpty() (bool, error) {
+ is, err := ExtractPorts(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractPorts accepts a Page struct, specifically a PortPage struct,
+// and extracts the elements into a slice of Port structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPorts(page pagination.Page) ([]Port, error) {
+ var resp struct {
+ Ports []Port `mapstructure:"ports" json:"ports"`
+ }
+
+ err := mapstructure.Decode(page.(PortPage).Body, &resp)
+
+ return resp.Ports, err
+}
diff --git a/openstack/networking/v2/ports/urls.go b/openstack/networking/v2/ports/urls.go
new file mode 100644
index 0000000..6d0572f
--- /dev/null
+++ b/openstack/networking/v2/ports/urls.go
@@ -0,0 +1,31 @@
+package ports
+
+import "github.com/rackspace/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("ports", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("ports")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/ports/urls_test.go b/openstack/networking/v2/ports/urls_test.go
new file mode 100644
index 0000000..7fadd4d
--- /dev/null
+++ b/openstack/networking/v2/ports/urls_test.go
@@ -0,0 +1,44 @@
+package ports
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint, ResourceBase: endpoint + "v2.0/"}
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "v2.0/ports"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/ports/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "v2.0/ports"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/ports/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/ports/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/networking/v2/subnets/doc.go b/openstack/networking/v2/subnets/doc.go
new file mode 100644
index 0000000..43e8296
--- /dev/null
+++ b/openstack/networking/v2/subnets/doc.go
@@ -0,0 +1,10 @@
+// Package subnets contains functionality for working with Neutron subnet
+// resources. A subnet represents an IP address block that can be used to
+// assign IP addresses to virtual instances. Each subnet must have a CIDR and
+// must be associated with a network. IPs can either be selected from the whole
+// subnet CIDR or from allocation pools specified by the user.
+//
+// A subnet can also have a gateway, a list of DNS name servers, and host routes.
+// This information is pushed to instances whose interfaces are associated with
+// the subnet.
+package subnets
diff --git a/openstack/networking/v2/subnets/errors.go b/openstack/networking/v2/subnets/errors.go
new file mode 100644
index 0000000..0db0a6e
--- /dev/null
+++ b/openstack/networking/v2/subnets/errors.go
@@ -0,0 +1,13 @@
+package subnets
+
+import "fmt"
+
+func err(str string) error {
+ return fmt.Errorf("%s", str)
+}
+
+var (
+ errNetworkIDRequired = err("A network ID is required")
+ errCIDRRequired = err("A valid CIDR is required")
+ errInvalidIPType = err("An IP type must either be 4 or 6")
+)
diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go
new file mode 100644
index 0000000..6cde048
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests.go
@@ -0,0 +1,271 @@
+package subnets
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AdminState gives users a solid type to work with for create and update
+// operations. It is recommended that users use the `Up` and `Down` enums.
+type AdminState *bool
+
+// Convenience vars for AdminStateUp values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Up AdminState = &iTrue
+ Down AdminState = &iFalse
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToSubnetListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the subnet attributes you want to see returned. SortKey allows you to sort
+// by a particular subnet attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Name string `q:"name"`
+ EnableDHCP *bool `q:"enable_dhcp"`
+ NetworkID string `q:"network_id"`
+ TenantID string `q:"tenant_id"`
+ IPVersion int `q:"ip_version"`
+ GatewayIP string `q:"gateway_ip"`
+ CIDR string `q:"cidr"`
+ ID string `q:"id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToSubnetListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToSubnetListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// subnets. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those subnets that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToSubnetListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return SubnetPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get retrieves a specific subnet based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// Valid IP types
+const (
+ IPv4 = 4
+ IPv6 = 6
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToSubnetCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts represents the attributes used when creating a new subnet.
+type CreateOpts struct {
+ // Required
+ NetworkID string
+ CIDR string
+ // Optional
+ Name string
+ TenantID string
+ AllocationPools []AllocationPool
+ GatewayIP string
+ IPVersion int
+ EnableDHCP *bool
+ DNSNameservers []string
+ HostRoutes []HostRoute
+}
+
+// ToSubnetCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToSubnetCreateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.NetworkID == "" {
+ return nil, errNetworkIDRequired
+ }
+ if opts.CIDR == "" {
+ return nil, errCIDRRequired
+ }
+ if opts.IPVersion != 0 && opts.IPVersion != IPv4 && opts.IPVersion != IPv6 {
+ return nil, errInvalidIPType
+ }
+
+ s["network_id"] = opts.NetworkID
+ s["cidr"] = opts.CIDR
+
+ if opts.EnableDHCP != nil {
+ s["enable_dhcp"] = &opts.EnableDHCP
+ }
+ if opts.Name != "" {
+ s["name"] = opts.Name
+ }
+ if opts.GatewayIP != "" {
+ s["gateway_ip"] = opts.GatewayIP
+ }
+ if opts.TenantID != "" {
+ s["tenant_id"] = opts.TenantID
+ }
+ if opts.IPVersion != 0 {
+ s["ip_version"] = opts.IPVersion
+ }
+ if len(opts.AllocationPools) != 0 {
+ s["allocation_pools"] = opts.AllocationPools
+ }
+ if len(opts.DNSNameservers) != 0 {
+ s["dns_nameservers"] = opts.DNSNameservers
+ }
+ if len(opts.HostRoutes) != 0 {
+ s["host_routes"] = opts.HostRoutes
+ }
+
+ return map[string]interface{}{"subnet": s}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new subnet using the values
+// provided. You must remember to provide a valid NetworkID, CIDR and IP version.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToSubnetCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToSubnetUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represents the attributes used when updating an existing subnet.
+type UpdateOpts struct {
+ Name string
+ GatewayIP string
+ DNSNameservers []string
+ HostRoutes []HostRoute
+ EnableDHCP *bool
+}
+
+// ToSubnetUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToSubnetUpdateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.EnableDHCP != nil {
+ s["enable_dhcp"] = &opts.EnableDHCP
+ }
+ if opts.Name != "" {
+ s["name"] = opts.Name
+ }
+ if opts.GatewayIP != "" {
+ s["gateway_ip"] = opts.GatewayIP
+ }
+ if opts.DNSNameservers != nil {
+ s["dns_nameservers"] = opts.DNSNameservers
+ }
+ if opts.HostRoutes != nil {
+ s["host_routes"] = opts.HostRoutes
+ }
+
+ return map[string]interface{}{"subnet": s}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing subnet using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToSubnetUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(updateURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+
+ return res
+}
+
+// Delete accepts a unique ID and deletes the subnet associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, id), nil)
+ return res
+}
+
+// IDFromName is a convenience function that returns a subnet's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ subnetCount := 0
+ subnetID := ""
+ if name == "" {
+ return "", fmt.Errorf("A subnet name must be provided.")
+ }
+ pager := List(client, nil)
+ pager.EachPage(func(page pagination.Page) (bool, error) {
+ subnetList, err := ExtractSubnets(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, s := range subnetList {
+ if s.Name == name {
+ subnetCount++
+ subnetID = s.ID
+ }
+ }
+ return true, nil
+ })
+
+ switch subnetCount {
+ case 0:
+ return "", fmt.Errorf("Unable to find subnet: %s", name)
+ case 1:
+ return subnetID, nil
+ default:
+ return "", fmt.Errorf("Found %d subnets matching %s", subnetCount, name)
+ }
+}
diff --git a/openstack/networking/v2/subnets/requests_test.go b/openstack/networking/v2/subnets/requests_test.go
new file mode 100644
index 0000000..987064a
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests_test.go
@@ -0,0 +1,362 @@
+package subnets
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/subnets", 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, `
+{
+ "subnets": [
+ {
+ "name": "private-subnet",
+ "enable_dhcp": true,
+ "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "10.0.0.2",
+ "end": "10.0.0.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "10.0.0.1",
+ "cidr": "10.0.0.0/24",
+ "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+ },
+ {
+ "name": "my_subnet",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.0.0.2",
+ "end": "192.255.255.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.0.0.1",
+ "cidr": "192.0.0.0/8",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractSubnets(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []Subnet{
+ Subnet{
+ Name: "private-subnet",
+ EnableDHCP: true,
+ NetworkID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ DNSNameservers: []string{},
+ AllocationPools: []AllocationPool{
+ AllocationPool{
+ Start: "10.0.0.2",
+ End: "10.0.0.254",
+ },
+ },
+ HostRoutes: []HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "10.0.0.1",
+ CIDR: "10.0.0.0/24",
+ ID: "08eae331-0402-425a-923c-34f7cfe39c1b",
+ },
+ Subnet{
+ Name: "my_subnet",
+ EnableDHCP: true,
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ DNSNameservers: []string{},
+ AllocationPools: []AllocationPool{
+ AllocationPool{
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ },
+ HostRoutes: []HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "192.0.0.1",
+ CIDR: "192.0.0.0/8",
+ ID: "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/subnets/54d6f61d-db07-451c-9ab3-b9609b6b6f0b", 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, `
+{
+ "subnet": {
+ "name": "my_subnet",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.0.0.2",
+ "end": "192.255.255.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.0.0.1",
+ "cidr": "192.0.0.0/8",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ }
+}
+ `)
+ })
+
+ s, err := Get(fake.ServiceClient(), "54d6f61d-db07-451c-9ab3-b9609b6b6f0b").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "my_subnet")
+ th.AssertEquals(t, s.EnableDHCP, true)
+ th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+ th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+ AllocationPool{
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "192.0.0.1")
+ th.AssertEquals(t, s.CIDR, "192.0.0.0/8")
+ th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0b")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/subnets", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet": {
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "ip_version": 4,
+ "cidr": "192.168.199.0/24",
+ "dns_nameservers": ["foo"],
+ "allocation_pools": [
+ {
+ "start": "192.168.199.2",
+ "end": "192.168.199.254"
+ }
+ ],
+ "host_routes": [{"destination":"","nexthop": "bar"}]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "subnet": {
+ "name": "",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.168.199.2",
+ "end": "192.168.199.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.168.199.1",
+ "cidr": "192.168.199.0/24",
+ "id": "3b80198d-4f7b-4f77-9ef5-774d54e17126"
+ }
+}
+ `)
+ })
+
+ opts := CreateOpts{
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ IPVersion: 4,
+ CIDR: "192.168.199.0/24",
+ AllocationPools: []AllocationPool{
+ AllocationPool{
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ },
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []HostRoute{
+ HostRoute{NextHop: "bar"},
+ },
+ }
+ s, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "")
+ th.AssertEquals(t, s.EnableDHCP, true)
+ th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+ th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+ AllocationPool{
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "192.168.199.1")
+ th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+ th.AssertEquals(t, s.ID, "3b80198d-4f7b-4f77-9ef5-774d54e17126")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = Create(fake.ServiceClient(), CreateOpts{NetworkID: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = Create(fake.ServiceClient(), CreateOpts{NetworkID: "foo", CIDR: "bar", IPVersion: 40})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet": {
+ "name": "my_new_subnet",
+ "dns_nameservers": ["foo"],
+ "host_routes": [{"destination":"","nexthop": "bar"}]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "subnet": {
+ "name": "my_new_subnet",
+ "enable_dhcp": true,
+ "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "10.0.0.2",
+ "end": "10.0.0.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "10.0.0.1",
+ "cidr": "10.0.0.0/24",
+ "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+ }
+}
+ `)
+ })
+
+ opts := UpdateOpts{
+ Name: "my_new_subnet",
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []HostRoute{
+ HostRoute{NextHop: "bar"},
+ },
+ }
+ s, err := Update(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b", opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "my_new_subnet")
+ th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go
new file mode 100644
index 0000000..77b956a
--- /dev/null
+++ b/openstack/networking/v2/subnets/results.go
@@ -0,0 +1,132 @@
+package subnets
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a subnet resource.
+func (r commonResult) Extract() (*Subnet, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Subnet *Subnet `json:"subnet"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Subnet, err
+}
+
+// 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
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// AllocationPool represents a sub-range of cidr available for dynamic
+// allocation to ports, e.g. {Start: "10.0.0.2", End: "10.0.0.254"}
+type AllocationPool struct {
+ Start string `json:"start"`
+ End string `json:"end"`
+}
+
+// HostRoute represents a route that should be used by devices with IPs from
+// a subnet (not including local subnet route).
+type HostRoute struct {
+ DestinationCIDR string `mapstructure:"destination" json:"destination"`
+ NextHop string `mapstructure:"nexthop" json:"nexthop"`
+}
+
+// Subnet represents a subnet. See package documentation for a top-level
+// description of what this is.
+type Subnet struct {
+ // UUID representing the subnet
+ ID string `mapstructure:"id" json:"id"`
+ // UUID of the parent network
+ NetworkID string `mapstructure:"network_id" json:"network_id"`
+ // Human-readable name for the subnet. Might not be unique.
+ Name string `mapstructure:"name" json:"name"`
+ // IP version, either `4' or `6'
+ IPVersion int `mapstructure:"ip_version" json:"ip_version"`
+ // CIDR representing IP range for this subnet, based on IP version
+ CIDR string `mapstructure:"cidr" json:"cidr"`
+ // Default gateway used by devices in this subnet
+ GatewayIP string `mapstructure:"gateway_ip" json:"gateway_ip"`
+ // DNS name servers used by hosts in this subnet.
+ DNSNameservers []string `mapstructure:"dns_nameservers" json:"dns_nameservers"`
+ // Sub-ranges of CIDR available for dynamic allocation to ports. See AllocationPool.
+ AllocationPools []AllocationPool `mapstructure:"allocation_pools" json:"allocation_pools"`
+ // Routes that should be used by devices with IPs from this subnet (not including local subnet route).
+ HostRoutes []HostRoute `mapstructure:"host_routes" json:"host_routes"`
+ // Specifies whether DHCP is enabled for this subnet or not.
+ EnableDHCP bool `mapstructure:"enable_dhcp" json:"enable_dhcp"`
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `mapstructure:"tenant_id" json:"tenant_id"`
+}
+
+// SubnetPage is the page returned by a pager when traversing over a collection
+// of subnets.
+type SubnetPage struct {
+ pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of subnets has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (p SubnetPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"subnets_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(p.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// IsEmpty checks whether a SubnetPage struct is empty.
+func (p SubnetPage) IsEmpty() (bool, error) {
+ is, err := ExtractSubnets(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractSubnets accepts a Page struct, specifically a SubnetPage struct,
+// and extracts the elements into a slice of Subnet structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractSubnets(page pagination.Page) ([]Subnet, error) {
+ var resp struct {
+ Subnets []Subnet `mapstructure:"subnets" json:"subnets"`
+ }
+
+ err := mapstructure.Decode(page.(SubnetPage).Body, &resp)
+
+ return resp.Subnets, err
+}
diff --git a/openstack/networking/v2/subnets/results_test.go b/openstack/networking/v2/subnets/results_test.go
new file mode 100644
index 0000000..d404838
--- /dev/null
+++ b/openstack/networking/v2/subnets/results_test.go
@@ -0,0 +1,54 @@
+package subnets
+
+import (
+ "encoding/json"
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "testing"
+)
+
+func TestHostRoute(t *testing.T) {
+ sejson := []byte(`
+ {"subnet": {
+ "name": "test-subnet",
+ "enable_dhcp": false,
+ "network_id": "3e66c41e-cbbd-4019-9aab-740b7e4150a0",
+ "tenant_id": "f86e123198cf42d19c8854c5f80c2f06",
+ "dns_nameservers": [],
+ "gateway_ip": "172.16.0.1",
+ "ipv6_ra_mode": null,
+ "allocation_pools": [
+ {
+ "start": "172.16.0.2",
+ "end": "172.16.255.254"
+ }
+ ],
+ "host_routes": [
+ {
+ "destination": "172.20.1.0/24",
+ "nexthop": "172.16.0.2"
+ }
+ ],
+ "ip_version": 4,
+ "ipv6_address_mode": null,
+ "cidr": "172.16.0.0/16",
+ "id": "6dcaa873-7115-41af-9ef5-915f73636e43",
+ "subnetpool_id": null
+ }}
+`)
+
+ var dejson interface{}
+ err := json.Unmarshal(sejson, &dejson)
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+
+ resp := commonResult{gophercloud.Result{Body: dejson}}
+ subnet, err := resp.Extract()
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+ route := subnet.HostRoutes[0]
+ th.AssertEquals(t, route.NextHop, "172.16.0.2")
+ th.AssertEquals(t, route.DestinationCIDR, "172.20.1.0/24")
+}
diff --git a/openstack/networking/v2/subnets/urls.go b/openstack/networking/v2/subnets/urls.go
new file mode 100644
index 0000000..0d02368
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls.go
@@ -0,0 +1,31 @@
+package subnets
+
+import "github.com/rackspace/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("subnets", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("subnets")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/subnets/urls_test.go b/openstack/networking/v2/subnets/urls_test.go
new file mode 100644
index 0000000..aeeddf3
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls_test.go
@@ -0,0 +1,44 @@
+package subnets
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint, ResourceBase: endpoint + "v2.0/"}
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint + "v2.0/subnets"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/subnets/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "v2.0/subnets"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/subnets/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "v2.0/subnets/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/accounts/doc.go b/openstack/objectstorage/v1/accounts/doc.go
new file mode 100644
index 0000000..f5f894a
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/doc.go
@@ -0,0 +1,8 @@
+// Package accounts contains functionality for working with Object Storage
+// account resources. An account is the top-level resource the object storage
+// hierarchy: containers belong to accounts, objects belong to containers.
+//
+// Another way of thinking of an account is like a namespace for all your
+// resources. It is synonymous with a project or tenant in other OpenStack
+// services.
+package accounts
diff --git a/openstack/objectstorage/v1/accounts/fixtures.go b/openstack/objectstorage/v1/accounts/fixtures.go
new file mode 100644
index 0000000..f22b687
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/fixtures.go
@@ -0,0 +1,38 @@
+// +build fixtures
+
+package accounts
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleGetAccountSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetAccountSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "HEAD")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Set("X-Account-Container-Count", "2")
+ w.Header().Set("X-Account-Bytes-Used", "14")
+ w.Header().Set("X-Account-Meta-Subject", "books")
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleUpdateAccountSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateAccountSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "X-Account-Meta-Gophercloud-Test", "accounts")
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/objectstorage/v1/accounts/requests.go b/openstack/objectstorage/v1/accounts/requests.go
new file mode 100644
index 0000000..66c46a9
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/requests.go
@@ -0,0 +1,107 @@
+package accounts
+
+import "github.com/rackspace/gophercloud"
+
+// GetOptsBuilder allows extensions to add additional headers to the Get
+// request.
+type GetOptsBuilder interface {
+ ToAccountGetMap() (map[string]string, error)
+}
+
+// GetOpts is a structure that contains parameters for getting an account's
+// metadata.
+type GetOpts struct {
+ Newest bool `h:"X-Newest"`
+}
+
+// ToAccountGetMap formats a GetOpts into a map[string]string of headers.
+func (opts GetOpts) ToAccountGetMap() (map[string]string, error) {
+ return gophercloud.BuildHeaders(opts)
+}
+
+// Get is a function that retrieves an account's metadata. To extract just the
+// custom metadata, call the ExtractMetadata method on the GetResult. To extract
+// all the headers that are returned (including the metadata), call the
+// ExtractHeader method on the GetResult.
+func Get(c *gophercloud.ServiceClient, opts GetOptsBuilder) GetResult {
+ var res GetResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToAccountGetMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("HEAD", getURL(c), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// UpdateOptsBuilder allows extensions to add additional headers to the Update
+// request.
+type UpdateOptsBuilder interface {
+ ToAccountUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that contains parameters for updating, creating, or
+// deleting an account's metadata.
+type UpdateOpts struct {
+ Metadata map[string]string
+ ContentType string `h:"Content-Type"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+ TempURLKey string `h:"X-Account-Meta-Temp-URL-Key"`
+ TempURLKey2 string `h:"X-Account-Meta-Temp-URL-Key-2"`
+}
+
+// ToAccountUpdateMap formats an UpdateOpts into a map[string]string of headers.
+func (opts UpdateOpts) ToAccountUpdateMap() (map[string]string, error) {
+ headers, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ headers["X-Account-Meta-"+k] = v
+ }
+ return headers, err
+}
+
+// Update is a function that creates, updates, or deletes an account's metadata.
+// To extract the headers returned, call the Extract method on the UpdateResult.
+func Update(c *gophercloud.ServiceClient, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+ h := make(map[string]string)
+
+ if opts != nil {
+ headers, err := opts.ToAccountUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("POST", updateURL(c), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{201, 202, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
diff --git a/openstack/objectstorage/v1/accounts/requests_test.go b/openstack/objectstorage/v1/accounts/requests_test.go
new file mode 100644
index 0000000..6454c0a
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/requests_test.go
@@ -0,0 +1,32 @@
+package accounts
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestUpdateAccount(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateAccountSuccessfully(t)
+
+ options := &UpdateOpts{Metadata: map[string]string{"gophercloud-test": "accounts"}}
+ res := Update(fake.ServiceClient(), options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetAccount(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetAccountSuccessfully(t)
+
+ expectedMetadata := map[string]string{"Subject": "books"}
+ res := Get(fake.ServiceClient(), &GetOpts{})
+ th.AssertNoErr(t, res.Err)
+ actualMetadata, _ := res.ExtractMetadata()
+ th.CheckDeepEquals(t, expectedMetadata, actualMetadata)
+ //headers, err := res.Extract()
+ //th.AssertNoErr(t, err)
+}
diff --git a/openstack/objectstorage/v1/accounts/results.go b/openstack/objectstorage/v1/accounts/results.go
new file mode 100644
index 0000000..6ab1a23
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/results.go
@@ -0,0 +1,102 @@
+package accounts
+
+import (
+ "strings"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// UpdateResult is returned from a call to the Update function.
+type UpdateResult struct {
+ gophercloud.HeaderResult
+}
+
+// UpdateHeader represents the headers returned in the response from an Update request.
+type UpdateHeader struct {
+ ContentLength string `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (ur UpdateResult) Extract() (UpdateHeader, error) {
+ var uh UpdateHeader
+ if ur.Err != nil {
+ return uh, ur.Err
+ }
+
+ if err := gophercloud.DecodeHeader(ur.Header, &uh); err != nil {
+ return uh, err
+ }
+
+ if date, ok := ur.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, ur.Header["Date"][0])
+ if err != nil {
+ return uh, err
+ }
+ uh.Date = t
+ }
+
+ return uh, nil
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ BytesUsed int64 `mapstructure:"X-Account-Bytes-Used"`
+ ContainerCount int `mapstructure:"X-Account-Container-Count"`
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ ObjectCount int64 `mapstructure:"X-Account-Object-Count"`
+ TransID string `mapstructure:"X-Trans-Id"`
+ TempURLKey string `mapstructure:"X-Account-Meta-Temp-URL-Key"`
+ TempURLKey2 string `mapstructure:"X-Account-Meta-Temp-URL-Key-2"`
+}
+
+// GetResult is returned from a call to the Get function.
+type GetResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (gr GetResult) Extract() (GetHeader, error) {
+ var gh GetHeader
+ if gr.Err != nil {
+ return gh, gr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(gr.Header, &gh); err != nil {
+ return gh, err
+ }
+
+ if date, ok := gr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, gr.Header["Date"][0])
+ if err != nil {
+ return gh, err
+ }
+ gh.Date = t
+ }
+
+ return gh, nil
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metatdata associated with the account.
+func (gr GetResult) ExtractMetadata() (map[string]string, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+
+ metadata := make(map[string]string)
+ for k, v := range gr.Header {
+ if strings.HasPrefix(k, "X-Account-Meta-") {
+ key := strings.TrimPrefix(k, "X-Account-Meta-")
+ metadata[key] = v[0]
+ }
+ }
+ return metadata, nil
+}
diff --git a/openstack/objectstorage/v1/accounts/urls.go b/openstack/objectstorage/v1/accounts/urls.go
new file mode 100644
index 0000000..9952fe4
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/urls.go
@@ -0,0 +1,11 @@
+package accounts
+
+import "github.com/rackspace/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+ return c.Endpoint
+}
+
+func updateURL(c *gophercloud.ServiceClient) string {
+ return getURL(c)
+}
diff --git a/openstack/objectstorage/v1/accounts/urls_test.go b/openstack/objectstorage/v1/accounts/urls_test.go
new file mode 100644
index 0000000..074d52d
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/urls_test.go
@@ -0,0 +1,26 @@
+package accounts
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient())
+ expected := endpoint
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient())
+ expected := endpoint
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/containers/doc.go b/openstack/objectstorage/v1/containers/doc.go
new file mode 100644
index 0000000..5fed553
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/doc.go
@@ -0,0 +1,8 @@
+// Package containers contains functionality for working with Object Storage
+// container resources. A container serves as a logical namespace for objects
+// that are placed inside it - an object with the same name in two different
+// containers represents two different objects.
+//
+// In addition to containing objects, you can also use the container to control
+// access to objects by using an access control list (ACL).
+package containers
diff --git a/openstack/objectstorage/v1/containers/fixtures.go b/openstack/objectstorage/v1/containers/fixtures.go
new file mode 100644
index 0000000..e607352
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/fixtures.go
@@ -0,0 +1,143 @@
+// +build fixtures
+
+package containers
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// ExpectedListInfo is the result expected from a call to `List` when full
+// info is requested.
+var ExpectedListInfo = []Container{
+ Container{
+ Count: 0,
+ Bytes: 0,
+ Name: "janeausten",
+ },
+ Container{
+ Count: 1,
+ Bytes: 14,
+ Name: "marktwain",
+ },
+}
+
+// ExpectedListNames is the result expected from a call to `List` when just
+// container names are requested.
+var ExpectedListNames = []string{"janeausten", "marktwain"}
+
+// HandleListContainerInfoSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `List` response when full info is requested.
+func HandleListContainerInfoSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `[
+ {
+ "count": 0,
+ "bytes": 0,
+ "name": "janeausten"
+ },
+ {
+ "count": 1,
+ "bytes": 14,
+ "name": "marktwain"
+ }
+ ]`)
+ case "janeausten":
+ fmt.Fprintf(w, `[
+ {
+ "count": 1,
+ "bytes": 14,
+ "name": "marktwain"
+ }
+ ]`)
+ case "marktwain":
+ fmt.Fprintf(w, `[]`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// HandleListContainerNamesSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `ListNames` response when only container names are requested.
+func HandleListContainerNamesSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "text/plain")
+
+ w.Header().Set("Content-Type", "text/plain")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, "janeausten\nmarktwain\n")
+ case "janeausten":
+ fmt.Fprintf(w, "marktwain\n")
+ case "marktwain":
+ fmt.Fprintf(w, ``)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// HandleCreateContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Create` response.
+func HandleCreateContainerSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Add("X-Container-Meta-Foo", "bar")
+ w.Header().Add("X-Trans-Id", "1234567")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleDeleteContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Delete` response.
+func HandleDeleteContainerSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", 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.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleUpdateContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateContainerSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleGetContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetContainerSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "HEAD")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/objectstorage/v1/containers/requests.go b/openstack/objectstorage/v1/containers/requests.go
new file mode 100644
index 0000000..50ff9f4
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/requests.go
@@ -0,0 +1,205 @@
+package containers
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToContainerListParams() (bool, string, error)
+}
+
+// ListOpts is a structure that holds options for listing containers.
+type ListOpts struct {
+ Full bool
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ EndMarker string `q:"end_marker"`
+ Format string `q:"format"`
+ Prefix string `q:"prefix"`
+ Delimiter string `q:"delimiter"`
+}
+
+// ToContainerListParams formats a ListOpts into a query string and boolean
+// representing whether to list complete information for each container.
+func (opts ListOpts) ToContainerListParams() (bool, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return false, "", err
+ }
+ return opts.Full, q.String(), nil
+}
+
+// List is a function that retrieves containers associated with the account as
+// well as account metadata. It returns a pager which can be iterated with the
+// EachPage function.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ headers := map[string]string{"Accept": "text/plain", "Content-Type": "text/plain"}
+
+ url := listURL(c)
+ if opts != nil {
+ full, query, err := opts.ToContainerListParams()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+
+ if full {
+ headers = map[string]string{"Accept": "application/json", "Content-Type": "application/json"}
+ }
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ p := ContainerPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ pager := pagination.NewPager(c, url, createPage)
+ pager.Headers = headers
+ return pager
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToContainerCreateMap() (map[string]string, error)
+}
+
+// CreateOpts is a structure that holds parameters for creating a container.
+type CreateOpts struct {
+ Metadata map[string]string
+ ContainerRead string `h:"X-Container-Read"`
+ ContainerSyncTo string `h:"X-Container-Sync-To"`
+ ContainerSyncKey string `h:"X-Container-Sync-Key"`
+ ContainerWrite string `h:"X-Container-Write"`
+ ContentType string `h:"Content-Type"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+ IfNoneMatch string `h:"If-None-Match"`
+ VersionsLocation string `h:"X-Versions-Location"`
+}
+
+// ToContainerCreateMap formats a CreateOpts into a map of headers.
+func (opts CreateOpts) ToContainerCreateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Container-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Create is a function that creates a new container.
+func Create(c *gophercloud.ServiceClient, containerName string, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToContainerCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("PUT", createURL(c, containerName), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{201, 202, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// Delete is a function that deletes a container.
+func Delete(c *gophercloud.ServiceClient, containerName string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, containerName), nil)
+ return res
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToContainerUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that holds parameters for updating, creating, or
+// deleting a container's metadata.
+type UpdateOpts struct {
+ Metadata map[string]string
+ ContainerRead string `h:"X-Container-Read"`
+ ContainerSyncTo string `h:"X-Container-Sync-To"`
+ ContainerSyncKey string `h:"X-Container-Sync-Key"`
+ ContainerWrite string `h:"X-Container-Write"`
+ ContentType string `h:"Content-Type"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+ RemoveVersionsLocation string `h:"X-Remove-Versions-Location"`
+ VersionsLocation string `h:"X-Versions-Location"`
+}
+
+// ToContainerUpdateMap formats a CreateOpts into a map of headers.
+func (opts UpdateOpts) ToContainerUpdateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Container-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Update is a function that creates, updates, or deletes a container's
+// metadata.
+func Update(c *gophercloud.ServiceClient, containerName string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToContainerUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("POST", updateURL(c, containerName), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{201, 202, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// Get is a function that retrieves the metadata of a container. To extract just
+// the custom metadata, pass the GetResult response to the ExtractMetadata
+// function.
+func Get(c *gophercloud.ServiceClient, containerName string) GetResult {
+ var res GetResult
+ resp, err := c.Request("HEAD", getURL(c, containerName), gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
diff --git a/openstack/objectstorage/v1/containers/requests_test.go b/openstack/objectstorage/v1/containers/requests_test.go
new file mode 100644
index 0000000..0ccd5a7
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/requests_test.go
@@ -0,0 +1,117 @@
+package containers
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+var metadata = map[string]string{"gophercloud-test": "containers"}
+
+func TestListContainerInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListContainerInfoSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), &ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ExpectedListInfo, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListAllContainerInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListContainerInfoSuccessfully(t)
+
+ allPages, err := List(fake.ServiceClient(), &ListOpts{Full: true}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := ExtractInfo(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedListInfo, actual)
+}
+
+func TestListContainerNames(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListContainerNamesSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), &ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNames(page)
+ if err != nil {
+ t.Errorf("Failed to extract container names: %v", err)
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, ExpectedListNames, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListAllContainerNames(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListContainerNamesSuccessfully(t)
+
+ allPages, err := List(fake.ServiceClient(), &ListOpts{Full: false}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := ExtractNames(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedListNames, actual)
+}
+
+func TestCreateContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateContainerSuccessfully(t)
+
+ options := CreateOpts{ContentType: "application/json", Metadata: map[string]string{"foo": "bar"}}
+ res := Create(fake.ServiceClient(), "testContainer", options)
+ c, err := res.Extract()
+ th.CheckNoErr(t, err)
+ th.CheckEquals(t, "bar", res.Header["X-Container-Meta-Foo"][0])
+ th.CheckEquals(t, "1234567", c.TransID)
+}
+
+func TestDeleteContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteContainerSuccessfully(t)
+
+ res := Delete(fake.ServiceClient(), "testContainer")
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestUpateContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateContainerSuccessfully(t)
+
+ options := &UpdateOpts{Metadata: map[string]string{"foo": "bar"}}
+ res := Update(fake.ServiceClient(), "testContainer", options)
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestGetContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetContainerSuccessfully(t)
+
+ _, err := Get(fake.ServiceClient(), "testContainer").ExtractMetadata()
+ th.CheckNoErr(t, err)
+}
diff --git a/openstack/objectstorage/v1/containers/results.go b/openstack/objectstorage/v1/containers/results.go
new file mode 100644
index 0000000..e682b8d
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/results.go
@@ -0,0 +1,270 @@
+package containers
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Container represents a container resource.
+type Container struct {
+ // The total number of bytes stored in the container.
+ Bytes int `json:"bytes" mapstructure:"bytes"`
+
+ // The total number of objects stored in the container.
+ Count int `json:"count" mapstructure:"count"`
+
+ // The name of the container.
+ Name string `json:"name" mapstructure:"name"`
+}
+
+// ContainerPage is the page returned by a pager when traversing over a
+// collection of containers.
+type ContainerPage struct {
+ pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a ListResult contains no container names.
+func (r ContainerPage) IsEmpty() (bool, error) {
+ names, err := ExtractNames(r)
+ if err != nil {
+ return true, err
+ }
+ return len(names) == 0, nil
+}
+
+// LastMarker returns the last container name in a ListResult.
+func (r ContainerPage) LastMarker() (string, error) {
+ names, err := ExtractNames(r)
+ if err != nil {
+ return "", err
+ }
+ if len(names) == 0 {
+ return "", nil
+ }
+ return names[len(names)-1], nil
+}
+
+// ExtractInfo is a function that takes a ListResult and returns the containers' information.
+func ExtractInfo(page pagination.Page) ([]Container, error) {
+ untyped := page.(ContainerPage).Body.([]interface{})
+ results := make([]Container, len(untyped))
+ for index, each := range untyped {
+ container := each.(map[string]interface{})
+ err := mapstructure.Decode(container, &results[index])
+ if err != nil {
+ return results, err
+ }
+ }
+ return results, nil
+}
+
+// ExtractNames is a function that takes a ListResult and returns the containers' names.
+func ExtractNames(page pagination.Page) ([]string, error) {
+ casted := page.(ContainerPage)
+ ct := casted.Header.Get("Content-Type")
+
+ switch {
+ case strings.HasPrefix(ct, "application/json"):
+ parsed, err := ExtractInfo(page)
+ if err != nil {
+ return nil, err
+ }
+
+ names := make([]string, 0, len(parsed))
+ for _, container := range parsed {
+ names = append(names, container.Name)
+ }
+ return names, nil
+ case strings.HasPrefix(ct, "text/plain"):
+ names := make([]string, 0, 50)
+
+ body := string(page.(ContainerPage).Body.([]uint8))
+ for _, name := range strings.Split(body, "\n") {
+ if len(name) > 0 {
+ names = append(names, name)
+ }
+ }
+
+ return names, nil
+ default:
+ return nil, fmt.Errorf("Cannot extract names from response with content-type: [%s]", ct)
+ }
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ AcceptRanges string `mapstructure:"Accept-Ranges"`
+ BytesUsed int64 `mapstructure:"X-Account-Bytes-Used"`
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ ObjectCount int64 `mapstructure:"X-Container-Object-Count"`
+ Read string `mapstructure:"X-Container-Read"`
+ TransID string `mapstructure:"X-Trans-Id"`
+ VersionsLocation string `mapstructure:"X-Versions-Location"`
+ Write string `mapstructure:"X-Container-Write"`
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (gr GetResult) Extract() (GetHeader, error) {
+ var gh GetHeader
+ if gr.Err != nil {
+ return gh, gr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(gr.Header, &gh); err != nil {
+ return gh, err
+ }
+
+ if date, ok := gr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, gr.Header["Date"][0])
+ if err != nil {
+ return gh, err
+ }
+ gh.Date = t
+ }
+
+ return gh, nil
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the container.
+func (gr GetResult) ExtractMetadata() (map[string]string, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+ metadata := make(map[string]string)
+ for k, v := range gr.Header {
+ if strings.HasPrefix(k, "X-Container-Meta-") {
+ key := strings.TrimPrefix(k, "X-Container-Meta-")
+ metadata[key] = v[0]
+ }
+ }
+ return metadata, nil
+}
+
+// CreateHeader represents the headers returned in the response from a Create request.
+type CreateHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// CreateResult represents the result of a create operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type CreateResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Create. To obtain
+// a map of headers, call the ExtractHeader method on the CreateResult.
+func (cr CreateResult) Extract() (CreateHeader, error) {
+ var ch CreateHeader
+ if cr.Err != nil {
+ return ch, cr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(cr.Header, &ch); err != nil {
+ return ch, err
+ }
+
+ if date, ok := cr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["Date"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.Date = t
+ }
+
+ return ch, nil
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// UpdateResult represents the result of an update operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type UpdateResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Update. To obtain
+// a map of headers, call the ExtractHeader method on the UpdateResult.
+func (ur UpdateResult) Extract() (UpdateHeader, error) {
+ var uh UpdateHeader
+ if ur.Err != nil {
+ return uh, ur.Err
+ }
+
+ if err := gophercloud.DecodeHeader(ur.Header, &uh); err != nil {
+ return uh, err
+ }
+
+ if date, ok := ur.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, ur.Header["Date"][0])
+ if err != nil {
+ return uh, err
+ }
+ uh.Date = t
+ }
+
+ return uh, nil
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// DeleteResult represents the result of a delete operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type DeleteResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Delete. To obtain
+// a map of headers, call the ExtractHeader method on the DeleteResult.
+func (dr DeleteResult) Extract() (DeleteHeader, error) {
+ var dh DeleteHeader
+ if dr.Err != nil {
+ return dh, dr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(dr.Header, &dh); err != nil {
+ return dh, err
+ }
+
+ if date, ok := dr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, dr.Header["Date"][0])
+ if err != nil {
+ return dh, err
+ }
+ dh.Date = t
+ }
+
+ return dh, nil
+}
diff --git a/openstack/objectstorage/v1/containers/urls.go b/openstack/objectstorage/v1/containers/urls.go
new file mode 100644
index 0000000..f864f84
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/urls.go
@@ -0,0 +1,23 @@
+package containers
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.Endpoint
+}
+
+func createURL(c *gophercloud.ServiceClient, container string) string {
+ return c.ServiceURL(container)
+}
+
+func getURL(c *gophercloud.ServiceClient, container string) string {
+ return createURL(c, container)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, container string) string {
+ return createURL(c, container)
+}
+
+func updateURL(c *gophercloud.ServiceClient, container string) string {
+ return createURL(c, container)
+}
diff --git a/openstack/objectstorage/v1/containers/urls_test.go b/openstack/objectstorage/v1/containers/urls_test.go
new file mode 100644
index 0000000..d043a2a
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/urls_test.go
@@ -0,0 +1,43 @@
+package containers
+
+import (
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "testing"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient())
+ expected := endpoint
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/objects/doc.go b/openstack/objectstorage/v1/objects/doc.go
new file mode 100644
index 0000000..30a9add
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/doc.go
@@ -0,0 +1,5 @@
+// Package objects contains functionality for working with Object Storage
+// object resources. An object is a resource that represents and contains data
+// - such as documents, images, and so on. You can also store custom metadata
+// with an object.
+package objects
diff --git a/openstack/objectstorage/v1/objects/fixtures.go b/openstack/objectstorage/v1/objects/fixtures.go
new file mode 100644
index 0000000..7a6e6e1
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/fixtures.go
@@ -0,0 +1,195 @@
+// +build fixtures
+
+package objects
+
+import (
+ "crypto/md5"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// HandleDownloadObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Download` response.
+func HandleDownloadObjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, "Successful download with Gophercloud")
+ })
+}
+
+// ExpectedListInfo is the result expected from a call to `List` when full
+// info is requested.
+var ExpectedListInfo = []Object{
+ Object{
+ Hash: "451e372e48e0f6b1114fa0724aa79fa1",
+ LastModified: "2009-11-10 23:00:00 +0000 UTC",
+ Bytes: 14,
+ Name: "goodbye",
+ ContentType: "application/octet-stream",
+ },
+ Object{
+ Hash: "451e372e48e0f6b1114fa0724aa79fa1",
+ LastModified: "2009-11-10 23:00:00 +0000 UTC",
+ Bytes: 14,
+ Name: "hello",
+ ContentType: "application/octet-stream",
+ },
+}
+
+// ExpectedListNames is the result expected from a call to `List` when just
+// object names are requested.
+var ExpectedListNames = []string{"hello", "goodbye"}
+
+// HandleListObjectsInfoSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `List` response when full info is requested.
+func HandleListObjectsInfoSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `[
+ {
+ "hash": "451e372e48e0f6b1114fa0724aa79fa1",
+ "last_modified": "2009-11-10 23:00:00 +0000 UTC",
+ "bytes": 14,
+ "name": "goodbye",
+ "content_type": "application/octet-stream"
+ },
+ {
+ "hash": "451e372e48e0f6b1114fa0724aa79fa1",
+ "last_modified": "2009-11-10 23:00:00 +0000 UTC",
+ "bytes": 14,
+ "name": "hello",
+ "content_type": "application/octet-stream"
+ }
+ ]`)
+ case "hello":
+ fmt.Fprintf(w, `[]`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// HandleListObjectNamesSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `List` response when only object names are requested.
+func HandleListObjectNamesSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "text/plain")
+
+ w.Header().Set("Content-Type", "text/plain")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, "hello\ngoodbye\n")
+ case "goodbye":
+ fmt.Fprintf(w, "")
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// HandleCreateTextObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux
+// that responds with a `Create` response. A Content-Type of "text/plain" is expected.
+func HandleCreateTextObjectSuccessfully(t *testing.T, content string) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "text/plain")
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ hash := md5.New()
+ io.WriteString(hash, content)
+ localChecksum := hash.Sum(nil)
+
+ w.Header().Set("ETag", fmt.Sprintf("%x", localChecksum))
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+// HandleCreateTypelessObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler
+// mux that responds with a `Create` response. No Content-Type header may be present in the request, so that server-
+// side content-type detection will be triggered properly.
+func HandleCreateTypelessObjectSuccessfully(t *testing.T, content string) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ if contentType, present := r.Header["Content-Type"]; present {
+ t.Errorf("Expected Content-Type header to be omitted, but was %#v", contentType)
+ }
+
+ hash := md5.New()
+ io.WriteString(hash, content)
+ localChecksum := hash.Sum(nil)
+
+ w.Header().Set("ETag", fmt.Sprintf("%x", localChecksum))
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+// HandleCopyObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Copy` response.
+func HandleCopyObjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "COPY")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestHeader(t, r, "Destination", "/newTestContainer/newTestObject")
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+// HandleDeleteObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Delete` response.
+func HandleDeleteObjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer/testObject", 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.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleUpdateObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateObjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestHeader(t, r, "X-Object-Meta-Gophercloud-Test", "objects")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// HandleGetObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetObjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "HEAD")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.Header().Add("X-Object-Meta-Gophercloud-Test", "objects")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/objectstorage/v1/objects/requests.go b/openstack/objectstorage/v1/objects/requests.go
new file mode 100644
index 0000000..f85add0
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/requests.go
@@ -0,0 +1,501 @@
+package objects
+
+import (
+ "bufio"
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/sha1"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "strings"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/objectstorage/v1/accounts"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToObjectListParams() (bool, string, error)
+}
+
+// ListOpts is a structure that holds parameters for listing objects.
+type ListOpts struct {
+ // Full is a true/false value that represents the amount of object information
+ // returned. If Full is set to true, then the content-type, number of bytes, hash
+ // date last modified, and name are returned. If set to false or not set, then
+ // only the object names are returned.
+ Full bool
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ EndMarker string `q:"end_marker"`
+ Format string `q:"format"`
+ Prefix string `q:"prefix"`
+ Delimiter string `q:"delimiter"`
+ Path string `q:"path"`
+}
+
+// ToObjectListParams formats a ListOpts into a query string and boolean
+// representing whether to list complete information for each object.
+func (opts ListOpts) ToObjectListParams() (bool, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return false, "", err
+ }
+ return opts.Full, q.String(), nil
+}
+
+// List is a function that retrieves all objects in a container. It also returns the details
+// for the container. To extract only the object information or names, pass the ListResult
+// response to the ExtractInfo or ExtractNames function, respectively.
+func List(c *gophercloud.ServiceClient, containerName string, opts ListOptsBuilder) pagination.Pager {
+ headers := map[string]string{"Accept": "text/plain", "Content-Type": "text/plain"}
+
+ url := listURL(c, containerName)
+ if opts != nil {
+ full, query, err := opts.ToObjectListParams()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+
+ if full {
+ headers = map[string]string{"Accept": "application/json", "Content-Type": "application/json"}
+ }
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ p := ObjectPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ pager := pagination.NewPager(c, url, createPage)
+ pager.Headers = headers
+ return pager
+}
+
+// DownloadOptsBuilder allows extensions to add additional parameters to the
+// Download request.
+type DownloadOptsBuilder interface {
+ ToObjectDownloadParams() (map[string]string, string, error)
+}
+
+// DownloadOpts is a structure that holds parameters for downloading an object.
+type DownloadOpts struct {
+ IfMatch string `h:"If-Match"`
+ IfModifiedSince time.Time `h:"If-Modified-Since"`
+ IfNoneMatch string `h:"If-None-Match"`
+ IfUnmodifiedSince time.Time `h:"If-Unmodified-Since"`
+ Range string `h:"Range"`
+ Expires string `q:"expires"`
+ MultipartManifest string `q:"multipart-manifest"`
+ Signature string `q:"signature"`
+}
+
+// ToObjectDownloadParams formats a DownloadOpts into a query string and map of
+// headers.
+func (opts DownloadOpts) ToObjectDownloadParams() (map[string]string, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return nil, "", err
+ }
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, q.String(), err
+ }
+ return h, q.String(), nil
+}
+
+// Download is a function that retrieves the content and metadata for an object.
+// To extract just the content, pass the DownloadResult response to the
+// ExtractContent function.
+func Download(c *gophercloud.ServiceClient, containerName, objectName string, opts DownloadOptsBuilder) DownloadResult {
+ var res DownloadResult
+
+ url := downloadURL(c, containerName, objectName)
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, query, err := opts.ToObjectDownloadParams()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+
+ url += query
+ }
+
+ resp, err := c.Request("GET", url, gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{200, 304},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ res.Body = resp.Body
+ }
+ res.Err = err
+
+ return res
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToObjectCreateParams() (map[string]string, string, error)
+}
+
+// CreateOpts is a structure that holds parameters for creating an object.
+type CreateOpts struct {
+ Metadata map[string]string
+ ContentDisposition string `h:"Content-Disposition"`
+ ContentEncoding string `h:"Content-Encoding"`
+ ContentLength int64 `h:"Content-Length"`
+ ContentType string `h:"Content-Type"`
+ CopyFrom string `h:"X-Copy-From"`
+ DeleteAfter int `h:"X-Delete-After"`
+ DeleteAt int `h:"X-Delete-At"`
+ DetectContentType string `h:"X-Detect-Content-Type"`
+ ETag string `h:"ETag"`
+ IfNoneMatch string `h:"If-None-Match"`
+ ObjectManifest string `h:"X-Object-Manifest"`
+ TransferEncoding string `h:"Transfer-Encoding"`
+ Expires string `q:"expires"`
+ MultipartManifest string `q:"multipart-manifest"`
+ Signature string `q:"signature"`
+}
+
+// ToObjectCreateParams formats a CreateOpts into a query string and map of
+// headers.
+func (opts CreateOpts) ToObjectCreateParams() (map[string]string, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return nil, "", err
+ }
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, q.String(), err
+ }
+
+ for k, v := range opts.Metadata {
+ h["X-Object-Meta-"+k] = v
+ }
+
+ return h, q.String(), nil
+}
+
+// Create is a function that creates a new object or replaces an existing object. If the returned response's ETag
+// header fails to match the local checksum, the failed request will automatically be retried up to a maximum of 3 times.
+func Create(c *gophercloud.ServiceClient, containerName, objectName string, content io.ReadSeeker, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ url := createURL(c, containerName, objectName)
+ h := make(map[string]string)
+
+ if opts != nil {
+ headers, query, err := opts.ToObjectCreateParams()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+
+ url += query
+ }
+
+ hash := md5.New()
+ bufioReader := bufio.NewReader(io.TeeReader(content, hash))
+ io.Copy(ioutil.Discard, bufioReader)
+ localChecksum := hash.Sum(nil)
+
+ h["ETag"] = fmt.Sprintf("%x", localChecksum)
+
+ _, err := content.Seek(0, 0)
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ ropts := gophercloud.RequestOpts{
+ RawBody: content,
+ MoreHeaders: h,
+ }
+
+ resp, err := c.Request("PUT", url, ropts)
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ if resp != nil {
+ res.Header = resp.Header
+ if resp.Header.Get("ETag") == fmt.Sprintf("%x", localChecksum) {
+ res.Err = err
+ return res
+ }
+ res.Err = fmt.Errorf("Local checksum does not match API ETag header")
+ }
+
+ return res
+}
+
+// CopyOptsBuilder allows extensions to add additional parameters to the
+// Copy request.
+type CopyOptsBuilder interface {
+ ToObjectCopyMap() (map[string]string, error)
+}
+
+// CopyOpts is a structure that holds parameters for copying one object to
+// another.
+type CopyOpts struct {
+ Metadata map[string]string
+ ContentDisposition string `h:"Content-Disposition"`
+ ContentEncoding string `h:"Content-Encoding"`
+ ContentType string `h:"Content-Type"`
+ Destination string `h:"Destination,required"`
+}
+
+// ToObjectCopyMap formats a CopyOpts into a map of headers.
+func (opts CopyOpts) ToObjectCopyMap() (map[string]string, error) {
+ if opts.Destination == "" {
+ return nil, fmt.Errorf("Required CopyOpts field 'Destination' not set.")
+ }
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Object-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Copy is a function that copies one object to another.
+func Copy(c *gophercloud.ServiceClient, containerName, objectName string, opts CopyOptsBuilder) CopyResult {
+ var res CopyResult
+ h := c.AuthenticatedHeaders()
+
+ headers, err := opts.ToObjectCopyMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+
+ url := copyURL(c, containerName, objectName)
+ resp, err := c.Request("COPY", url, gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{201},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// DeleteOptsBuilder allows extensions to add additional parameters to the
+// Delete request.
+type DeleteOptsBuilder interface {
+ ToObjectDeleteQuery() (string, error)
+}
+
+// DeleteOpts is a structure that holds parameters for deleting an object.
+type DeleteOpts struct {
+ MultipartManifest string `q:"multipart-manifest"`
+}
+
+// ToObjectDeleteQuery formats a DeleteOpts into a query string.
+func (opts DeleteOpts) ToObjectDeleteQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// Delete is a function that deletes an object.
+func Delete(c *gophercloud.ServiceClient, containerName, objectName string, opts DeleteOptsBuilder) DeleteResult {
+ var res DeleteResult
+ url := deleteURL(c, containerName, objectName)
+
+ if opts != nil {
+ query, err := opts.ToObjectDeleteQuery()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ url += query
+ }
+
+ resp, err := c.Delete(url, nil)
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// GetOptsBuilder allows extensions to add additional parameters to the
+// Get request.
+type GetOptsBuilder interface {
+ ToObjectGetQuery() (string, error)
+}
+
+// GetOpts is a structure that holds parameters for getting an object's metadata.
+type GetOpts struct {
+ Expires string `q:"expires"`
+ Signature string `q:"signature"`
+}
+
+// ToObjectGetQuery formats a GetOpts into a query string.
+func (opts GetOpts) ToObjectGetQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// Get is a function that retrieves the metadata of an object. To extract just the custom
+// metadata, pass the GetResult response to the ExtractMetadata function.
+func Get(c *gophercloud.ServiceClient, containerName, objectName string, opts GetOptsBuilder) GetResult {
+ var res GetResult
+ url := getURL(c, containerName, objectName)
+
+ if opts != nil {
+ query, err := opts.ToObjectGetQuery()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ url += query
+ }
+
+ resp, err := c.Request("HEAD", url, gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToObjectUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that holds parameters for updating, creating, or deleting an
+// object's metadata.
+type UpdateOpts struct {
+ Metadata map[string]string
+ ContentDisposition string `h:"Content-Disposition"`
+ ContentEncoding string `h:"Content-Encoding"`
+ ContentType string `h:"Content-Type"`
+ DeleteAfter int `h:"X-Delete-After"`
+ DeleteAt int `h:"X-Delete-At"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+}
+
+// ToObjectUpdateMap formats a UpdateOpts into a map of headers.
+func (opts UpdateOpts) ToObjectUpdateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Object-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Update is a function that creates, updates, or deletes an object's metadata.
+func Update(c *gophercloud.ServiceClient, containerName, objectName string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToObjectUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ url := updateURL(c, containerName, objectName)
+ resp, err := c.Request("POST", url, gophercloud.RequestOpts{
+ MoreHeaders: h,
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// HTTPMethod represents an HTTP method string (e.g. "GET").
+type HTTPMethod string
+
+var (
+ // GET represents an HTTP "GET" method.
+ GET HTTPMethod = "GET"
+ // POST represents an HTTP "POST" method.
+ POST HTTPMethod = "POST"
+)
+
+// CreateTempURLOpts are options for creating a temporary URL for an object.
+type CreateTempURLOpts struct {
+ // (REQUIRED) Method is the HTTP method to allow for users of the temp URL. Valid values
+ // are "GET" and "POST".
+ Method HTTPMethod
+ // (REQUIRED) TTL is the number of seconds the temp URL should be active.
+ TTL int
+ // (Optional) Split is the string on which to split the object URL. Since only
+ // the object path is used in the hash, the object URL needs to be parsed. If
+ // empty, the default OpenStack URL split point will be used ("/v1/").
+ Split string
+}
+
+// CreateTempURL is a function for creating a temporary URL for an object. It
+// allows users to have "GET" or "POST" access to a particular tenant's object
+// for a limited amount of time.
+func CreateTempURL(c *gophercloud.ServiceClient, containerName, objectName string, opts CreateTempURLOpts) (string, error) {
+ if opts.Split == "" {
+ opts.Split = "/v1/"
+ }
+ duration := time.Duration(opts.TTL) * time.Second
+ expiry := time.Now().Add(duration).Unix()
+ getHeader, err := accounts.Get(c, nil).Extract()
+ if err != nil {
+ return "", err
+ }
+ secretKey := []byte(getHeader.TempURLKey)
+ url := getURL(c, containerName, objectName)
+ splitPath := strings.Split(url, opts.Split)
+ baseURL, objectPath := splitPath[0], splitPath[1]
+ objectPath = opts.Split + objectPath
+ body := fmt.Sprintf("%s\n%d\n%s", opts.Method, expiry, objectPath)
+ hash := hmac.New(sha1.New, secretKey)
+ hash.Write([]byte(body))
+ hexsum := fmt.Sprintf("%x", hash.Sum(nil))
+ return fmt.Sprintf("%s%s?temp_url_sig=%s&temp_url_expires=%d", baseURL, objectPath, hexsum, expiry), nil
+}
diff --git a/openstack/objectstorage/v1/objects/requests_test.go b/openstack/objectstorage/v1/objects/requests_test.go
new file mode 100644
index 0000000..f7d6822
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/requests_test.go
@@ -0,0 +1,165 @@
+package objects
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestDownloadReader(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDownloadObjectSuccessfully(t)
+
+ response := Download(fake.ServiceClient(), "testContainer", "testObject", nil)
+ defer response.Body.Close()
+
+ // Check reader
+ buf := bytes.NewBuffer(make([]byte, 0))
+ io.CopyN(buf, response.Body, 10)
+ th.CheckEquals(t, "Successful", string(buf.Bytes()))
+}
+
+func TestDownloadExtraction(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDownloadObjectSuccessfully(t)
+
+ response := Download(fake.ServiceClient(), "testContainer", "testObject", nil)
+
+ // Check []byte extraction
+ bytes, err := response.ExtractContent()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "Successful download with Gophercloud", string(bytes))
+}
+
+func TestListObjectInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListObjectsInfoSuccessfully(t)
+
+ count := 0
+ options := &ListOpts{Full: true}
+ err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ExpectedListInfo, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListObjectNames(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListObjectNamesSuccessfully(t)
+
+ count := 0
+ options := &ListOpts{Full: false}
+ err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNames(page)
+ if err != nil {
+ t.Errorf("Failed to extract container names: %v", err)
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, ExpectedListNames, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestCreateObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ content := "Did gyre and gimble in the wabe"
+
+ HandleCreateTextObjectSuccessfully(t, content)
+
+ options := &CreateOpts{ContentType: "text/plain"}
+ res := Create(fake.ServiceClient(), "testContainer", "testObject", strings.NewReader(content), options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateObjectWithoutContentType(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ content := "The sky was the color of television, tuned to a dead channel."
+
+ HandleCreateTypelessObjectSuccessfully(t, content)
+
+ res := Create(fake.ServiceClient(), "testContainer", "testObject", strings.NewReader(content), &CreateOpts{})
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestErrorIsRaisedForChecksumMismatch(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("ETag", "acbd18db4cc2f85cedef654fccc4a4d8")
+ w.WriteHeader(http.StatusCreated)
+ })
+
+ content := strings.NewReader("The sky was the color of television, tuned to a dead channel.")
+ res := Create(fake.ServiceClient(), "testContainer", "testObject", content, &CreateOpts{})
+
+ err := fmt.Errorf("Local checksum does not match API ETag header")
+ th.AssertDeepEquals(t, err, res.Err)
+}
+
+func TestCopyObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCopyObjectSuccessfully(t)
+
+ options := &CopyOpts{Destination: "/newTestContainer/newTestObject"}
+ res := Copy(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestDeleteObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteObjectSuccessfully(t)
+
+ res := Delete(fake.ServiceClient(), "testContainer", "testObject", nil)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpateObjectMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateObjectSuccessfully(t)
+
+ options := &UpdateOpts{Metadata: map[string]string{"Gophercloud-Test": "objects"}}
+ res := Update(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetObjectSuccessfully(t)
+
+ expected := map[string]string{"Gophercloud-Test": "objects"}
+ actual, err := Get(fake.ServiceClient(), "testContainer", "testObject", nil).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/objects/results.go b/openstack/objectstorage/v1/objects/results.go
new file mode 100644
index 0000000..ecb2c54
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/results.go
@@ -0,0 +1,438 @@
+package objects
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Object is a structure that holds information related to a storage object.
+type Object struct {
+ // Bytes is the total number of bytes that comprise the object.
+ Bytes int64 `json:"bytes" mapstructure:"bytes"`
+
+ // ContentType is the content type of the object.
+ ContentType string `json:"content_type" mapstructure:"content_type"`
+
+ // Hash represents the MD5 checksum value of the object's content.
+ Hash string `json:"hash" mapstructure:"hash"`
+
+ // LastModified is the RFC3339Milli time the object was last modified, represented
+ // as a string. For any given object (obj), this value may be parsed to a time.Time:
+ // lastModified, err := time.Parse(gophercloud.RFC3339Milli, obj.LastModified)
+ LastModified string `json:"last_modified" mapstructure:"last_modified"`
+
+ // Name is the unique name for the object.
+ Name string `json:"name" mapstructure:"name"`
+}
+
+// ObjectPage is a single page of objects that is returned from a call to the
+// List function.
+type ObjectPage struct {
+ pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a ListResult contains no object names.
+func (r ObjectPage) IsEmpty() (bool, error) {
+ names, err := ExtractNames(r)
+ if err != nil {
+ return true, err
+ }
+ return len(names) == 0, nil
+}
+
+// LastMarker returns the last object name in a ListResult.
+func (r ObjectPage) LastMarker() (string, error) {
+ names, err := ExtractNames(r)
+ if err != nil {
+ return "", err
+ }
+ if len(names) == 0 {
+ return "", nil
+ }
+ return names[len(names)-1], nil
+}
+
+// ExtractInfo is a function that takes a page of objects and returns their full information.
+func ExtractInfo(page pagination.Page) ([]Object, error) {
+ untyped := page.(ObjectPage).Body.([]interface{})
+ results := make([]Object, len(untyped))
+ for index, each := range untyped {
+ object := each.(map[string]interface{})
+ err := mapstructure.Decode(object, &results[index])
+ if err != nil {
+ return results, err
+ }
+ }
+ return results, nil
+}
+
+// ExtractNames is a function that takes a page of objects and returns only their names.
+func ExtractNames(page pagination.Page) ([]string, error) {
+ casted := page.(ObjectPage)
+ ct := casted.Header.Get("Content-Type")
+ switch {
+ case strings.HasPrefix(ct, "application/json"):
+ parsed, err := ExtractInfo(page)
+ if err != nil {
+ return nil, err
+ }
+
+ names := make([]string, 0, len(parsed))
+ for _, object := range parsed {
+ names = append(names, object.Name)
+ }
+
+ return names, nil
+ case strings.HasPrefix(ct, "text/plain"):
+ names := make([]string, 0, 50)
+
+ body := string(page.(ObjectPage).Body.([]uint8))
+ for _, name := range strings.Split(body, "\n") {
+ if len(name) > 0 {
+ names = append(names, name)
+ }
+ }
+
+ return names, nil
+ case strings.HasPrefix(ct, "text/html"):
+ return []string{}, nil
+ default:
+ return nil, fmt.Errorf("Cannot extract names from response with content-type: [%s]", ct)
+ }
+}
+
+// DownloadHeader represents the headers returned in the response from a Download request.
+type DownloadHeader struct {
+ AcceptRanges string `mapstructure:"Accept-Ranges"`
+ ContentDisposition string `mapstructure:"Content-Disposition"`
+ ContentEncoding string `mapstructure:"Content-Encoding"`
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ DeleteAt time.Time `mapstructure:"-"`
+ ETag string `mapstructure:"Etag"`
+ LastModified time.Time `mapstructure:"-"`
+ ObjectManifest string `mapstructure:"X-Object-Manifest"`
+ StaticLargeObject bool `mapstructure:"X-Static-Large-Object"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// DownloadResult is a *http.Response that is returned from a call to the Download function.
+type DownloadResult struct {
+ gophercloud.HeaderResult
+ Body io.ReadCloser
+}
+
+// Extract will return a struct of headers returned from a call to Download. To obtain
+// a map of headers, call the ExtractHeader method on the DownloadResult.
+func (dr DownloadResult) Extract() (DownloadHeader, error) {
+ var dh DownloadHeader
+ if dr.Err != nil {
+ return dh, dr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(dr.Header, &dh); err != nil {
+ return dh, err
+ }
+
+ if date, ok := dr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, date[0])
+ if err != nil {
+ return dh, err
+ }
+ dh.Date = t
+ }
+
+ if date, ok := dr.Header["Last-Modified"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, date[0])
+ if err != nil {
+ return dh, err
+ }
+ dh.LastModified = t
+ }
+
+ if date, ok := dr.Header["X-Delete-At"]; ok && len(date) > 0 {
+ unix, err := strconv.ParseInt(date[0], 10, 64)
+ if err != nil {
+ return dh, err
+ }
+ dh.DeleteAt = time.Unix(unix, 0)
+ }
+
+ return dh, nil
+}
+
+// ExtractContent is a function that takes a DownloadResult's io.Reader body
+// and reads all available data into a slice of bytes. Please be aware that due
+// the nature of io.Reader is forward-only - meaning that it can only be read
+// once and not rewound. You can recreate a reader from the output of this
+// function by using bytes.NewReader(downloadBytes)
+func (dr DownloadResult) ExtractContent() ([]byte, error) {
+ if dr.Err != nil {
+ return nil, dr.Err
+ }
+ body, err := ioutil.ReadAll(dr.Body)
+ if err != nil {
+ return nil, err
+ }
+ dr.Body.Close()
+ return body, nil
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ ContentDisposition string `mapstructure:"Content-Disposition"`
+ ContentEncoding string `mapstructure:"Content-Encoding"`
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ DeleteAt time.Time `mapstructure:"-"`
+ ETag string `mapstructure:"Etag"`
+ LastModified time.Time `mapstructure:"-"`
+ ObjectManifest string `mapstructure:"X-Object-Manifest"`
+ StaticLargeObject bool `mapstructure:"X-Static-Large-Object"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// GetResult is a *http.Response that is returned from a call to the Get function.
+type GetResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (gr GetResult) Extract() (GetHeader, error) {
+ var gh GetHeader
+ if gr.Err != nil {
+ return gh, gr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(gr.Header, &gh); err != nil {
+ return gh, err
+ }
+
+ if date, ok := gr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, gr.Header["Date"][0])
+ if err != nil {
+ return gh, err
+ }
+ gh.Date = t
+ }
+
+ if date, ok := gr.Header["Last-Modified"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, gr.Header["Last-Modified"][0])
+ if err != nil {
+ return gh, err
+ }
+ gh.LastModified = t
+ }
+
+ if date, ok := gr.Header["X-Delete-At"]; ok && len(date) > 0 {
+ unix, err := strconv.ParseInt(date[0], 10, 64)
+ if err != nil {
+ return gh, err
+ }
+ gh.DeleteAt = time.Unix(unix, 0)
+ }
+
+ return gh, nil
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the object.
+func (gr GetResult) ExtractMetadata() (map[string]string, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+ metadata := make(map[string]string)
+ for k, v := range gr.Header {
+ if strings.HasPrefix(k, "X-Object-Meta-") {
+ key := strings.TrimPrefix(k, "X-Object-Meta-")
+ metadata[key] = v[0]
+ }
+ }
+ return metadata, nil
+}
+
+// CreateHeader represents the headers returned in the response from a Create request.
+type CreateHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ ETag string `mapstructure:"Etag"`
+ LastModified time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Create. To obtain
+// a map of headers, call the ExtractHeader method on the CreateResult.
+func (cr CreateResult) Extract() (CreateHeader, error) {
+ var ch CreateHeader
+ if cr.Err != nil {
+ return ch, cr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(cr.Header, &ch); err != nil {
+ return ch, err
+ }
+
+ if date, ok := cr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["Date"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.Date = t
+ }
+
+ if date, ok := cr.Header["Last-Modified"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["Last-Modified"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.LastModified = t
+ }
+
+ return ch, nil
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Update. To obtain
+// a map of headers, call the ExtractHeader method on the UpdateResult.
+func (ur UpdateResult) Extract() (UpdateHeader, error) {
+ var uh UpdateHeader
+ if ur.Err != nil {
+ return uh, ur.Err
+ }
+
+ if err := gophercloud.DecodeHeader(ur.Header, &uh); err != nil {
+ return uh, err
+ }
+
+ if date, ok := ur.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, ur.Header["Date"][0])
+ if err != nil {
+ return uh, err
+ }
+ uh.Date = t
+ }
+
+ return uh, nil
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Delete. To obtain
+// a map of headers, call the ExtractHeader method on the DeleteResult.
+func (dr DeleteResult) Extract() (DeleteHeader, error) {
+ var dh DeleteHeader
+ if dr.Err != nil {
+ return dh, dr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(dr.Header, &dh); err != nil {
+ return dh, err
+ }
+
+ if date, ok := dr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, dr.Header["Date"][0])
+ if err != nil {
+ return dh, err
+ }
+ dh.Date = t
+ }
+
+ return dh, nil
+}
+
+// CopyHeader represents the headers returned in the response from a Copy request.
+type CopyHeader struct {
+ ContentLength int64 `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ CopiedFrom string `mapstructure:"X-Copied-From"`
+ CopiedFromLastModified time.Time `mapstructure:"-"`
+ Date time.Time `mapstructure:"-"`
+ ETag string `mapstructure:"Etag"`
+ LastModified time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// CopyResult represents the result of a copy operation.
+type CopyResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Copy. To obtain
+// a map of headers, call the ExtractHeader method on the CopyResult.
+func (cr CopyResult) Extract() (CopyHeader, error) {
+ var ch CopyHeader
+ if cr.Err != nil {
+ return ch, cr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(cr.Header, &ch); err != nil {
+ return ch, err
+ }
+
+ if date, ok := cr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["Date"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.Date = t
+ }
+
+ if date, ok := cr.Header["Last-Modified"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["Last-Modified"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.LastModified = t
+ }
+
+ if date, ok := cr.Header["X-Copied-From-Last-Modified"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, cr.Header["X-Copied-From-Last-Modified"][0])
+ if err != nil {
+ return ch, err
+ }
+ ch.CopiedFromLastModified = t
+ }
+
+ return ch, nil
+}
diff --git a/openstack/objectstorage/v1/objects/urls.go b/openstack/objectstorage/v1/objects/urls.go
new file mode 100644
index 0000000..d2ec62c
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/urls.go
@@ -0,0 +1,33 @@
+package objects
+
+import (
+ "github.com/rackspace/gophercloud"
+)
+
+func listURL(c *gophercloud.ServiceClient, container string) string {
+ return c.ServiceURL(container)
+}
+
+func copyURL(c *gophercloud.ServiceClient, container, object string) string {
+ return c.ServiceURL(container, object)
+}
+
+func createURL(c *gophercloud.ServiceClient, container, object string) string {
+ return copyURL(c, container, object)
+}
+
+func getURL(c *gophercloud.ServiceClient, container, object string) string {
+ return copyURL(c, container, object)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, container, object string) string {
+ return copyURL(c, container, object)
+}
+
+func downloadURL(c *gophercloud.ServiceClient, container, object string) string {
+ return copyURL(c, container, object)
+}
+
+func updateURL(c *gophercloud.ServiceClient, container, object string) string {
+ return copyURL(c, container, object)
+}
diff --git a/openstack/objectstorage/v1/objects/urls_test.go b/openstack/objectstorage/v1/objects/urls_test.go
new file mode 100644
index 0000000..1dcfe35
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/urls_test.go
@@ -0,0 +1,56 @@
+package objects
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestListURL(t *testing.T) {
+ actual := listURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestCopyURL(t *testing.T) {
+ actual := copyURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestDownloadURL(t *testing.T) {
+ actual := downloadURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo", "bar")
+ expected := endpoint + "foo/bar"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/apiversions/doc.go b/openstack/orchestration/v1/apiversions/doc.go
new file mode 100644
index 0000000..f2db622
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/doc.go
@@ -0,0 +1,4 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Heat service. This functionality is not
+// restricted to this particular version.
+package apiversions
diff --git a/openstack/orchestration/v1/apiversions/requests.go b/openstack/orchestration/v1/apiversions/requests.go
new file mode 100644
index 0000000..f6454c8
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/requests.go
@@ -0,0 +1,13 @@
+package apiversions
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListVersions lists all the Neutron API versions available to end-users
+func ListVersions(c *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(c, apiVersionsURL(c), func(r pagination.PageResult) pagination.Page {
+ return APIVersionPage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/orchestration/v1/apiversions/requests_test.go b/openstack/orchestration/v1/apiversions/requests_test.go
new file mode 100644
index 0000000..a2fc980
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/requests_test.go
@@ -0,0 +1,89 @@
+package apiversions
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", 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, `
+{
+ "versions": [
+ {
+ "status": "CURRENT",
+ "id": "v1.0",
+ "links": [
+ {
+ "href": "http://23.253.228.211:8000/v1",
+ "rel": "self"
+ }
+ ]
+ }
+ ]
+}`)
+ })
+
+ count := 0
+
+ ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractAPIVersions(page)
+ if err != nil {
+ t.Errorf("Failed to extract API versions: %v", err)
+ return false, err
+ }
+
+ expected := []APIVersion{
+ APIVersion{
+ Status: "CURRENT",
+ ID: "v1.0",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://23.253.228.211:8000/v1",
+ Rel: "self",
+ },
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := ExtractAPIVersions(page); err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+ return true, nil
+ })
+}
diff --git a/openstack/orchestration/v1/apiversions/results.go b/openstack/orchestration/v1/apiversions/results.go
new file mode 100644
index 0000000..0700ab0
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/results.go
@@ -0,0 +1,42 @@
+package apiversions
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Neutron. It contains the status of
+// the API, and its unique ID.
+type APIVersion struct {
+ Status string `mapstructure:"status"`
+ ID string `mapstructure:"id"`
+ Links []gophercloud.Link `mapstructure:"links"`
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+ is, err := ExtractAPIVersions(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(page pagination.Page) ([]APIVersion, error) {
+ var resp struct {
+ Versions []APIVersion `mapstructure:"versions"`
+ }
+
+ err := mapstructure.Decode(page.(APIVersionPage).Body, &resp)
+
+ return resp.Versions, err
+}
diff --git a/openstack/orchestration/v1/apiversions/urls.go b/openstack/orchestration/v1/apiversions/urls.go
new file mode 100644
index 0000000..55d6e0e
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/urls.go
@@ -0,0 +1,7 @@
+package apiversions
+
+import "github.com/rackspace/gophercloud"
+
+func apiVersionsURL(c *gophercloud.ServiceClient) string {
+ return c.Endpoint
+}
diff --git a/openstack/orchestration/v1/buildinfo/doc.go b/openstack/orchestration/v1/buildinfo/doc.go
new file mode 100644
index 0000000..183e8df
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/doc.go
@@ -0,0 +1,2 @@
+// Package buildinfo provides build information about heat deployments.
+package buildinfo
diff --git a/openstack/orchestration/v1/buildinfo/fixtures.go b/openstack/orchestration/v1/buildinfo/fixtures.go
new file mode 100644
index 0000000..20ea09b
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/fixtures.go
@@ -0,0 +1,45 @@
+package buildinfo
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &BuildInfo{
+ API: Revision{
+ Revision: "2.4.5",
+ },
+ Engine: Revision{
+ Revision: "1.2.1",
+ },
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+ "api": {
+ "revision": "2.4.5"
+ },
+ "engine": {
+ "revision": "1.2.1"
+ }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/build_info`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/build_info", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
diff --git a/openstack/orchestration/v1/buildinfo/requests.go b/openstack/orchestration/v1/buildinfo/requests.go
new file mode 100644
index 0000000..9e03e5c
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/requests.go
@@ -0,0 +1,10 @@
+package buildinfo
+
+import "github.com/rackspace/gophercloud"
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c), &res.Body, nil)
+ return res
+}
diff --git a/openstack/orchestration/v1/buildinfo/requests_test.go b/openstack/orchestration/v1/buildinfo/requests_test.go
new file mode 100644
index 0000000..1e0fe23
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/requests_test.go
@@ -0,0 +1,20 @@
+package buildinfo
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := Get(fake.ServiceClient()).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/buildinfo/results.go b/openstack/orchestration/v1/buildinfo/results.go
new file mode 100644
index 0000000..683a434
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/results.go
@@ -0,0 +1,37 @@
+package buildinfo
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+)
+
+// Revision represents the API/Engine revision of a Heat deployment.
+type Revision struct {
+ Revision string `mapstructure:"revision"`
+}
+
+// BuildInfo represents the build information for a Heat deployment.
+type BuildInfo struct {
+ API Revision `mapstructure:"api"`
+ Engine Revision `mapstructure:"engine"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a BuildInfo object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*BuildInfo, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res BuildInfo
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return &res, nil
+}
diff --git a/openstack/orchestration/v1/buildinfo/urls.go b/openstack/orchestration/v1/buildinfo/urls.go
new file mode 100644
index 0000000..2c873d0
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/urls.go
@@ -0,0 +1,7 @@
+package buildinfo
+
+import "github.com/rackspace/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("build_info")
+}
diff --git a/openstack/orchestration/v1/stackevents/doc.go b/openstack/orchestration/v1/stackevents/doc.go
new file mode 100644
index 0000000..51cdd97
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/doc.go
@@ -0,0 +1,4 @@
+// Package stackevents provides operations for finding, listing, and retrieving
+// stack events. Stack events are events that take place on stacks such as
+// updating and abandoning.
+package stackevents
diff --git a/openstack/orchestration/v1/stackevents/fixtures.go b/openstack/orchestration/v1/stackevents/fixtures.go
new file mode 100644
index 0000000..235787a
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/fixtures.go
@@ -0,0 +1,446 @@
+package stackevents
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []Event{
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_COMPLETE",
+ PhysicalResourceID: "49181cd6-169a-4130-9455-31185bbfc5bf",
+ ID: "93940999-7d40-44ae-8de4-19624e7b8d18",
+ },
+}
+
+// FindOutput represents the response body from a Find request.
+const FindOutput = `
+{
+ "events": [
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:11",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_IN_PROGRESS",
+ "physical_resource_id": null,
+ "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+ },
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:27",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_COMPLETE",
+ "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+ "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+ }
+ ]
+}`
+
+// HandleFindSuccessfully creates an HTTP handler at `/stacks/postman_stack/events`
+// on the test handler mux that responds with a `Find` response.
+func HandleFindSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/postman_stack/events", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []Event{
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_COMPLETE",
+ PhysicalResourceID: "49181cd6-169a-4130-9455-31185bbfc5bf",
+ ID: "93940999-7d40-44ae-8de4-19624e7b8d18",
+ },
+}
+
+// ListOutput represents the response body from a List request.
+const ListOutput = `
+{
+ "events": [
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:11",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_IN_PROGRESS",
+ "physical_resource_id": null,
+ "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+ },
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:27",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_COMPLETE",
+ "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+ "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+ }
+ ]
+}`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/events`
+// on the test handler mux that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/events", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, output)
+ case "93940999-7d40-44ae-8de4-19624e7b8d18":
+ fmt.Fprintf(w, `{"events":[]}`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// ListResourceEventsExpected represents the expected object from a ListResourceEvents request.
+var ListResourceEventsExpected = []Event{
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_COMPLETE",
+ PhysicalResourceID: "49181cd6-169a-4130-9455-31185bbfc5bf",
+ ID: "93940999-7d40-44ae-8de4-19624e7b8d18",
+ },
+}
+
+// ListResourceEventsOutput represents the response body from a ListResourceEvents request.
+const ListResourceEventsOutput = `
+{
+ "events": [
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:11",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_IN_PROGRESS",
+ "physical_resource_id": null,
+ "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+ },
+ {
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:27",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_COMPLETE",
+ "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+ "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+ }
+ ]
+}`
+
+// HandleListResourceEventsSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events`
+// on the test handler mux that responds with a `ListResourceEvents` response.
+func HandleListResourceEventsSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, output)
+ case "93940999-7d40-44ae-8de4-19624e7b8d18":
+ fmt.Fprintf(w, `{"events":[]}`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "resource",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_COMPLETE",
+ PhysicalResourceID: "49181cd6-169a-4130-9455-31185bbfc5bf",
+ ID: "93940999-7d40-44ae-8de4-19624e7b8d18",
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+ "event":{
+ "resource_name": "hello_world",
+ "event_time": "2015-02-05T21:33:27",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "resource"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "resource_status": "CREATE_COMPLETE",
+ "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+ "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+ }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events/93940999-7d40-44ae-8de4-19624e7b8d18`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events/93940999-7d40-44ae-8de4-19624e7b8d18", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
diff --git a/openstack/orchestration/v1/stackevents/requests.go b/openstack/orchestration/v1/stackevents/requests.go
new file mode 100644
index 0000000..70c6b97
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/requests.go
@@ -0,0 +1,203 @@
+package stackevents
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Find retrieves stack events for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) FindResult {
+ var res FindResult
+
+ _, res.Err = c.Request("GET", findURL(c, stackName), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ })
+ return res
+}
+
+// SortDir is a type for specifying in which direction to sort a list of events.
+type SortDir string
+
+// SortKey is a type for specifying by which key to sort a list of events.
+type SortKey string
+
+// ResourceStatus is a type for specifying by which resource status to filter a
+// list of events.
+type ResourceStatus string
+
+// ResourceAction is a type for specifying by which resource action to filter a
+// list of events.
+type ResourceAction string
+
+var (
+ // ResourceStatusInProgress is used to filter a List request by the 'IN_PROGRESS' status.
+ ResourceStatusInProgress ResourceStatus = "IN_PROGRESS"
+ // ResourceStatusComplete is used to filter a List request by the 'COMPLETE' status.
+ ResourceStatusComplete ResourceStatus = "COMPLETE"
+ // ResourceStatusFailed is used to filter a List request by the 'FAILED' status.
+ ResourceStatusFailed ResourceStatus = "FAILED"
+
+ // ResourceActionCreate is used to filter a List request by the 'CREATE' action.
+ ResourceActionCreate ResourceAction = "CREATE"
+ // ResourceActionDelete is used to filter a List request by the 'DELETE' action.
+ ResourceActionDelete ResourceAction = "DELETE"
+ // ResourceActionUpdate is used to filter a List request by the 'UPDATE' action.
+ ResourceActionUpdate ResourceAction = "UPDATE"
+ // ResourceActionRollback is used to filter a List request by the 'ROLLBACK' action.
+ ResourceActionRollback ResourceAction = "ROLLBACK"
+ // ResourceActionSuspend is used to filter a List request by the 'SUSPEND' action.
+ ResourceActionSuspend ResourceAction = "SUSPEND"
+ // ResourceActionResume is used to filter a List request by the 'RESUME' action.
+ ResourceActionResume ResourceAction = "RESUME"
+ // ResourceActionAbandon is used to filter a List request by the 'ABANDON' action.
+ ResourceActionAbandon ResourceAction = "ABANDON"
+
+ // SortAsc is used to sort a list of stacks in ascending order.
+ SortAsc SortDir = "asc"
+ // SortDesc is used to sort a list of stacks in descending order.
+ SortDesc SortDir = "desc"
+
+ // SortName is used to sort a list of stacks by name.
+ SortName SortKey = "name"
+ // SortResourceType is used to sort a list of stacks by resource type.
+ SortResourceType SortKey = "resource_type"
+ // SortCreatedAt is used to sort a list of stacks by date created.
+ SortCreatedAt SortKey = "created_at"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToStackEventListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+ // The stack resource ID with which to start the listing.
+ Marker string `q:"marker"`
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+ // Filters the event list by the specified ResourceAction. You can use this
+ // filter multiple times to filter by multiple resource actions: CREATE, DELETE,
+ // UPDATE, ROLLBACK, SUSPEND, RESUME or ADOPT.
+ ResourceActions []ResourceAction `q:"resource_action"`
+ // Filters the event list by the specified resource_status. You can use this
+ // filter multiple times to filter by multiple resource statuses: IN_PROGRESS,
+ // COMPLETE or FAILED.
+ ResourceStatuses []ResourceStatus `q:"resource_status"`
+ // Filters the event list by the specified resource_name. You can use this
+ // filter multiple times to filter by multiple resource names.
+ ResourceNames []string `q:"resource_name"`
+ // Filters the event list by the specified resource_type. You can use this
+ // filter multiple times to filter by multiple resource types: OS::Nova::Server,
+ // OS::Cinder::Volume, and so on.
+ ResourceTypes []string `q:"resource_type"`
+ // Sorts the event list by: resource_type or created_at.
+ SortKey SortKey `q:"sort_keys"`
+ // The sort direction of the event list. Which is asc (ascending) or desc (descending).
+ SortDir SortDir `q:"sort_dir"`
+}
+
+// ToStackEventListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackEventListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(client *gophercloud.ServiceClient, stackName, stackID string, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client, stackName, stackID)
+
+ if opts != nil {
+ query, err := opts.ToStackEventListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// ListResourceEventsOptsBuilder allows extensions to add additional parameters to the
+// ListResourceEvents request.
+type ListResourceEventsOptsBuilder interface {
+ ToResourceEventListQuery() (string, error)
+}
+
+// ListResourceEventsOpts allows the filtering and sorting of paginated resource events through
+// the API. Marker and Limit are used for pagination.
+type ListResourceEventsOpts struct {
+ // The stack resource ID with which to start the listing.
+ Marker string `q:"marker"`
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+ // Filters the event list by the specified ResourceAction. You can use this
+ // filter multiple times to filter by multiple resource actions: CREATE, DELETE,
+ // UPDATE, ROLLBACK, SUSPEND, RESUME or ADOPT.
+ ResourceActions []string `q:"resource_action"`
+ // Filters the event list by the specified resource_status. You can use this
+ // filter multiple times to filter by multiple resource statuses: IN_PROGRESS,
+ // COMPLETE or FAILED.
+ ResourceStatuses []string `q:"resource_status"`
+ // Filters the event list by the specified resource_name. You can use this
+ // filter multiple times to filter by multiple resource names.
+ ResourceNames []string `q:"resource_name"`
+ // Filters the event list by the specified resource_type. You can use this
+ // filter multiple times to filter by multiple resource types: OS::Nova::Server,
+ // OS::Cinder::Volume, and so on.
+ ResourceTypes []string `q:"resource_type"`
+ // Sorts the event list by: resource_type or created_at.
+ SortKey SortKey `q:"sort_keys"`
+ // The sort direction of the event list. Which is asc (ascending) or desc (descending).
+ SortDir SortDir `q:"sort_dir"`
+}
+
+// ToResourceEventListQuery formats a ListResourceEventsOpts into a query string.
+func (opts ListResourceEventsOpts) ToResourceEventListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListResourceEvents makes a request against the API to list resources for the given stack.
+func ListResourceEvents(client *gophercloud.ServiceClient, stackName, stackID, resourceName string, opts ListResourceEventsOptsBuilder) pagination.Pager {
+ url := listResourceEventsURL(client, stackName, stackID, resourceName)
+
+ if opts != nil {
+ query, err := opts.ToResourceEventListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, stackName, stackID, resourceName, eventID), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
diff --git a/openstack/orchestration/v1/stackevents/requests_test.go b/openstack/orchestration/v1/stackevents/requests_test.go
new file mode 100644
index 0000000..a4da4d0
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/requests_test.go
@@ -0,0 +1,71 @@
+package stackevents
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestFindEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleFindSuccessfully(t, FindOutput)
+
+ actual, err := Find(fake.ServiceClient(), "postman_stack").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := FindExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t, ListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractEvents(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListResourceEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListResourceEventsSuccessfully(t, ListResourceEventsOutput)
+
+ count := 0
+ err := ListResourceEvents(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractEvents(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListResourceEventsExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetEvent(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", "93940999-7d40-44ae-8de4-19624e7b8d18").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stackevents/results.go b/openstack/orchestration/v1/stackevents/results.go
new file mode 100644
index 0000000..cf9e240
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/results.go
@@ -0,0 +1,172 @@
+package stackevents
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Event represents a stack event.
+type Event struct {
+ // The name of the resource for which the event occurred.
+ ResourceName string `mapstructure:"resource_name"`
+ // The time the event occurred.
+ Time time.Time `mapstructure:"-"`
+ // The URLs to the event.
+ Links []gophercloud.Link `mapstructure:"links"`
+ // The logical ID of the stack resource.
+ LogicalResourceID string `mapstructure:"logical_resource_id"`
+ // The reason of the status of the event.
+ ResourceStatusReason string `mapstructure:"resource_status_reason"`
+ // The status of the event.
+ ResourceStatus string `mapstructure:"resource_status"`
+ // The physical ID of the stack resource.
+ PhysicalResourceID string `mapstructure:"physical_resource_id"`
+ // The event ID.
+ ID string `mapstructure:"id"`
+ // Properties of the stack resource.
+ ResourceProperties map[string]interface{} `mapstructure:"resource_properties"`
+}
+
+// FindResult represents the result of a Find operation.
+type FindResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a slice of Event objects and is called after a
+// Find operation.
+func (r FindResult) Extract() ([]Event, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Res []Event `mapstructure:"events"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ events := r.Body.(map[string]interface{})["events"].([]interface{})
+
+ for i, eventRaw := range events {
+ event := eventRaw.(map[string]interface{})
+ if date, ok := event["event_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Res[i].Time = t
+ }
+ }
+
+ return res.Res, nil
+}
+
+// EventPage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResources call.
+type EventPage struct {
+ pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (r EventPage) IsEmpty() (bool, error) {
+ events, err := ExtractEvents(r)
+ if err != nil {
+ return true, err
+ }
+ return len(events) == 0, nil
+}
+
+// LastMarker returns the last stack ID in a ListResult.
+func (r EventPage) LastMarker() (string, error) {
+ events, err := ExtractEvents(r)
+ if err != nil {
+ return "", err
+ }
+ if len(events) == 0 {
+ return "", nil
+ }
+ return events[len(events)-1].ID, nil
+}
+
+// ExtractEvents interprets the results of a single page from a List() call, producing a slice of Event entities.
+func ExtractEvents(page pagination.Page) ([]Event, error) {
+ casted := page.(EventPage).Body
+
+ var res struct {
+ Res []Event `mapstructure:"events"`
+ }
+
+ if err := mapstructure.Decode(casted, &res); err != nil {
+ return nil, err
+ }
+
+ var events []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ events = casted.(map[string]interface{})["events"].([]interface{})
+ case map[string][]interface{}:
+ events = casted.(map[string][]interface{})["events"]
+ default:
+ return res.Res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, eventRaw := range events {
+ event := eventRaw.(map[string]interface{})
+ if date, ok := event["event_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Res[i].Time = t
+ }
+ }
+
+ return res.Res, nil
+}
+
+// ExtractResourceEvents interprets the results of a single page from a
+// ListResourceEvents() call, producing a slice of Event entities.
+func ExtractResourceEvents(page pagination.Page) ([]Event, error) {
+ return ExtractEvents(page)
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to an Event object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*Event, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Res *Event `mapstructure:"event"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ event := r.Body.(map[string]interface{})["event"].(map[string]interface{})
+
+ if date, ok := event["event_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Res.Time = t
+ }
+
+ return res.Res, nil
+}
diff --git a/openstack/orchestration/v1/stackevents/urls.go b/openstack/orchestration/v1/stackevents/urls.go
new file mode 100644
index 0000000..8b5eceb
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/urls.go
@@ -0,0 +1,19 @@
+package stackevents
+
+import "github.com/rackspace/gophercloud"
+
+func findURL(c *gophercloud.ServiceClient, stackName string) string {
+ return c.ServiceURL("stacks", stackName, "events")
+}
+
+func listURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "events")
+}
+
+func listResourceEventsURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "events")
+}
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "events", eventID)
+}
diff --git a/openstack/orchestration/v1/stackresources/doc.go b/openstack/orchestration/v1/stackresources/doc.go
new file mode 100644
index 0000000..e4f8b08
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/doc.go
@@ -0,0 +1,5 @@
+// Package stackresources provides operations for working with stack resources.
+// A resource is a template artifact that represents some component of your
+// desired architecture (a Cloud Server, a group of scaled Cloud Servers, a load
+// balancer, some configuration management system, and so forth).
+package stackresources
diff --git a/openstack/orchestration/v1/stackresources/fixtures.go b/openstack/orchestration/v1/stackresources/fixtures.go
new file mode 100644
index 0000000..952dc54
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/fixtures.go
@@ -0,0 +1,439 @@
+package stackresources
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []Resource{
+ Resource{
+ Name: "hello_world",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ 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",
+ },
+}
+
+// FindOutput represents the response body from a Find request.
+const FindOutput = `
+{
+ "resources": [
+ {
+ "description": "Some resource",
+ "attributes": {"SXSW": "atx"},
+ "resource_name": "hello_world",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "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",
+ "resource_type": "OS::Nova::Server"
+ }
+ ]
+}`
+
+// HandleFindSuccessfully creates an HTTP handler at `/stacks/hello_world/resources`
+// on the test handler mux that responds with a `Find` response.
+func HandleFindSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/hello_world/resources", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []Resource{
+ Resource{
+ Name: "hello_world",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ Rel: "stack",
+ },
+ },
+ 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",
+ },
+}
+
+// ListOutput represents the response body from a List request.
+const ListOutput = `{
+ "resources": [
+ {
+ "resource_name": "hello_world",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "hello_world",
+ "resource_status_reason": "state changed",
+ "updated_time": "2015-02-05T21:33:11",
+ "required_by": [],
+ "resource_status": "CREATE_IN_PROGRESS",
+ "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+ "creation_time": "2015-02-05T21:33:10",
+ "resource_type": "OS::Nova::Server",
+ "attributes": {"SXSW": "atx"},
+ "description": "Some resource"
+ }
+]
+}`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources`
+// on the test handler mux that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, output)
+ case "49181cd6-169a-4130-9455-31185bbfc5bf":
+ fmt.Fprintf(w, `{"resources":[]}`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &Resource{
+ Name: "wordpress_instance",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e",
+ Rel: "stack",
+ },
+ },
+ 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{}{},
+ Status: "CREATE_COMPLETE",
+ PhysicalID: "00e3a2fe-c65d-403c-9483-4db9930dd194",
+ Type: "OS::Nova::Server",
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+ "resource": {
+ "description": "Some resource",
+ "attributes": {"SXSW": "atx"},
+ "resource_name": "wordpress_instance",
+ "description": "",
+ "links": [
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance",
+ "rel": "self"
+ },
+ {
+ "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e",
+ "rel": "stack"
+ }
+ ],
+ "logical_resource_id": "wordpress_instance",
+ "resource_status": "CREATE_COMPLETE",
+ "updated_time": "2014-12-10T18:34:35",
+ "required_by": [],
+ "resource_status_reason": "state changed",
+ "physical_resource_id": "00e3a2fe-c65d-403c-9483-4db9930dd194",
+ "resource_type": "OS::Nova::Server"
+ }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// MetadataExpected represents the expected object from a Metadata request.
+var MetadataExpected = map[string]string{
+ "number": "7",
+ "animal": "auk",
+}
+
+// MetadataOutput represents the response body from a Metadata request.
+const MetadataOutput = `
+{
+ "metadata": {
+ "number": "7",
+ "animal": "auk"
+ }
+}`
+
+// HandleMetadataSuccessfully creates an HTTP handler at `/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance/metadata`
+// on the test handler mux that responds with a `Metadata` response.
+func HandleMetadataSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance/metadata", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// ListTypesExpected represents the expected object from a ListTypes request.
+var ListTypesExpected = ResourceTypes{
+ "OS::Nova::Server",
+ "OS::Heat::RandomString",
+ "OS::Swift::Container",
+ "OS::Trove::Instance",
+ "OS::Nova::FloatingIPAssociation",
+ "OS::Cinder::VolumeAttachment",
+ "OS::Nova::FloatingIP",
+ "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 = `
+{
+ "resource_types": [
+ "OS::Nova::Server",
+ "OS::Heat::RandomString",
+ "OS::Swift::Container",
+ "OS::Trove::Instance",
+ "OS::Nova::FloatingIPAssociation",
+ "OS::Cinder::VolumeAttachment",
+ "OS::Nova::FloatingIP",
+ "OS::Nova::KeyPair"
+ ]
+}`
+
+// HandleListTypesSuccessfully creates an HTTP handler at `/resource_types`
+// on the test handler mux that responds with a `ListTypes` response.
+func HandleListTypesSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/resource_types", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// GetSchemaExpected represents the expected object from a Schema request.
+var GetSchemaExpected = &TypeSchema{
+ Attributes: map[string]interface{}{
+ "an_attribute": map[string]interface{}{
+ "description": "An attribute description .",
+ },
+ },
+ Properties: map[string]interface{}{
+ "a_property": map[string]interface{}{
+ "update_allowed": false,
+ "required": true,
+ "type": "string",
+ "description": "A resource description.",
+ },
+ },
+ 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.
+const GetSchemaOutput = `
+{
+ "attributes": {
+ "an_attribute": {
+ "description": "An attribute description ."
+ }
+ },
+ "properties": {
+ "a_property": {
+ "update_allowed": false,
+ "required": true,
+ "type": "string",
+ "description": "A resource description."
+ }
+ },
+ "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`
+// on the test handler mux that responds with a `Schema` response.
+func HandleGetSchemaSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/resource_types/OS::Heat::AResourceName", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// GetTemplateExpected represents the expected object from a Template request.
+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 = `
+{
+ "HeatTemplateFormatVersion": "2012-12-12",
+ "Outputs": {
+ "private_key": {
+ "Description": "The private key if it has been saved.",
+ "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"private_key\"]}"
+ },
+ "public_key": {
+ "Description": "The public key.",
+ "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"public_key\"]}"
+ }
+ },
+ "Parameters": {
+ "name": {
+ "Description": "The name of the key pair.",
+ "Type": "String"
+ },
+ "public_key": {
+ "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": {
+ "AllowedValues": [
+ "True",
+ "true",
+ "False",
+ "false"
+ ],
+ "Default": false,
+ "Description": "True if the system should remember a generated private key; False otherwise.",
+ "Type": "String"
+ }
+ },
+ "Resources": {
+ "KeyPair": {
+ "Properties": {
+ "name": {
+ "Ref": "name"
+ },
+ "public_key": {
+ "Ref": "public_key"
+ },
+ "save_private_key": {
+ "Ref": "save_private_key"
+ }
+ },
+ "Type": "OS::Nova::KeyPair"
+ }
+ }
+}`
+
+// HandleGetTemplateSuccessfully creates an HTTP handler at `/resource_types/OS::Heat::AResourceName/template`
+// on the test handler mux that responds with a `Template` response.
+func HandleGetTemplateSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/resource_types/OS::Heat::AResourceName/template", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
diff --git a/openstack/orchestration/v1/stackresources/requests.go b/openstack/orchestration/v1/stackresources/requests.go
new file mode 100644
index 0000000..fcb8d8a
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/requests.go
@@ -0,0 +1,113 @@
+package stackresources
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Find retrieves stack resources for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) FindResult {
+ var res FindResult
+
+ // Send request to API
+ _, res.Err = c.Request("GET", findURL(c, stackName), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ })
+ return res
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToStackResourceListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+ // Include resources from nest stacks up to Depth levels of recursion.
+ Depth int `q:"nested_depth"`
+}
+
+// ToStackResourceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackResourceListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(client *gophercloud.ServiceClient, stackName, stackID string, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client, stackName, stackID)
+
+ if opts != nil {
+ query, err := opts.ToStackResourceListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return ResourcePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) GetResult {
+ var res GetResult
+
+ // Send request to API
+ _, res.Err = c.Get(getURL(c, stackName, stackID, resourceName), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Metadata retreives the metadata for the given stack resource.
+func Metadata(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) MetadataResult {
+ var res MetadataResult
+
+ // Send request to API
+ _, res.Err = c.Get(metadataURL(c, stackName, stackID, resourceName), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// ListTypes makes a request against the API to list resource types.
+func ListTypes(client *gophercloud.ServiceClient) pagination.Pager {
+ url := listTypesURL(client)
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return ResourceTypePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// Schema retreives the schema for the given resource type.
+func Schema(c *gophercloud.ServiceClient, resourceType string) SchemaResult {
+ var res SchemaResult
+
+ // Send request to API
+ _, res.Err = c.Get(schemaURL(c, resourceType), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Template retreives the template representation for the given resource type.
+func Template(c *gophercloud.ServiceClient, resourceType string) TemplateResult {
+ var res TemplateResult
+
+ // Send request to API
+ _, res.Err = c.Get(templateURL(c, resourceType), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
diff --git a/openstack/orchestration/v1/stackresources/requests_test.go b/openstack/orchestration/v1/stackresources/requests_test.go
new file mode 100644
index 0000000..e5045a7
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/requests_test.go
@@ -0,0 +1,111 @@
+package stackresources
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestFindResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleFindSuccessfully(t, FindOutput)
+
+ actual, err := Find(fake.ServiceClient(), "hello_world").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := FindExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t, ListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractResources(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetResource(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestResourceMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMetadataSuccessfully(t, MetadataOutput)
+
+ actual, err := Metadata(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := MetadataExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResourceTypes(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListTypesSuccessfully(t, ListTypesOutput)
+
+ count := 0
+ err := ListTypes(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractResourceTypes(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListTypesExpected, actual)
+ // test if sorting works
+ sort.Sort(actual)
+ th.CheckDeepEquals(t, SortedListTypesExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGetResourceSchema(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSchemaSuccessfully(t, GetSchemaOutput)
+
+ actual, err := Schema(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetSchemaExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestGetResourceTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetTemplateSuccessfully(t, GetTemplateOutput)
+
+ actual, err := Template(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetTemplateExpected
+ th.AssertDeepEquals(t, expected, string(actual))
+}
diff --git a/openstack/orchestration/v1/stackresources/results.go b/openstack/orchestration/v1/stackresources/results.go
new file mode 100644
index 0000000..6ddc766
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/results.go
@@ -0,0 +1,284 @@
+package stackresources
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Resource represents a stack resource.
+type Resource struct {
+ 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.
+type FindResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a slice of Resource objects and is called after a
+// Find operation.
+func (r FindResult) Extract() ([]Resource, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Res []Resource `mapstructure:"resources"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ resources := r.Body.(map[string]interface{})["resources"].([]interface{})
+
+ for i, resourceRaw := range resources {
+ resource := resourceRaw.(map[string]interface{})
+ if date, ok := resource["updated_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ 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
+}
+
+// ResourcePage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResources call.
+type ResourcePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (r ResourcePage) IsEmpty() (bool, error) {
+ resources, err := ExtractResources(r)
+ if err != nil {
+ return true, err
+ }
+ return len(resources) == 0, 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
+
+ var response struct {
+ Resources []Resource `mapstructure:"resources"`
+ }
+ if err := mapstructure.Decode(casted, &response); err != nil {
+ return nil, err
+ }
+ var resources []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ resources = casted.(map[string]interface{})["resources"].([]interface{})
+ case map[string][]interface{}:
+ resources = casted.(map[string][]interface{})["resources"]
+ default:
+ return response.Resources, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, resourceRaw := range resources {
+ resource := resourceRaw.(map[string]interface{})
+ if date, ok := resource["updated_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ 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, nil
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a Resource object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*Resource, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Res *Resource `mapstructure:"resource"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ resource := r.Body.(map[string]interface{})["resource"].(map[string]interface{})
+
+ if date, ok := resource["updated_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ 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
+}
+
+// MetadataResult represents the result of a Metadata operation.
+type MetadataResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a map object and is called after a
+// Metadata operation.
+func (r MetadataResult) Extract() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Meta map[string]string `mapstructure:"metadata"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return res.Meta, nil
+}
+
+// ResourceTypePage abstracts the raw results of making a ListTypes() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResourceTypes call.
+type ResourceTypePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ResourceTypePage contains no resource types.
+func (r ResourceTypePage) IsEmpty() (bool, error) {
+ rts, err := ExtractResourceTypes(r)
+ if err != nil {
+ return true, err
+ }
+ 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) (ResourceTypes, error) {
+ casted := page.(ResourceTypePage).Body
+
+ var response struct {
+ ResourceTypes ResourceTypes `mapstructure:"resource_types"`
+ }
+
+ 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"`
+ SupportStatus map[string]interface{} `mapstructure:"support_status"`
+}
+
+// SchemaResult represents the result of a Schema operation.
+type SchemaResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a TypeSchema object and is called after a
+// Schema operation.
+func (r SchemaResult) Extract() (*TypeSchema, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res TypeSchema
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return &res, nil
+}
+
+// TemplateResult represents the result of a Template operation.
+type TemplateResult struct {
+ gophercloud.Result
+}
+
+// Extract returns the template and is called after a
+// Template operation.
+func (r TemplateResult) Extract() ([]byte, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ template, err := json.MarshalIndent(r.Body, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return template, nil
+}
diff --git a/openstack/orchestration/v1/stackresources/urls.go b/openstack/orchestration/v1/stackresources/urls.go
new file mode 100644
index 0000000..ef078d9
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/urls.go
@@ -0,0 +1,31 @@
+package stackresources
+
+import "github.com/rackspace/gophercloud"
+
+func findURL(c *gophercloud.ServiceClient, stackName string) string {
+ return c.ServiceURL("stacks", stackName, "resources")
+}
+
+func listURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "resources")
+}
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName)
+}
+
+func metadataURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "metadata")
+}
+
+func listTypesURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("resource_types")
+}
+
+func schemaURL(c *gophercloud.ServiceClient, typeName string) string {
+ return c.ServiceURL("resource_types", typeName)
+}
+
+func templateURL(c *gophercloud.ServiceClient, typeName string) string {
+ return c.ServiceURL("resource_types", typeName, "template")
+}
diff --git a/openstack/orchestration/v1/stacks/doc.go b/openstack/orchestration/v1/stacks/doc.go
new file mode 100644
index 0000000..19231b5
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/doc.go
@@ -0,0 +1,8 @@
+// Package stacks provides operation for working with Heat stacks. A stack is a
+// group of resources (servers, load balancers, databases, and so forth)
+// combined to fulfill a useful purpose. Based on a template, Heat orchestration
+// engine creates an instantiated set of resources (a stack) to run the
+// application framework or component specified (in the template). A stack is a
+// running instance of a template. The result of creating a stack is a deployment
+// of the application framework or component.
+package stacks
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
new file mode 100644
index 0000000..83f5dec
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/fixtures.go
@@ -0,0 +1,604 @@
+package stacks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// CreateExpected represents the expected object from a Create request.
+var CreateExpected = &CreatedStack{
+ ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://168.28.170.117:8004/v1/98606384f58drad0bhdb7d02779549ac/stacks/stackcreated/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Rel: "self",
+ },
+ },
+}
+
+// CreateOutput represents the response body from a Create request.
+const CreateOutput = `
+{
+ "stack": {
+ "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "links": [
+ {
+ "href": "http://168.28.170.117:8004/v1/98606384f58drad0bhdb7d02779549ac/stacks/stackcreated/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "rel": "self"
+ }
+ ]
+ }
+}`
+
+// HandleCreateSuccessfully creates an HTTP handler at `/stacks` on the test handler mux
+// that responds with a `Create` response.
+func HandleCreateSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, output)
+ })
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []ListedStack{
+ ListedStack{
+ Description: "Simple template to test heat commands",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Rel: "self",
+ },
+ },
+ StatusReason: "Stack CREATE completed successfully",
+ Name: "postman_stack",
+ 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",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada",
+ Rel: "self",
+ },
+ },
+ StatusReason: "Stack successfully updated",
+ Name: "gophercloud-test-stack-2",
+ CreationTime: time.Date(2014, 12, 11, 17, 39, 16, 0, time.UTC),
+ 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"},
+ },
+}
+
+// FullListOutput represents the response body from a List request without a marker.
+const FullListOutput = `
+{
+ "stacks": [
+ {
+ "description": "Simple template to test heat commands",
+ "links": [
+ {
+ "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "rel": "self"
+ }
+ ],
+ "stack_status_reason": "Stack CREATE completed successfully",
+ "stack_name": "postman_stack",
+ "creation_time": "2015-02-03T20:07:39",
+ "updated_time": null,
+ "stack_status": "CREATE_COMPLETE",
+ "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "tags": ["rackspace", "atx"]
+ },
+ {
+ "description": "Simple template to test heat commands",
+ "links": [
+ {
+ "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada",
+ "rel": "self"
+ }
+ ],
+ "stack_status_reason": "Stack successfully updated",
+ "stack_name": "gophercloud-test-stack-2",
+ "creation_time": "2014-12-11T17:39:16",
+ "updated_time": "2014-12-11T17:40:37",
+ "stack_status": "UPDATE_COMPLETE",
+ "id": "db6977b2-27aa-4775-9ae7-6213212d4ada",
+ "tags": ["sfo", "satx"]
+ }
+ ]
+}
+`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks` on the test handler mux
+// that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, output)
+ case "db6977b2-27aa-4775-9ae7-6213212d4ada":
+ fmt.Fprintf(w, `[]`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &RetrievedStack{
+ DisableRollback: true,
+ Description: "Simple template to test heat commands",
+ Parameters: map[string]string{
+ "flavor": "m1.tiny",
+ "OS::stack_name": "postman_stack",
+ "OS::stack_id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ },
+ StatusReason: "Stack CREATE completed successfully",
+ Name: "postman_stack",
+ Outputs: []map[string]interface{}{},
+ CreationTime: time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Rel: "self",
+ },
+ },
+ Capabilities: []interface{}{},
+ NotificationTopics: []interface{}{},
+ 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.
+const GetOutput = `
+{
+ "stack": {
+ "disable_rollback": true,
+ "description": "Simple template to test heat commands",
+ "parameters": {
+ "flavor": "m1.tiny",
+ "OS::stack_name": "postman_stack",
+ "OS::stack_id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87"
+ },
+ "stack_status_reason": "Stack CREATE completed successfully",
+ "stack_name": "postman_stack",
+ "outputs": [],
+ "creation_time": "2015-02-03T20:07:39",
+ "links": [
+ {
+ "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "rel": "self"
+ }
+ ],
+ "capabilities": [],
+ "notification_topics": [],
+ "timeout_mins": null,
+ "stack_status": "CREATE_COMPLETE",
+ "updated_time": null,
+ "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "template_description": "Simple template to test heat commands",
+ "tags": ["rackspace", "atx"]
+ }
+}
+`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// HandleUpdateSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with an `Update` response.
+func HandleUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ 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.StatusAccepted)
+ })
+}
+
+// HandleDeleteSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with a `Delete` response.
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada", 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.StatusNoContent)
+ })
+}
+
+// GetExpected represents the expected object from a Get request.
+var PreviewExpected = &PreviewedStack{
+ DisableRollback: true,
+ Description: "Simple template to test heat commands",
+ Parameters: map[string]string{
+ "flavor": "m1.tiny",
+ "OS::stack_name": "postman_stack",
+ "OS::stack_id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ },
+ Name: "postman_stack",
+ CreationTime: time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC),
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Rel: "self",
+ },
+ },
+ Capabilities: []interface{}{},
+ NotificationTopics: []interface{}{},
+ ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ TemplateDescription: "Simple template to test heat commands",
+}
+
+// HandlePreviewSuccessfully creates an HTTP handler at `/stacks/preview`
+// on the test handler mux that responds with a `Preview` response.
+func HandlePreviewSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/preview", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ 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, output)
+ })
+}
+
+// AbandonExpected represents the expected object from an Abandon request.
+var AbandonExpected = &AbandonedStack{
+ Status: "COMPLETE",
+ Name: "postman_stack",
+ 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",
+ },
+ },
+ },
+ },
+ Action: "CREATE",
+ ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Resources: map[string]interface{}{
+ "hello_world": map[string]interface{}{
+ "status": "COMPLETE",
+ "name": "hello_world",
+ "resource_id": "8a310d36-46fc-436f-8be4-37a696b8ac63",
+ "action": "CREATE",
+ "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.
+const AbandonOutput = `
+{
+ "status": "COMPLETE",
+ "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"
+ }
+ }
+ }
+ },
+ "action": "CREATE",
+ "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ "resources": {
+ "hello_world": {
+ "status": "COMPLETE",
+ "name": "hello_world",
+ "resource_id": "8a310d36-46fc-436f-8be4-37a696b8ac63",
+ "action": "CREATE",
+ "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, 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, 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
new file mode 100644
index 0000000..1fc484d
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/requests.go
@@ -0,0 +1,682 @@
+package stacks
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Rollback is used to specify whether or not a stack can be rolled back.
+type Rollback *bool
+
+var (
+ disable = true
+ // Disable is used to specify that a stack cannot be rolled back.
+ Disable Rollback = &disable
+ enable = false
+ // Enable is used to specify that a stack can be rolled back.
+ Enable Rollback = &enable
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToStackCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+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:
+ // import "io/ioutil"
+ // var opts stacks.CreateOpts
+ // b, err := ioutil.ReadFile("path/to/you/template/file.json")
+ // if err != nil {
+ // // handle error...
+ // }
+ // opts.Template = string(b)
+ Template string
+ // (OPTIONAL) Enables or disables deletion of all stack resources when a stack
+ // 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"}`
+ Files map[string]interface{}
+ // (OPTIONAL) User-defined parameters to pass to the template.
+ 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.
+func (opts CreateOpts) ToStackCreateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return s, errors.New("Required field 'Name' not provided.")
+ }
+ s["stack_name"] = opts.Name
+ 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 {
+ 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.Parameters != nil {
+ s["parameters"] = opts.Parameters
+ }
+
+ if opts.Timeout != 0 {
+ s["timeout_mins"] = opts.Timeout
+ }
+
+ if opts.Tags != nil {
+ s["tags"] = strings.Join(opts.Tags, ",")
+ }
+ return s, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new stack using the values
+// provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToStackCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// AdoptOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Adopt function in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type AdoptOptsBuilder interface {
+ ToStackAdoptMap() (map[string]interface{}, error)
+}
+
+// AdoptOpts is the common options struct used in this package's Adopt
+// operation.
+type AdoptOpts struct {
+ // (REQUIRED) Existing resources data represented as a string to add to the
+ // new stack. Data returned by Abandon could be provided as AdoptsStackData.
+ AdoptStackData string
+ // (REQUIRED) The name of the stack. It must start with an alphabetic character.
+ 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:
+ // import "io/ioutil"
+ // var opts stacks.CreateOpts
+ // b, err := ioutil.ReadFile("path/to/you/template/file.json")
+ // if err != nil {
+ // // handle error...
+ // }
+ // opts.Template = string(b)
+ Template string
+ // (OPTIONAL) Enables or disables deletion of all stack resources when a stack
+ // 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"}`
+ Files map[string]interface{}
+ // (OPTIONAL) User-defined parameters to pass to the template.
+ Parameters map[string]string
+}
+
+// ToStackAdoptMap casts a CreateOpts struct to a map.
+func (opts AdoptOpts) ToStackAdoptMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return s, errors.New("Required field 'Name' not provided.")
+ }
+ s["stack_name"] = opts.Name
+ Files := make(map[string]string)
+ if opts.AdoptStackData != "" {
+ s["adopt_stack_data"] = opts.AdoptStackData
+ } else 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("One of AdoptStackData, Template, TemplateURL or TemplateOpts must be provided.")
+ }
+ } else {
+ 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.Parameters != nil {
+ s["parameters"] = opts.Parameters
+ }
+
+ if opts.Timeout != 0 {
+ s["timeout"] = opts.Timeout
+ }
+ s["timeout_mins"] = opts.Timeout
+
+ return s, nil
+}
+
+// Adopt accepts an AdoptOpts struct and creates a new stack using the resources
+// from another stack.
+func Adopt(c *gophercloud.ServiceClient, opts AdoptOptsBuilder) AdoptResult {
+ var res AdoptResult
+
+ reqBody, err := opts.ToStackAdoptMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(adoptURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// SortDir is a type for specifying in which direction to sort a list of stacks.
+type SortDir string
+
+// SortKey is a type for specifying by which key to sort a list of stacks.
+type SortKey string
+
+var (
+ // SortAsc is used to sort a list of stacks in ascending order.
+ SortAsc SortDir = "asc"
+ // SortDesc is used to sort a list of stacks in descending order.
+ SortDesc SortDir = "desc"
+ // SortName is used to sort a list of stacks by name.
+ SortName SortKey = "name"
+ // SortStatus is used to sort a list of stacks by status.
+ SortStatus SortKey = "status"
+ // SortCreatedAt is used to sort a list of stacks by date created.
+ SortCreatedAt SortKey = "created_at"
+ // SortUpdatedAt is used to sort a list of stacks by date updated.
+ SortUpdatedAt SortKey = "updated_at"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToStackListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the network attributes you want to see returned. SortKey allows you to sort
+// by a particular network attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Status string `q:"status"`
+ Name string `q:"name"`
+ Marker string `q:"marker"`
+ Limit int `q:"limit"`
+ SortKey SortKey `q:"sort_keys"`
+ SortDir SortDir `q:"sort_dir"`
+}
+
+// ToStackListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// stacks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToStackListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return StackPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Get retreives a stack based on the stack name and stack ID.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, stackName, stackID), &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Update operation in this package.
+type UpdateOptsBuilder interface {
+ ToStackUpdateMap() (map[string]interface{}, error)
+}
+
+// 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:
+ // import "io/ioutil"
+ // var opts stacks.CreateOpts
+ // b, err := ioutil.ReadFile("path/to/you/template/file.json")
+ // if err != nil {
+ // // handle error...
+ // }
+ // 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"}`
+ Files map[string]interface{}
+ // (OPTIONAL) User-defined parameters to pass to the template.
+ 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{})
+ 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 {
+ 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.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 {
+ s["timeout_mins"] = opts.Timeout
+ }
+
+ if opts.Tags != nil {
+ s["tags"] = strings.Join(opts.Tags, ",")
+ }
+
+ return s, nil
+}
+
+// Update accepts an UpdateOpts struct and updates an existing stack using the values
+// provided.
+func Update(c *gophercloud.ServiceClient, stackName, stackID string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToStackUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(updateURL(c, stackName, stackID), reqBody, nil, nil)
+ return res
+}
+
+// Delete deletes a stack based on the stack name and stack ID.
+func Delete(c *gophercloud.ServiceClient, stackName, stackID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, stackName, stackID), nil)
+ return res
+}
+
+// PreviewOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Preview operation in this package.
+type PreviewOptsBuilder interface {
+ ToStackPreviewMap() (map[string]interface{}, error)
+}
+
+// PreviewOpts contains the common options struct used in this package's Preview
+// operation.
+type PreviewOpts struct {
+ // (REQUIRED) The name of the stack. It must start with an alphabetic character.
+ 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:
+ // import "io/ioutil"
+ // var opts stacks.CreateOpts
+ // b, err := ioutil.ReadFile("path/to/you/template/file.json")
+ // if err != nil {
+ // // handle error...
+ // }
+ // opts.Template = string(b)
+ Template string
+ // (OPTIONAL) Enables or disables deletion of all stack resources when a stack
+ // 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"}`
+ Files map[string]interface{}
+ // (OPTIONAL) User-defined parameters to pass to the template.
+ Parameters map[string]string
+}
+
+// ToStackPreviewMap casts a PreviewOpts struct to a map.
+func (opts PreviewOpts) ToStackPreviewMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return s, errors.New("Required field 'Name' not provided.")
+ }
+ s["stack_name"] = opts.Name
+ 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 {
+ 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.Parameters != nil {
+ s["parameters"] = opts.Parameters
+ }
+
+ if opts.Timeout != 0 {
+ s["timeout_mins"] = opts.Timeout
+ }
+
+ return s, nil
+}
+
+// Preview accepts a PreviewOptsBuilder interface and creates a preview of a stack using the values
+// provided.
+func Preview(c *gophercloud.ServiceClient, opts PreviewOptsBuilder) PreviewResult {
+ var res PreviewResult
+
+ reqBody, err := opts.ToStackPreviewMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Post(previewURL(c), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Abandon deletes the stack with the provided stackName and stackID, but leaves its
+// resources intact, and returns data describing the stack and its resources.
+func Abandon(c *gophercloud.ServiceClient, stackName, stackID string) AbandonResult {
+ var res AbandonResult
+ _, res.Err = c.Delete(abandonURL(c, stackName, stackID), &gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+ return res
+}
diff --git a/openstack/orchestration/v1/stacks/requests_test.go b/openstack/orchestration/v1/stacks/requests_test.go
new file mode 100644
index 0000000..0fde44b
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/requests_test.go
@@ -0,0 +1,358 @@
+package stacks
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreateStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t, CreateOutput)
+
+ createOpts := CreateOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ Template: `
+ {
+ "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"
+ }
+ }
+ }
+ }
+ }`,
+ DisableRollback: Disable,
+ }
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ 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()
+ HandleCreateSuccessfully(t, CreateOutput)
+
+ adoptOpts := AdoptOpts{
+ AdoptStackData: `{environment{parameters{}}}`,
+ Name: "stackcreated",
+ Timeout: 60,
+ Template: `
+ {
+ "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"
+ }
+ }
+ }
+ }
+ }`,
+ DisableRollback: Disable,
+ }
+ actual, err := Adopt(fake.ServiceClient(), adoptOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ 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()
+ HandleListSuccessfully(t, FullListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractStacks(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateSuccessfully(t)
+
+ updateOpts := UpdateOpts{
+ 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 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()
+ HandleDeleteSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestPreviewStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePreviewSuccessfully(t, GetOutput)
+
+ previewOpts := PreviewOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ Template: `
+ {
+ "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"
+ }
+ }
+ }
+ }
+ }`,
+ DisableRollback: Disable,
+ }
+ actual, err := Preview(fake.ServiceClient(), previewOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ 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
new file mode 100644
index 0000000..432bc8e
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/results.go
@@ -0,0 +1,313 @@
+package stacks
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreatedStack represents the object extracted from a Create operation.
+type CreatedStack struct {
+ ID string `mapstructure:"id"`
+ Links []gophercloud.Link `mapstructure:"links"`
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a CreatedStack object and is called after a
+// Create operation.
+func (r CreateResult) Extract() (*CreatedStack, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Stack *CreatedStack `mapstructure:"stack"`
+ }
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return res.Stack, nil
+}
+
+// AdoptResult represents the result of an Adopt operation. AdoptResult has the
+// same form as CreateResult.
+type AdoptResult struct {
+ CreateResult
+}
+
+// StackPage is a pagination.Pager that is returned from a call to the List function.
+type StackPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Stacks.
+func (r StackPage) IsEmpty() (bool, error) {
+ stacks, err := ExtractStacks(r)
+ if err != nil {
+ return true, err
+ }
+ return len(stacks) == 0, nil
+}
+
+// ListedStack represents an element in the slice extracted from a List operation.
+type ListedStack struct {
+ CreationTime time.Time `mapstructure:"-"`
+ Description string `mapstructure:"description"`
+ ID string `mapstructure:"id"`
+ Links []gophercloud.Link `mapstructure:"links"`
+ Name string `mapstructure:"stack_name"`
+ Status string `mapstructure:"stack_status"`
+ StatusReason string `mapstructure:"stack_status_reason"`
+ Tags []string `mapstructure:"tags"`
+ UpdatedTime time.Time `mapstructure:"-"`
+}
+
+// ExtractStacks extracts and returns a slice of ListedStack. It is used while iterating
+// over a stacks.List call.
+func ExtractStacks(page pagination.Page) ([]ListedStack, error) {
+ casted := page.(StackPage).Body
+
+ var res struct {
+ Stacks []ListedStack `mapstructure:"stacks"`
+ }
+
+ err := mapstructure.Decode(casted, &res)
+ if err != nil {
+ return nil, err
+ }
+
+ var rawStacks []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ rawStacks = casted.(map[string]interface{})["stacks"].([]interface{})
+ case map[string][]interface{}:
+ rawStacks = casted.(map[string][]interface{})["stacks"]
+ default:
+ return res.Stacks, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawStacks {
+ thisStack := (rawStacks[i]).(map[string]interface{})
+
+ if t, ok := thisStack["creation_time"].(string); ok && t != "" {
+ creationTime, err := time.Parse(gophercloud.STACK_TIME_FMT, t)
+ if err != nil {
+ return res.Stacks, err
+ }
+ res.Stacks[i].CreationTime = creationTime
+ }
+
+ if t, ok := thisStack["updated_time"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(gophercloud.STACK_TIME_FMT, t)
+ if err != nil {
+ return res.Stacks, err
+ }
+ res.Stacks[i].UpdatedTime = updatedTime
+ }
+ }
+
+ return res.Stacks, nil
+}
+
+// RetrievedStack represents the object extracted from a Get operation.
+type RetrievedStack 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"`
+ NotificationTopics []interface{} `mapstructure:"notification_topics"`
+ Outputs []map[string]interface{} `mapstructure:"outputs"`
+ Parameters map[string]string `mapstructure:"parameters"`
+ 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:"-"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a RetrievedStack object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*RetrievedStack, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Stack *RetrievedStack `mapstructure:"stack"`
+ }
+
+ config := &mapstructure.DecoderConfig{
+ Result: &res,
+ WeaklyTypedInput: true,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := decoder.Decode(r.Body); err != nil {
+ return nil, err
+ }
+
+ b := r.Body.(map[string]interface{})["stack"].(map[string]interface{})
+
+ if date, ok := b["creation_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Stack.CreationTime = t
+ }
+
+ if date, ok := b["updated_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Stack.UpdatedTime = t
+ }
+
+ return res.Stack, err
+}
+
+// UpdateResult represents the result of a Update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// 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 []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.
+type PreviewResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a PreviewedStack object and is called after a
+// Preview operation.
+func (r PreviewResult) Extract() (*PreviewedStack, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Stack *PreviewedStack `mapstructure:"stack"`
+ }
+
+ config := &mapstructure.DecoderConfig{
+ Result: &res,
+ WeaklyTypedInput: true,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := decoder.Decode(r.Body); err != nil {
+ return nil, err
+ }
+
+ b := r.Body.(map[string]interface{})["stack"].(map[string]interface{})
+
+ if date, ok := b["creation_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Stack.CreationTime = t
+ }
+
+ if date, ok := b["updated_time"]; ok && date != nil {
+ t, err := time.Parse(gophercloud.STACK_TIME_FMT, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.Stack.UpdatedTime = t
+ }
+
+ return res.Stack, err
+}
+
+// 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"`
+ 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.
+type AbandonResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to an AbandonedStack object and is called after an
+// Abandon operation.
+func (r AbandonResult) Extract() (*AbandonedStack, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res AbandonedStack
+
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return &res, nil
+}
+
+// String converts an AbandonResult to a string. This is useful to when passing
+// the result of an Abandon operation to an AdoptOpts AdoptStackData field.
+func (r AbandonResult) String() (string, error) {
+ out, err := json.Marshal(r)
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
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/urls.go b/openstack/orchestration/v1/stacks/urls.go
new file mode 100644
index 0000000..3dd2bb3
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/urls.go
@@ -0,0 +1,35 @@
+package stacks
+
+import "github.com/rackspace/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("stacks")
+}
+
+func adoptURL(c *gophercloud.ServiceClient) string {
+ return createURL(c)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return createURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, name, id string) string {
+ return c.ServiceURL("stacks", name, id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, name, id string) string {
+ return getURL(c, name, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, name, id string) string {
+ return getURL(c, name, id)
+}
+
+func previewURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("stacks", "preview")
+}
+
+func abandonURL(c *gophercloud.ServiceClient, name, id string) string {
+ return c.ServiceURL("stacks", name, id, "abandon")
+}
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/doc.go b/openstack/orchestration/v1/stacktemplates/doc.go
new file mode 100644
index 0000000..5af0bd6
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/doc.go
@@ -0,0 +1,8 @@
+// Package stacktemplates provides operations for working with Heat templates.
+// A Cloud Orchestration template is a portable file, written in a user-readable
+// language, that describes how a set of resources should be assembled and what
+// software should be installed in order to produce a working stack. The template
+// specifies what resources should be used, what attributes can be set, and other
+// parameters that are critical to the successful, repeatable automation of a
+// specific application stack.
+package stacktemplates
diff --git a/openstack/orchestration/v1/stacktemplates/fixtures.go b/openstack/orchestration/v1/stacktemplates/fixtures.go
new file mode 100644
index 0000000..fa9b301
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/fixtures.go
@@ -0,0 +1,95 @@
+package stacktemplates
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// GetExpected represents the expected object from a Get request.
+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 = `
+{
+ "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"
+ }
+ }
+ }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/template`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/template", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, output)
+ })
+}
+
+// ValidateExpected represents the expected object from a Validate request.
+var ValidateExpected = &ValidatedTemplate{
+ Description: "Simple template to test heat commands",
+ Parameters: map[string]interface{}{
+ "flavor": map[string]interface{}{
+ "Default": "m1.tiny",
+ "Type": "String",
+ "NoEcho": "false",
+ "Description": "",
+ "Label": "flavor",
+ },
+ },
+}
+
+// ValidateOutput represents the response body from a Validate request.
+const ValidateOutput = `
+{
+ "Description": "Simple template to test heat commands",
+ "Parameters": {
+ "flavor": {
+ "Default": "m1.tiny",
+ "Type": "String",
+ "NoEcho": "false",
+ "Description": "",
+ "Label": "flavor"
+ }
+ }
+}`
+
+// HandleValidateSuccessfully creates an HTTP handler at `/validate`
+// on the test handler mux that responds with a `Validate` response.
+func HandleValidateSuccessfully(t *testing.T, output string) {
+ th.Mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ 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, output)
+ })
+}
diff --git a/openstack/orchestration/v1/stacktemplates/requests.go b/openstack/orchestration/v1/stacktemplates/requests.go
new file mode 100644
index 0000000..c0cea35
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/requests.go
@@ -0,0 +1,58 @@
+package stacktemplates
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) GetResult {
+ var res GetResult
+ _, res.Err = c.Request("GET", getURL(c, stackName, stackID), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ })
+ return res
+}
+
+// ValidateOptsBuilder describes struct types that can be accepted by the Validate call.
+// The ValidateOpts struct in this package does.
+type ValidateOptsBuilder interface {
+ ToStackTemplateValidateMap() (map[string]interface{}, error)
+}
+
+// ValidateOpts specifies the template validation parameters.
+type ValidateOpts struct {
+ 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 != "" {
+ vo["template"] = opts.Template
+ return vo, nil
+ }
+ if opts.TemplateURL != "" {
+ vo["template_url"] = opts.TemplateURL
+ return vo, nil
+ }
+ return vo, fmt.Errorf("One of Template or TemplateURL is required.")
+}
+
+// Validate validates the given stack template.
+func Validate(c *gophercloud.ServiceClient, opts ValidateOptsBuilder) ValidateResult {
+ var res ValidateResult
+
+ reqBody, err := opts.ToStackTemplateValidateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(validateURL(c), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
diff --git a/openstack/orchestration/v1/stacktemplates/requests_test.go b/openstack/orchestration/v1/stacktemplates/requests_test.go
new file mode 100644
index 0000000..42667c9
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/requests_test.go
@@ -0,0 +1,57 @@
+package stacktemplates
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, string(actual))
+}
+
+func TestValidateTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleValidateSuccessfully(t, ValidateOutput)
+
+ opts := ValidateOpts{
+ 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)
+
+ expected := ValidateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stacktemplates/results.go b/openstack/orchestration/v1/stacktemplates/results.go
new file mode 100644
index 0000000..4e9ba5a
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/results.go
@@ -0,0 +1,51 @@
+package stacktemplates
+
+import (
+ "encoding/json"
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+)
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// 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
+ }
+ template, err := json.MarshalIndent(r.Body, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return template, nil
+}
+
+// ValidatedTemplate represents the parsed object returned from a Validate request.
+type ValidatedTemplate struct {
+ Description string `mapstructure:"Description"`
+ Parameters map[string]interface{} `mapstructure:"Parameters"`
+ ParameterGroups map[string]interface{} `mapstructure:"ParameterGroups"`
+}
+
+// ValidateResult represents the result of a Validate operation.
+type ValidateResult struct {
+ gophercloud.Result
+}
+
+// Extract returns a pointer to a ValidatedTemplate object and is called after a
+// Validate operation.
+func (r ValidateResult) Extract() (*ValidatedTemplate, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res ValidatedTemplate
+ if err := mapstructure.Decode(r.Body, &res); err != nil {
+ return nil, err
+ }
+
+ return &res, nil
+}
diff --git a/openstack/orchestration/v1/stacktemplates/urls.go b/openstack/orchestration/v1/stacktemplates/urls.go
new file mode 100644
index 0000000..c30b7ca
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/urls.go
@@ -0,0 +1,11 @@
+package stacktemplates
+
+import "github.com/rackspace/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+ return c.ServiceURL("stacks", stackName, stackID, "template")
+}
+
+func validateURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("validate")
+}
diff --git a/openstack/utils/choose_version.go b/openstack/utils/choose_version.go
new file mode 100644
index 0000000..b697ba8
--- /dev/null
+++ b/openstack/utils/choose_version.go
@@ -0,0 +1,114 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// Version is a supported API version, corresponding to a vN package within the appropriate service.
+type Version struct {
+ ID string
+ Suffix string
+ Priority int
+}
+
+var goodStatus = map[string]bool{
+ "current": true,
+ "supported": true,
+ "stable": true,
+}
+
+// ChooseVersion queries the base endpoint of an API to choose the most recent non-experimental alternative from a service's
+// published versions.
+// It returns the highest-Priority Version among the alternatives that are provided, as well as its corresponding endpoint.
+func ChooseVersion(client *gophercloud.ProviderClient, recognized []*Version) (*Version, string, error) {
+ type linkResp struct {
+ Href string `json:"href"`
+ Rel string `json:"rel"`
+ }
+
+ type valueResp struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Links []linkResp `json:"links"`
+ }
+
+ type versionsResp struct {
+ Values []valueResp `json:"values"`
+ }
+
+ type response struct {
+ Versions versionsResp `json:"versions"`
+ }
+
+ normalize := func(endpoint string) string {
+ if !strings.HasSuffix(endpoint, "/") {
+ return endpoint + "/"
+ }
+ return endpoint
+ }
+ identityEndpoint := normalize(client.IdentityEndpoint)
+
+ // If a full endpoint is specified, check version suffixes for a match first.
+ for _, v := range recognized {
+ if strings.HasSuffix(identityEndpoint, v.Suffix) {
+ return v, identityEndpoint, nil
+ }
+ }
+
+ var resp response
+ _, err := client.Request("GET", client.IdentityBase, gophercloud.RequestOpts{
+ JSONResponse: &resp,
+ OkCodes: []int{200, 300},
+ })
+
+ if err != nil {
+ return nil, "", err
+ }
+
+ byID := make(map[string]*Version)
+ for _, version := range recognized {
+ byID[version.ID] = version
+ }
+
+ var highest *Version
+ var endpoint string
+
+ for _, value := range resp.Versions.Values {
+ href := ""
+ for _, link := range value.Links {
+ if link.Rel == "self" {
+ href = normalize(link.Href)
+ }
+ }
+
+ if matching, ok := byID[value.ID]; ok {
+ // Prefer a version that exactly matches the provided endpoint.
+ if href == identityEndpoint {
+ if href == "" {
+ return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", value.ID, client.IdentityBase)
+ }
+ return matching, href, nil
+ }
+
+ // Otherwise, find the highest-priority version with a whitelisted status.
+ if goodStatus[strings.ToLower(value.Status)] {
+ if highest == nil || matching.Priority > highest.Priority {
+ highest = matching
+ endpoint = href
+ }
+ }
+ }
+ }
+
+ if highest == nil {
+ return nil, "", fmt.Errorf("No supported version available from endpoint %s", client.IdentityBase)
+ }
+ if endpoint == "" {
+ return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", highest.ID, client.IdentityBase)
+ }
+
+ return highest, endpoint, nil
+}
diff --git a/openstack/utils/choose_version_test.go b/openstack/utils/choose_version_test.go
new file mode 100644
index 0000000..388d689
--- /dev/null
+++ b/openstack/utils/choose_version_test.go
@@ -0,0 +1,118 @@
+package utils
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func setupVersionHandler() {
+ testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "versions": {
+ "values": [
+ {
+ "status": "stable",
+ "id": "v3.0",
+ "links": [
+ { "href": "%s/v3.0", "rel": "self" }
+ ]
+ },
+ {
+ "status": "stable",
+ "id": "v2.0",
+ "links": [
+ { "href": "%s/v2.0", "rel": "self" }
+ ]
+ }
+ ]
+ }
+ }
+ `, testhelper.Server.URL, testhelper.Server.URL)
+ })
+}
+
+func TestChooseVersion(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ setupVersionHandler()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "blarg"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "hargl"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: "",
+ }
+ v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v3 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v3, v)
+ }
+
+ expected := testhelper.Endpoint() + "v3.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
+
+func TestChooseVersionOpinionatedLink(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+ setupVersionHandler()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "nope"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "northis"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+ }
+ v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v2 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+ }
+
+ expected := testhelper.Endpoint() + "v2.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
+
+func TestChooseVersionFromSuffix(t *testing.T) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "/v2.0/"}
+ v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "/v3.0/"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+ }
+ v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+ if err != nil {
+ t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+ }
+
+ if v != v2 {
+ t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+ }
+
+ expected := testhelper.Endpoint() + "v2.0/"
+ if endpoint != expected {
+ t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+ }
+}
diff --git a/pagination/http.go b/pagination/http.go
new file mode 100644
index 0000000..1b3fe94
--- /dev/null
+++ b/pagination/http.go
@@ -0,0 +1,60 @@
+package pagination
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// PageResult stores the HTTP response that returned the current page of results.
+type PageResult struct {
+ gophercloud.Result
+ url.URL
+}
+
+// PageResultFrom parses an HTTP response as JSON and returns a PageResult containing the
+// results, interpreting it as JSON if the content type indicates.
+func PageResultFrom(resp *http.Response) (PageResult, error) {
+ var parsedBody interface{}
+
+ defer resp.Body.Close()
+ rawBody, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return PageResult{}, err
+ }
+
+ if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
+ err = json.Unmarshal(rawBody, &parsedBody)
+ if err != nil {
+ return PageResult{}, err
+ }
+ } else {
+ parsedBody = rawBody
+ }
+
+ return PageResultFromParsed(resp, parsedBody), err
+}
+
+// PageResultFromParsed constructs a PageResult from an HTTP response that has already had its
+// body parsed as JSON (and closed).
+func PageResultFromParsed(resp *http.Response, body interface{}) PageResult {
+ return PageResult{
+ Result: gophercloud.Result{
+ Body: body,
+ Header: resp.Header,
+ },
+ URL: *resp.Request.URL,
+ }
+}
+
+// Request performs an HTTP request and extracts the http.Response from the result.
+func Request(client *gophercloud.ServiceClient, headers map[string]string, url string) (*http.Response, error) {
+ return client.Request("GET", url, gophercloud.RequestOpts{
+ MoreHeaders: headers,
+ OkCodes: []int{200, 204},
+ })
+}
diff --git a/pagination/linked.go b/pagination/linked.go
new file mode 100644
index 0000000..e9bd8de
--- /dev/null
+++ b/pagination/linked.go
@@ -0,0 +1,67 @@
+package pagination
+
+import "fmt"
+
+// LinkedPageBase may be embedded to implement a page that provides navigational "Next" and "Previous" links within its result.
+type LinkedPageBase struct {
+ PageResult
+
+ // LinkPath lists the keys that should be traversed within a response to arrive at the "next" pointer.
+ // If any link along the path is missing, an empty URL will be returned.
+ // If any link results in an unexpected value type, an error will be returned.
+ // When left as "nil", []string{"links", "next"} will be used as a default.
+ LinkPath []string
+}
+
+// NextPageURL extracts the pagination structure from a JSON response and returns the "next" link, if one is present.
+// It assumes that the links are available in a "links" element of the top-level response object.
+// If this is not the case, override NextPageURL on your result type.
+func (current LinkedPageBase) NextPageURL() (string, error) {
+ var path []string
+ var key string
+
+ if current.LinkPath == nil {
+ path = []string{"links", "next"}
+ } else {
+ path = current.LinkPath
+ }
+
+ submap, ok := current.Body.(map[string]interface{})
+ if !ok {
+ return "", fmt.Errorf("Expected an object, but was %#v", current.Body)
+ }
+
+ for {
+ key, path = path[0], path[1:len(path)]
+
+ value, ok := submap[key]
+ if !ok {
+ return "", nil
+ }
+
+ if len(path) > 0 {
+ submap, ok = value.(map[string]interface{})
+ if !ok {
+ return "", fmt.Errorf("Expected an object, but was %#v", value)
+ }
+ } else {
+ if value == nil {
+ // Actual null element.
+ return "", nil
+ }
+
+ url, ok := value.(string)
+ if !ok {
+ return "", fmt.Errorf("Expected a string, but was %#v", value)
+ }
+
+ return url, nil
+ }
+ }
+}
+
+// GetBody returns the linked page's body. This method is needed to satisfy the
+// Page interface.
+func (current LinkedPageBase) GetBody() interface{} {
+ return current.Body
+}
diff --git a/pagination/linked_test.go b/pagination/linked_test.go
new file mode 100644
index 0000000..1ac0f73
--- /dev/null
+++ b/pagination/linked_test.go
@@ -0,0 +1,120 @@
+package pagination
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// LinkedPager sample and test cases.
+
+type LinkedPageResult struct {
+ LinkedPageBase
+}
+
+func (r LinkedPageResult) IsEmpty() (bool, error) {
+ is, err := ExtractLinkedInts(r)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+func ExtractLinkedInts(page Page) ([]int, error) {
+ var response struct {
+ Ints []int `mapstructure:"ints"`
+ }
+
+ err := mapstructure.Decode(page.(LinkedPageResult).Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return response.Ints, nil
+}
+
+func createLinked(t *testing.T) Pager {
+ testhelper.SetupHTTP()
+
+ testhelper.Mux.HandleFunc("/page1", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{ "ints": [1, 2, 3], "links": { "next": "%s/page2" } }`, testhelper.Server.URL)
+ })
+
+ testhelper.Mux.HandleFunc("/page2", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{ "ints": [4, 5, 6], "links": { "next": "%s/page3" } }`, testhelper.Server.URL)
+ })
+
+ testhelper.Mux.HandleFunc("/page3", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{ "ints": [7, 8, 9], "links": { "next": null } }`)
+ })
+
+ client := createClient()
+
+ createPage := func(r PageResult) Page {
+ return LinkedPageResult{LinkedPageBase{PageResult: r}}
+ }
+
+ return NewPager(client, testhelper.Server.URL+"/page1", createPage)
+}
+
+func TestEnumerateLinked(t *testing.T) {
+ pager := createLinked(t)
+ defer testhelper.TeardownHTTP()
+
+ callCount := 0
+ err := pager.EachPage(func(page Page) (bool, error) {
+ actual, err := ExtractLinkedInts(page)
+ if err != nil {
+ return false, err
+ }
+
+ t.Logf("Handler invoked with %v", actual)
+
+ var expected []int
+ switch callCount {
+ case 0:
+ expected = []int{1, 2, 3}
+ case 1:
+ expected = []int{4, 5, 6}
+ case 2:
+ expected = []int{7, 8, 9}
+ default:
+ t.Fatalf("Unexpected call count: %d", callCount)
+ return false, nil
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Call %d: Expected %#v, but was %#v", callCount, expected, actual)
+ }
+
+ callCount++
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("Unexpected error for page iteration: %v", err)
+ }
+
+ if callCount != 3 {
+ t.Errorf("Expected 3 calls, but was %d", callCount)
+ }
+}
+
+func TestAllPagesLinked(t *testing.T) {
+ pager := createLinked(t)
+ defer testhelper.TeardownHTTP()
+
+ page, err := pager.AllPages()
+ testhelper.AssertNoErr(t, err)
+
+ expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
+ actual, err := ExtractLinkedInts(page)
+ testhelper.AssertNoErr(t, err)
+ testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/pagination/marker.go b/pagination/marker.go
new file mode 100644
index 0000000..f355afc
--- /dev/null
+++ b/pagination/marker.go
@@ -0,0 +1,40 @@
+package pagination
+
+// MarkerPage is a stricter Page interface that describes additional functionality required for use with NewMarkerPager.
+// For convenience, embed the MarkedPageBase struct.
+type MarkerPage interface {
+ Page
+
+ // LastMarker returns the last "marker" value on this page.
+ LastMarker() (string, error)
+}
+
+// MarkerPageBase is a page in a collection that's paginated by "limit" and "marker" query parameters.
+type MarkerPageBase struct {
+ PageResult
+
+ // Owner is a reference to the embedding struct.
+ Owner MarkerPage
+}
+
+// NextPageURL generates the URL for the page of results after this one.
+func (current MarkerPageBase) NextPageURL() (string, error) {
+ currentURL := current.URL
+
+ mark, err := current.Owner.LastMarker()
+ if err != nil {
+ return "", err
+ }
+
+ q := currentURL.Query()
+ q.Set("marker", mark)
+ currentURL.RawQuery = q.Encode()
+
+ return currentURL.String(), nil
+}
+
+// GetBody returns the linked page's body. This method is needed to satisfy the
+// Page interface.
+func (current MarkerPageBase) GetBody() interface{} {
+ return current.Body
+}
diff --git a/pagination/marker_test.go b/pagination/marker_test.go
new file mode 100644
index 0000000..f4d55be
--- /dev/null
+++ b/pagination/marker_test.go
@@ -0,0 +1,126 @@
+package pagination
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// MarkerPager sample and test cases.
+
+type MarkerPageResult struct {
+ MarkerPageBase
+}
+
+func (r MarkerPageResult) IsEmpty() (bool, error) {
+ results, err := ExtractMarkerStrings(r)
+ if err != nil {
+ return true, err
+ }
+ return len(results) == 0, err
+}
+
+func (r MarkerPageResult) LastMarker() (string, error) {
+ results, err := ExtractMarkerStrings(r)
+ if err != nil {
+ return "", err
+ }
+ if len(results) == 0 {
+ return "", nil
+ }
+ return results[len(results)-1], nil
+}
+
+func createMarkerPaged(t *testing.T) Pager {
+ testhelper.SetupHTTP()
+
+ testhelper.Mux.HandleFunc("/page", func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ ms := r.Form["marker"]
+ switch {
+ case len(ms) == 0:
+ fmt.Fprintf(w, "aaa\nbbb\nccc")
+ case len(ms) == 1 && ms[0] == "ccc":
+ fmt.Fprintf(w, "ddd\neee\nfff")
+ case len(ms) == 1 && ms[0] == "fff":
+ fmt.Fprintf(w, "ggg\nhhh\niii")
+ case len(ms) == 1 && ms[0] == "iii":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Errorf("Request with unexpected marker: [%v]", ms)
+ }
+ })
+
+ client := createClient()
+
+ createPage := func(r PageResult) Page {
+ p := MarkerPageResult{MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ return NewPager(client, testhelper.Server.URL+"/page", createPage)
+}
+
+func ExtractMarkerStrings(page Page) ([]string, error) {
+ content := page.(MarkerPageResult).Body.([]uint8)
+ parts := strings.Split(string(content), "\n")
+ results := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if len(part) > 0 {
+ results = append(results, part)
+ }
+ }
+ return results, nil
+}
+
+func TestEnumerateMarker(t *testing.T) {
+ pager := createMarkerPaged(t)
+ defer testhelper.TeardownHTTP()
+
+ callCount := 0
+ err := pager.EachPage(func(page Page) (bool, error) {
+ actual, err := ExtractMarkerStrings(page)
+ if err != nil {
+ return false, err
+ }
+
+ t.Logf("Handler invoked with %v", actual)
+
+ var expected []string
+ switch callCount {
+ case 0:
+ expected = []string{"aaa", "bbb", "ccc"}
+ case 1:
+ expected = []string{"ddd", "eee", "fff"}
+ case 2:
+ expected = []string{"ggg", "hhh", "iii"}
+ default:
+ t.Fatalf("Unexpected call count: %d", callCount)
+ return false, nil
+ }
+
+ testhelper.CheckDeepEquals(t, expected, actual)
+
+ callCount++
+ return true, nil
+ })
+ testhelper.AssertNoErr(t, err)
+ testhelper.AssertEquals(t, callCount, 3)
+}
+
+func TestAllPagesMarker(t *testing.T) {
+ pager := createMarkerPaged(t)
+ defer testhelper.TeardownHTTP()
+
+ page, err := pager.AllPages()
+ testhelper.AssertNoErr(t, err)
+
+ expected := []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii"}
+ actual, err := ExtractMarkerStrings(page)
+ testhelper.AssertNoErr(t, err)
+ testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/pagination/null.go b/pagination/null.go
new file mode 100644
index 0000000..ae57e18
--- /dev/null
+++ b/pagination/null.go
@@ -0,0 +1,20 @@
+package pagination
+
+// nullPage is an always-empty page that trivially satisfies all Page interfacts.
+// It's useful to be returned along with an error.
+type nullPage struct{}
+
+// NextPageURL always returns "" to indicate that there are no more pages to return.
+func (p nullPage) NextPageURL() (string, error) {
+ return "", nil
+}
+
+// IsEmpty always returns true to prevent iteration over nullPages.
+func (p nullPage) IsEmpty() (bool, error) {
+ return true, nil
+}
+
+// LastMark always returns "" because the nullPage contains no items to have a mark.
+func (p nullPage) LastMark() (string, error) {
+ return "", nil
+}
diff --git a/pagination/pager.go b/pagination/pager.go
new file mode 100644
index 0000000..a7593ac
--- /dev/null
+++ b/pagination/pager.go
@@ -0,0 +1,226 @@
+package pagination
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+var (
+ // ErrPageNotAvailable is returned from a Pager when a next or previous page is requested, but does not exist.
+ ErrPageNotAvailable = errors.New("The requested page does not exist.")
+)
+
+// Page must be satisfied by the result type of any resource collection.
+// It allows clients to interact with the resource uniformly, regardless of whether or not or how it's paginated.
+// Generally, rather than implementing this interface directly, implementors should embed one of the concrete PageBase structs,
+// instead.
+// Depending on the pagination strategy of a particular resource, there may be an additional subinterface that the result type
+// will need to implement.
+type Page interface {
+
+ // NextPageURL generates the URL for the page of data that follows this collection.
+ // Return "" if no such page exists.
+ NextPageURL() (string, error)
+
+ // IsEmpty returns true if this Page has no items in it.
+ IsEmpty() (bool, error)
+
+ // GetBody returns the Page Body. This is used in the `AllPages` method.
+ GetBody() interface{}
+}
+
+// Pager knows how to advance through a specific resource collection, one page at a time.
+type Pager struct {
+ client *gophercloud.ServiceClient
+
+ initialURL string
+
+ createPage func(r PageResult) Page
+
+ Err error
+
+ // Headers supplies additional HTTP headers to populate on each paged request.
+ Headers map[string]string
+}
+
+// NewPager constructs a manually-configured pager.
+// Supply the URL for the first page, a function that requests a specific page given a URL, and a function that counts a page.
+func NewPager(client *gophercloud.ServiceClient, initialURL string, createPage func(r PageResult) Page) Pager {
+ return Pager{
+ client: client,
+ initialURL: initialURL,
+ createPage: createPage,
+ }
+}
+
+// WithPageCreator returns a new Pager that substitutes a different page creation function. This is
+// useful for overriding List functions in delegation.
+func (p Pager) WithPageCreator(createPage func(r PageResult) Page) Pager {
+ return Pager{
+ client: p.client,
+ initialURL: p.initialURL,
+ createPage: createPage,
+ }
+}
+
+func (p Pager) fetchNextPage(url string) (Page, error) {
+ resp, err := Request(p.client, p.Headers, url)
+ if err != nil {
+ return nil, err
+ }
+
+ remembered, err := PageResultFrom(resp)
+ if err != nil {
+ return nil, err
+ }
+
+ return p.createPage(remembered), nil
+}
+
+// EachPage iterates over each page returned by a Pager, yielding one at a time to a handler function.
+// Return "false" from the handler to prematurely stop iterating.
+func (p Pager) EachPage(handler func(Page) (bool, error)) error {
+ if p.Err != nil {
+ return p.Err
+ }
+ currentURL := p.initialURL
+ for {
+ currentPage, err := p.fetchNextPage(currentURL)
+ if err != nil {
+ return err
+ }
+
+ empty, err := currentPage.IsEmpty()
+ if err != nil {
+ return err
+ }
+ if empty {
+ return nil
+ }
+
+ ok, err := handler(currentPage)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return nil
+ }
+
+ currentURL, err = currentPage.NextPageURL()
+ if err != nil {
+ return err
+ }
+ if currentURL == "" {
+ return nil
+ }
+ }
+}
+
+// AllPages returns all the pages from a `List` operation in a single page,
+// allowing the user to retrieve all the pages at once.
+func (p Pager) AllPages() (Page, error) {
+ // pagesSlice holds all the pages until they get converted into as Page Body.
+ var pagesSlice []interface{}
+ // body will contain the final concatenated Page body.
+ var body reflect.Value
+
+ // Grab a test page to ascertain the page body type.
+ testPage, err := p.fetchNextPage(p.initialURL)
+ if err != nil {
+ return nil, err
+ }
+ // Store the page type so we can use reflection to create a new mega-page of
+ // that type.
+ pageType := reflect.TypeOf(testPage)
+
+ // Switch on the page body type. Recognized types are `map[string]interface{}`,
+ // `[]byte`, and `[]interface{}`.
+ switch testPage.GetBody().(type) {
+ case map[string]interface{}:
+ // key is the map key for the page body if the body type is `map[string]interface{}`.
+ var key string
+ // Iterate over the pages to concatenate the bodies.
+ err := p.EachPage(func(page Page) (bool, error) {
+ b := page.GetBody().(map[string]interface{})
+ for k := range b {
+ // If it's a linked page, we don't want the `links`, we want the other one.
+ if !strings.HasSuffix(k, "links") {
+ key = k
+ }
+ }
+ pagesSlice = append(pagesSlice, b[key].([]interface{})...)
+ return true, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ // Set body to value of type `map[string]interface{}`
+ body = reflect.MakeMap(reflect.MapOf(reflect.TypeOf(key), reflect.TypeOf(pagesSlice)))
+ body.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(pagesSlice))
+ case []byte:
+ // Iterate over the pages to concatenate the bodies.
+ err := p.EachPage(func(page Page) (bool, error) {
+ b := page.GetBody().([]byte)
+ pagesSlice = append(pagesSlice, b)
+ // seperate pages with a comma
+ pagesSlice = append(pagesSlice, []byte{10})
+ return true, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(pagesSlice) > 0 {
+ // Remove the trailing comma.
+ pagesSlice = pagesSlice[:len(pagesSlice)-1]
+ }
+ var b []byte
+ // Combine the slice of slices in to a single slice.
+ for _, slice := range pagesSlice {
+ b = append(b, slice.([]byte)...)
+ }
+ // Set body to value of type `bytes`.
+ body = reflect.New(reflect.TypeOf(b)).Elem()
+ body.SetBytes(b)
+ case []interface{}:
+ // Iterate over the pages to concatenate the bodies.
+ err := p.EachPage(func(page Page) (bool, error) {
+ b := page.GetBody().([]interface{})
+ pagesSlice = append(pagesSlice, b...)
+ return true, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ // Set body to value of type `[]interface{}`
+ body = reflect.MakeSlice(reflect.TypeOf(pagesSlice), len(pagesSlice), len(pagesSlice))
+ for i, s := range pagesSlice {
+ body.Index(i).Set(reflect.ValueOf(s))
+ }
+ default:
+ return nil, fmt.Errorf("Page body has unrecognized type.")
+ }
+
+ // Each `Extract*` function is expecting a specific type of page coming back,
+ // otherwise the type assertion in those functions will fail. pageType is needed
+ // to create a type in this method that has the same type that the `Extract*`
+ // function is expecting and set the Body of that object to the concatenated
+ // pages.
+ page := reflect.New(pageType)
+ // Set the page body to be the concatenated pages.
+ page.Elem().FieldByName("Body").Set(body)
+ // Set any additional headers that were pass along. The `objectstorage` pacakge,
+ // for example, passes a Content-Type header.
+ h := make(http.Header)
+ for k, v := range p.Headers {
+ h.Add(k, v)
+ }
+ page.Elem().FieldByName("Header").Set(reflect.ValueOf(h))
+ // Type assert the page to a Page interface so that the type assertion in the
+ // `Extract*` methods will work.
+ return page.Elem().Interface().(Page), err
+}
diff --git a/pagination/pagination_test.go b/pagination/pagination_test.go
new file mode 100644
index 0000000..f3e4de1
--- /dev/null
+++ b/pagination/pagination_test.go
@@ -0,0 +1,13 @@
+package pagination
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+func createClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{TokenID: "abc123"},
+ Endpoint: testhelper.Endpoint(),
+ }
+}
diff --git a/pagination/pkg.go b/pagination/pkg.go
new file mode 100644
index 0000000..912daea
--- /dev/null
+++ b/pagination/pkg.go
@@ -0,0 +1,4 @@
+/*
+Package pagination contains utilities and convenience structs that implement common pagination idioms within OpenStack APIs.
+*/
+package pagination
diff --git a/pagination/single.go b/pagination/single.go
new file mode 100644
index 0000000..f78d4ab
--- /dev/null
+++ b/pagination/single.go
@@ -0,0 +1,15 @@
+package pagination
+
+// SinglePageBase may be embedded in a Page that contains all of the results from an operation at once.
+type SinglePageBase PageResult
+
+// NextPageURL always returns "" to indicate that there are no more pages to return.
+func (current SinglePageBase) NextPageURL() (string, error) {
+ return "", nil
+}
+
+// GetBody returns the single page's body. This method is needed to satisfy the
+// Page interface.
+func (current SinglePageBase) GetBody() interface{} {
+ return current.Body
+}
diff --git a/pagination/single_test.go b/pagination/single_test.go
new file mode 100644
index 0000000..4af0fee
--- /dev/null
+++ b/pagination/single_test.go
@@ -0,0 +1,84 @@
+package pagination
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// SinglePage sample and test cases.
+
+type SinglePageResult struct {
+ SinglePageBase
+}
+
+func (r SinglePageResult) IsEmpty() (bool, error) {
+ is, err := ExtractSingleInts(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+func ExtractSingleInts(page Page) ([]int, error) {
+ var response struct {
+ Ints []int `mapstructure:"ints"`
+ }
+
+ err := mapstructure.Decode(page.(SinglePageResult).Body, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return response.Ints, nil
+}
+
+func setupSinglePaged() Pager {
+ testhelper.SetupHTTP()
+ client := createClient()
+
+ testhelper.Mux.HandleFunc("/only", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{ "ints": [1, 2, 3] }`)
+ })
+
+ createPage := func(r PageResult) Page {
+ return SinglePageResult{SinglePageBase(r)}
+ }
+
+ return NewPager(client, testhelper.Server.URL+"/only", createPage)
+}
+
+func TestEnumerateSinglePaged(t *testing.T) {
+ callCount := 0
+ pager := setupSinglePaged()
+ defer testhelper.TeardownHTTP()
+
+ err := pager.EachPage(func(page Page) (bool, error) {
+ callCount++
+
+ expected := []int{1, 2, 3}
+ actual, err := ExtractSingleInts(page)
+ testhelper.AssertNoErr(t, err)
+ testhelper.CheckDeepEquals(t, expected, actual)
+ return true, nil
+ })
+ testhelper.CheckNoErr(t, err)
+ testhelper.CheckEquals(t, 1, callCount)
+}
+
+func TestAllPagesSingle(t *testing.T) {
+ pager := setupSinglePaged()
+ defer testhelper.TeardownHTTP()
+
+ page, err := pager.AllPages()
+ testhelper.AssertNoErr(t, err)
+
+ expected := []int{1, 2, 3}
+ actual, err := ExtractSingleInts(page)
+ testhelper.AssertNoErr(t, err)
+ testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/params.go b/params.go
new file mode 100644
index 0000000..4d0f1e6
--- /dev/null
+++ b/params.go
@@ -0,0 +1,271 @@
+package gophercloud
+
+import (
+ "fmt"
+ "net/url"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// EnabledState is a convenience type, mostly used in Create and Update
+// operations. Because the zero value of a bool is FALSE, we need to use a
+// pointer instead to indicate zero-ness.
+type EnabledState *bool
+
+// Convenience vars for EnabledState values.
+var (
+ iTrue = true
+ iFalse = false
+
+ Enabled EnabledState = &iTrue
+ Disabled EnabledState = &iFalse
+)
+
+// IntToPointer is a function for converting integers into integer pointers.
+// This is useful when passing in options to operations.
+func IntToPointer(i int) *int {
+ return &i
+}
+
+/*
+MaybeString is an internal function to be used by request methods in individual
+resource packages.
+
+It takes a string that might be a zero value and returns either a pointer to its
+address or nil. This is useful for allowing users to conveniently omit values
+from an options struct by leaving them zeroed, but still pass nil to the JSON
+serializer so they'll be omitted from the request body.
+*/
+func MaybeString(original string) *string {
+ if original != "" {
+ return &original
+ }
+ return nil
+}
+
+/*
+MaybeInt is an internal function to be used by request methods in individual
+resource packages.
+
+Like MaybeString, it accepts an int that may or may not be a zero value, and
+returns either a pointer to its address or nil. It's intended to hint that the
+JSON serializer should omit its field.
+*/
+func MaybeInt(original int) *int {
+ if original != 0 {
+ return &original
+ }
+ return nil
+}
+
+var t time.Time
+
+func isZero(v reflect.Value) bool {
+ switch v.Kind() {
+ case reflect.Func, reflect.Map, reflect.Slice:
+ return v.IsNil()
+ case reflect.Array:
+ z := true
+ for i := 0; i < v.Len(); i++ {
+ z = z && isZero(v.Index(i))
+ }
+ return z
+ case reflect.Struct:
+ if v.Type() == reflect.TypeOf(t) {
+ if v.Interface().(time.Time).IsZero() {
+ return true
+ }
+ return false
+ }
+ z := true
+ for i := 0; i < v.NumField(); i++ {
+ z = z && isZero(v.Field(i))
+ }
+ return z
+ }
+ // Compare other types directly:
+ z := reflect.Zero(v.Type())
+ return v.Interface() == z.Interface()
+}
+
+/*
+BuildQueryString is an internal function to be used by request methods in
+individual resource packages.
+
+It accepts a tagged structure and expands it into a URL struct. Field names are
+converted into query parameters based on a "q" tag. For example:
+
+ type struct Something {
+ Bar string `q:"x_bar"`
+ Baz int `q:"lorem_ipsum"`
+ }
+
+ instance := Something{
+ Bar: "AAA",
+ Baz: "BBB",
+ }
+
+will be converted into "?x_bar=AAA&lorem_ipsum=BBB".
+
+The struct's fields may be strings, integers, or boolean values. Fields left at
+their type's zero value will be omitted from the query.
+*/
+func BuildQueryString(opts interface{}) (*url.URL, error) {
+ optsValue := reflect.ValueOf(opts)
+ if optsValue.Kind() == reflect.Ptr {
+ optsValue = optsValue.Elem()
+ }
+
+ optsType := reflect.TypeOf(opts)
+ if optsType.Kind() == reflect.Ptr {
+ optsType = optsType.Elem()
+ }
+
+ params := url.Values{}
+
+ if optsValue.Kind() == reflect.Struct {
+ for i := 0; i < optsValue.NumField(); i++ {
+ v := optsValue.Field(i)
+ f := optsType.Field(i)
+ qTag := f.Tag.Get("q")
+
+ // if the field has a 'q' tag, it goes in the query string
+ if qTag != "" {
+ tags := strings.Split(qTag, ",")
+
+ // if the field is set, add it to the slice of query pieces
+ if !isZero(v) {
+ switch v.Kind() {
+ case reflect.String:
+ params.Add(tags[0], v.String())
+ case reflect.Int:
+ params.Add(tags[0], strconv.FormatInt(v.Int(), 10))
+ case reflect.Bool:
+ params.Add(tags[0], strconv.FormatBool(v.Bool()))
+ case reflect.Slice:
+ switch v.Type().Elem() {
+ case reflect.TypeOf(0):
+ for i := 0; i < v.Len(); i++ {
+ params.Add(tags[0], strconv.FormatInt(v.Index(i).Int(), 10))
+ }
+ default:
+ for i := 0; i < v.Len(); i++ {
+ params.Add(tags[0], v.Index(i).String())
+ }
+ }
+ }
+ } else {
+ // Otherwise, the field is not set.
+ if len(tags) == 2 && tags[1] == "required" {
+ // And the field is required. Return an error.
+ return nil, fmt.Errorf("Required query parameter [%s] not set.", f.Name)
+ }
+ }
+ }
+ }
+
+ return &url.URL{RawQuery: params.Encode()}, nil
+ }
+ // Return an error if the underlying type of 'opts' isn't a struct.
+ return nil, fmt.Errorf("Options type is not a struct.")
+}
+
+/*
+BuildHeaders is an internal function to be used by request methods in
+individual resource packages.
+
+It accepts an arbitrary tagged structure and produces a string map that's
+suitable for use as the HTTP headers of an outgoing request. Field names are
+mapped to header names based in "h" tags.
+
+ type struct Something {
+ Bar string `h:"x_bar"`
+ Baz int `h:"lorem_ipsum"`
+ }
+
+ instance := Something{
+ Bar: "AAA",
+ Baz: "BBB",
+ }
+
+will be converted into:
+
+ map[string]string{
+ "x_bar": "AAA",
+ "lorem_ipsum": "BBB",
+ }
+
+Untagged fields and fields left at their zero values are skipped. Integers,
+booleans and string values are supported.
+*/
+func BuildHeaders(opts interface{}) (map[string]string, error) {
+ optsValue := reflect.ValueOf(opts)
+ if optsValue.Kind() == reflect.Ptr {
+ optsValue = optsValue.Elem()
+ }
+
+ optsType := reflect.TypeOf(opts)
+ if optsType.Kind() == reflect.Ptr {
+ optsType = optsType.Elem()
+ }
+
+ optsMap := make(map[string]string)
+ if optsValue.Kind() == reflect.Struct {
+ for i := 0; i < optsValue.NumField(); i++ {
+ v := optsValue.Field(i)
+ f := optsType.Field(i)
+ hTag := f.Tag.Get("h")
+
+ // if the field has a 'h' tag, it goes in the header
+ if hTag != "" {
+ tags := strings.Split(hTag, ",")
+
+ // if the field is set, add it to the slice of query pieces
+ if !isZero(v) {
+ switch v.Kind() {
+ case reflect.String:
+ optsMap[tags[0]] = v.String()
+ case reflect.Int:
+ optsMap[tags[0]] = strconv.FormatInt(v.Int(), 10)
+ case reflect.Bool:
+ optsMap[tags[0]] = strconv.FormatBool(v.Bool())
+ }
+ } else {
+ // Otherwise, the field is not set.
+ if len(tags) == 2 && tags[1] == "required" {
+ // And the field is required. Return an error.
+ return optsMap, fmt.Errorf("Required header not set.")
+ }
+ }
+ }
+
+ }
+ return optsMap, nil
+ }
+ // Return an error if the underlying type of 'opts' isn't a struct.
+ return optsMap, fmt.Errorf("Options type is not a struct.")
+}
+
+// IDSliceToQueryString takes a slice of elements and converts them into a query
+// string. For example, if name=foo and slice=[]int{20, 40, 60}, then the
+// result would be `?name=20&name=40&name=60'
+func IDSliceToQueryString(name string, ids []int) string {
+ str := ""
+ for k, v := range ids {
+ if k == 0 {
+ str += "?"
+ } else {
+ str += "&"
+ }
+ str += fmt.Sprintf("%s=%s", name, strconv.Itoa(v))
+ }
+ return str
+}
+
+// IntWithinRange returns TRUE if an integer falls within a defined range, and
+// FALSE if not.
+func IntWithinRange(val, min, max int) bool {
+ return val > min && val < max
+}
diff --git a/params_test.go b/params_test.go
new file mode 100644
index 0000000..2f40eec
--- /dev/null
+++ b/params_test.go
@@ -0,0 +1,165 @@
+package gophercloud
+
+import (
+ "net/url"
+ "reflect"
+ "testing"
+ "time"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestMaybeString(t *testing.T) {
+ testString := ""
+ var expected *string
+ actual := MaybeString(testString)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testString = "carol"
+ expected = &testString
+ actual = MaybeString(testString)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestMaybeInt(t *testing.T) {
+ testInt := 0
+ var expected *int
+ actual := MaybeInt(testInt)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testInt = 4
+ expected = &testInt
+ actual = MaybeInt(testInt)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestBuildQueryString(t *testing.T) {
+ type testVar string
+ opts := struct {
+ J int `q:"j"`
+ R string `q:"r,required"`
+ C bool `q:"c"`
+ S []string `q:"s"`
+ TS []testVar `q:"ts"`
+ TI []int `q:"ti"`
+ }{
+ J: 2,
+ R: "red",
+ C: true,
+ S: []string{"one", "two", "three"},
+ TS: []testVar{"a", "b"},
+ TI: []int{1, 2},
+ }
+ expected := &url.URL{RawQuery: "c=true&j=2&r=red&s=one&s=two&s=three&ti=1&ti=2&ts=a&ts=b"}
+ actual, err := BuildQueryString(&opts)
+ if err != nil {
+ t.Errorf("Error building query string: %v", err)
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ opts = struct {
+ J int `q:"j"`
+ R string `q:"r,required"`
+ C bool `q:"c"`
+ S []string `q:"s"`
+ TS []testVar `q:"ts"`
+ TI []int `q:"ti"`
+ }{
+ J: 2,
+ C: true,
+ }
+ _, err = BuildQueryString(&opts)
+ if err == nil {
+ t.Errorf("Expected error: 'Required field not set'")
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ _, err = BuildQueryString(map[string]interface{}{"Number": 4})
+ if err == nil {
+ t.Errorf("Expected error: 'Options type is not a struct'")
+ }
+}
+
+func TestBuildHeaders(t *testing.T) {
+ testStruct := struct {
+ Accept string `h:"Accept"`
+ Num int `h:"Number,required"`
+ Style bool `h:"Style"`
+ }{
+ Accept: "application/json",
+ Num: 4,
+ Style: true,
+ }
+ expected := map[string]string{"Accept": "application/json", "Number": "4", "Style": "true"}
+ actual, err := BuildHeaders(&testStruct)
+ th.CheckNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testStruct.Num = 0
+ _, err = BuildHeaders(&testStruct)
+ if err == nil {
+ t.Errorf("Expected error: 'Required header not set'")
+ }
+
+ _, err = BuildHeaders(map[string]interface{}{"Number": 4})
+ if err == nil {
+ t.Errorf("Expected error: 'Options type is not a struct'")
+ }
+}
+
+func TestIsZero(t *testing.T) {
+ var testMap map[string]interface{}
+ testMapValue := reflect.ValueOf(testMap)
+ expected := true
+ actual := isZero(testMapValue)
+ th.CheckEquals(t, expected, actual)
+ testMap = map[string]interface{}{"empty": false}
+ testMapValue = reflect.ValueOf(testMap)
+ expected = false
+ actual = isZero(testMapValue)
+ th.CheckEquals(t, expected, actual)
+
+ var testArray [2]string
+ testArrayValue := reflect.ValueOf(testArray)
+ expected = true
+ actual = isZero(testArrayValue)
+ th.CheckEquals(t, expected, actual)
+ testArray = [2]string{"one", "two"}
+ testArrayValue = reflect.ValueOf(testArray)
+ expected = false
+ actual = isZero(testArrayValue)
+ th.CheckEquals(t, expected, actual)
+
+ var testStruct struct {
+ A string
+ B time.Time
+ }
+ testStructValue := reflect.ValueOf(testStruct)
+ expected = true
+ actual = isZero(testStructValue)
+ th.CheckEquals(t, expected, actual)
+ testStruct = struct {
+ A string
+ B time.Time
+ }{
+ B: time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC),
+ }
+ testStructValue = reflect.ValueOf(testStruct)
+ expected = false
+ actual = isZero(testStructValue)
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestQueriesAreEscaped(t *testing.T) {
+ type foo struct {
+ Name string `q:"something"`
+ Shape string `q:"else"`
+ }
+
+ expected := &url.URL{RawQuery: "else=Triangl+e&something=blah%2B%3F%21%21foo"}
+
+ actual, err := BuildQueryString(foo{Name: "blah+?!!foo", Shape: "Triangl e"})
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/provider_client.go b/provider_client.go
new file mode 100644
index 0000000..9264355
--- /dev/null
+++ b/provider_client.go
@@ -0,0 +1,308 @@
+package gophercloud
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+)
+
+// DefaultUserAgent is the default User-Agent string set in the request header.
+const DefaultUserAgent = "gophercloud/1.0.0"
+
+// UserAgent represents a User-Agent header.
+type UserAgent struct {
+ // prepend is the slice of User-Agent strings to prepend to DefaultUserAgent.
+ // All the strings to prepend are accumulated and prepended in the Join method.
+ prepend []string
+}
+
+// Prepend prepends a user-defined string to the default User-Agent string. Users
+// may pass in one or more strings to prepend.
+func (ua *UserAgent) Prepend(s ...string) {
+ ua.prepend = append(s, ua.prepend...)
+}
+
+// Join concatenates all the user-defined User-Agend strings with the default
+// Gophercloud User-Agent string.
+func (ua *UserAgent) Join() string {
+ uaSlice := append(ua.prepend, DefaultUserAgent)
+ return strings.Join(uaSlice, " ")
+}
+
+// ProviderClient stores details that are required to interact with any
+// services within a specific provider's API.
+//
+// Generally, you acquire a ProviderClient by calling the NewClient method in
+// the appropriate provider's child package, providing whatever authentication
+// credentials are required.
+type ProviderClient struct {
+ // IdentityBase is the base URL used for a particular provider's identity
+ // service - it will be used when issuing authenticatation requests. It
+ // should point to the root resource of the identity service, not a specific
+ // identity version.
+ IdentityBase string
+
+ // IdentityEndpoint is the identity endpoint. This may be a specific version
+ // of the identity service. If this is the case, this endpoint is used rather
+ // than querying versions first.
+ IdentityEndpoint string
+
+ // TokenID is the ID of the most recently issued valid token.
+ TokenID string
+
+ // EndpointLocator describes how this provider discovers the endpoints for
+ // its constituent services.
+ EndpointLocator EndpointLocator
+
+ // HTTPClient allows users to interject arbitrary http, https, or other transit behaviors.
+ HTTPClient http.Client
+
+ // UserAgent represents the User-Agent header in the HTTP request.
+ UserAgent UserAgent
+
+ // ReauthFunc is the function used to re-authenticate the user if the request
+ // fails with a 401 HTTP response code. This a needed because there may be multiple
+ // authentication functions for different Identity service versions.
+ ReauthFunc func() error
+}
+
+// AuthenticatedHeaders returns a map of HTTP headers that are common for all
+// authenticated service requests.
+func (client *ProviderClient) AuthenticatedHeaders() map[string]string {
+ if client.TokenID == "" {
+ return map[string]string{}
+ }
+ return map[string]string{"X-Auth-Token": client.TokenID}
+}
+
+// RequestOpts customizes the behavior of the provider.Request() method.
+type RequestOpts struct {
+ // JSONBody, if provided, will be encoded as JSON and used as the body of the HTTP request. The
+ // content type of the request will default to "application/json" unless overridden by MoreHeaders.
+ // It's an error to specify both a JSONBody and a RawBody.
+ JSONBody interface{}
+ // RawBody contains an io.ReadSeeker that will be consumed by the request directly. No content-type
+ // will be set unless one is provided explicitly by MoreHeaders.
+ RawBody io.ReadSeeker
+
+ // JSONResponse, if provided, will be populated with the contents of the response body parsed as
+ // JSON.
+ JSONResponse interface{}
+ // OkCodes contains a list of numeric HTTP status codes that should be interpreted as success. If
+ // the response has a different code, an error will be returned.
+ OkCodes []int
+
+ // MoreHeaders specifies additional HTTP headers to be provide on the request. If a header is
+ // provided with a blank value (""), that header will be *omitted* instead: use this to suppress
+ // the default Accept header or an inferred Content-Type, for example.
+ MoreHeaders map[string]string
+}
+
+// UnexpectedResponseCodeError is returned by the Request method when a response code other than
+// those listed in OkCodes is encountered.
+type UnexpectedResponseCodeError struct {
+ URL string
+ Method string
+ Expected []int
+ Actual int
+ Body []byte
+}
+
+func (err *UnexpectedResponseCodeError) Error() string {
+ return fmt.Sprintf(
+ "Expected HTTP response code %v when accessing [%s %s], but got %d instead\n%s",
+ err.Expected, err.Method, err.URL, err.Actual, err.Body,
+ )
+}
+
+var applicationJSON = "application/json"
+
+// Request performs an HTTP request using the ProviderClient's current HTTPClient. An authentication
+// header will automatically be provided.
+func (client *ProviderClient) Request(method, url string, options RequestOpts) (*http.Response, error) {
+ var body io.ReadSeeker
+ var contentType *string
+
+ // Derive the content body by either encoding an arbitrary object as JSON, or by taking a provided
+ // io.ReadSeeker as-is. Default the content-type to application/json.
+ if options.JSONBody != nil {
+ if options.RawBody != nil {
+ panic("Please provide only one of JSONBody or RawBody to gophercloud.Request().")
+ }
+
+ rendered, err := json.Marshal(options.JSONBody)
+ if err != nil {
+ return nil, err
+ }
+
+ body = bytes.NewReader(rendered)
+ contentType = &applicationJSON
+ }
+
+ if options.RawBody != nil {
+ body = options.RawBody
+ }
+
+ // Construct the http.Request.
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ // Populate the request headers. Apply options.MoreHeaders last, to give the caller the chance to
+ // modify or omit any header.
+ if contentType != nil {
+ req.Header.Set("Content-Type", *contentType)
+ }
+ req.Header.Set("Accept", applicationJSON)
+
+ for k, v := range client.AuthenticatedHeaders() {
+ req.Header.Add(k, v)
+ }
+
+ // Set the User-Agent header
+ req.Header.Set("User-Agent", client.UserAgent.Join())
+
+ if options.MoreHeaders != nil {
+ for k, v := range options.MoreHeaders {
+ if v != "" {
+ req.Header.Set(k, v)
+ } else {
+ req.Header.Del(k)
+ }
+ }
+ }
+
+ // Issue the request.
+ resp, err := client.HTTPClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ if client.ReauthFunc != nil {
+ err = client.ReauthFunc()
+ if err != nil {
+ return nil, fmt.Errorf("Error trying to re-authenticate: %s", err)
+ }
+ if options.RawBody != nil {
+ options.RawBody.Seek(0, 0)
+ }
+ resp.Body.Close()
+ resp, err = client.Request(method, url, options)
+ if err != nil {
+ return nil, fmt.Errorf("Successfully re-authenticated, but got error executing request: %s", err)
+ }
+
+ return resp, nil
+ }
+ }
+
+ // Allow default OkCodes if none explicitly set
+ if options.OkCodes == nil {
+ options.OkCodes = defaultOkCodes(method)
+ }
+
+ // Validate the HTTP response status.
+ var ok bool
+ for _, code := range options.OkCodes {
+ if resp.StatusCode == code {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ body, _ := ioutil.ReadAll(resp.Body)
+ resp.Body.Close()
+ return resp, &UnexpectedResponseCodeError{
+ URL: url,
+ Method: method,
+ Expected: options.OkCodes,
+ Actual: resp.StatusCode,
+ Body: body,
+ }
+ }
+
+ // Parse the response body as JSON, if requested to do so.
+ if options.JSONResponse != nil {
+ defer resp.Body.Close()
+ if err := json.NewDecoder(resp.Body).Decode(options.JSONResponse); err != nil {
+ return nil, err
+ }
+ }
+
+ return resp, nil
+}
+
+func defaultOkCodes(method string) []int {
+ switch {
+ case method == "GET":
+ return []int{200}
+ case method == "POST":
+ return []int{201, 202}
+ case method == "PUT":
+ return []int{201, 202}
+ case method == "DELETE":
+ return []int{202, 204}
+ }
+
+ return []int{}
+}
+
+func (client *ProviderClient) Get(url string, JSONResponse *interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+ return client.Request("GET", url, *opts)
+}
+
+func (client *ProviderClient) Post(url string, JSONBody interface{}, JSONResponse *interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if v, ok := (JSONBody).(io.ReadSeeker); ok {
+ opts.RawBody = v
+ } else if JSONBody != nil {
+ opts.JSONBody = JSONBody
+ }
+
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ return client.Request("POST", url, *opts)
+}
+
+func (client *ProviderClient) Put(url string, JSONBody interface{}, JSONResponse *interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if v, ok := (JSONBody).(io.ReadSeeker); ok {
+ opts.RawBody = v
+ } else if JSONBody != nil {
+ opts.JSONBody = JSONBody
+ }
+
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ return client.Request("PUT", url, *opts)
+}
+
+func (client *ProviderClient) Delete(url string, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ return client.Request("DELETE", url, *opts)
+}
diff --git a/provider_client_test.go b/provider_client_test.go
new file mode 100644
index 0000000..d79d862
--- /dev/null
+++ b/provider_client_test.go
@@ -0,0 +1,35 @@
+package gophercloud
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedHeaders(t *testing.T) {
+ p := &ProviderClient{
+ TokenID: "1234",
+ }
+ expected := map[string]string{"X-Auth-Token": "1234"}
+ actual := p.AuthenticatedHeaders()
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestUserAgent(t *testing.T) {
+ p := &ProviderClient{}
+
+ p.UserAgent.Prepend("custom-user-agent/2.4.0")
+ expected := "custom-user-agent/2.4.0 gophercloud/1.0.0"
+ actual := p.UserAgent.Join()
+ th.CheckEquals(t, expected, actual)
+
+ p.UserAgent.Prepend("another-custom-user-agent/0.3.0", "a-third-ua/5.9.0")
+ expected = "another-custom-user-agent/0.3.0 a-third-ua/5.9.0 custom-user-agent/2.4.0 gophercloud/1.0.0"
+ actual = p.UserAgent.Join()
+ th.CheckEquals(t, expected, actual)
+
+ p.UserAgent = UserAgent{}
+ expected = "gophercloud/1.0.0"
+ actual = p.UserAgent.Join()
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/rackspace/auth_env.go b/rackspace/auth_env.go
new file mode 100644
index 0000000..5852c3c
--- /dev/null
+++ b/rackspace/auth_env.go
@@ -0,0 +1,57 @@
+package rackspace
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/rackspace/gophercloud"
+)
+
+var nilOptions = gophercloud.AuthOptions{}
+
+// ErrNoAuthUrl, ErrNoUsername, and ErrNoPassword errors indicate of the
+// required RS_AUTH_URL, RS_USERNAME, or RS_PASSWORD environment variables,
+// respectively, remain undefined. See the AuthOptions() function for more details.
+var (
+ ErrNoAuthURL = fmt.Errorf("Environment variable RS_AUTH_URL or OS_AUTH_URL need to be set.")
+ ErrNoUsername = fmt.Errorf("Environment variable RS_USERNAME or OS_USERNAME need to be set.")
+ ErrNoPassword = fmt.Errorf("Environment variable RS_API_KEY or RS_PASSWORD needs to be set.")
+)
+
+func prefixedEnv(base string) string {
+ value := os.Getenv("RS_" + base)
+ if value == "" {
+ value = os.Getenv("OS_" + base)
+ }
+ return value
+}
+
+// AuthOptionsFromEnv fills out an identity.AuthOptions structure with the
+// settings found on the various Rackspace RS_* environment variables.
+func AuthOptionsFromEnv() (gophercloud.AuthOptions, error) {
+ authURL := prefixedEnv("AUTH_URL")
+ username := prefixedEnv("USERNAME")
+ password := prefixedEnv("PASSWORD")
+ apiKey := prefixedEnv("API_KEY")
+
+ if authURL == "" {
+ return nilOptions, ErrNoAuthURL
+ }
+
+ if username == "" {
+ return nilOptions, ErrNoUsername
+ }
+
+ if password == "" && apiKey == "" {
+ return nilOptions, ErrNoPassword
+ }
+
+ ao := gophercloud.AuthOptions{
+ IdentityEndpoint: authURL,
+ Username: username,
+ Password: password,
+ APIKey: apiKey,
+ }
+
+ return ao, nil
+}
diff --git a/rackspace/blockstorage/v1/snapshots/delegate.go b/rackspace/blockstorage/v1/snapshots/delegate.go
new file mode 100644
index 0000000..1cd1b6e
--- /dev/null
+++ b/rackspace/blockstorage/v1/snapshots/delegate.go
@@ -0,0 +1,131 @@
+package snapshots
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/snapshots"
+)
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("snapshots", id)
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToSnapshotCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a Snapshot. This object is passed to
+// the snapshots.Create function. For more information about these parameters,
+// see the Snapshot object.
+type CreateOpts struct {
+ // REQUIRED
+ VolumeID string
+ // OPTIONAL
+ Description string
+ // OPTIONAL
+ Force bool
+ // OPTIONAL
+ Name string
+}
+
+// ToSnapshotCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToSnapshotCreateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.VolumeID == "" {
+ return nil, errors.New("Required CreateOpts field 'VolumeID' not set.")
+ }
+
+ s["volume_id"] = opts.VolumeID
+
+ if opts.Description != "" {
+ s["display_description"] = opts.Description
+ }
+ if opts.Name != "" {
+ s["display_name"] = opts.Name
+ }
+ if opts.Force {
+ s["force"] = opts.Force
+ }
+
+ return map[string]interface{}{"snapshot": s}, nil
+}
+
+// Create will create a new Snapshot based on the values in CreateOpts. To
+// extract the Snapshot object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ return CreateResult{os.Create(client, opts)}
+}
+
+// Delete will delete the existing Snapshot with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
+
+// Get retrieves the Snapshot with the provided ID. To extract the Snapshot
+// object from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
+
+// List returns Snapshots.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client, os.ListOpts{})
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+ ToSnapshotUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ Name string
+ Description string
+}
+
+// ToSnapshotUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToSnapshotUpdateMap() (map[string]interface{}, error) {
+ s := make(map[string]interface{})
+
+ if opts.Name != "" {
+ s["display_name"] = opts.Name
+ }
+ if opts.Description != "" {
+ s["display_description"] = opts.Description
+ }
+
+ return map[string]interface{}{"snapshot": s}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing snapshot using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, snapshotID string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToSnapshotUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Request("PUT", updateURL(c, snapshotID), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ JSONResponse: &res.Body,
+ OkCodes: []int{200, 201},
+ })
+
+ return res
+}
diff --git a/rackspace/blockstorage/v1/snapshots/delegate_test.go b/rackspace/blockstorage/v1/snapshots/delegate_test.go
new file mode 100644
index 0000000..1a02b46
--- /dev/null
+++ b/rackspace/blockstorage/v1/snapshots/delegate_test.go
@@ -0,0 +1,97 @@
+package snapshots
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/snapshots"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const endpoint = "http://localhost:57909/v1/12345"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestUpdateURL(t *testing.T) {
+ actual := updateURL(endpointClient(), "foo")
+ expected := endpoint + "snapshots/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockListResponse(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractSnapshots(page)
+ if err != nil {
+ t.Errorf("Failed to extract snapshots: %v", err)
+ return false, err
+ }
+
+ expected := []Snapshot{
+ Snapshot{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "snapshot-001",
+ },
+ Snapshot{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "snapshot-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockGetResponse(t)
+
+ v, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, v.Name, "snapshot-001")
+ th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockCreateResponse(t)
+
+ options := &CreateOpts{VolumeID: "1234", Name: "snapshot-001"}
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.VolumeID, "1234")
+ th.AssertEquals(t, n.Name, "snapshot-001")
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockDeleteResponse(t)
+
+ res := Delete(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/blockstorage/v1/snapshots/doc.go b/rackspace/blockstorage/v1/snapshots/doc.go
new file mode 100644
index 0000000..ad6064f
--- /dev/null
+++ b/rackspace/blockstorage/v1/snapshots/doc.go
@@ -0,0 +1,3 @@
+// Package snapshots provides information and interaction with the snapshot
+// API resource for the Rackspace Block Storage service.
+package snapshots
diff --git a/rackspace/blockstorage/v1/snapshots/results.go b/rackspace/blockstorage/v1/snapshots/results.go
new file mode 100644
index 0000000..c81644c
--- /dev/null
+++ b/rackspace/blockstorage/v1/snapshots/results.go
@@ -0,0 +1,147 @@
+package snapshots
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/snapshots"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Status is the type used to represent a snapshot's status
+type Status string
+
+// Constants to use for supported statuses
+const (
+ Creating Status = "CREATING"
+ Available Status = "AVAILABLE"
+ Deleting Status = "DELETING"
+ Error Status = "ERROR"
+ DeleteError Status = "ERROR_DELETING"
+)
+
+// Snapshot is the Rackspace representation of an external block storage device.
+type Snapshot struct {
+ // The timestamp when this snapshot was created.
+ CreatedAt string `mapstructure:"created_at"`
+
+ // The human-readable description for this snapshot.
+ Description string `mapstructure:"display_description"`
+
+ // The human-readable name for this snapshot.
+ Name string `mapstructure:"display_name"`
+
+ // The UUID for this snapshot.
+ ID string `mapstructure:"id"`
+
+ // The random metadata associated with this snapshot. Note: unlike standard
+ // OpenStack snapshots, this cannot actually be set.
+ Metadata map[string]string `mapstructure:"metadata"`
+
+ // Indicates the current progress of the snapshot's backup procedure.
+ Progress string `mapstructure:"os-extended-snapshot-attributes:progress"`
+
+ // The project ID.
+ ProjectID string `mapstructure:"os-extended-snapshot-attributes:project_id"`
+
+ // The size of the volume which this snapshot backs up.
+ Size int `mapstructure:"size"`
+
+ // The status of the snapshot.
+ Status Status `mapstructure:"status"`
+
+ // The ID of the volume which this snapshot seeks to back up.
+ VolumeID string `mapstructure:"volume_id"`
+}
+
+// CreateResult represents the result of a create operation
+type CreateResult struct {
+ os.CreateResult
+}
+
+// GetResult represents the result of a get operation
+type GetResult struct {
+ os.GetResult
+}
+
+// UpdateResult represents the result of an update operation
+type UpdateResult struct {
+ gophercloud.Result
+}
+
+func commonExtract(resp interface{}, err error) (*Snapshot, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var respStruct struct {
+ Snapshot *Snapshot `json:"snapshot"`
+ }
+
+ err = mapstructure.Decode(resp, &respStruct)
+
+ return respStruct.Snapshot, err
+}
+
+// Extract will get the Snapshot object out of the GetResult object.
+func (r GetResult) Extract() (*Snapshot, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the CreateResult object.
+func (r CreateResult) Extract() (*Snapshot, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the UpdateResult object.
+func (r UpdateResult) Extract() (*Snapshot, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// ExtractSnapshots extracts and returns Snapshots. It is used while iterating over a snapshots.List call.
+func ExtractSnapshots(page pagination.Page) ([]Snapshot, error) {
+ var response struct {
+ Snapshots []Snapshot `json:"snapshots"`
+ }
+
+ err := mapstructure.Decode(page.(os.ListResult).Body, &response)
+ return response.Snapshots, err
+}
+
+// WaitUntilComplete will continually poll a snapshot until it successfully
+// transitions to a specified state. It will do this for at most the number of
+// seconds specified.
+func (snapshot Snapshot) WaitUntilComplete(c *gophercloud.ServiceClient, timeout int) error {
+ return gophercloud.WaitFor(timeout, func() (bool, error) {
+ // Poll resource
+ current, err := Get(c, snapshot.ID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ // Has it been built yet?
+ if current.Progress == "100%" {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
+
+// WaitUntilDeleted will continually poll a snapshot until it has been
+// successfully deleted, i.e. returns a 404 status.
+func (snapshot Snapshot) WaitUntilDeleted(c *gophercloud.ServiceClient, timeout int) error {
+ return gophercloud.WaitFor(timeout, func() (bool, error) {
+ // Poll resource
+ _, err := Get(c, snapshot.ID).Extract()
+
+ // Check for a 404
+ if casted, ok := err.(*gophercloud.UnexpectedResponseCodeError); ok && casted.Actual == 404 {
+ return true, nil
+ } else if err != nil {
+ return false, err
+ }
+
+ return false, nil
+ })
+}
diff --git a/rackspace/blockstorage/v1/volumes/delegate.go b/rackspace/blockstorage/v1/volumes/delegate.go
new file mode 100644
index 0000000..4383494
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumes/delegate.go
@@ -0,0 +1,75 @@
+package volumes
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type CreateOpts struct {
+ os.CreateOpts
+}
+
+func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) {
+ if opts.Size < 75 || opts.Size > 1024 {
+ return nil, fmt.Errorf("Size field must be between 75 and 1024")
+ }
+
+ return opts.CreateOpts.ToVolumeCreateMap()
+}
+
+// Create will create a new Volume based on the values in CreateOpts. To extract
+// the Volume object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) CreateResult {
+ return CreateResult{os.Create(client, opts)}
+}
+
+// Delete will delete the existing Volume with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
+
+// Get retrieves the Volume with the provided ID. To extract the Volume object
+// from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
+
+// List returns volumes optionally limited by the conditions provided in ListOpts.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client, os.ListOpts{})
+}
+
+// UpdateOpts contain options for updating an existing Volume. This object is passed
+// to the volumes.Update function. For more information about the parameters, see
+// the Volume object.
+type UpdateOpts struct {
+ // OPTIONAL
+ Name string
+ // OPTIONAL
+ Description string
+}
+
+// ToVolumeUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) {
+ v := make(map[string]interface{})
+
+ if opts.Description != "" {
+ v["display_description"] = opts.Description
+ }
+ if opts.Name != "" {
+ v["display_name"] = opts.Name
+ }
+
+ return map[string]interface{}{"volume": v}, nil
+}
+
+// Update will update the Volume with provided information. To extract the updated
+// Volume from the response, call the Extract method on the UpdateResult.
+func Update(client *gophercloud.ServiceClient, id string, opts os.UpdateOptsBuilder) UpdateResult {
+ return UpdateResult{os.Update(client, id, opts)}
+}
diff --git a/rackspace/blockstorage/v1/volumes/delegate_test.go b/rackspace/blockstorage/v1/volumes/delegate_test.go
new file mode 100644
index 0000000..b6831f2
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumes/delegate_test.go
@@ -0,0 +1,107 @@
+package volumes
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes/testing"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockListResponse(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVolumes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volumes: %v", err)
+ return false, err
+ }
+
+ expected := []Volume{
+ Volume{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ },
+ Volume{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockGetResponse(t)
+
+ v, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, v.Name, "vol-001")
+ th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockCreateResponse(t)
+
+ n, err := Create(fake.ServiceClient(), CreateOpts{volumes.CreateOpts{Size: 75}}).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Size, 4)
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestSizeRange(t *testing.T) {
+ _, err := Create(fake.ServiceClient(), CreateOpts{volumes.CreateOpts{Size: 1}}).Extract()
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ _, err = Create(fake.ServiceClient(), CreateOpts{volumes.CreateOpts{Size: 2000}}).Extract()
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockDeleteResponse(t)
+
+ res := Delete(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockUpdateResponse(t)
+
+ options := &UpdateOpts{Name: "vol-002"}
+ v, err := Update(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22", options).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "vol-002", v.Name)
+}
diff --git a/rackspace/blockstorage/v1/volumes/doc.go b/rackspace/blockstorage/v1/volumes/doc.go
new file mode 100644
index 0000000..b2be25c
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumes/doc.go
@@ -0,0 +1,3 @@
+// Package volumes provides information and interaction with the volume
+// API resource for the Rackspace Block Storage service.
+package volumes
diff --git a/rackspace/blockstorage/v1/volumes/results.go b/rackspace/blockstorage/v1/volumes/results.go
new file mode 100644
index 0000000..c7c2cc4
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumes/results.go
@@ -0,0 +1,66 @@
+package volumes
+
+import (
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/rackspace/gophercloud/pagination"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// Volume wraps an Openstack volume
+type Volume os.Volume
+
+// CreateResult represents the result of a create operation
+type CreateResult struct {
+ os.CreateResult
+}
+
+// GetResult represents the result of a get operation
+type GetResult struct {
+ os.GetResult
+}
+
+// UpdateResult represents the result of an update operation
+type UpdateResult struct {
+ os.UpdateResult
+}
+
+func commonExtract(resp interface{}, err error) (*Volume, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var respStruct struct {
+ Volume *Volume `json:"volume"`
+ }
+
+ err = mapstructure.Decode(resp, &respStruct)
+
+ return respStruct.Volume, err
+}
+
+// Extract will get the Volume object out of the GetResult object.
+func (r GetResult) Extract() (*Volume, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Volume object out of the CreateResult object.
+func (r CreateResult) Extract() (*Volume, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Volume object out of the UpdateResult object.
+func (r UpdateResult) Extract() (*Volume, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call.
+func ExtractVolumes(page pagination.Page) ([]Volume, error) {
+ var response struct {
+ Volumes []Volume `json:"volumes"`
+ }
+
+ err := mapstructure.Decode(page.(os.ListResult).Body, &response)
+
+ return response.Volumes, err
+}
diff --git a/rackspace/blockstorage/v1/volumetypes/delegate.go b/rackspace/blockstorage/v1/volumetypes/delegate.go
new file mode 100644
index 0000000..c96b3e4
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumetypes/delegate.go
@@ -0,0 +1,18 @@
+package volumetypes
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumetypes"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns all volume types.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// Get will retrieve the volume type with the provided ID. To extract the volume
+// type from the result, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
diff --git a/rackspace/blockstorage/v1/volumetypes/delegate_test.go b/rackspace/blockstorage/v1/volumetypes/delegate_test.go
new file mode 100644
index 0000000..6e65c90
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumetypes/delegate_test.go
@@ -0,0 +1,64 @@
+package volumetypes
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumetypes"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockListResponse(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVolumeTypes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volume types: %v", err)
+ return false, err
+ }
+
+ expected := []VolumeType{
+ VolumeType{
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-type-001",
+ ExtraSpecs: map[string]interface{}{
+ "capabilities": "gpu",
+ },
+ },
+ VolumeType{
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-type-002",
+ ExtraSpecs: map[string]interface{}{},
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.MockGetResponse(t)
+
+ vt, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, vt.ExtraSpecs, map[string]interface{}{"serverNumber": "2"})
+ th.AssertEquals(t, vt.Name, "vol-type-001")
+ th.AssertEquals(t, vt.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
diff --git a/rackspace/blockstorage/v1/volumetypes/doc.go b/rackspace/blockstorage/v1/volumetypes/doc.go
new file mode 100644
index 0000000..70122b7
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumetypes/doc.go
@@ -0,0 +1,3 @@
+// Package volumetypes provides information and interaction with the volume type
+// API resource for the Rackspace Block Storage service.
+package volumetypes
diff --git a/rackspace/blockstorage/v1/volumetypes/results.go b/rackspace/blockstorage/v1/volumetypes/results.go
new file mode 100644
index 0000000..39c8d6f
--- /dev/null
+++ b/rackspace/blockstorage/v1/volumetypes/results.go
@@ -0,0 +1,37 @@
+package volumetypes
+
+import (
+ "github.com/mitchellh/mapstructure"
+ os "github.com/rackspace/gophercloud/openstack/blockstorage/v1/volumetypes"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type VolumeType os.VolumeType
+
+type GetResult struct {
+ os.GetResult
+}
+
+// Extract will get the Volume Type struct out of the response.
+func (r GetResult) Extract() (*VolumeType, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ VolumeType *VolumeType `json:"volume_type" mapstructure:"volume_type"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.VolumeType, err
+}
+
+func ExtractVolumeTypes(page pagination.Page) ([]VolumeType, error) {
+ var response struct {
+ VolumeTypes []VolumeType `mapstructure:"volume_types"`
+ }
+
+ err := mapstructure.Decode(page.(os.ListResult).Body, &response)
+ return response.VolumeTypes, err
+}
diff --git a/rackspace/cdn/v1/base/delegate.go b/rackspace/cdn/v1/base/delegate.go
new file mode 100644
index 0000000..5af7e07
--- /dev/null
+++ b/rackspace/cdn/v1/base/delegate.go
@@ -0,0 +1,18 @@
+package base
+
+import (
+ "github.com/rackspace/gophercloud"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/base"
+)
+
+// Get retrieves the home document, allowing the user to discover the
+// entire API.
+func Get(c *gophercloud.ServiceClient) os.GetResult {
+ return os.Get(c)
+}
+
+// Ping retrieves a ping to the server.
+func Ping(c *gophercloud.ServiceClient) os.PingResult {
+ return os.Ping(c)
+}
diff --git a/rackspace/cdn/v1/base/delegate_test.go b/rackspace/cdn/v1/base/delegate_test.go
new file mode 100644
index 0000000..731fc6d
--- /dev/null
+++ b/rackspace/cdn/v1/base/delegate_test.go
@@ -0,0 +1,44 @@
+package base
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/base"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetHomeDocument(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t)
+
+ actual, err := Get(fake.ServiceClient()).Extract()
+ th.CheckNoErr(t, err)
+
+ expected := os.HomeDocument{
+ "rel/cdn": map[string]interface{}{
+ "href-template": "services{?marker,limit}",
+ "href-vars": map[string]interface{}{
+ "marker": "param/marker",
+ "limit": "param/limit",
+ },
+ "hints": map[string]interface{}{
+ "allow": []string{"GET"},
+ "formats": map[string]interface{}{
+ "application/json": map[string]interface{}{},
+ },
+ },
+ },
+ }
+ th.CheckDeepEquals(t, expected, *actual)
+}
+
+func TestPing(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandlePingSuccessfully(t)
+
+ err := Ping(fake.ServiceClient()).ExtractErr()
+ th.CheckNoErr(t, err)
+}
diff --git a/rackspace/cdn/v1/base/doc.go b/rackspace/cdn/v1/base/doc.go
new file mode 100644
index 0000000..5582306
--- /dev/null
+++ b/rackspace/cdn/v1/base/doc.go
@@ -0,0 +1,4 @@
+// Package base provides information and interaction with the base API
+// resource in the Rackspace CDN service. This API resource allows for
+// retrieving the Home Document and pinging the root URL.
+package base
diff --git a/rackspace/cdn/v1/flavors/delegate.go b/rackspace/cdn/v1/flavors/delegate.go
new file mode 100644
index 0000000..7152fa2
--- /dev/null
+++ b/rackspace/cdn/v1/flavors/delegate.go
@@ -0,0 +1,18 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a single page of CDN flavors.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(c)
+}
+
+// Get retrieves a specific flavor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(c, id)
+}
diff --git a/rackspace/cdn/v1/flavors/delegate_test.go b/rackspace/cdn/v1/flavors/delegate_test.go
new file mode 100644
index 0000000..d6d299d
--- /dev/null
+++ b/rackspace/cdn/v1/flavors/delegate_test.go
@@ -0,0 +1,90 @@
+package flavors
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleListCDNFlavorsSuccessfully(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractFlavors(page)
+ if err != nil {
+ t.Errorf("Failed to extract flavors: %v", err)
+ return false, err
+ }
+
+ expected := []os.Flavor{
+ os.Flavor{
+ ID: "europe",
+ Providers: []os.Provider{
+ os.Provider{
+ Provider: "Fastly",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://www.fastly.com",
+ Rel: "provider_url",
+ },
+ },
+ },
+ },
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+ Rel: "self",
+ },
+ },
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleGetCDNFlavorSuccessfully(t)
+
+ expected := &os.Flavor{
+ ID: "asia",
+ Providers: []os.Provider{
+ os.Provider{
+ Provider: "ChinaCache",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "http://www.chinacache.com",
+ Rel: "provider_url",
+ },
+ },
+ },
+ },
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+ Rel: "self",
+ },
+ },
+ }
+
+ actual, err := Get(fake.ServiceClient(), "asia").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/cdn/v1/flavors/doc.go b/rackspace/cdn/v1/flavors/doc.go
new file mode 100644
index 0000000..4ad966e
--- /dev/null
+++ b/rackspace/cdn/v1/flavors/doc.go
@@ -0,0 +1,6 @@
+// Package flavors provides information and interaction with the flavors API
+// resource in the Rackspace CDN service. This API resource allows for
+// listing flavors and retrieving a specific flavor.
+//
+// A flavor is a mapping configuration to a CDN provider.
+package flavors
diff --git a/rackspace/cdn/v1/serviceassets/delegate.go b/rackspace/cdn/v1/serviceassets/delegate.go
new file mode 100644
index 0000000..07c93a8
--- /dev/null
+++ b/rackspace/cdn/v1/serviceassets/delegate.go
@@ -0,0 +1,13 @@
+package serviceassets
+
+import (
+ "github.com/rackspace/gophercloud"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/serviceassets"
+)
+
+// Delete accepts a unique ID and deletes the CDN service asset associated with
+// it.
+func Delete(c *gophercloud.ServiceClient, id string, opts os.DeleteOptsBuilder) os.DeleteResult {
+ return os.Delete(c, id, opts)
+}
diff --git a/rackspace/cdn/v1/serviceassets/delegate_test.go b/rackspace/cdn/v1/serviceassets/delegate_test.go
new file mode 100644
index 0000000..328e168
--- /dev/null
+++ b/rackspace/cdn/v1/serviceassets/delegate_test.go
@@ -0,0 +1,19 @@
+package serviceassets
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/serviceassets"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleDeleteCDNAssetSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", nil).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/cdn/v1/serviceassets/doc.go b/rackspace/cdn/v1/serviceassets/doc.go
new file mode 100644
index 0000000..46b3d50
--- /dev/null
+++ b/rackspace/cdn/v1/serviceassets/doc.go
@@ -0,0 +1,7 @@
+// Package serviceassets provides information and interaction with the
+// serviceassets API resource in the Rackspace CDN service. This API resource
+// allows for deleting cached assets.
+//
+// A service distributes assets across the network. Service assets let you
+// interrogate properties about these assets and perform certain actions on them.
+package serviceassets
diff --git a/rackspace/cdn/v1/services/delegate.go b/rackspace/cdn/v1/services/delegate.go
new file mode 100644
index 0000000..e3f1459
--- /dev/null
+++ b/rackspace/cdn/v1/services/delegate.go
@@ -0,0 +1,37 @@
+package services
+
+import (
+ "github.com/rackspace/gophercloud"
+
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/services"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// CDN services. It accepts a ListOpts struct, which allows for pagination via
+// marker and limit.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Create accepts a CreateOpts struct and creates a new CDN service using the
+// values provided.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Get retrieves a specific service based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(c, id)
+}
+
+// Update accepts a UpdateOpts struct and updates an existing CDN service using
+// the values provided.
+func Update(c *gophercloud.ServiceClient, id string, patches []os.Patch) os.UpdateResult {
+ return os.Update(c, id, patches)
+}
+
+// Delete accepts a unique ID and deletes the CDN service associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(c, id)
+}
diff --git a/rackspace/cdn/v1/services/delegate_test.go b/rackspace/cdn/v1/services/delegate_test.go
new file mode 100644
index 0000000..6c48365
--- /dev/null
+++ b/rackspace/cdn/v1/services/delegate_test.go
@@ -0,0 +1,359 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/cdn/v1/services"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleListCDNServiceSuccessfully(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient(), &os.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractServices(page)
+ if err != nil {
+ t.Errorf("Failed to extract services: %v", err)
+ return false, err
+ }
+
+ expected := []os.Service{
+ os.Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []os.Domain{
+ os.Domain{
+ Domain: "www.mywebsite.com",
+ },
+ },
+ Origins: []os.Origin{
+ os.Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []os.CacheRule{
+ os.CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ os.CacheRule{
+ Name: "home",
+ TTL: 17200,
+ Rules: []os.TTLRule{
+ os.TTLRule{
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ os.CacheRule{
+ Name: "images",
+ TTL: 12800,
+ Rules: []os.TTLRule{
+ os.TTLRule{
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []os.Restriction{
+ os.Restriction{
+ Name: "website only",
+ Rules: []os.RestrictionRule{
+ os.RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "asia",
+ Status: "deployed",
+ Errors: []os.Error{},
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "mywebsite.com.cdn123.poppycdn.net",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+ Rel: "flavor",
+ },
+ },
+ },
+ os.Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ Name: "myothersite.com",
+ Domains: []os.Domain{
+ os.Domain{
+ Domain: "www.myothersite.com",
+ },
+ },
+ Origins: []os.Origin{
+ os.Origin{
+ Origin: "44.33.22.11",
+ Port: 80,
+ SSL: false,
+ },
+ os.Origin{
+ Origin: "77.66.55.44",
+ Port: 80,
+ SSL: false,
+ Rules: []os.OriginRule{
+ os.OriginRule{
+ Name: "videos",
+ RequestURL: "^/videos/*.m3u",
+ },
+ },
+ },
+ },
+ Caching: []os.CacheRule{
+ os.CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ Restrictions: []os.Restriction{},
+ FlavorID: "europe",
+ Status: "deployed",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "myothersite.com.poppycdn.net",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+ Rel: "flavor",
+ },
+ },
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleCreateCDNServiceSuccessfully(t)
+
+ createOpts := os.CreateOpts{
+ Name: "mywebsite.com",
+ Domains: []os.Domain{
+ os.Domain{
+ Domain: "www.mywebsite.com",
+ },
+ os.Domain{
+ Domain: "blog.mywebsite.com",
+ },
+ },
+ Origins: []os.Origin{
+ os.Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Restrictions: []os.Restriction{
+ os.Restriction{
+ Name: "website only",
+ Rules: []os.RestrictionRule{
+ os.RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ Caching: []os.CacheRule{
+ os.CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ FlavorID: "cdn",
+ }
+
+ expected := "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleGetCDNServiceSuccessfully(t)
+
+ expected := &os.Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []os.Domain{
+ os.Domain{
+ Domain: "www.mywebsite.com",
+ Protocol: "http",
+ },
+ },
+ Origins: []os.Origin{
+ os.Origin{
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []os.CacheRule{
+ os.CacheRule{
+ Name: "default",
+ TTL: 3600,
+ },
+ os.CacheRule{
+ Name: "home",
+ TTL: 17200,
+ Rules: []os.TTLRule{
+ os.TTLRule{
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ os.CacheRule{
+ Name: "images",
+ TTL: 12800,
+ Rules: []os.TTLRule{
+ os.TTLRule{
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []os.Restriction{
+ os.Restriction{
+ Name: "website only",
+ Rules: []os.RestrictionRule{
+ os.RestrictionRule{
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "cdn",
+ Status: "deployed",
+ Errors: []os.Error{},
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Rel: "self",
+ },
+ gophercloud.Link{
+ Href: "blog.mywebsite.com.cdn1.raxcdn.com",
+ Rel: "access_url",
+ },
+ gophercloud.Link{
+ Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/flavors/cdn",
+ Rel: "flavor",
+ },
+ },
+ }
+
+ actual, err := Get(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestSuccessfulUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleUpdateCDNServiceSuccessfully(t)
+
+ expected := "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+ ops := []os.Patch{
+ // Append a single Domain
+ os.Append{Value: os.Domain{Domain: "appended.mocksite4.com"}},
+ // Insert a single Domain
+ os.Insertion{
+ Index: 4,
+ Value: os.Domain{Domain: "inserted.mocksite4.com"},
+ },
+ // Bulk addition
+ os.Append{
+ Value: os.DomainList{
+ os.Domain{Domain: "bulkadded1.mocksite4.com"},
+ os.Domain{Domain: "bulkadded2.mocksite4.com"},
+ },
+ },
+ // Replace a single Origin
+ os.Replacement{
+ Index: 2,
+ Value: os.Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+ },
+ // Bulk replace Origins
+ os.Replacement{
+ Index: 0, // Ignored
+ Value: os.OriginList{
+ os.Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+ os.Origin{Origin: "55.44.33.22", Port: 443, SSL: true},
+ },
+ },
+ // Remove a single CacheRule
+ os.Removal{
+ Index: 8,
+ Path: os.PathCaching,
+ },
+ // Bulk removal
+ os.Removal{
+ All: true,
+ Path: os.PathCaching,
+ },
+ // Service name replacement
+ os.NameReplacement{
+ NewName: "differentServiceName",
+ },
+ }
+
+ actual, err := Update(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", ops).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleDeleteCDNServiceSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/cdn/v1/services/doc.go b/rackspace/cdn/v1/services/doc.go
new file mode 100644
index 0000000..ee6e2a5
--- /dev/null
+++ b/rackspace/cdn/v1/services/doc.go
@@ -0,0 +1,7 @@
+// Package services provides information and interaction with the services API
+// resource in the Rackspace CDN service. This API resource allows for
+// listing, creating, updating, retrieving, and deleting services.
+//
+// A service represents an application that has its content cached to the edge
+// nodes.
+package services
diff --git a/rackspace/client.go b/rackspace/client.go
new file mode 100644
index 0000000..a8f413e
--- /dev/null
+++ b/rackspace/client.go
@@ -0,0 +1,224 @@
+package rackspace
+
+import (
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack"
+ "github.com/rackspace/gophercloud/openstack/utils"
+ tokens2 "github.com/rackspace/gophercloud/rackspace/identity/v2/tokens"
+)
+
+const (
+ // RackspaceUSIdentity is an identity endpoint located in the United States.
+ RackspaceUSIdentity = "https://identity.api.rackspacecloud.com/v2.0/"
+
+ // RackspaceUKIdentity is an identity endpoint located in the UK.
+ RackspaceUKIdentity = "https://lon.identity.api.rackspacecloud.com/v2.0/"
+)
+
+const (
+ v20 = "v2.0"
+)
+
+// NewClient creates a client that's prepared to communicate with the Rackspace API, but is not
+// yet authenticated. Most users will probably prefer using the AuthenticatedClient function
+// instead.
+//
+// Provide the base URL of the identity endpoint you wish to authenticate against as "endpoint".
+// Often, this will be either RackspaceUSIdentity or RackspaceUKIdentity.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+ if endpoint == "" {
+ return os.NewClient(RackspaceUSIdentity)
+ }
+ return os.NewClient(endpoint)
+}
+
+// AuthenticatedClient logs in to Rackspace with the provided credentials and constructs a
+// ProviderClient that's ready to operate.
+//
+// If the provided AuthOptions does not specify an explicit IdentityEndpoint, it will default to
+// the canonical, production Rackspace US identity endpoint.
+func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
+ client, err := NewClient(options.IdentityEndpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ err = Authenticate(client, options)
+ if err != nil {
+ return nil, err
+ }
+ return client, nil
+}
+
+// Authenticate or re-authenticate against the most recent identity service supported at the
+// provided endpoint.
+func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ versions := []*utils.Version{
+ &utils.Version{ID: v20, Priority: 20, Suffix: "/v2.0/"},
+ }
+
+ chosen, endpoint, err := utils.ChooseVersion(client, versions)
+ if err != nil {
+ return err
+ }
+
+ switch chosen.ID {
+ case v20:
+ return v2auth(client, endpoint, options)
+ default:
+ // The switch statement must be out of date from the versions list.
+ return fmt.Errorf("Unrecognized identity version: %s", chosen.ID)
+ }
+}
+
+// AuthenticateV2 explicitly authenticates with v2 of the identity service.
+func AuthenticateV2(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+ return v2auth(client, "", options)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+ v2Client := NewIdentityV2(client)
+ if endpoint != "" {
+ v2Client.Endpoint = endpoint
+ }
+
+ result := tokens2.Create(v2Client, tokens2.WrapOptions(options))
+
+ token, err := result.ExtractToken()
+ if err != nil {
+ return err
+ }
+
+ catalog, err := result.ExtractServiceCatalog()
+ if err != nil {
+ return err
+ }
+
+ if options.AllowReauth {
+ client.ReauthFunc = func() error {
+ return AuthenticateV2(client, options)
+ }
+ }
+ client.TokenID = token.ID
+ client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+ return os.V2EndpointURL(catalog, opts)
+ }
+
+ return nil
+}
+
+// NewIdentityV2 creates a ServiceClient that may be used to access the v2 identity service.
+func NewIdentityV2(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+ v2Endpoint := client.IdentityBase + "v2.0/"
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: v2Endpoint,
+ }
+}
+
+// NewComputeV2 creates a ServiceClient that may be used to access the v2 compute service.
+func NewComputeV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("compute")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: url,
+ }, nil
+}
+
+// NewObjectCDNV1 creates a ServiceClient that may be used with the Rackspace v1 CDN.
+func NewObjectCDNV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("rax:object-cdn")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewObjectStorageV1 creates a ServiceClient that may be used with the Rackspace v1 object storage package.
+func NewObjectStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ return os.NewObjectStorageV1(client, eo)
+}
+
+// NewBlockStorageV1 creates a ServiceClient that can be used to access the
+// Rackspace Cloud Block Storage v1 API.
+func NewBlockStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("volume")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewLBV1 creates a ServiceClient that can be used to access the Rackspace
+// Cloud Load Balancer v1 API.
+func NewLBV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("rax:load-balancer")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewNetworkV2 creates a ServiceClient that can be used to access the Rackspace
+// Networking v2 API.
+func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("network")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewCDNV1 creates a ServiceClient that may be used to access the Rackspace v1
+// CDN service.
+func NewCDNV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("rax:cdn")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewOrchestrationV1 creates a ServiceClient that may be used to access the v1 orchestration service.
+func NewOrchestrationV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("orchestration")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewRackConnectV3 creates a ServiceClient that may be used to access the v3 RackConnect service.
+func NewRackConnectV3(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("rax:rackconnect")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewDBV1 creates a ServiceClient that may be used to access the v1 DB service.
+func NewDBV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("rax:database")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
diff --git a/rackspace/client_test.go b/rackspace/client_test.go
new file mode 100644
index 0000000..73b1c88
--- /dev/null
+++ b/rackspace/client_test.go
@@ -0,0 +1,38 @@
+package rackspace
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV2(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `
+ {
+ "access": {
+ "token": {
+ "id": "01234567890",
+ "expires": "2014-10-01T10:00:00.000000Z"
+ },
+ "serviceCatalog": []
+ }
+ }
+ `)
+ })
+
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ APIKey: "09876543210",
+ IdentityEndpoint: th.Endpoint() + "v2.0/",
+ }
+ client, err := AuthenticatedClient(options)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "01234567890", client.TokenID)
+}
diff --git a/rackspace/compute/v2/bootfromvolume/delegate.go b/rackspace/compute/v2/bootfromvolume/delegate.go
new file mode 100644
index 0000000..2580459
--- /dev/null
+++ b/rackspace/compute/v2/bootfromvolume/delegate.go
@@ -0,0 +1,12 @@
+package bootfromvolume
+
+import (
+ "github.com/rackspace/gophercloud"
+ osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ osServers "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// Create requests the creation of a server from the given block device mapping.
+func Create(client *gophercloud.ServiceClient, opts osServers.CreateOptsBuilder) osServers.CreateResult {
+ return osBFV.Create(client, opts)
+}
diff --git a/rackspace/compute/v2/bootfromvolume/delegate_test.go b/rackspace/compute/v2/bootfromvolume/delegate_test.go
new file mode 100644
index 0000000..571a1be
--- /dev/null
+++ b/rackspace/compute/v2/bootfromvolume/delegate_test.go
@@ -0,0 +1,54 @@
+package bootfromvolume
+
+import (
+ "testing"
+
+ osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := osBFV.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []osBFV.BlockDevice{
+ osBFV.BlockDevice{
+ UUID: "123456",
+ SourceType: osBFV.Image,
+ DestinationType: "volume",
+ VolumeSize: 10,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "flavorName": "",
+ "imageName": "",
+ "block_device_mapping_v2":[
+ {
+ "uuid":"123456",
+ "source_type":"image",
+ "destination_type":"volume",
+ "boot_index": "0",
+ "delete_on_termination": "false",
+ "volume_size": "10"
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/rackspace/compute/v2/flavors/delegate.go b/rackspace/compute/v2/flavors/delegate.go
new file mode 100644
index 0000000..081ea47
--- /dev/null
+++ b/rackspace/compute/v2/flavors/delegate.go
@@ -0,0 +1,43 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListOpts helps control the results returned by the List() function. For example, a flavor with a
+// minDisk field of 10 will not be returned if you specify MinDisk set to 20.
+type ListOpts struct {
+
+ // MinDisk and MinRAM, if provided, elide flavors that do not meet your criteria.
+ MinDisk int `q:"minDisk"`
+ MinRAM int `q:"minRam"`
+
+ // Marker specifies the ID of the last flavor in the previous page.
+ Marker string `q:"marker"`
+
+ // Limit instructs List to refrain from sending excessively large lists of flavors.
+ Limit int `q:"limit"`
+}
+
+// ToFlavorListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToFlavorListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListDetail enumerates the server images available to your account.
+func ListDetail(client *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.ListDetail(client, opts)
+}
+
+// Get returns details about a single flavor, identity by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, id), &res.Body, nil)
+ return res
+}
diff --git a/rackspace/compute/v2/flavors/delegate_test.go b/rackspace/compute/v2/flavors/delegate_test.go
new file mode 100644
index 0000000..204081d
--- /dev/null
+++ b/rackspace/compute/v2/flavors/delegate_test.go
@@ -0,0 +1,62 @@
+package flavors
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListFlavors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, ListOutput)
+ case "performance1-2":
+ fmt.Fprintf(w, `{ "flavors": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ count := 0
+ err := ListDetail(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ actual, err := ExtractFlavors(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedFlavorSlice, actual)
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGetFlavor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/flavors/performance1-1", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+
+ actual, err := Get(client.ServiceClient(), "performance1-1").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &Performance1Flavor, actual)
+}
diff --git a/rackspace/compute/v2/flavors/doc.go b/rackspace/compute/v2/flavors/doc.go
new file mode 100644
index 0000000..278229a
--- /dev/null
+++ b/rackspace/compute/v2/flavors/doc.go
@@ -0,0 +1,3 @@
+// Package flavors provides information and interaction with the flavor
+// API resource for the Rackspace Cloud Servers service.
+package flavors
diff --git a/rackspace/compute/v2/flavors/fixtures.go b/rackspace/compute/v2/flavors/fixtures.go
new file mode 100644
index 0000000..957dccf
--- /dev/null
+++ b/rackspace/compute/v2/flavors/fixtures.go
@@ -0,0 +1,137 @@
+// +build fixtures
+
+package flavors
+
+// ListOutput is a sample response of a flavor List request.
+const ListOutput = `
+{
+ "flavors": [
+ {
+ "OS-FLV-EXT-DATA:ephemeral": 0,
+ "OS-FLV-WITH-EXT-SPECS:extra_specs": {
+ "class": "performance1",
+ "disk_io_index": "40",
+ "number_of_data_disks": "0",
+ "policy_class": "performance_flavor",
+ "resize_policy_class": "performance_flavor"
+ },
+ "disk": 20,
+ "id": "performance1-1",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/864477/flavors/performance1-1",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/864477/flavors/performance1-1",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "1 GB Performance",
+ "ram": 1024,
+ "rxtx_factor": 200,
+ "swap": "",
+ "vcpus": 1
+ },
+ {
+ "OS-FLV-EXT-DATA:ephemeral": 20,
+ "OS-FLV-WITH-EXT-SPECS:extra_specs": {
+ "class": "performance1",
+ "disk_io_index": "40",
+ "number_of_data_disks": "1",
+ "policy_class": "performance_flavor",
+ "resize_policy_class": "performance_flavor"
+ },
+ "disk": 40,
+ "id": "performance1-2",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/864477/flavors/performance1-2",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/864477/flavors/performance1-2",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "2 GB Performance",
+ "ram": 2048,
+ "rxtx_factor": 400,
+ "swap": "",
+ "vcpus": 2
+ }
+ ]
+}`
+
+// GetOutput is a sample response from a flavor Get request. Its contents correspond to the
+// Performance1Flavor struct.
+const GetOutput = `
+{
+ "flavor": {
+ "OS-FLV-EXT-DATA:ephemeral": 0,
+ "OS-FLV-WITH-EXT-SPECS:extra_specs": {
+ "class": "performance1",
+ "disk_io_index": "40",
+ "number_of_data_disks": "0",
+ "policy_class": "performance_flavor",
+ "resize_policy_class": "performance_flavor"
+ },
+ "disk": 20,
+ "id": "performance1-1",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/864477/flavors/performance1-1",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/864477/flavors/performance1-1",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "1 GB Performance",
+ "ram": 1024,
+ "rxtx_factor": 200,
+ "swap": "",
+ "vcpus": 1
+ }
+}
+`
+
+// Performance1Flavor is the expected result of parsing GetOutput, or the first element of
+// ListOutput.
+var Performance1Flavor = Flavor{
+ ID: "performance1-1",
+ Disk: 20,
+ RAM: 1024,
+ Name: "1 GB Performance",
+ RxTxFactor: 200.0,
+ Swap: 0,
+ VCPUs: 1,
+ ExtraSpecs: ExtraSpecs{
+ NumDataDisks: 0,
+ Class: "performance1",
+ DiskIOIndex: 0,
+ PolicyClass: "performance_flavor",
+ },
+}
+
+// Performance2Flavor is the second result expected from parsing ListOutput.
+var Performance2Flavor = Flavor{
+ ID: "performance1-2",
+ Disk: 40,
+ RAM: 2048,
+ Name: "2 GB Performance",
+ RxTxFactor: 400.0,
+ Swap: 0,
+ VCPUs: 2,
+ ExtraSpecs: ExtraSpecs{
+ NumDataDisks: 0,
+ Class: "performance1",
+ DiskIOIndex: 0,
+ PolicyClass: "performance_flavor",
+ },
+}
+
+// ExpectedFlavorSlice is the slice of Flavor structs that are expected to be parsed from
+// ListOutput.
+var ExpectedFlavorSlice = []Flavor{Performance1Flavor, Performance2Flavor}
diff --git a/rackspace/compute/v2/flavors/results.go b/rackspace/compute/v2/flavors/results.go
new file mode 100644
index 0000000..af444a7
--- /dev/null
+++ b/rackspace/compute/v2/flavors/results.go
@@ -0,0 +1,104 @@
+package flavors
+
+import (
+ "reflect"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/mitchellh/mapstructure"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtraSpecs provide additional information about the flavor.
+type ExtraSpecs struct {
+ // The number of data disks
+ NumDataDisks int `mapstructure:"number_of_data_disks"`
+ // The flavor class
+ Class string `mapstructure:"class"`
+ // Relative measure of disk I/O performance from 0-99, where higher is faster
+ DiskIOIndex int `mapstructure:"disk_io_index"`
+ PolicyClass string `mapstructure:"policy_class"`
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+ // The Id field contains the flavor's unique identifier.
+ // For example, this identifier will be useful when specifying which hardware configuration to use for a new server instance.
+ ID string `mapstructure:"id"`
+
+ // The Disk and RA< fields provide a measure of storage space offered by the flavor, in GB and MB, respectively.
+ Disk int `mapstructure:"disk"`
+ RAM int `mapstructure:"ram"`
+
+ // The Name field provides a human-readable moniker for the flavor.
+ Name string `mapstructure:"name"`
+
+ RxTxFactor float64 `mapstructure:"rxtx_factor"`
+
+ // Swap indicates how much space is reserved for swap.
+ // If not provided, this field will be set to 0.
+ Swap int `mapstructure:"swap"`
+
+ // VCPUs indicates how many (virtual) CPUs are available for this flavor.
+ VCPUs int `mapstructure:"vcpus"`
+
+ // ExtraSpecs provides extra information about the flavor
+ ExtraSpecs ExtraSpecs `mapstructure:"OS-FLV-WITH-EXT-SPECS:extra_specs"`
+}
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract provides access to the individual Flavor returned by the Get function.
+func (gr GetResult) Extract() (*Flavor, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+
+ var result struct {
+ Flavor Flavor `mapstructure:"flavor"`
+ }
+
+ cfg := &mapstructure.DecoderConfig{
+ DecodeHook: defaulter,
+ Result: &result,
+ }
+ decoder, err := mapstructure.NewDecoder(cfg)
+ if err != nil {
+ return nil, err
+ }
+ err = decoder.Decode(gr.Body)
+ return &result.Flavor, err
+}
+
+func defaulter(from, to reflect.Kind, v interface{}) (interface{}, error) {
+ if (from == reflect.String) && (to == reflect.Int) {
+ return 0, nil
+ }
+ return v, nil
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(page pagination.Page) ([]Flavor, error) {
+ casted := page.(os.FlavorPage).Body
+ var container struct {
+ Flavors []Flavor `mapstructure:"flavors"`
+ }
+
+ cfg := &mapstructure.DecoderConfig{
+ DecodeHook: defaulter,
+ Result: &container,
+ }
+ decoder, err := mapstructure.NewDecoder(cfg)
+ if err != nil {
+ return container.Flavors, err
+ }
+ err = decoder.Decode(casted)
+ if err != nil {
+ return container.Flavors, err
+ }
+
+ return container.Flavors, nil
+}
diff --git a/rackspace/compute/v2/flavors/urls.go b/rackspace/compute/v2/flavors/urls.go
new file mode 100644
index 0000000..f4e2c3d
--- /dev/null
+++ b/rackspace/compute/v2/flavors/urls.go
@@ -0,0 +1,9 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+)
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("flavors", id)
+}
diff --git a/rackspace/compute/v2/images/delegate.go b/rackspace/compute/v2/images/delegate.go
new file mode 100644
index 0000000..18e1f31
--- /dev/null
+++ b/rackspace/compute/v2/images/delegate.go
@@ -0,0 +1,22 @@
+package images
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ListDetail enumerates the available server images.
+func ListDetail(client *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.ListDetail(client, opts)
+}
+
+// Get acquires additional detail about a specific image by ID.
+func Get(client *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(client, id)
+}
+
+// ExtractImages interprets a page as a collection of server images.
+func ExtractImages(page pagination.Page) ([]os.Image, error) {
+ return os.ExtractImages(page)
+}
diff --git a/rackspace/compute/v2/images/delegate_test.go b/rackspace/compute/v2/images/delegate_test.go
new file mode 100644
index 0000000..db0a6e3
--- /dev/null
+++ b/rackspace/compute/v2/images/delegate_test.go
@@ -0,0 +1,62 @@
+package images
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListImageDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/images/detail", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ r.ParseForm()
+ marker := r.Form.Get("marker")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, ListOutput)
+ case "e19a734c-c7e6-443a-830c-242209c4d65d":
+ fmt.Fprintf(w, `{ "images": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ count := 0
+ err := ListDetail(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractImages(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedImageSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGetImageDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/images/e19a734c-c7e6-443a-830c-242209c4d65d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+
+ actual, err := Get(client.ServiceClient(), "e19a734c-c7e6-443a-830c-242209c4d65d").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &UbuntuImage, actual)
+}
diff --git a/rackspace/compute/v2/images/doc.go b/rackspace/compute/v2/images/doc.go
new file mode 100644
index 0000000..cfae806
--- /dev/null
+++ b/rackspace/compute/v2/images/doc.go
@@ -0,0 +1,3 @@
+// Package images provides information and interaction with the image
+// API resource for the Rackspace Cloud Servers service.
+package images
diff --git a/rackspace/compute/v2/images/fixtures.go b/rackspace/compute/v2/images/fixtures.go
new file mode 100644
index 0000000..ccfbdc6
--- /dev/null
+++ b/rackspace/compute/v2/images/fixtures.go
@@ -0,0 +1,200 @@
+// +build fixtures
+
+package images
+
+import (
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+)
+
+// ListOutput is an example response from an /images/detail request.
+const ListOutput = `
+{
+ "images": [
+ {
+ "OS-DCF:diskConfig": "MANUAL",
+ "OS-EXT-IMG-SIZE:size": 1.017415075e+09,
+ "created": "2014-10-01T15:49:02Z",
+ "id": "30aa010e-080e-4d4b-a7f9-09fc55b07d69",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/111222/images/30aa010e-080e-4d4b-a7f9-09fc55b07d69",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/30aa010e-080e-4d4b-a7f9-09fc55b07d69",
+ "rel": "bookmark"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/30aa010e-080e-4d4b-a7f9-09fc55b07d69",
+ "rel": "alternate",
+ "type": "application/vnd.openstack.image"
+ }
+ ],
+ "metadata": {
+ "auto_disk_config": "disabled",
+ "cache_in_nova": "True",
+ "com.rackspace__1__build_core": "1",
+ "com.rackspace__1__build_managed": "1",
+ "com.rackspace__1__build_rackconnect": "1",
+ "com.rackspace__1__options": "0",
+ "com.rackspace__1__platform_target": "PublicCloud",
+ "com.rackspace__1__release_build_date": "2014-10-01_15-46-08",
+ "com.rackspace__1__release_id": "100",
+ "com.rackspace__1__release_version": "10",
+ "com.rackspace__1__source": "kickstart",
+ "com.rackspace__1__visible_core": "1",
+ "com.rackspace__1__visible_managed": "0",
+ "com.rackspace__1__visible_rackconnect": "0",
+ "image_type": "base",
+ "org.openstack__1__architecture": "x64",
+ "org.openstack__1__os_distro": "org.archlinux",
+ "org.openstack__1__os_version": "2014.8",
+ "os_distro": "arch",
+ "os_type": "linux",
+ "vm_mode": "hvm"
+ },
+ "minDisk": 20,
+ "minRam": 512,
+ "name": "Arch 2014.10 (PVHVM)",
+ "progress": 100,
+ "status": "ACTIVE",
+ "updated": "2014-10-01T19:37:58Z"
+ },
+ {
+ "OS-DCF:diskConfig": "AUTO",
+ "OS-EXT-IMG-SIZE:size": 1.060306463e+09,
+ "created": "2014-10-01T12:58:11Z",
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "alternate",
+ "type": "application/vnd.openstack.image"
+ }
+ ],
+ "metadata": {
+ "auto_disk_config": "True",
+ "cache_in_nova": "True",
+ "com.rackspace__1__build_core": "1",
+ "com.rackspace__1__build_managed": "1",
+ "com.rackspace__1__build_rackconnect": "1",
+ "com.rackspace__1__options": "0",
+ "com.rackspace__1__platform_target": "PublicCloud",
+ "com.rackspace__1__release_build_date": "2014-10-01_12-31-03",
+ "com.rackspace__1__release_id": "1007",
+ "com.rackspace__1__release_version": "6",
+ "com.rackspace__1__source": "kickstart",
+ "com.rackspace__1__visible_core": "1",
+ "com.rackspace__1__visible_managed": "1",
+ "com.rackspace__1__visible_rackconnect": "1",
+ "image_type": "base",
+ "org.openstack__1__architecture": "x64",
+ "org.openstack__1__os_distro": "com.ubuntu",
+ "org.openstack__1__os_version": "14.04",
+ "os_distro": "ubuntu",
+ "os_type": "linux",
+ "vm_mode": "xen"
+ },
+ "minDisk": 20,
+ "minRam": 512,
+ "name": "Ubuntu 14.04 LTS (Trusty Tahr)",
+ "progress": 100,
+ "status": "ACTIVE",
+ "updated": "2014-10-01T15:51:44Z"
+ }
+ ]
+}
+`
+
+// GetOutput is an example response from an /images request.
+const GetOutput = `
+{
+ "image": {
+ "OS-DCF:diskConfig": "AUTO",
+ "OS-EXT-IMG-SIZE:size": 1060306463,
+ "created": "2014-10-01T12:58:11Z",
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": [
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/v2/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "self"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark"
+ },
+ {
+ "href": "https://iad.servers.api.rackspacecloud.com/111222/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "alternate",
+ "type": "application/vnd.openstack.image"
+ }
+ ],
+ "metadata": {
+ "auto_disk_config": "True",
+ "cache_in_nova": "True",
+ "com.rackspace__1__build_core": "1",
+ "com.rackspace__1__build_managed": "1",
+ "com.rackspace__1__build_rackconnect": "1",
+ "com.rackspace__1__options": "0",
+ "com.rackspace__1__platform_target": "PublicCloud",
+ "com.rackspace__1__release_build_date": "2014-10-01_12-31-03",
+ "com.rackspace__1__release_id": "1007",
+ "com.rackspace__1__release_version": "6",
+ "com.rackspace__1__source": "kickstart",
+ "com.rackspace__1__visible_core": "1",
+ "com.rackspace__1__visible_managed": "1",
+ "com.rackspace__1__visible_rackconnect": "1",
+ "image_type": "base",
+ "org.openstack__1__architecture": "x64",
+ "org.openstack__1__os_distro": "com.ubuntu",
+ "org.openstack__1__os_version": "14.04",
+ "os_distro": "ubuntu",
+ "os_type": "linux",
+ "vm_mode": "xen"
+ },
+ "minDisk": 20,
+ "minRam": 512,
+ "name": "Ubuntu 14.04 LTS (Trusty Tahr)",
+ "progress": 100,
+ "status": "ACTIVE",
+ "updated": "2014-10-01T15:51:44Z"
+ }
+}
+`
+
+// ArchImage is the first Image structure that should be parsed from ListOutput.
+var ArchImage = os.Image{
+ ID: "30aa010e-080e-4d4b-a7f9-09fc55b07d69",
+ Name: "Arch 2014.10 (PVHVM)",
+ Created: "2014-10-01T15:49:02Z",
+ Updated: "2014-10-01T19:37:58Z",
+ MinDisk: 20,
+ MinRAM: 512,
+ Progress: 100,
+ Status: "ACTIVE",
+}
+
+// UbuntuImage is the second Image structure that should be parsed from ListOutput and
+// the only image that should be extracted from GetOutput.
+var UbuntuImage = os.Image{
+ ID: "e19a734c-c7e6-443a-830c-242209c4d65d",
+ Name: "Ubuntu 14.04 LTS (Trusty Tahr)",
+ Created: "2014-10-01T12:58:11Z",
+ Updated: "2014-10-01T15:51:44Z",
+ MinDisk: 20,
+ MinRAM: 512,
+ Progress: 100,
+ Status: "ACTIVE",
+}
+
+// ExpectedImageSlice is the collection of images that should be parsed from ListOutput,
+// in order.
+var ExpectedImageSlice = []os.Image{ArchImage, UbuntuImage}
diff --git a/rackspace/compute/v2/keypairs/delegate.go b/rackspace/compute/v2/keypairs/delegate.go
new file mode 100644
index 0000000..3e53525
--- /dev/null
+++ b/rackspace/compute/v2/keypairs/delegate.go
@@ -0,0 +1,33 @@
+package keypairs
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of KeyPairs.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// Create requests the creation of a new keypair on the server, or to import a pre-existing
+// keypair.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, opts)
+}
+
+// Get returns public data about a previously uploaded KeyPair.
+func Get(client *gophercloud.ServiceClient, name string) os.GetResult {
+ return os.Get(client, name)
+}
+
+// Delete requests the deletion of a previous stored KeyPair from the server.
+func Delete(client *gophercloud.ServiceClient, name string) os.DeleteResult {
+ return os.Delete(client, name)
+}
+
+// ExtractKeyPairs interprets a page of results as a slice of KeyPairs.
+func ExtractKeyPairs(page pagination.Page) ([]os.KeyPair, error) {
+ return os.ExtractKeyPairs(page)
+}
diff --git a/rackspace/compute/v2/keypairs/delegate_test.go b/rackspace/compute/v2/keypairs/delegate_test.go
new file mode 100644
index 0000000..62e5df9
--- /dev/null
+++ b/rackspace/compute/v2/keypairs/delegate_test.go
@@ -0,0 +1,72 @@
+package keypairs
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListSuccessfully(t)
+
+ count := 0
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractKeyPairs(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, os.ExpectedKeyPairSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreateSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), os.CreateOpts{
+ Name: "createdkey",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &os.CreatedKeyPair, actual)
+}
+
+func TestImport(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleImportSuccessfully(t)
+
+ actual, err := Create(client.ServiceClient(), os.CreateOpts{
+ Name: "importedkey",
+ PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &os.ImportedKeyPair, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t)
+
+ actual, err := Get(client.ServiceClient(), "firstkey").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &os.FirstKeyPair, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDeleteSuccessfully(t)
+
+ err := Delete(client.ServiceClient(), "deletedkey").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/compute/v2/keypairs/doc.go b/rackspace/compute/v2/keypairs/doc.go
new file mode 100644
index 0000000..3171375
--- /dev/null
+++ b/rackspace/compute/v2/keypairs/doc.go
@@ -0,0 +1,3 @@
+// Package keypairs provides information and interaction with the keypair
+// API resource for the Rackspace Cloud Servers service.
+package keypairs
diff --git a/rackspace/compute/v2/networks/doc.go b/rackspace/compute/v2/networks/doc.go
new file mode 100644
index 0000000..8e5c773
--- /dev/null
+++ b/rackspace/compute/v2/networks/doc.go
@@ -0,0 +1,3 @@
+// Package networks provides information and interaction with the network
+// API resource for the Rackspace Cloud Servers service.
+package networks
diff --git a/rackspace/compute/v2/networks/requests.go b/rackspace/compute/v2/networks/requests.go
new file mode 100644
index 0000000..cebbffd
--- /dev/null
+++ b/rackspace/compute/v2/networks/requests.go
@@ -0,0 +1,89 @@
+package networks
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(c, listURL(c), createPage)
+}
+
+// Get retrieves a specific network based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToNetworkCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // REQUIRED. See Network object for more info.
+ CIDR string
+ // REQUIRED. See Network object for more info.
+ Label string
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+ n := make(map[string]interface{})
+
+ if opts.CIDR == "" {
+ return nil, errors.New("Required field CIDR not set.")
+ }
+ if opts.Label == "" {
+ return nil, errors.New("Required field Label not set.")
+ }
+
+ n["label"] = opts.Label
+ n["cidr"] = opts.CIDR
+ return map[string]interface{}{"network": n}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. This operation does not actually require a request body, i.e. the
+// CreateOpts struct argument can be empty.
+//
+// The tenant ID that is contained in the URI is the tenant that creates the
+// network. An admin user, however, has the option of specifying another tenant
+// ID in the CreateOpts struct.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToNetworkCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ // Send request to API
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return res
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, networkID), nil)
+ return res
+}
diff --git a/rackspace/compute/v2/networks/requests_test.go b/rackspace/compute/v2/networks/requests_test.go
new file mode 100644
index 0000000..6f44c1c
--- /dev/null
+++ b/rackspace/compute/v2/networks/requests_test.go
@@ -0,0 +1,156 @@
+package networks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/os-networksv2", 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, `
+{
+ "networks": [
+ {
+ "label": "test-network-1",
+ "cidr": "192.168.100.0/24",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ },
+ {
+ "label": "test-network-2",
+ "cidr": "192.30.250.00/18",
+ "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324"
+ }
+ ]
+}
+ `)
+ })
+
+ client := fake.ServiceClient()
+ count := 0
+
+ err := List(client).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []Network{
+ Network{
+ Label: "test-network-1",
+ CIDR: "192.168.100.0/24",
+ ID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ },
+ Network{
+ Label: "test-network-2",
+ CIDR: "192.30.250.00/18",
+ ID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/os-networksv2/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "network": {
+ "label": "test-network-1",
+ "cidr": "192.168.100.0/24",
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.CIDR, "192.168.100.0/24")
+ th.AssertEquals(t, n.Label, "test-network-1")
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/os-networksv2", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "label": "test-network-1",
+ "cidr": "192.168.100.0/24"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "label": "test-network-1",
+ "cidr": "192.168.100.0/24",
+ "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+ }
+}
+ `)
+ })
+
+ options := CreateOpts{Label: "test-network-1", CIDR: "192.168.100.0/24"}
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Label, "test-network-1")
+ th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/os-networksv2/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/compute/v2/networks/results.go b/rackspace/compute/v2/networks/results.go
new file mode 100644
index 0000000..eb6a76c
--- /dev/null
+++ b/rackspace/compute/v2/networks/results.go
@@ -0,0 +1,81 @@
+package networks
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a network resource.
+func (r commonResult) Extract() (*Network, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ Network *Network `json:"network"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return res.Network, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Network represents, well, a network.
+type Network struct {
+ // UUID for the network
+ ID string `mapstructure:"id" json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Label string `mapstructure:"label" json:"label"`
+
+ // Classless Inter-Domain Routing
+ CIDR string `mapstructure:"cidr" json:"cidr"`
+}
+
+// NetworkPage is the page returned by a pager when traversing over a
+// collection of networks.
+type NetworkPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if the NetworkPage contains no Networks.
+func (r NetworkPage) IsEmpty() (bool, error) {
+ networks, err := ExtractNetworks(r)
+ if err != nil {
+ return true, err
+ }
+ return len(networks) == 0, nil
+}
+
+// ExtractNetworks accepts a Page struct, specifically a NetworkPage struct,
+// and extracts the elements into a slice of Network structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractNetworks(page pagination.Page) ([]Network, error) {
+ var resp struct {
+ Networks []Network `mapstructure:"networks" json:"networks"`
+ }
+
+ err := mapstructure.Decode(page.(NetworkPage).Body, &resp)
+
+ return resp.Networks, err
+}
diff --git a/rackspace/compute/v2/networks/urls.go b/rackspace/compute/v2/networks/urls.go
new file mode 100644
index 0000000..19a21aa
--- /dev/null
+++ b/rackspace/compute/v2/networks/urls.go
@@ -0,0 +1,27 @@
+package networks
+
+import "github.com/rackspace/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("os-networksv2", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("os-networksv2")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return rootURL(c)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return resourceURL(c, id)
+}
diff --git a/rackspace/compute/v2/networks/urls_test.go b/rackspace/compute/v2/networks/urls_test.go
new file mode 100644
index 0000000..983992e
--- /dev/null
+++ b/rackspace/compute/v2/networks/urls_test.go
@@ -0,0 +1,38 @@
+package networks
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestGetURL(t *testing.T) {
+ actual := getURL(endpointClient(), "foo")
+ expected := endpoint + "os-networksv2/foo"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "os-networksv2"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := createURL(endpointClient())
+ expected := endpoint + "os-networksv2"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "foo")
+ expected := endpoint + "os-networksv2/foo"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/rackspace/compute/v2/servers/delegate.go b/rackspace/compute/v2/servers/delegate.go
new file mode 100644
index 0000000..7810d15
--- /dev/null
+++ b/rackspace/compute/v2/servers/delegate.go
@@ -0,0 +1,116 @@
+package servers
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List makes a request against the API to list servers accessible to you.
+func List(client *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(client, opts)
+}
+
+// Create requests a server to be provisioned to the user in the current tenant.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, opts)
+}
+
+// Update requests an existing server to be updated with the supplied options.
+func Update(client *gophercloud.ServiceClient, id string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(client, id, opts)
+}
+
+// Delete requests that a server previously provisioned be removed from your account.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
+
+// Get requests details on a single server, by ID.
+func Get(client *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(client, id)
+}
+
+// ChangeAdminPassword alters the administrator or root password for a specified server.
+func ChangeAdminPassword(client *gophercloud.ServiceClient, id, newPassword string) os.ActionResult {
+ return os.ChangeAdminPassword(client, id, newPassword)
+}
+
+// Reboot requests that a given server reboot. Two methods exist for rebooting a server:
+//
+// os.HardReboot (aka PowerCycle) restarts the server instance by physically cutting power to the
+// machine, or if a VM, terminating it at the hypervisor level. It's done. Caput. Full stop. Then,
+// after a brief wait, power is restored or the VM instance restarted.
+//
+// os.SoftReboot (aka OSReboot) simply tells the OS to restart under its own procedures. E.g., in
+// Linux, asking it to enter runlevel 6, or executing "sudo shutdown -r now", or by asking Windows to restart the machine.
+func Reboot(client *gophercloud.ServiceClient, id string, how os.RebootMethod) os.ActionResult {
+ return os.Reboot(client, id, how)
+}
+
+// Rebuild will reprovision the server according to the configuration options provided in the
+// RebuildOpts struct.
+func Rebuild(client *gophercloud.ServiceClient, id string, opts os.RebuildOptsBuilder) os.RebuildResult {
+ return os.Rebuild(client, id, opts)
+}
+
+// Resize instructs the provider to change the flavor of the server.
+// Note that this implies rebuilding it.
+// Unfortunately, one cannot pass rebuild parameters to the resize function.
+// When the resize completes, the server will be in RESIZE_VERIFY state.
+// While in this state, you can explore the use of the new server's configuration.
+// If you like it, call ConfirmResize() to commit the resize permanently.
+// Otherwise, call RevertResize() to restore the old configuration.
+func Resize(client *gophercloud.ServiceClient, id string, opts os.ResizeOptsBuilder) os.ActionResult {
+ return os.Resize(client, id, opts)
+}
+
+// ConfirmResize confirms a previous resize operation on a server.
+// See Resize() for more details.
+func ConfirmResize(client *gophercloud.ServiceClient, id string) os.ActionResult {
+ return os.ConfirmResize(client, id)
+}
+
+// WaitForStatus will continually poll a server until it successfully transitions to a specified
+// status. It will do this for at most the number of seconds specified.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+ return os.WaitForStatus(c, id, status, secs)
+}
+
+// ExtractServers interprets the results of a single page from a List() call, producing a slice of Server entities.
+func ExtractServers(page pagination.Page) ([]os.Server, error) {
+ return os.ExtractServers(page)
+}
+
+// ListAddresses makes a request against the API to list the servers IP addresses.
+func ListAddresses(client *gophercloud.ServiceClient, id string) pagination.Pager {
+ return os.ListAddresses(client, id)
+}
+
+// ExtractAddresses interprets the results of a single page from a ListAddresses() call, producing a map of Address slices.
+func ExtractAddresses(page pagination.Page) (map[string][]os.Address, error) {
+ return os.ExtractAddresses(page)
+}
+
+// ListAddressesByNetwork makes a request against the API to list the servers IP addresses
+// for the given network.
+func ListAddressesByNetwork(client *gophercloud.ServiceClient, id, network string) pagination.Pager {
+ return os.ListAddressesByNetwork(client, id, network)
+}
+
+// ExtractNetworkAddresses interprets the results of a single page from a ListAddressesByNetwork() call, producing a map of Address slices.
+func ExtractNetworkAddresses(page pagination.Page) ([]os.Address, error) {
+ return os.ExtractNetworkAddresses(page)
+}
+
+// Metadata requests all the metadata for the given server ID.
+func Metadata(client *gophercloud.ServiceClient, id string) os.GetMetadataResult {
+ return os.Metadata(client, id)
+}
+
+// UpdateMetadata updates (or creates) all the metadata specified by opts for the given server ID.
+// This operation does not affect already-existing metadata that is not specified
+// by opts.
+func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts os.UpdateMetadataOptsBuilder) os.UpdateMetadataResult {
+ return os.UpdateMetadata(client, id, opts)
+}
diff --git a/rackspace/compute/v2/servers/delegate_test.go b/rackspace/compute/v2/servers/delegate_test.go
new file mode 100644
index 0000000..03e7ace
--- /dev/null
+++ b/rackspace/compute/v2/servers/delegate_test.go
@@ -0,0 +1,182 @@
+package servers
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListServers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/detail", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, ListOutput)
+ })
+
+ count := 0
+ err := List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractServers(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedServerSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleServerCreationSuccessfully(t, CreateOutput)
+
+ actual, err := Create(client.ServiceClient(), os.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, &CreatedServer, actual)
+}
+
+func TestDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleServerDeletionSuccessfully(t)
+
+ res := Delete(client.ServiceClient(), "asdfasdfasdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, GetOutput)
+ })
+
+ actual, err := Get(client.ServiceClient(), "8c65cb68-0681-4c30-bc88-6b83a8a26aee").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &GophercloudServer, actual)
+}
+
+func TestUpdateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "server": { "name": "test-server-updated" } }`)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ fmt.Fprintf(w, UpdateOutput)
+ })
+
+ opts := os.UpdateOpts{
+ Name: "test-server-updated",
+ }
+ actual, err := Update(client.ServiceClient(), "8c65cb68-0681-4c30-bc88-6b83a8a26aee", opts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &GophercloudUpdatedServer, actual)
+}
+
+func TestChangeAdminPassword(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleAdminPasswordChangeSuccessfully(t)
+
+ res := ChangeAdminPassword(client.ServiceClient(), "1234asdf", "new-password")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestReboot(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleRebootSuccessfully(t)
+
+ res := Reboot(client.ServiceClient(), "1234asdf", os.SoftReboot)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRebuildServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleRebuildSuccessfully(t, GetOutput)
+
+ opts := os.RebuildOpts{
+ Name: "new-name",
+ AdminPass: "swordfish",
+ ImageID: "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+ AccessIPv4: "1.2.3.4",
+ }
+ actual, err := Rebuild(client.ServiceClient(), "1234asdf", opts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &GophercloudServer, actual)
+}
+
+func TestListAddresses(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleAddressListSuccessfully(t)
+
+ expected := os.ListAddressesExpected
+ pages := 0
+ err := ListAddresses(client.ServiceClient(), "asdfasdfasdf").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractAddresses(page)
+ th.AssertNoErr(t, err)
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 networks, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, pages)
+}
+
+func TestListAddressesByNetwork(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleNetworkAddressListSuccessfully(t)
+
+ expected := os.ListNetworkAddressesExpected
+ pages := 0
+ err := ListAddressesByNetwork(client.ServiceClient(), "asdfasdfasdf", "public").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractNetworkAddresses(page)
+ th.AssertNoErr(t, err)
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 addresses, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, pages)
+}
diff --git a/rackspace/compute/v2/servers/doc.go b/rackspace/compute/v2/servers/doc.go
new file mode 100644
index 0000000..c9f77f6
--- /dev/null
+++ b/rackspace/compute/v2/servers/doc.go
@@ -0,0 +1,3 @@
+// Package servers provides information and interaction with the server
+// API resource for the Rackspace Cloud Servers service.
+package servers
diff --git a/rackspace/compute/v2/servers/fixtures.go b/rackspace/compute/v2/servers/fixtures.go
new file mode 100644
index 0000000..75cccd0
--- /dev/null
+++ b/rackspace/compute/v2/servers/fixtures.go
@@ -0,0 +1,574 @@
+// +build fixtures
+
+package servers
+
+import (
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// ListOutput is the recorded output of a Rackspace servers.List request.
+const ListOutput = `
+{
+ "servers": [
+ {
+ "OS-DCF:diskConfig": "MANUAL",
+ "OS-EXT-STS:power_state": 1,
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "accessIPv4": "1.2.3.4",
+ "accessIPv6": "1111:4822:7818:121:2000:9b5e:7438:a2d0",
+ "addresses": {
+ "private": [
+ {
+ "addr": "10.208.230.113",
+ "version": 4
+ }
+ ],
+ "public": [
+ {
+ "addr": "2001:4800:7818:101:2000:9b5e:7428:a2d0",
+ "version": 6
+ },
+ {
+ "addr": "104.130.131.164",
+ "version": 4
+ }
+ ]
+ },
+ "created": "2014-09-23T12:34:58Z",
+ "flavor": {
+ "id": "performance1-8",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-8",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "hostId": "e8951a524bc465b0898aeac7674da6fe1495e253ae1ea17ddb2c2475",
+ "id": "59818cee-bc8c-44eb-8073-673ee65105f7",
+ "image": {
+ "id": "255df5fb-e3d4-45a3-9a07-c976debf7c14",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/255df5fb-e3d4-45a3-9a07-c976debf7c14",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "key_name": "mykey",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/59818cee-bc8c-44eb-8073-673ee65105f7",
+ "rel": "self"
+ },
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/59818cee-bc8c-44eb-8073-673ee65105f7",
+ "rel": "bookmark"
+ }
+ ],
+ "metadata": {},
+ "name": "devstack",
+ "progress": 100,
+ "status": "ACTIVE",
+ "tenant_id": "111111",
+ "updated": "2014-09-23T12:38:19Z",
+ "user_id": "14ae7bb21d81422694655f3cc30f2930"
+ },
+ {
+ "OS-DCF:diskConfig": "MANUAL",
+ "OS-EXT-STS:power_state": 1,
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "accessIPv4": "1.1.2.3",
+ "accessIPv6": "2222:4444:7817:101:be76:4eff:f0e5:9e02",
+ "addresses": {
+ "private": [
+ {
+ "addr": "10.10.20.30",
+ "version": 4
+ }
+ ],
+ "public": [
+ {
+ "addr": "1.1.2.3",
+ "version": 4
+ },
+ {
+ "addr": "2222:4444:7817:101:be76:4eff:f0e5:9e02",
+ "version": 6
+ }
+ ]
+ },
+ "created": "2014-07-21T19:32:55Z",
+ "flavor": {
+ "id": "performance1-2",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-2",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "hostId": "f859679906d6b1a38c1bd516b78f4dcc7d5fcf012578fa3ce460716c",
+ "id": "25f1c7f5-e00a-4715-b354-16e24b2f4630",
+ "image": {
+ "id": "bb02b1a3-bc77-4d17-ab5b-421d89850fca",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/bb02b1a3-bc77-4d17-ab5b-421d89850fca",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "key_name": "otherkey",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/25f1c7f5-e00a-4715-b355-16e24b2f4630",
+ "rel": "self"
+ },
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/25f1c7f5-e00a-4715-b355-16e24b2f4630",
+ "rel": "bookmark"
+ }
+ ],
+ "metadata": {},
+ "name": "peril-dfw",
+ "progress": 100,
+ "status": "ACTIVE",
+ "tenant_id": "111111",
+ "updated": "2014-07-21T19:34:24Z",
+ "user_id": "14ae7bb21d81422694655f3cc30f2930"
+ }
+ ]
+}
+`
+
+// GetOutput is the recorded output of a Rackspace servers.Get request.
+const GetOutput = `
+{
+ "server": {
+ "OS-DCF:diskConfig": "AUTO",
+ "OS-EXT-STS:power_state": 1,
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "accessIPv4": "1.2.4.8",
+ "accessIPv6": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "addresses": {
+ "private": [
+ {
+ "addr": "10.20.40.80",
+ "version": 4
+ }
+ ],
+ "public": [
+ {
+ "addr": "1.2.4.8",
+ "version": 4
+ },
+ {
+ "addr": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "version": 6
+ }
+ ]
+ },
+ "created": "2014-10-21T14:42:16Z",
+ "flavor": {
+ "id": "performance1-1",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "hostId": "430d2ae02de0a7af77012c94778145eccf67e75b1fac0528aa10d4a7",
+ "id": "8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "image": {
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "key_name": null,
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "self"
+ },
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "bookmark"
+ }
+ ],
+ "metadata": {},
+ "name": "Gophercloud-pxpGGuey",
+ "progress": 100,
+ "status": "ACTIVE",
+ "tenant_id": "111111",
+ "updated": "2014-10-21T14:42:57Z",
+ "user_id": "14ae7bb21d81423694655f4dd30f2930"
+ }
+}
+`
+
+// UpdateOutput is the recorded output of a Rackspace servers.Update request.
+const UpdateOutput = `
+{
+ "server": {
+ "OS-DCF:diskConfig": "AUTO",
+ "OS-EXT-STS:power_state": 1,
+ "OS-EXT-STS:task_state": null,
+ "OS-EXT-STS:vm_state": "active",
+ "accessIPv4": "1.2.4.8",
+ "accessIPv6": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "addresses": {
+ "private": [
+ {
+ "addr": "10.20.40.80",
+ "version": 4
+ }
+ ],
+ "public": [
+ {
+ "addr": "1.2.4.8",
+ "version": 4
+ },
+ {
+ "addr": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "version": 6
+ }
+ ]
+ },
+ "created": "2014-10-21T14:42:16Z",
+ "flavor": {
+ "id": "performance1-1",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "hostId": "430d2ae02de0a7af77012c94778145eccf67e75b1fac0528aa10d4a7",
+ "id": "8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "image": {
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "key_name": null,
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "self"
+ },
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "bookmark"
+ }
+ ],
+ "metadata": {},
+ "name": "test-server-updated",
+ "progress": 100,
+ "status": "ACTIVE",
+ "tenant_id": "111111",
+ "updated": "2014-10-21T14:42:57Z",
+ "user_id": "14ae7bb21d81423694655f4dd30f2930"
+ }
+}
+`
+
+// CreateOutput contains a sample of Rackspace's response to a Create call.
+const CreateOutput = `
+{
+ "server": {
+ "OS-DCF:diskConfig": "AUTO",
+ "adminPass": "v7tADqbE5pr9",
+ "id": "bb63327b-6a2f-34bc-b0ef-4b6d97ea637e",
+ "links": [
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/bb63327b-6a2f-34bc-b0ef-4b6d97ea637e",
+ "rel": "self"
+ },
+ {
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/bb63327b-6a2f-34bc-b0ef-4b6d97ea637e",
+ "rel": "bookmark"
+ }
+ ]
+ }
+}
+`
+
+// DevstackServer is the expected first result from parsing ListOutput.
+var DevstackServer = os.Server{
+ ID: "59818cee-bc8c-44eb-8073-673ee65105f7",
+ Name: "devstack",
+ TenantID: "111111",
+ UserID: "14ae7bb21d81422694655f3cc30f2930",
+ HostID: "e8951a524bc465b0898aeac7674da6fe1495e253ae1ea17ddb2c2475",
+ Updated: "2014-09-23T12:38:19Z",
+ Created: "2014-09-23T12:34:58Z",
+ AccessIPv4: "1.2.3.4",
+ AccessIPv6: "1111:4822:7818:121:2000:9b5e:7438:a2d0",
+ Progress: 100,
+ Status: "ACTIVE",
+ Image: map[string]interface{}{
+ "id": "255df5fb-e3d4-45a3-9a07-c976debf7c14",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/255df5fb-e3d4-45a3-9a07-c976debf7c14",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "performance1-8",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-8",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "addr": "10.20.30.40",
+ "version": float64(4.0),
+ },
+ },
+ "public": []interface{}{
+ map[string]interface{}{
+ "addr": "1111:4822:7818:121:2000:9b5e:7438:a2d0",
+ "version": float64(6.0),
+ },
+ map[string]interface{}{
+ "addr": "1.2.3.4",
+ "version": float64(4.0),
+ },
+ },
+ },
+ Metadata: map[string]interface{}{},
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/59918cee-bd9d-44eb-8173-673ee75105f7",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/59818cee-bc8c-44eb-8073-673ee65105f7",
+ "rel": "bookmark",
+ },
+ },
+ KeyName: "mykey",
+ AdminPass: "",
+}
+
+// PerilServer is the expected second result from parsing ListOutput.
+var PerilServer = os.Server{
+ ID: "25f1c7f5-e00a-4715-b354-16e24b2f4630",
+ Name: "peril-dfw",
+ TenantID: "111111",
+ UserID: "14ae7bb21d81422694655f3cc30f2930",
+ HostID: "f859679906d6b1a38c1bd516b78f4dcc7d5fcf012578fa3ce460716c",
+ Updated: "2014-07-21T19:34:24Z",
+ Created: "2014-07-21T19:32:55Z",
+ AccessIPv4: "1.1.2.3",
+ AccessIPv6: "2222:4444:7817:101:be76:4eff:f0e5:9e02",
+ Progress: 100,
+ Status: "ACTIVE",
+ Image: map[string]interface{}{
+ "id": "bb02b1a3-bc77-4d17-ab5b-421d89850fca",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/bb02b1a3-bc77-4d17-ab5b-421d89850fca",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "performance1-2",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-2",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "addr": "10.10.20.30",
+ "version": float64(4.0),
+ },
+ },
+ "public": []interface{}{
+ map[string]interface{}{
+ "addr": "2222:4444:7817:101:be76:4eff:f0e5:9e02",
+ "version": float64(6.0),
+ },
+ map[string]interface{}{
+ "addr": "1.1.2.3",
+ "version": float64(4.0),
+ },
+ },
+ },
+ Metadata: map[string]interface{}{},
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/25f1c7f5-e00a-4715-b355-16e24b2f4630",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/25f1c7f5-e00a-4715-b355-16e24b2f4630",
+ "rel": "bookmark",
+ },
+ },
+ KeyName: "otherkey",
+ AdminPass: "",
+}
+
+// GophercloudServer is the expected result from parsing GetOutput.
+var GophercloudServer = os.Server{
+ ID: "8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ Name: "Gophercloud-pxpGGuey",
+ TenantID: "111111",
+ UserID: "14ae7bb21d81423694655f4dd30f2930",
+ HostID: "430d2ae02de0a7af77012c94778145eccf67e75b1fac0528aa10d4a7",
+ Updated: "2014-10-21T14:42:57Z",
+ Created: "2014-10-21T14:42:16Z",
+ AccessIPv4: "1.2.4.8",
+ AccessIPv6: "2001:4800:6666:105:2a0f:c056:f594:7777",
+ Progress: 100,
+ Status: "ACTIVE",
+ Image: map[string]interface{}{
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "performance1-1",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-1",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "addr": "10.20.40.80",
+ "version": float64(4.0),
+ },
+ },
+ "public": []interface{}{
+ map[string]interface{}{
+ "addr": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "version": float64(6.0),
+ },
+ map[string]interface{}{
+ "addr": "1.2.4.8",
+ "version": float64(4.0),
+ },
+ },
+ },
+ Metadata: map[string]interface{}{},
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "bookmark",
+ },
+ },
+ KeyName: "",
+ AdminPass: "",
+}
+
+// GophercloudUpdatedServer is the expected result from parsing UpdateOutput.
+var GophercloudUpdatedServer = os.Server{
+ ID: "8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ Name: "test-server-updated",
+ TenantID: "111111",
+ UserID: "14ae7bb21d81423694655f4dd30f2930",
+ HostID: "430d2ae02de0a7af77012c94778145eccf67e75b1fac0528aa10d4a7",
+ Updated: "2014-10-21T14:42:57Z",
+ Created: "2014-10-21T14:42:16Z",
+ AccessIPv4: "1.2.4.8",
+ AccessIPv6: "2001:4800:6666:105:2a0f:c056:f594:7777",
+ Progress: 100,
+ Status: "ACTIVE",
+ Image: map[string]interface{}{
+ "id": "e19a734c-c7e6-443a-830c-242209c4d65d",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/images/e19a734c-c7e6-443a-830c-242209c4d65d",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Flavor: map[string]interface{}{
+ "id": "performance1-1",
+ "links": []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/flavors/performance1-1",
+ "rel": "bookmark",
+ },
+ },
+ },
+ Addresses: map[string]interface{}{
+ "private": []interface{}{
+ map[string]interface{}{
+ "addr": "10.20.40.80",
+ "version": float64(4.0),
+ },
+ },
+ "public": []interface{}{
+ map[string]interface{}{
+ "addr": "2001:4800:6666:105:2a0f:c056:f594:7777",
+ "version": float64(6.0),
+ },
+ map[string]interface{}{
+ "addr": "1.2.4.8",
+ "version": float64(4.0),
+ },
+ },
+ },
+ Metadata: map[string]interface{}{},
+ Links: []interface{}{
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/v2/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "self",
+ },
+ map[string]interface{}{
+ "href": "https://dfw.servers.api.rackspacecloud.com/111111/servers/8c65cb68-0681-4c30-bc88-6b83a8a26aee",
+ "rel": "bookmark",
+ },
+ },
+ KeyName: "",
+ AdminPass: "",
+}
+
+// CreatedServer is the partial Server struct that can be parsed from CreateOutput.
+var CreatedServer = os.Server{
+ ID: "bb63327b-6a2f-34bc-b0ef-4b6d97ea637e",
+ AdminPass: "v7tADqbE5pr9",
+ Links: []interface{}{},
+}
+
+// ExpectedServerSlice is the collection of servers, in order, that should be parsed from ListOutput.
+var ExpectedServerSlice = []os.Server{DevstackServer, PerilServer}
diff --git a/rackspace/compute/v2/servers/requests.go b/rackspace/compute/v2/servers/requests.go
new file mode 100644
index 0000000..d4472a0
--- /dev/null
+++ b/rackspace/compute/v2/servers/requests.go
@@ -0,0 +1,178 @@
+package servers
+
+import (
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// CreateOpts specifies all of the options that Rackspace accepts in its Create request, including
+// the union of all extensions that Rackspace supports.
+type CreateOpts struct {
+ // Name [required] is the name to assign to the newly launched server.
+ Name string
+
+ // ImageRef [optional; required if ImageName is not provided] is the ID or full
+ // URL to the image that contains the server's OS and initial state.
+ // Also optional if using the boot-from-volume extension.
+ ImageRef string
+
+ // ImageName [optional; required if ImageRef is not provided] is the name of the
+ // image that contains the server's OS and initial state.
+ // Also optional if using the boot-from-volume extension.
+ ImageName string
+
+ // FlavorRef [optional; required if FlavorName is not provided] is the ID or
+ // full URL to the flavor that describes the server's specs.
+ FlavorRef string
+
+ // FlavorName [optional; required if FlavorRef is not provided] is the name of
+ // the flavor that describes the server's specs.
+ FlavorName string
+
+ // SecurityGroups [optional] lists the names of the security groups to which this server should belong.
+ SecurityGroups []string
+
+ // UserData [optional] contains configuration information or scripts to use upon launch.
+ // Create will base64-encode it for you.
+ UserData []byte
+
+ // AvailabilityZone [optional] in which to launch the server.
+ AvailabilityZone string
+
+ // Networks [optional] dictates how this server will be attached to available networks.
+ // By default, the server will be attached to all isolated networks for the tenant.
+ Networks []os.Network
+
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string
+
+ // Personality [optional] includes files to inject into the server at launch.
+ // Create will base64-encode file contents for you.
+ Personality os.Personality
+
+ // ConfigDrive [optional] enables metadata injection through a configuration drive.
+ ConfigDrive bool
+
+ // AdminPass [optional] sets the root user password. If not set, a randomly-generated
+ // password will be created and returned in the response.
+ AdminPass string
+
+ // Rackspace-specific extensions begin here.
+
+ // KeyPair [optional] specifies the name of the SSH KeyPair to be injected into the newly launched
+ // server. See the "keypairs" extension in OpenStack compute v2.
+ KeyPair string
+
+ // DiskConfig [optional] controls how the created server's disk is partitioned. See the "diskconfig"
+ // extension in OpenStack compute v2.
+ DiskConfig diskconfig.DiskConfig
+
+ // BlockDevice [optional] will create the server from a volume, which is created from an image,
+ // a snapshot, or another volume.
+ BlockDevice []bootfromvolume.BlockDevice
+}
+
+// ToServerCreateMap constructs a request body using all of the OpenStack extensions that are
+// active on Rackspace.
+func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) {
+ base := os.CreateOpts{
+ Name: opts.Name,
+ ImageRef: opts.ImageRef,
+ ImageName: opts.ImageName,
+ FlavorRef: opts.FlavorRef,
+ FlavorName: opts.FlavorName,
+ SecurityGroups: opts.SecurityGroups,
+ UserData: opts.UserData,
+ AvailabilityZone: opts.AvailabilityZone,
+ Networks: opts.Networks,
+ Metadata: opts.Metadata,
+ Personality: opts.Personality,
+ ConfigDrive: opts.ConfigDrive,
+ AdminPass: opts.AdminPass,
+ }
+
+ drive := diskconfig.CreateOptsExt{
+ CreateOptsBuilder: base,
+ DiskConfig: opts.DiskConfig,
+ }
+
+ res, err := drive.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(opts.BlockDevice) != 0 {
+ bfv := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: drive,
+ BlockDevice: opts.BlockDevice,
+ }
+
+ res, err = bfv.ToServerCreateMap()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // key_name doesn't actually come from the extension (or at least isn't documented there) so
+ // we need to add it manually.
+ serverMap := res["server"].(map[string]interface{})
+ if opts.KeyPair != "" {
+ serverMap["key_name"] = opts.KeyPair
+ }
+
+ return res, nil
+}
+
+// RebuildOpts represents all of the configuration options used in a server rebuild operation that
+// are supported by Rackspace.
+type RebuildOpts struct {
+ // Required. The ID of the image you want your server to be provisioned on
+ ImageID string
+
+ // Name to set the server to
+ Name string
+
+ // Required. The server's admin password
+ AdminPass string
+
+ // AccessIPv4 [optional] provides a new IPv4 address for the instance.
+ AccessIPv4 string
+
+ // AccessIPv6 [optional] provides a new IPv6 address for the instance.
+ AccessIPv6 string
+
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string
+
+ // Personality [optional] includes files to inject into the server at launch.
+ // Rebuild will base64-encode file contents for you.
+ Personality os.Personality
+
+ // Rackspace-specific stuff begins here.
+
+ // DiskConfig [optional] controls how the created server's disk is partitioned. See the "diskconfig"
+ // extension in OpenStack compute v2.
+ DiskConfig diskconfig.DiskConfig
+}
+
+// ToServerRebuildMap constructs a request body using all of the OpenStack extensions that are
+// active on Rackspace.
+func (opts RebuildOpts) ToServerRebuildMap() (map[string]interface{}, error) {
+ base := os.RebuildOpts{
+ ImageID: opts.ImageID,
+ Name: opts.Name,
+ AdminPass: opts.AdminPass,
+ AccessIPv4: opts.AccessIPv4,
+ AccessIPv6: opts.AccessIPv6,
+ Metadata: opts.Metadata,
+ Personality: opts.Personality,
+ }
+
+ drive := diskconfig.RebuildOptsExt{
+ RebuildOptsBuilder: base,
+ DiskConfig: opts.DiskConfig,
+ }
+
+ return drive.ToServerRebuildMap()
+}
diff --git a/rackspace/compute/v2/servers/requests_test.go b/rackspace/compute/v2/servers/requests_test.go
new file mode 100644
index 0000000..828b5dc
--- /dev/null
+++ b/rackspace/compute/v2/servers/requests_test.go
@@ -0,0 +1,59 @@
+package servers
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ opts := CreateOpts{
+ Name: "createdserver",
+ ImageRef: "image-id",
+ FlavorRef: "flavor-id",
+ KeyPair: "mykey",
+ DiskConfig: diskconfig.Manual,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "image-id",
+ "flavorRef": "flavor-id",
+ "flavorName": "",
+ "imageName": "",
+ "key_name": "mykey",
+ "OS-DCF:diskConfig": "MANUAL"
+ }
+ }
+ `
+ actual, err := opts.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestRebuildOpts(t *testing.T) {
+ opts := RebuildOpts{
+ Name: "rebuiltserver",
+ AdminPass: "swordfish",
+ ImageID: "asdfasdfasdf",
+ DiskConfig: diskconfig.Auto,
+ }
+
+ actual, err := opts.ToServerRebuildMap()
+ th.AssertNoErr(t, err)
+
+ expected := `
+ {
+ "rebuild": {
+ "name": "rebuiltserver",
+ "imageRef": "asdfasdfasdf",
+ "adminPass": "swordfish",
+ "OS-DCF:diskConfig": "AUTO"
+ }
+ }
+ `
+ th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/rackspace/compute/v2/virtualinterfaces/requests.go b/rackspace/compute/v2/virtualinterfaces/requests.go
new file mode 100644
index 0000000..1ff7c5a
--- /dev/null
+++ b/rackspace/compute/v2/virtualinterfaces/requests.go
@@ -0,0 +1,45 @@
+package virtualinterfaces
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return VirtualInterfacePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(c, listURL(c, instanceID), createPage)
+}
+
+// Create creates a new virtual interface for a network and attaches the network
+// to the server instance.
+func Create(c *gophercloud.ServiceClient, instanceID, networkID string) CreateResult {
+ var res CreateResult
+
+ reqBody := map[string]map[string]string{
+ "virtual_interface": {
+ "network_id": networkID,
+ },
+ }
+
+ // Send request to API
+ _, res.Err = c.Post(createURL(c, instanceID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return res
+}
+
+// Delete deletes the interface with interfaceID attached to the instance with
+// instanceID.
+func Delete(c *gophercloud.ServiceClient, instanceID, interfaceID string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, instanceID, interfaceID), &gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ return res
+}
diff --git a/rackspace/compute/v2/virtualinterfaces/requests_test.go b/rackspace/compute/v2/virtualinterfaces/requests_test.go
new file mode 100644
index 0000000..d40af9c
--- /dev/null
+++ b/rackspace/compute/v2/virtualinterfaces/requests_test.go
@@ -0,0 +1,165 @@
+package virtualinterfaces
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/12345/os-virtual-interfacesv2", 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, `
+{
+ "virtual_interfaces": [
+ {
+ "id": "de7c6d53-b895-4b4a-963c-517ccb0f0775",
+ "ip_addresses": [
+ {
+ "address": "192.168.0.2",
+ "network_id": "f212726e-6321-4210-9bae-a13f5a33f83f",
+ "network_label": "superprivate_xml"
+ }
+ ],
+ "mac_address": "BC:76:4E:04:85:20"
+ },
+ {
+ "id": "e14e789d-3b98-44a6-9c2d-c23eb1d1465c",
+ "ip_addresses": [
+ {
+ "address": "10.181.1.30",
+ "network_id": "3b324a1b-31b8-4db5-9fe5-4a2067f60297",
+ "network_label": "private"
+ }
+ ],
+ "mac_address": "BC:76:4E:04:81:55"
+ }
+ ]
+}
+ `)
+ })
+
+ client := fake.ServiceClient()
+ count := 0
+
+ err := List(client, "12345").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVirtualInterfaces(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []VirtualInterface{
+ VirtualInterface{
+ MACAddress: "BC:76:4E:04:85:20",
+ IPAddresses: []IPAddress{
+ IPAddress{
+ Address: "192.168.0.2",
+ NetworkID: "f212726e-6321-4210-9bae-a13f5a33f83f",
+ NetworkLabel: "superprivate_xml",
+ },
+ },
+ ID: "de7c6d53-b895-4b4a-963c-517ccb0f0775",
+ },
+ VirtualInterface{
+ MACAddress: "BC:76:4E:04:81:55",
+ IPAddresses: []IPAddress{
+ IPAddress{
+ Address: "10.181.1.30",
+ NetworkID: "3b324a1b-31b8-4db5-9fe5-4a2067f60297",
+ NetworkLabel: "private",
+ },
+ },
+ ID: "e14e789d-3b98-44a6-9c2d-c23eb1d1465c",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/12345/os-virtual-interfacesv2", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "virtual_interface": {
+ "network_id": "6789"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `{
+ "virtual_interfaces": [
+ {
+ "id": "de7c6d53-b895-4b4a-963c-517ccb0f0775",
+ "ip_addresses": [
+ {
+ "address": "192.168.0.2",
+ "network_id": "f212726e-6321-4210-9bae-a13f5a33f83f",
+ "network_label": "superprivate_xml"
+ }
+ ],
+ "mac_address": "BC:76:4E:04:85:20"
+ }
+ ]
+ }`)
+ })
+
+ expected := &VirtualInterface{
+ MACAddress: "BC:76:4E:04:85:20",
+ IPAddresses: []IPAddress{
+ IPAddress{
+ Address: "192.168.0.2",
+ NetworkID: "f212726e-6321-4210-9bae-a13f5a33f83f",
+ NetworkLabel: "superprivate_xml",
+ },
+ },
+ ID: "de7c6d53-b895-4b4a-963c-517ccb0f0775",
+ }
+
+ actual, err := Create(fake.ServiceClient(), "12345", "6789").Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/servers/12345/os-virtual-interfacesv2/6789", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "12345", "6789")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/compute/v2/virtualinterfaces/results.go b/rackspace/compute/v2/virtualinterfaces/results.go
new file mode 100644
index 0000000..26fa7f3
--- /dev/null
+++ b/rackspace/compute/v2/virtualinterfaces/results.go
@@ -0,0 +1,81 @@
+package virtualinterfaces
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a network resource.
+func (r commonResult) Extract() (*VirtualInterface, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var res struct {
+ VirtualInterfaces []VirtualInterface `mapstructure:"virtual_interfaces" json:"virtual_interfaces"`
+ }
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ return &res.VirtualInterfaces[0], err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// IPAddress represents a vitual address attached to a VirtualInterface.
+type IPAddress struct {
+ Address string `mapstructure:"address" json:"address"`
+ NetworkID string `mapstructure:"network_id" json:"network_id"`
+ NetworkLabel string `mapstructure:"network_label" json:"network_label"`
+}
+
+// VirtualInterface represents a virtual interface.
+type VirtualInterface struct {
+ // UUID for the virtual interface
+ ID string `mapstructure:"id" json:"id"`
+
+ MACAddress string `mapstructure:"mac_address" json:"mac_address"`
+
+ IPAddresses []IPAddress `mapstructure:"ip_addresses" json:"ip_addresses"`
+}
+
+// VirtualInterfacePage is the page returned by a pager when traversing over a
+// collection of virtual interfaces.
+type VirtualInterfacePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if the NetworkPage contains no Networks.
+func (r VirtualInterfacePage) IsEmpty() (bool, error) {
+ networks, err := ExtractVirtualInterfaces(r)
+ if err != nil {
+ return true, err
+ }
+ return len(networks) == 0, nil
+}
+
+// ExtractVirtualInterfaces accepts a Page struct, specifically a VirtualInterfacePage struct,
+// and extracts the elements into a slice of VirtualInterface structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractVirtualInterfaces(page pagination.Page) ([]VirtualInterface, error) {
+ var resp struct {
+ VirtualInterfaces []VirtualInterface `mapstructure:"virtual_interfaces" json:"virtual_interfaces"`
+ }
+
+ err := mapstructure.Decode(page.(VirtualInterfacePage).Body, &resp)
+
+ return resp.VirtualInterfaces, err
+}
diff --git a/rackspace/compute/v2/virtualinterfaces/urls.go b/rackspace/compute/v2/virtualinterfaces/urls.go
new file mode 100644
index 0000000..9e5693e
--- /dev/null
+++ b/rackspace/compute/v2/virtualinterfaces/urls.go
@@ -0,0 +1,15 @@
+package virtualinterfaces
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient, instanceID string) string {
+ return c.ServiceURL("servers", instanceID, "os-virtual-interfacesv2")
+}
+
+func createURL(c *gophercloud.ServiceClient, instanceID string) string {
+ return c.ServiceURL("servers", instanceID, "os-virtual-interfacesv2")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, instanceID, interfaceID string) string {
+ return c.ServiceURL("servers", instanceID, "os-virtual-interfacesv2", interfaceID)
+}
diff --git a/rackspace/compute/v2/virtualinterfaces/urls_test.go b/rackspace/compute/v2/virtualinterfaces/urls_test.go
new file mode 100644
index 0000000..6732e4e
--- /dev/null
+++ b/rackspace/compute/v2/virtualinterfaces/urls_test.go
@@ -0,0 +1,32 @@
+package virtualinterfaces
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestCreateURL(t *testing.T) {
+ actual := createURL(endpointClient(), "12345")
+ expected := endpoint + "servers/12345/os-virtual-interfacesv2"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestListURL(t *testing.T) {
+ actual := createURL(endpointClient(), "12345")
+ expected := endpoint + "servers/12345/os-virtual-interfacesv2"
+ th.AssertEquals(t, expected, actual)
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient(), "12345", "6789")
+ expected := endpoint + "servers/12345/os-virtual-interfacesv2/6789"
+ th.AssertEquals(t, expected, actual)
+}
diff --git a/rackspace/compute/v2/volumeattach/delegate.go b/rackspace/compute/v2/volumeattach/delegate.go
new file mode 100644
index 0000000..c6003e0
--- /dev/null
+++ b/rackspace/compute/v2/volumeattach/delegate.go
@@ -0,0 +1,27 @@
+package volumeattach
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of VolumeAttachments.
+func List(client *gophercloud.ServiceClient, serverID string) pagination.Pager {
+ return os.List(client, serverID)
+}
+
+// Create requests the creation of a new volume attachment on the server
+func Create(client *gophercloud.ServiceClient, serverID string, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, serverID, opts)
+}
+
+// Get returns public data about a previously created VolumeAttachment.
+func Get(client *gophercloud.ServiceClient, serverID, aID string) os.GetResult {
+ return os.Get(client, serverID, aID)
+}
+
+// Delete requests the deletion of a previous stored VolumeAttachment from the server.
+func Delete(client *gophercloud.ServiceClient, serverID, aID string) os.DeleteResult {
+ return os.Delete(client, serverID, aID)
+}
diff --git a/rackspace/compute/v2/volumeattach/delegate_test.go b/rackspace/compute/v2/volumeattach/delegate_test.go
new file mode 100644
index 0000000..f7ef45e
--- /dev/null
+++ b/rackspace/compute/v2/volumeattach/delegate_test.go
@@ -0,0 +1,95 @@
+package volumeattach
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ fixtures "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/volumeattach/testing"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+// FirstVolumeAttachment is the first result in ListOutput.
+var FirstVolumeAttachment = volumeattach.VolumeAttachment{
+ Device: "/dev/vdd",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+}
+
+// SecondVolumeAttachment is the first result in ListOutput.
+var SecondVolumeAttachment = volumeattach.VolumeAttachment{
+ Device: "/dev/vdc",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+// ExpectedVolumeAttachmentSlide is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedVolumeAttachmentSlice = []volumeattach.VolumeAttachment{FirstVolumeAttachment, SecondVolumeAttachment}
+
+//CreatedVolumeAttachment is the parsed result from CreatedOutput.
+var CreatedVolumeAttachment = volumeattach.VolumeAttachment{
+ Device: "/dev/vdc",
+ ID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleListSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ count := 0
+ err := List(client.ServiceClient(), serverId).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := volumeattach.ExtractVolumeAttachments(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedVolumeAttachmentSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleCreateSuccessfully(t)
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := Create(client.ServiceClient(), serverId, volumeattach.CreateOpts{
+ Device: "/dev/vdc",
+ VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedVolumeAttachment, actual)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleGetSuccessfully(t)
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := Get(client.ServiceClient(), serverId, aId).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &SecondVolumeAttachment, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixtures.HandleDeleteSuccessfully(t)
+ aId := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverId := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ err := Delete(client.ServiceClient(), serverId, aId).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/compute/v2/volumeattach/doc.go b/rackspace/compute/v2/volumeattach/doc.go
new file mode 100644
index 0000000..2164908
--- /dev/null
+++ b/rackspace/compute/v2/volumeattach/doc.go
@@ -0,0 +1,3 @@
+// Package volumeattach provides the ability to attach and detach volume
+// to instances to Rackspace servers
+package volumeattach
diff --git a/rackspace/db/v1/backups/doc.go b/rackspace/db/v1/backups/doc.go
new file mode 100644
index 0000000..664eead
--- /dev/null
+++ b/rackspace/db/v1/backups/doc.go
@@ -0,0 +1,6 @@
+// Package backups provides information and interaction with the backup API
+// resource in the Rackspace Database service.
+//
+// A backup is a copy of a database instance that can be used to restore it to
+// some defined point in history.
+package backups
diff --git a/rackspace/db/v1/backups/fixtures.go b/rackspace/db/v1/backups/fixtures.go
new file mode 100644
index 0000000..45c2376
--- /dev/null
+++ b/rackspace/db/v1/backups/fixtures.go
@@ -0,0 +1,66 @@
+package backups
+
+import "time"
+
+var (
+ timestamp = "2015-11-12T14:22:42Z"
+ timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var getResp = `
+{
+ "backup": {
+ "created": "` + timestamp + `",
+ "description": "My Backup",
+ "id": "61f12fef-edb1-4561-8122-e7c00ef26a82",
+ "instance_id": "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ "locationRef": null,
+ "name": "snapshot",
+ "parent_id": null,
+ "size": 100,
+ "status": "NEW",
+ "datastore": {
+ "version": "5.1",
+ "type": "MySQL",
+ "version_id": "20000000-0000-0000-0000-000000000002"
+ },
+ "updated": "` + timestamp + `"
+ }
+}
+`
+
+var createReq = `
+{
+ "backup": {
+ "description": "My Backup",
+ "instance": "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ "name": "snapshot"
+ }
+}
+`
+
+var createResp = getResp
+
+var listResp = `
+{
+ "backups": [
+ {
+ "status": "COMPLETED",
+ "updated": "` + timestamp + `",
+ "description": "Backup from Restored Instance",
+ "datastore": {
+ "version": "5.1",
+ "type": "MySQL",
+ "version_id": "20000000-0000-0000-0000-000000000002"
+ },
+ "id": "87972694-4be2-40f5-83f8-501656e0032a",
+ "size": 0.141026,
+ "name": "restored_backup",
+ "created": "` + timestamp + `",
+ "instance_id": "29af2cd9-0674-48ab-b87a-b160f00208e6",
+ "parent_id": null,
+ "locationRef": "http://localhost/path/to/backup"
+ }
+ ]
+}
+`
diff --git a/rackspace/db/v1/backups/requests.go b/rackspace/db/v1/backups/requests.go
new file mode 100644
index 0000000..9170d78
--- /dev/null
+++ b/rackspace/db/v1/backups/requests.go
@@ -0,0 +1,138 @@
+package backups
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the top-level interface for creating JSON maps.
+type CreateOptsBuilder interface {
+ ToBackupCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is responsible for configuring newly provisioned backups.
+type CreateOpts struct {
+ // [REQUIRED] The name of the backup. The only restriction is the name must
+ // be less than 64 characters long.
+ Name string
+
+ // [REQUIRED] The ID of the instance being backed up.
+ InstanceID string
+
+ // [OPTIONAL] A human-readable explanation of the backup.
+ Description string
+}
+
+// ToBackupCreateMap will create a JSON map for the Create operation.
+func (opts CreateOpts) ToBackupCreateMap() (map[string]interface{}, error) {
+ if opts.Name == "" {
+ return nil, errors.New("Name is a required field")
+ }
+ if opts.InstanceID == "" {
+ return nil, errors.New("InstanceID is a required field")
+ }
+
+ backup := map[string]interface{}{
+ "name": opts.Name,
+ "instance": opts.InstanceID,
+ }
+
+ if opts.Description != "" {
+ backup["description"] = opts.Description
+ }
+
+ return map[string]interface{}{"backup": backup}, nil
+}
+
+// Create asynchronously creates a new backup for a specified database instance.
+// During the backup process, write access on MyISAM databases will be
+// temporarily disabled; innoDB databases will be unaffected. During this time,
+// you will not be able to add or delete databases or users; nor delete, stop
+// or reboot the instance itself. Only one backup is permitted at once.
+//
+// Backups are not deleted when database instances are deleted; you must
+// manually delete any backups created using Delete(). Backups are saved to your
+// Cloud Files account in a new container called z_CLOUDDB_BACKUPS. It is
+// strongly recommended you do not alter this container or its contents; usual
+// storage costs apply.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToBackupCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("POST", baseURL(client), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ JSONResponse: &res.Body,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// ListOptsBuilder is the top-level interface for creating query strings.
+type ListOptsBuilder interface {
+ ToBackupListQuery() (string, error)
+}
+
+// ListOpts allows you to refine a list search by certain parameters.
+type ListOpts struct {
+ // The type of datastore by which to filter.
+ Datastore string `q:"datastore"`
+}
+
+// ToBackupListQuery converts a ListOpts struct into a query string.
+func (opts ListOpts) ToBackupListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List will list all the saved backups for all database instances.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := baseURL(client)
+
+ if opts != nil {
+ query, err := opts.ToBackupListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return BackupPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, url, pageFn)
+}
+
+// Get will retrieve details for a particular backup based on its unique ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+
+ _, res.Err = client.Request("GET", resourceURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// Delete will permanently delete a backup.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = client.Request("DELETE", resourceURL(client, id), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/rackspace/db/v1/backups/requests_test.go b/rackspace/db/v1/backups/requests_test.go
new file mode 100644
index 0000000..d706733
--- /dev/null
+++ b/rackspace/db/v1/backups/requests_test.go
@@ -0,0 +1,131 @@
+package backups
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ backupID = "{backupID}"
+ _rootURL = "/backups"
+ resURL = _rootURL + "/" + backupID
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "POST", createReq, createResp, 202)
+
+ opts := CreateOpts{
+ Name: "snapshot",
+ Description: "My Backup",
+ InstanceID: "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ }
+
+ instance, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Backup{
+ Created: timeVal,
+ Description: "My Backup",
+ ID: "61f12fef-edb1-4561-8122-e7c00ef26a82",
+ InstanceID: "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ LocationRef: "",
+ Name: "snapshot",
+ ParentID: "",
+ Size: 100,
+ Status: "NEW",
+ Updated: timeVal,
+ Datastore: datastores.DatastorePartial{
+ Version: "5.1",
+ Type: "MySQL",
+ VersionID: "20000000-0000-0000-0000-000000000002",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, instance)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "GET", "", listResp, 200)
+
+ pages := 0
+
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ actual, err := ExtractBackups(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Backup{
+ Backup{
+ Created: timeVal,
+ Description: "Backup from Restored Instance",
+ ID: "87972694-4be2-40f5-83f8-501656e0032a",
+ InstanceID: "29af2cd9-0674-48ab-b87a-b160f00208e6",
+ LocationRef: "http://localhost/path/to/backup",
+ Name: "restored_backup",
+ ParentID: "",
+ Size: 0.141026,
+ Status: "COMPLETED",
+ Updated: timeVal,
+ Datastore: datastores.DatastorePartial{
+ Version: "5.1",
+ Type: "MySQL",
+ VersionID: "20000000-0000-0000-0000-000000000002",
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "GET", "", getResp, 200)
+
+ instance, err := Get(fake.ServiceClient(), backupID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Backup{
+ Created: timeVal,
+ Description: "My Backup",
+ ID: "61f12fef-edb1-4561-8122-e7c00ef26a82",
+ InstanceID: "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ LocationRef: "",
+ Name: "snapshot",
+ ParentID: "",
+ Size: 100,
+ Status: "NEW",
+ Updated: timeVal,
+ Datastore: datastores.DatastorePartial{
+ Version: "5.1",
+ Type: "MySQL",
+ VersionID: "20000000-0000-0000-0000-000000000002",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, instance)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+
+ err := Delete(fake.ServiceClient(), backupID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/db/v1/backups/results.go b/rackspace/db/v1/backups/results.go
new file mode 100644
index 0000000..04faf32
--- /dev/null
+++ b/rackspace/db/v1/backups/results.go
@@ -0,0 +1,149 @@
+package backups
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Status represents the various states a Backup can be in.
+type Status string
+
+// Enum types for the status.
+const (
+ StatusNew Status = "NEW"
+ StatusBuilding Status = "BUILDING"
+ StatusCompleted Status = "COMPLETED"
+ StatusFailed Status = "FAILED"
+ StatusDeleteFailed Status = "DELETE_FAILED"
+)
+
+// Backup represents a Backup API resource.
+type Backup struct {
+ Description string
+ ID string
+ InstanceID string `json:"instance_id" mapstructure:"instance_id"`
+ LocationRef string
+ Name string
+ ParentID string `json:"parent_id" mapstructure:"parent_id"`
+ Size float64
+ Status Status
+ Created time.Time `mapstructure:"-"`
+ Updated time.Time `mapstructure:"-"`
+ Datastore datastores.DatastorePartial
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will retrieve a Backup struct from an operation's result.
+func (r commonResult) Extract() (*Backup, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Backup Backup `mapstructure:"backup"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ val := r.Body.(map[string]interface{})["backup"].(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Backup, err
+ }
+ response.Backup.Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Backup, err
+ }
+ response.Backup.Updated = updatedTime
+ }
+
+ return &response.Backup, err
+}
+
+// BackupPage represents a page of backups.
+type BackupPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an BackupPage struct is empty.
+func (r BackupPage) IsEmpty() (bool, error) {
+ is, err := ExtractBackups(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractBackups will retrieve a slice of Backup structs from a paginated collection.
+func ExtractBackups(page pagination.Page) ([]Backup, error) {
+ casted := page.(BackupPage).Body
+
+ var resp struct {
+ Backups []Backup `mapstructure:"backups" json:"backups"`
+ }
+
+ if err := mapstructure.Decode(casted, &resp); err != nil {
+ return nil, err
+ }
+
+ var vals []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ vals = casted.(map[string]interface{})["backups"].([]interface{})
+ case map[string][]interface{}:
+ vals = casted.(map[string][]interface{})["backups"]
+ default:
+ return resp.Backups, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, v := range vals {
+ val := v.(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Backups, err
+ }
+ resp.Backups[i].Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Backups, err
+ }
+ resp.Backups[i].Updated = updatedTime
+ }
+ }
+
+ return resp.Backups, nil
+}
diff --git a/rackspace/db/v1/backups/urls.go b/rackspace/db/v1/backups/urls.go
new file mode 100644
index 0000000..553444e
--- /dev/null
+++ b/rackspace/db/v1/backups/urls.go
@@ -0,0 +1,11 @@
+package backups
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("backups")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, backupID string) string {
+ return c.ServiceURL("backups", backupID)
+}
diff --git a/rackspace/db/v1/configurations/delegate.go b/rackspace/db/v1/configurations/delegate.go
new file mode 100644
index 0000000..d8cb48a
--- /dev/null
+++ b/rackspace/db/v1/configurations/delegate.go
@@ -0,0 +1,79 @@
+package configurations
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/configurations"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all of the available configurations.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// Create will create a new configuration group.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, opts)
+}
+
+// Get will retrieve the details for a specified configuration group.
+func Get(client *gophercloud.ServiceClient, configID string) os.GetResult {
+ return os.Get(client, configID)
+}
+
+// Update will modify an existing configuration group by performing a merge
+// between new and existing values. If the key already exists, the new value
+// will overwrite. All other keys will remain unaffected.
+func Update(client *gophercloud.ServiceClient, configID string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(client, configID, opts)
+}
+
+// Replace will modify an existing configuration group by overwriting the
+// entire parameter group with the new values provided. Any existing keys not
+// included in UpdateOptsBuilder will be deleted.
+func Replace(client *gophercloud.ServiceClient, configID string, opts os.UpdateOptsBuilder) os.ReplaceResult {
+ return os.Replace(client, configID, opts)
+}
+
+// Delete will permanently delete a configuration group. Please note that
+// config groups cannot be deleted whilst still attached to running instances -
+// you must detach and then delete them.
+func Delete(client *gophercloud.ServiceClient, configID string) os.DeleteResult {
+ return os.Delete(client, configID)
+}
+
+// ListInstances will list all the instances associated with a particular
+// configuration group.
+func ListInstances(client *gophercloud.ServiceClient, configID string) pagination.Pager {
+ return os.ListInstances(client, configID)
+}
+
+// ListDatastoreParams will list all the available and supported parameters
+// that can be used for a particular datastore ID and a particular version.
+// For example, if you are wondering how you can configure a MySQL 5.6 instance,
+// you can use this operation (you will need to retrieve the MySQL datastore ID
+// by using the datastores API).
+func ListDatastoreParams(client *gophercloud.ServiceClient, datastoreID, versionID string) pagination.Pager {
+ return os.ListDatastoreParams(client, datastoreID, versionID)
+}
+
+// GetDatastoreParam will retrieve information about a specific configuration
+// parameter. For example, you can use this operation to understand more about
+// "innodb_file_per_table" configuration param for MySQL datastores. You will
+// need the param's ID first, which can be attained by using the ListDatastoreParams
+// operation.
+func GetDatastoreParam(client *gophercloud.ServiceClient, datastoreID, versionID, paramID string) os.ParamResult {
+ return os.GetDatastoreParam(client, datastoreID, versionID, paramID)
+}
+
+// ListGlobalParams is similar to ListDatastoreParams but does not require a
+// DatastoreID.
+func ListGlobalParams(client *gophercloud.ServiceClient, versionID string) pagination.Pager {
+ return os.ListGlobalParams(client, versionID)
+}
+
+// GetGlobalParam is similar to GetDatastoreParam but does not require a
+// DatastoreID.
+func GetGlobalParam(client *gophercloud.ServiceClient, versionID, paramID string) os.ParamResult {
+ return os.GetGlobalParam(client, versionID, paramID)
+}
diff --git a/rackspace/db/v1/configurations/delegate_test.go b/rackspace/db/v1/configurations/delegate_test.go
new file mode 100644
index 0000000..580f02a
--- /dev/null
+++ b/rackspace/db/v1/configurations/delegate_test.go
@@ -0,0 +1,237 @@
+package configurations
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/db/v1/configurations"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/instances"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ configID = "{configID}"
+ _baseURL = "/configurations"
+ resURL = _baseURL + "/" + configID
+
+ dsID = "{datastoreID}"
+ versionID = "{versionID}"
+ paramID = "{paramID}"
+ dsParamListURL = "/datastores/" + dsID + "/versions/" + versionID + "/parameters"
+ dsParamGetURL = "/datastores/" + dsID + "/versions/" + versionID + "/parameters/" + paramID
+ globalParamListURL = "/datastores/versions/" + versionID + "/parameters"
+ globalParamGetURL = "/datastores/versions/" + versionID + "/parameters/" + paramID
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _baseURL, "GET", "", listConfigsJSON, 200)
+
+ count := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractConfigs(page)
+ th.AssertNoErr(t, err)
+
+ expected := []os.Config{exampleConfig}
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "GET", "", getConfigJSON, 200)
+
+ config, err := Get(fake.ServiceClient(), configID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &exampleConfig, config)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _baseURL, "POST", createReq, createConfigJSON, 200)
+
+ opts := os.CreateOpts{
+ Datastore: &os.DatastoreOpts{
+ Type: "a00000a0-00a0-0a00-00a0-000a000000aa",
+ Version: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ },
+ Description: "example description",
+ Name: "example-configuration-name",
+ Values: map[string]interface{}{
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120,
+ },
+ }
+
+ config, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &exampleConfigWithValues, config)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PATCH", updateReq, "", 200)
+
+ opts := os.UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := Update(fake.ServiceClient(), configID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestReplace(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PUT", updateReq, "", 202)
+
+ opts := os.UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := Replace(fake.ServiceClient(), configID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+
+ err := Delete(fake.ServiceClient(), configID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListInstances(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL+"/instances", "GET", "", listInstancesJSON, 200)
+
+ expectedInstance := instances.Instance{
+ ID: "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ Name: "json_rack_instance",
+ }
+
+ pages := 0
+ err := ListInstances(fake.ServiceClient(), configID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := instances.ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.AssertDeepEquals(t, actual, []instances.Instance{expectedInstance})
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestListDSParams(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, dsParamListURL, "GET", "", listParamsJSON, 200)
+
+ pages := 0
+ err := ListDatastoreParams(fake.ServiceClient(), dsID, versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []os.Param{
+ os.Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ os.Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ os.Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ os.Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+ }
+
+ th.AssertDeepEquals(t, actual, expected)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetDSParam(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, dsParamGetURL, "GET", "", getParamJSON, 200)
+
+ param, err := GetDatastoreParam(fake.ServiceClient(), dsID, versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &os.Param{
+ Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+ }
+
+ th.AssertDeepEquals(t, expected, param)
+}
+
+func TestListGlobalParams(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, globalParamListURL, "GET", "", listParamsJSON, 200)
+
+ pages := 0
+ err := ListGlobalParams(fake.ServiceClient(), versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []os.Param{
+ os.Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ os.Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ os.Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ os.Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+ }
+
+ th.AssertDeepEquals(t, actual, expected)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetGlobalParam(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, globalParamGetURL, "GET", "", getParamJSON, 200)
+
+ param, err := GetGlobalParam(fake.ServiceClient(), versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &os.Param{
+ Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+ }
+
+ th.AssertDeepEquals(t, expected, param)
+}
diff --git a/rackspace/db/v1/configurations/doc.go b/rackspace/db/v1/configurations/doc.go
new file mode 100644
index 0000000..48c51d6
--- /dev/null
+++ b/rackspace/db/v1/configurations/doc.go
@@ -0,0 +1 @@
+package configurations
diff --git a/rackspace/db/v1/configurations/fixtures.go b/rackspace/db/v1/configurations/fixtures.go
new file mode 100644
index 0000000..d8a2233
--- /dev/null
+++ b/rackspace/db/v1/configurations/fixtures.go
@@ -0,0 +1,159 @@
+package configurations
+
+import (
+ "fmt"
+ "time"
+
+ os "github.com/rackspace/gophercloud/openstack/db/v1/configurations"
+)
+
+var (
+ timestamp = "2015-11-12T14:22:42Z"
+ timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var singleConfigJSON = `
+{
+ "created": "` + timestamp + `",
+ "datastore_name": "mysql",
+ "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+ "datastore_version_name": "5.6",
+ "description": "example_description",
+ "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ "name": "example-configuration-name",
+ "updated": "` + timestamp + `"
+}
+`
+
+var singleConfigWithValuesJSON = `
+{
+ "created": "` + timestamp + `",
+ "datastore_name": "mysql",
+ "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+ "datastore_version_name": "5.6",
+ "description": "example description",
+ "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ "instance_count": 0,
+ "name": "example-configuration-name",
+ "updated": "` + timestamp + `",
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120
+ }
+}
+`
+
+var (
+ listConfigsJSON = fmt.Sprintf(`{"configurations": [%s]}`, singleConfigJSON)
+ getConfigJSON = fmt.Sprintf(`{"configuration": %s}`, singleConfigJSON)
+ createConfigJSON = fmt.Sprintf(`{"configuration": %s}`, singleConfigWithValuesJSON)
+)
+
+var createReq = `
+{
+ "configuration": {
+ "datastore": {
+ "type": "a00000a0-00a0-0a00-00a0-000a000000aa",
+ "version": "b00000b0-00b0-0b00-00b0-000b000000bb"
+ },
+ "description": "example description",
+ "name": "example-configuration-name",
+ "values": {
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120
+ }
+ }
+}
+`
+
+var updateReq = `
+{
+ "configuration": {
+ "values": {
+ "connect_timeout": 300
+ }
+ }
+}
+`
+
+var listInstancesJSON = `
+{
+ "instances": [
+ {
+ "id": "d4603f69-ec7e-4e9b-803f-600b9205576f",
+ "name": "json_rack_instance"
+ }
+ ]
+}
+`
+
+var listParamsJSON = `
+{
+ "configuration-parameters": [
+ {
+ "max": 1,
+ "min": 0,
+ "name": "innodb_file_per_table",
+ "restart_required": true,
+ "type": "integer"
+ },
+ {
+ "max": 4294967296,
+ "min": 0,
+ "name": "key_buffer_size",
+ "restart_required": false,
+ "type": "integer"
+ },
+ {
+ "max": 65535,
+ "min": 2,
+ "name": "connect_timeout",
+ "restart_required": false,
+ "type": "integer"
+ },
+ {
+ "max": 4294967296,
+ "min": 0,
+ "name": "join_buffer_size",
+ "restart_required": false,
+ "type": "integer"
+ }
+ ]
+}
+`
+
+var getParamJSON = `
+{
+ "max": 1,
+ "min": 0,
+ "name": "innodb_file_per_table",
+ "restart_required": true,
+ "type": "integer"
+}
+`
+
+var exampleConfig = os.Config{
+ Created: timeVal,
+ DatastoreName: "mysql",
+ DatastoreVersionID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ DatastoreVersionName: "5.6",
+ Description: "example_description",
+ ID: "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ Name: "example-configuration-name",
+ Updated: timeVal,
+}
+
+var exampleConfigWithValues = os.Config{
+ Created: timeVal,
+ DatastoreName: "mysql",
+ DatastoreVersionID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ DatastoreVersionName: "5.6",
+ Description: "example description",
+ ID: "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+ Name: "example-configuration-name",
+ Updated: timeVal,
+ Values: map[string]interface{}{
+ "collation_server": "latin1_swedish_ci",
+ "connect_timeout": 120,
+ },
+}
diff --git a/rackspace/db/v1/databases/delegate.go b/rackspace/db/v1/databases/delegate.go
new file mode 100644
index 0000000..56552d1
--- /dev/null
+++ b/rackspace/db/v1/databases/delegate.go
@@ -0,0 +1,19 @@
+package databases
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func Create(client *gophercloud.ServiceClient, instanceID string, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, instanceID, opts)
+}
+
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ return os.List(client, instanceID)
+}
+
+func Delete(client *gophercloud.ServiceClient, instanceID, dbName string) os.DeleteResult {
+ return os.Delete(client, instanceID, dbName)
+}
diff --git a/rackspace/db/v1/databases/delegate_test.go b/rackspace/db/v1/databases/delegate_test.go
new file mode 100644
index 0000000..b9e50a5
--- /dev/null
+++ b/rackspace/db/v1/databases/delegate_test.go
@@ -0,0 +1,71 @@
+package databases
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+var (
+ instanceID = "{instanceID}"
+ rootURL = "/instances"
+ resURL = rootURL + "/" + instanceID
+ uRootURL = resURL + "/root"
+ aURL = resURL + "/action"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreate(t)
+
+ opts := os.BatchCreateOpts{
+ os.CreateOpts{Name: "testingdb", CharSet: "utf8", Collate: "utf8_general_ci"},
+ os.CreateOpts{Name: "sampledb"},
+ }
+
+ res := Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleList(t)
+
+ expectedDBs := []os.Database{
+ os.Database{Name: "anotherexampledb"},
+ os.Database{Name: "exampledb"},
+ os.Database{Name: "nextround"},
+ os.Database{Name: "sampledb"},
+ os.Database{Name: "testingdb"},
+ }
+
+ pages := 0
+ err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractDBs(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, expectedDBs, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDelete(t)
+
+ err := os.Delete(fake.ServiceClient(), instanceID, "{dbName}").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/db/v1/databases/doc.go b/rackspace/db/v1/databases/doc.go
new file mode 100644
index 0000000..1a178b6
--- /dev/null
+++ b/rackspace/db/v1/databases/doc.go
@@ -0,0 +1,3 @@
+// Package databases provides information and interaction with the database API
+// resource in the Rackspace Database service.
+package databases
diff --git a/rackspace/db/v1/databases/urls.go b/rackspace/db/v1/databases/urls.go
new file mode 100644
index 0000000..18cbec7
--- /dev/null
+++ b/rackspace/db/v1/databases/urls.go
@@ -0,0 +1 @@
+package databases
diff --git a/rackspace/db/v1/datastores/delegate.go b/rackspace/db/v1/datastores/delegate.go
new file mode 100644
index 0000000..573496d
--- /dev/null
+++ b/rackspace/db/v1/datastores/delegate.go
@@ -0,0 +1,28 @@
+package datastores
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all available flavors.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// Get retrieves the details for a particular flavor.
+func Get(client *gophercloud.ServiceClient, flavorID string) os.GetResult {
+ return os.Get(client, flavorID)
+}
+
+// ListVersions will list all of the available versions for a specified
+// datastore type.
+func ListVersions(client *gophercloud.ServiceClient, datastoreID string) pagination.Pager {
+ return os.ListVersions(client, datastoreID)
+}
+
+// GetVersion will retrieve the details of a specified datastore version.
+func GetVersion(client *gophercloud.ServiceClient, datastoreID, versionID string) os.GetVersionResult {
+ return os.GetVersion(client, datastoreID, versionID)
+}
diff --git a/rackspace/db/v1/datastores/delegate_test.go b/rackspace/db/v1/datastores/delegate_test.go
new file mode 100644
index 0000000..71111b9
--- /dev/null
+++ b/rackspace/db/v1/datastores/delegate_test.go
@@ -0,0 +1,79 @@
+package datastores
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores", "GET", "", os.ListDSResp, 200)
+
+ pages := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractDatastores(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []os.Datastore{os.ExampleDatastore}, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}", "GET", "", os.GetDSResp, 200)
+
+ ds, err := Get(fake.ServiceClient(), "{dsID}").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &os.ExampleDatastore, ds)
+}
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}/versions", "GET", "", os.ListVersionsResp, 200)
+
+ pages := 0
+
+ err := ListVersions(fake.ServiceClient(), "{dsID}").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractVersions(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, os.ExampleVersions, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetVersion(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores/{dsID}/versions/{versionID}", "GET", "", os.GetVersionResp, 200)
+
+ ds, err := GetVersion(fake.ServiceClient(), "{dsID}", "{versionID}").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &os.ExampleVersion1, ds)
+}
diff --git a/rackspace/db/v1/datastores/doc.go b/rackspace/db/v1/datastores/doc.go
new file mode 100644
index 0000000..f36997a
--- /dev/null
+++ b/rackspace/db/v1/datastores/doc.go
@@ -0,0 +1 @@
+package datastores
diff --git a/rackspace/db/v1/flavors/delegate.go b/rackspace/db/v1/flavors/delegate.go
new file mode 100644
index 0000000..689b81e
--- /dev/null
+++ b/rackspace/db/v1/flavors/delegate.go
@@ -0,0 +1,17 @@
+package flavors
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all available flavors.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// Get retrieves the details for a particular flavor.
+func Get(client *gophercloud.ServiceClient, flavorID string) os.GetResult {
+ return os.Get(client, flavorID)
+}
diff --git a/rackspace/db/v1/flavors/delegate_test.go b/rackspace/db/v1/flavors/delegate_test.go
new file mode 100644
index 0000000..f5f6442
--- /dev/null
+++ b/rackspace/db/v1/flavors/delegate_test.go
@@ -0,0 +1,95 @@
+package flavors
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListFlavors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleList(t)
+
+ pages := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := os.ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []os.Flavor{
+ os.Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/1", Rel: "bookmark"},
+ },
+ },
+ os.Flavor{
+ ID: "2",
+ Name: "m1.small",
+ RAM: 1024,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/2", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/2", Rel: "bookmark"},
+ },
+ },
+ os.Flavor{
+ ID: "3",
+ Name: "m1.medium",
+ RAM: 2048,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/3", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/3", Rel: "bookmark"},
+ },
+ },
+ os.Flavor{
+ ID: "4",
+ Name: "m1.large",
+ RAM: 4096,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/4", Rel: "self"},
+ gophercloud.Link{Href: "https://openstack.example.com/flavors/4", Rel: "bookmark"},
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ if pages != 1 {
+ t.Errorf("Expected one page, got %d", pages)
+ }
+}
+
+func TestGetFlavor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGet(t)
+
+ actual, err := Get(fake.ServiceClient(), "{flavorID}").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &os.Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/db/v1/flavors/doc.go b/rackspace/db/v1/flavors/doc.go
new file mode 100644
index 0000000..922a4e6
--- /dev/null
+++ b/rackspace/db/v1/flavors/doc.go
@@ -0,0 +1,3 @@
+// Package flavors provides information and interaction with the flavor API
+// resource in the Rackspace Database service.
+package flavors
diff --git a/rackspace/db/v1/instances/delegate.go b/rackspace/db/v1/instances/delegate.go
new file mode 100644
index 0000000..f2656fe
--- /dev/null
+++ b/rackspace/db/v1/instances/delegate.go
@@ -0,0 +1,49 @@
+package instances
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+)
+
+// Get retrieves the status and information for a specified database instance.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
+
+// Delete permanently destroys the database instance.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
+
+// EnableRootUser enables the login from any host for the root user and
+// provides the user with a generated root password.
+func EnableRootUser(client *gophercloud.ServiceClient, id string) os.UserRootResult {
+ return os.EnableRootUser(client, id)
+}
+
+// IsRootEnabled checks an instance to see if root access is enabled. It returns
+// True if root user is enabled for the specified database instance or False
+// otherwise.
+func IsRootEnabled(client *gophercloud.ServiceClient, id string) (bool, error) {
+ return os.IsRootEnabled(client, id)
+}
+
+// Restart will restart only the MySQL Instance. Restarting MySQL will
+// erase any dynamic configuration settings that you have made within MySQL.
+// The MySQL service will be unavailable until the instance restarts.
+func Restart(client *gophercloud.ServiceClient, id string) os.ActionResult {
+ return os.Restart(client, id)
+}
+
+// Resize changes the memory size of the instance, assuming a valid
+// flavorRef is provided. It will also restart the MySQL service.
+func Resize(client *gophercloud.ServiceClient, id, flavorRef string) os.ActionResult {
+ return os.Resize(client, id, flavorRef)
+}
+
+// ResizeVolume will resize the attached volume for an instance. It supports
+// only increasing the volume size and does not support decreasing the size.
+// The volume size is in gigabytes (GB) and must be an integer.
+func ResizeVolume(client *gophercloud.ServiceClient, id string, size int) os.ActionResult {
+ return os.ResizeVolume(client, id, size)
+}
diff --git a/rackspace/db/v1/instances/delegate_test.go b/rackspace/db/v1/instances/delegate_test.go
new file mode 100644
index 0000000..716e0a4
--- /dev/null
+++ b/rackspace/db/v1/instances/delegate_test.go
@@ -0,0 +1,107 @@
+package instances
+
+import (
+ "testing"
+
+ osDBs "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ osUsers "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ _rootURL = "/instances"
+ resURL = "/instances/" + instanceID
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "POST", createReq, createResp, 200)
+
+ opts := CreateOpts{
+ Name: "json_rack_instance",
+ FlavorRef: "1",
+ Databases: osDBs.BatchCreateOpts{
+ osDBs.CreateOpts{CharSet: "utf8", Collate: "utf8_general_ci", Name: "sampledb"},
+ osDBs.CreateOpts{Name: "nextround"},
+ },
+ Users: osUsers.BatchCreateOpts{
+ osUsers.CreateOpts{
+ Name: "demouser",
+ Password: "demopassword",
+ Databases: osDBs.BatchCreateOpts{
+ osDBs.CreateOpts{Name: "sampledb"},
+ },
+ },
+ },
+ Size: 2,
+ RestorePoint: "1234567890",
+ }
+
+ instance, err := Create(fake.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expectedInstance, instance)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "GET", "", getResp, 200)
+
+ instance, err := Get(fake.ServiceClient(), instanceID).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expectedInstance, instance)
+}
+
+func TestDeleteInstance(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDelete(t)
+
+ res := Delete(fake.ServiceClient(), instanceID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestEnableRootUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleEnableRoot(t)
+
+ expected := &osUsers.User{Name: "root", Password: "secretsecret"}
+
+ user, err := EnableRootUser(fake.ServiceClient(), instanceID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestRestart(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleRestart(t)
+
+ res := Restart(fake.ServiceClient(), instanceID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleResize(t)
+
+ res := Resize(fake.ServiceClient(), instanceID, "2")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResizeVolume(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleResizeVol(t)
+
+ res := ResizeVolume(fake.ServiceClient(), instanceID, 4)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/db/v1/instances/doc.go b/rackspace/db/v1/instances/doc.go
new file mode 100644
index 0000000..0c8ad63
--- /dev/null
+++ b/rackspace/db/v1/instances/doc.go
@@ -0,0 +1,3 @@
+// Package instances provides information and interaction with the instance API
+// resource in the Rackspace Database service.
+package instances
diff --git a/rackspace/db/v1/instances/fixtures.go b/rackspace/db/v1/instances/fixtures.go
new file mode 100644
index 0000000..c5ff37a
--- /dev/null
+++ b/rackspace/db/v1/instances/fixtures.go
@@ -0,0 +1,340 @@
+package instances
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+)
+
+var (
+ timestamp = "2015-11-12T14:22:42Z"
+ timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var instance = `
+{
+ "created": "` + timestamp + `",
+ "datastore": {
+ "type": "mysql",
+ "version": "5.6"
+ },
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1",
+ "rel": "self"
+ },
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1",
+ "rel": "self"
+ }
+ ],
+ "hostname": "e09ad9a3f73309469cf1f43d11e79549caf9acf2.rackspaceclouddb.com",
+ "id": "{instanceID}",
+ "name": "json_rack_instance",
+ "status": "BUILD",
+ "updated": "` + timestamp + `",
+ "volume": {
+ "size": 2
+ }
+}
+`
+
+var createReq = `
+{
+ "instance": {
+ "databases": [
+ {
+ "character_set": "utf8",
+ "collate": "utf8_general_ci",
+ "name": "sampledb"
+ },
+ {
+ "name": "nextround"
+ }
+ ],
+ "flavorRef": "1",
+ "name": "json_rack_instance",
+ "users": [
+ {
+ "databases": [
+ {
+ "name": "sampledb"
+ }
+ ],
+ "name": "demouser",
+ "password": "demopassword"
+ }
+ ],
+ "volume": {
+ "size": 2
+ },
+ "restorePoint": {
+ "backupRef": "1234567890"
+ }
+ }
+}
+`
+
+var createReplicaReq = `
+{
+ "instance": {
+ "volume": {
+ "size": 1
+ },
+ "flavorRef": "9",
+ "name": "t2s1_ALT_GUEST",
+ "replica_of": "6bdca2fc-418e-40bd-a595-62abda61862d"
+ }
+}
+`
+
+var createReplicaResp = `
+{
+ "instance": {
+ "status": "BUILD",
+ "updated": "` + timestamp + `",
+ "name": "t2s1_ALT_GUEST",
+ "links": [
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/v1.0/5919009/instances/8367c312-7c40-4a66-aab1-5767478914fc",
+ "rel": "self"
+ },
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/instances/8367c312-7c40-4a66-aab1-5767478914fc",
+ "rel": "bookmark"
+ }
+ ],
+ "created": "` + timestamp + `",
+ "id": "8367c312-7c40-4a66-aab1-5767478914fc",
+ "volume": {
+ "size": 1
+ },
+ "flavor": {
+ "id": "9"
+ },
+ "datastore": {
+ "version": "5.6",
+ "type": "mysql"
+ },
+ "replica_of": {
+ "id": "6bdca2fc-418e-40bd-a595-62abda61862d"
+ }
+ }
+}
+`
+
+var listReplicasResp = `
+{
+ "instances": [
+ {
+ "status": "ACTIVE",
+ "name": "t1s1_ALT_GUEST",
+ "links": [
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/v1.0/1234/instances/3c691f06-bf9a-4618-b7ec-2817ce0cf254",
+ "rel": "self"
+ },
+ {
+ "href": "https://ord.databases.api.rackspacecloud.com/instances/3c691f06-bf9a-4618-b7ec-2817ce0cf254",
+ "rel": "bookmark"
+ }
+ ],
+ "ip": [
+ "10.0.0.3"
+ ],
+ "id": "3c691f06-bf9a-4618-b7ec-2817ce0cf254",
+ "volume": {
+ "size": 1
+ },
+ "flavor": {
+ "id": "9"
+ },
+ "datastore": {
+ "version": "5.6",
+ "type": "mysql"
+ },
+ "replica_of": {
+ "id": "8b499b45-52d6-402d-b398-f9d8f279c69a"
+ }
+ }
+ ]
+}
+`
+
+var getReplicaResp = `
+{
+ "instance": {
+ "status": "ACTIVE",
+ "updated": "` + timestamp + `",
+ "name": "t1_ALT_GUEST",
+ "created": "` + timestamp + `",
+ "ip": [
+ "10.0.0.2"
+ ],
+ "replicas": [
+ {
+ "id": "3c691f06-bf9a-4618-b7ec-2817ce0cf254"
+ }
+ ],
+ "id": "8b499b45-52d6-402d-b398-f9d8f279c69a",
+ "volume": {
+ "used": 0.54,
+ "size": 1
+ },
+ "flavor": {
+ "id": "9"
+ },
+ "datastore": {
+ "version": "5.6",
+ "type": "mysql"
+ }
+ }
+}
+`
+
+var detachReq = `
+{
+ "instance": {
+ "replica_of": "",
+ "slave_of": ""
+ }
+}
+`
+
+var getConfigResp = `
+{
+ "instance": {
+ "configuration": {
+ "basedir": "/usr",
+ "connect_timeout": "15",
+ "datadir": "/var/lib/mysql",
+ "default_storage_engine": "innodb",
+ "innodb_buffer_pool_instances": "1",
+ "innodb_buffer_pool_size": "175M",
+ "innodb_checksum_algorithm": "crc32",
+ "innodb_data_file_path": "ibdata1:10M:autoextend",
+ "innodb_file_per_table": "1",
+ "innodb_io_capacity": "200",
+ "innodb_log_file_size": "256M",
+ "innodb_log_files_in_group": "2",
+ "innodb_open_files": "8192",
+ "innodb_thread_concurrency": "0",
+ "join_buffer_size": "1M",
+ "key_buffer_size": "50M",
+ "local-infile": "0",
+ "log-error": "/var/log/mysql/mysqld.log",
+ "max_allowed_packet": "16M",
+ "max_connect_errors": "10000",
+ "max_connections": "40",
+ "max_heap_table_size": "16M",
+ "myisam-recover": "BACKUP",
+ "open_files_limit": "8192",
+ "performance_schema": "off",
+ "pid_file": "/var/run/mysqld/mysqld.pid",
+ "port": "3306",
+ "query_cache_limit": "1M",
+ "query_cache_size": "8M",
+ "query_cache_type": "1",
+ "read_buffer_size": "256K",
+ "read_rnd_buffer_size": "1M",
+ "server_id": "1",
+ "skip-external-locking": "1",
+ "skip_name_resolve": "1",
+ "sort_buffer_size": "256K",
+ "table_open_cache": "4096",
+ "thread_stack": "192K",
+ "tmp_table_size": "16M",
+ "tmpdir": "/var/tmp",
+ "user": "mysql",
+ "wait_timeout": "3600"
+ }
+ }
+}
+`
+
+var associateReq = `{"instance": {"configuration": "{configGroupID}"}}`
+
+var listBackupsResp = `
+{
+ "backups": [
+ {
+ "status": "COMPLETED",
+ "updated": "` + timestamp + `",
+ "description": "Backup from Restored Instance",
+ "datastore": {
+ "version": "5.1",
+ "type": "MySQL",
+ "version_id": "20000000-0000-0000-0000-000000000002"
+ },
+ "id": "87972694-4be2-40f5-83f8-501656e0032a",
+ "size": 0.141026,
+ "name": "restored_backup",
+ "created": "` + timestamp + `",
+ "instance_id": "29af2cd9-0674-48ab-b87a-b160f00208e6",
+ "parent_id": null,
+ "locationRef": "http://localhost/path/to/backup"
+ }
+ ]
+}
+`
+
+var (
+ createResp = fmt.Sprintf(`{"instance":%s}`, instance)
+ getResp = fmt.Sprintf(`{"instance":%s}`, instance)
+ associateResp = fmt.Sprintf(`{"instance":%s}`, instance)
+ listInstancesResp = fmt.Sprintf(`{"instances":[%s]}`, instance)
+)
+
+var instanceID = "{instanceID}"
+
+var expectedInstance = &Instance{
+ Created: timeVal,
+ Updated: timeVal,
+ Datastore: datastores.DatastorePartial{Type: "mysql", Version: "5.6"},
+ Flavor: flavors.Flavor{
+ ID: "1",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1", Rel: "self"},
+ gophercloud.Link{Href: "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1", Rel: "bookmark"},
+ },
+ },
+ Hostname: "e09ad9a3f73309469cf1f43d11e79549caf9acf2.rackspaceclouddb.com",
+ ID: instanceID,
+ Links: []gophercloud.Link{
+ gophercloud.Link{Href: "https://ord.databases.api.rackspacecloud.com/v1.0/1234/flavors/1", Rel: "self"},
+ },
+ Name: "json_rack_instance",
+ Status: "BUILD",
+ Volume: os.Volume{Size: 2},
+}
+
+var expectedReplica = &Instance{
+ Status: "BUILD",
+ Updated: timeVal,
+ Name: "t2s1_ALT_GUEST",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Rel: "self", Href: "https://ord.databases.api.rackspacecloud.com/v1.0/5919009/instances/8367c312-7c40-4a66-aab1-5767478914fc"},
+ gophercloud.Link{Rel: "bookmark", Href: "https://ord.databases.api.rackspacecloud.com/instances/8367c312-7c40-4a66-aab1-5767478914fc"},
+ },
+ Created: timeVal,
+ ID: "8367c312-7c40-4a66-aab1-5767478914fc",
+ Volume: os.Volume{Size: 1},
+ Flavor: flavors.Flavor{ID: "9"},
+ Datastore: datastores.DatastorePartial{Version: "5.6", Type: "mysql"},
+ ReplicaOf: &Instance{
+ ID: "6bdca2fc-418e-40bd-a595-62abda61862d",
+ },
+}
diff --git a/rackspace/db/v1/instances/requests.go b/rackspace/db/v1/instances/requests.go
new file mode 100644
index 0000000..f4df692
--- /dev/null
+++ b/rackspace/db/v1/instances/requests.go
@@ -0,0 +1,199 @@
+package instances
+
+import (
+ "github.com/rackspace/gophercloud"
+ osDBs "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ osUsers "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/backups"
+)
+
+// CreateOpts is the struct responsible for configuring a new database instance.
+type CreateOpts struct {
+ // Either the integer UUID (in string form) of the flavor, or its URI
+ // reference as specified in the response from the List() call. Required.
+ FlavorRef string
+
+ // Specifies the volume size in gigabytes (GB). The value must be between 1
+ // and 300. Required.
+ Size int
+
+ // Name of the instance to create. The length of the name is limited to
+ // 255 characters and any characters are permitted. Optional.
+ Name string
+
+ // A slice of database information options.
+ Databases osDBs.CreateOptsBuilder
+
+ // A slice of user information options.
+ Users osUsers.CreateOptsBuilder
+
+ // ID of the configuration group to associate with the instance. Optional.
+ ConfigID string
+
+ // Options to configure the type of datastore the instance will use. This is
+ // optional, and if excluded will default to MySQL.
+ Datastore *os.DatastoreOpts
+
+ // Specifies the backup ID from which to restore the database instance. There
+ // are some things to be aware of before using this field. When you execute
+ // the Restore Backup operation, a new database instance is created to store
+ // the backup whose ID is specified by the restorePoint attribute. This will
+ // mean that:
+ // - All users, passwords and access that were on the instance at the time of
+ // the backup will be restored along with the databases.
+ // - You can create new users or databases if you want, but they cannot be
+ // the same as the ones from the instance that was backed up.
+ RestorePoint string
+
+ ReplicaOf string
+}
+
+func (opts CreateOpts) ToInstanceCreateMap() (map[string]interface{}, error) {
+ instance, err := os.CreateOpts{
+ FlavorRef: opts.FlavorRef,
+ Size: opts.Size,
+ Name: opts.Name,
+ Databases: opts.Databases,
+ Users: opts.Users,
+ }.ToInstanceCreateMap()
+
+ if err != nil {
+ return nil, err
+ }
+
+ instance = instance["instance"].(map[string]interface{})
+
+ if opts.ConfigID != "" {
+ instance["configuration"] = opts.ConfigID
+ }
+
+ if opts.Datastore != nil {
+ ds, err := opts.Datastore.ToMap()
+ if err != nil {
+ return nil, err
+ }
+ instance["datastore"] = ds
+ }
+
+ if opts.RestorePoint != "" {
+ instance["restorePoint"] = map[string]string{"backupRef": opts.RestorePoint}
+ }
+
+ if opts.ReplicaOf != "" {
+ instance["replica_of"] = opts.ReplicaOf
+ }
+
+ return map[string]interface{}{"instance": instance}, nil
+}
+
+// Create asynchronously provisions a new database instance. It requires the
+// user to specify a flavor and a volume size. The API service then provisions
+// the instance with the requested flavor and sets up a volume of the specified
+// size, which is the storage for the database instance.
+//
+// Although this call only allows the creation of 1 instance per request, you
+// can create an instance with multiple databases and users. The default
+// binding for a MySQL instance is port 3306.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) CreateResult {
+ return CreateResult{os.Create(client, opts)}
+}
+
+// ListOpts specifies all of the query options to be used when returning a list
+// of database instances.
+type ListOpts struct {
+ // IncludeHA includes or excludes High Availability instances from the result set
+ IncludeHA bool `q:"include_ha"`
+
+ // IncludeReplicas includes or excludes Replica instances from the result set
+ IncludeReplicas bool `q:"include_replicas"`
+}
+
+// ToInstanceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToInstanceListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List retrieves the status and information for all database instances.
+func List(client *gophercloud.ServiceClient, opts *ListOpts) pagination.Pager {
+ url := baseURL(client)
+
+ if opts != nil {
+ query, err := opts.ToInstanceListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return os.InstancePage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, url, createPageFn)
+}
+
+// GetDefaultConfig lists the default configuration settings from the template
+// that was applied to the specified instance. In a sense, this is the vanilla
+// configuration setting applied to an instance. Further configuration can be
+// applied by associating an instance with a configuration group.
+func GetDefaultConfig(client *gophercloud.ServiceClient, id string) ConfigResult {
+ var res ConfigResult
+
+ _, res.Err = client.Request("GET", configURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// AssociateWithConfigGroup associates a specified instance to a specified
+// configuration group. If any of the parameters within a configuration group
+// require a restart, then the instance will transition into a restart.
+func AssociateWithConfigGroup(client *gophercloud.ServiceClient, instanceID, configGroupID string) UpdateResult {
+ reqBody := map[string]string{
+ "configuration": configGroupID,
+ }
+
+ var res UpdateResult
+
+ _, res.Err = client.Request("PUT", resourceURL(client, instanceID), gophercloud.RequestOpts{
+ JSONBody: map[string]map[string]string{"instance": reqBody},
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// DetachFromConfigGroup will detach an instance from all config groups.
+func DetachFromConfigGroup(client *gophercloud.ServiceClient, instanceID string) UpdateResult {
+ return AssociateWithConfigGroup(client, instanceID, "")
+}
+
+// ListBackups will list all the backups for a specified database instance.
+func ListBackups(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return backups.BackupPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, backupsURL(client, instanceID), pageFn)
+}
+
+// DetachReplica will detach a specified replica instance from its source
+// instance, effectively allowing it to operate independently. Detaching a
+// replica will restart the MySQL service on the instance.
+func DetachReplica(client *gophercloud.ServiceClient, replicaID string) DetachResult {
+ var res DetachResult
+
+ _, res.Err = client.Request("PATCH", resourceURL(client, replicaID), gophercloud.RequestOpts{
+ JSONBody: map[string]interface{}{"instance": map[string]string{"replica_of": "", "slave_of": ""}},
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/rackspace/db/v1/instances/requests_test.go b/rackspace/db/v1/instances/requests_test.go
new file mode 100644
index 0000000..7fa4601
--- /dev/null
+++ b/rackspace/db/v1/instances/requests_test.go
@@ -0,0 +1,246 @@
+package instances
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/db/v1/backups"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+func TestInstanceList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixture.SetupHandler(t, "/instances", "GET", "", listInstancesResp, 200)
+
+ opts := &ListOpts{
+ IncludeHA: false,
+ IncludeReplicas: false,
+ }
+
+ pages := 0
+ err := List(fake.ServiceClient(), opts).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []Instance{*expectedInstance}, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetConfig(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL+"/configuration", "GET", "", getConfigResp, 200)
+
+ config, err := GetDefaultConfig(fake.ServiceClient(), instanceID).Extract()
+
+ expected := map[string]string{
+ "basedir": "/usr",
+ "connect_timeout": "15",
+ "datadir": "/var/lib/mysql",
+ "default_storage_engine": "innodb",
+ "innodb_buffer_pool_instances": "1",
+ "innodb_buffer_pool_size": "175M",
+ "innodb_checksum_algorithm": "crc32",
+ "innodb_data_file_path": "ibdata1:10M:autoextend",
+ "innodb_file_per_table": "1",
+ "innodb_io_capacity": "200",
+ "innodb_log_file_size": "256M",
+ "innodb_log_files_in_group": "2",
+ "innodb_open_files": "8192",
+ "innodb_thread_concurrency": "0",
+ "join_buffer_size": "1M",
+ "key_buffer_size": "50M",
+ "local-infile": "0",
+ "log-error": "/var/log/mysql/mysqld.log",
+ "max_allowed_packet": "16M",
+ "max_connect_errors": "10000",
+ "max_connections": "40",
+ "max_heap_table_size": "16M",
+ "myisam-recover": "BACKUP",
+ "open_files_limit": "8192",
+ "performance_schema": "off",
+ "pid_file": "/var/run/mysqld/mysqld.pid",
+ "port": "3306",
+ "query_cache_limit": "1M",
+ "query_cache_size": "8M",
+ "query_cache_type": "1",
+ "read_buffer_size": "256K",
+ "read_rnd_buffer_size": "1M",
+ "server_id": "1",
+ "skip-external-locking": "1",
+ "skip_name_resolve": "1",
+ "sort_buffer_size": "256K",
+ "table_open_cache": "4096",
+ "thread_stack": "192K",
+ "tmp_table_size": "16M",
+ "tmpdir": "/var/tmp",
+ "user": "mysql",
+ "wait_timeout": "3600",
+ }
+
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, config)
+}
+
+func TestAssociateWithConfigGroup(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PUT", associateReq, associateResp, 202)
+
+ res := AssociateWithConfigGroup(fake.ServiceClient(), instanceID, "{configGroupID}")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestListBackups(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL+"/backups", "GET", "", listBackupsResp, 200)
+
+ pages := 0
+
+ err := ListBackups(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ actual, err := backups.ExtractBackups(page)
+ th.AssertNoErr(t, err)
+
+ expected := []backups.Backup{
+ backups.Backup{
+ Created: timeVal,
+ Description: "Backup from Restored Instance",
+ ID: "87972694-4be2-40f5-83f8-501656e0032a",
+ InstanceID: "29af2cd9-0674-48ab-b87a-b160f00208e6",
+ LocationRef: "http://localhost/path/to/backup",
+ Name: "restored_backup",
+ ParentID: "",
+ Size: 0.141026,
+ Status: "COMPLETED",
+ Updated: timeVal,
+ Datastore: datastores.DatastorePartial{Version: "5.1", Type: "MySQL", VersionID: "20000000-0000-0000-0000-000000000002"},
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestCreateReplica(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "POST", createReplicaReq, createReplicaResp, 200)
+
+ opts := CreateOpts{
+ Name: "t2s1_ALT_GUEST",
+ FlavorRef: "9",
+ Size: 1,
+ ReplicaOf: "6bdca2fc-418e-40bd-a595-62abda61862d",
+ }
+
+ replica, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expectedReplica, replica)
+}
+
+func TestListReplicas(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "GET", "", listReplicasResp, 200)
+
+ pages := 0
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []Instance{
+ Instance{
+ Status: "ACTIVE",
+ Name: "t1s1_ALT_GUEST",
+ Links: []gophercloud.Link{
+ gophercloud.Link{Rel: "self", Href: "https://ord.databases.api.rackspacecloud.com/v1.0/1234/instances/3c691f06-bf9a-4618-b7ec-2817ce0cf254"},
+ gophercloud.Link{Rel: "bookmark", Href: "https://ord.databases.api.rackspacecloud.com/instances/3c691f06-bf9a-4618-b7ec-2817ce0cf254"},
+ },
+ ID: "3c691f06-bf9a-4618-b7ec-2817ce0cf254",
+ IP: []string{"10.0.0.3"},
+ Volume: os.Volume{Size: 1},
+ Flavor: flavors.Flavor{ID: "9"},
+ Datastore: datastores.DatastorePartial{Version: "5.6", Type: "mysql"},
+ ReplicaOf: &Instance{
+ ID: "8b499b45-52d6-402d-b398-f9d8f279c69a",
+ },
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGetReplica(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "GET", "", getReplicaResp, 200)
+
+ replica, err := Get(fake.ServiceClient(), instanceID).Extract()
+ th.AssertNoErr(t, err)
+
+ expectedReplica := &Instance{
+ Status: "ACTIVE",
+ Updated: timeVal,
+ Name: "t1_ALT_GUEST",
+ Created: timeVal,
+ IP: []string{
+ "10.0.0.2",
+ },
+ Replicas: []Instance{
+ Instance{ID: "3c691f06-bf9a-4618-b7ec-2817ce0cf254"},
+ },
+ ID: "8b499b45-52d6-402d-b398-f9d8f279c69a",
+ Volume: os.Volume{
+ Used: 0.54,
+ Size: 1,
+ },
+ Flavor: flavors.Flavor{ID: "9"},
+ Datastore: datastores.DatastorePartial{
+ Version: "5.6",
+ Type: "mysql",
+ },
+ }
+
+ th.AssertDeepEquals(t, replica, expectedReplica)
+}
+
+func TestDetachReplica(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, resURL, "PATCH", detachReq, "", 202)
+
+ err := DetachReplica(fake.ServiceClient(), instanceID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/db/v1/instances/results.go b/rackspace/db/v1/instances/results.go
new file mode 100644
index 0000000..cdcc9c7
--- /dev/null
+++ b/rackspace/db/v1/instances/results.go
@@ -0,0 +1,191 @@
+package instances
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/db/v1/datastores"
+ "github.com/rackspace/gophercloud/openstack/db/v1/flavors"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/instances"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Instance represents a remote MySQL instance.
+type Instance struct {
+ // Indicates the datetime that the instance was created
+ Created time.Time `mapstructure:"-"`
+
+ // Indicates the most recent datetime that the instance was updated.
+ Updated time.Time `mapstructure:"-"`
+
+ // Indicates how the instance stores data.
+ Datastore datastores.DatastorePartial
+
+ // Indicates the hardware flavor the instance uses.
+ Flavor flavors.Flavor
+
+ // A DNS-resolvable hostname associated with the database instance (rather
+ // than an IPv4 address). Since the hostname always resolves to the correct
+ // IP address of the database instance, this relieves the user from the task
+ // of maintaining the mapping. Note that although the IP address may likely
+ // change on resizing, migrating, and so forth, the hostname always resolves
+ // to the correct database instance.
+ Hostname string
+
+ // Indicates the unique identifier for the instance resource.
+ ID string
+
+ // Exposes various links that reference the instance resource.
+ Links []gophercloud.Link
+
+ // The human-readable name of the instance.
+ Name string
+
+ // The build status of the instance.
+ Status string
+
+ // Information about the attached volume of the instance.
+ Volume os.Volume
+
+ // IP indicates the various IP addresses which allow access.
+ IP []string
+
+ // Indicates whether this instance is a replica of another source instance.
+ ReplicaOf *Instance `mapstructure:"replica_of" json:"replica_of"`
+
+ // Indicates whether this instance is the source of other replica instances.
+ Replicas []Instance
+}
+
+func commonExtract(err error, body interface{}) (*Instance, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Instance Instance `mapstructure:"instance"`
+ }
+
+ err = mapstructure.Decode(body, &response)
+
+ val := body.(map[string]interface{})["instance"].(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Instance, err
+ }
+ response.Instance.Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &response.Instance, err
+ }
+ response.Instance.Updated = updatedTime
+ }
+
+ return &response.Instance, err
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ os.CreateResult
+}
+
+// Extract will retrieve an instance from a create result.
+func (r CreateResult) Extract() (*Instance, error) {
+ return commonExtract(r.Err, r.Body)
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ os.GetResult
+}
+
+// Extract will extract an Instance from a GetResult.
+func (r GetResult) Extract() (*Instance, error) {
+ return commonExtract(r.Err, r.Body)
+}
+
+// ConfigResult represents the result of getting default configuration for an
+// instance.
+type ConfigResult struct {
+ gophercloud.Result
+}
+
+// DetachResult represents the result of detaching a replica from its source.
+type DetachResult struct {
+ gophercloud.ErrResult
+}
+
+// Extract will extract the configuration information (in the form of a map)
+// about a particular instance.
+func (r ConfigResult) Extract() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Instance struct {
+ Config map[string]string `mapstructure:"configuration"`
+ } `mapstructure:"instance"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return response.Instance.Config, err
+}
+
+// UpdateResult represents the result of an Update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// ExtractInstances retrieves a slice of instances from a paginated collection.
+func ExtractInstances(page pagination.Page) ([]Instance, error) {
+ casted := page.(os.InstancePage).Body
+
+ var resp struct {
+ Instances []Instance `mapstructure:"instances"`
+ }
+
+ if err := mapstructure.Decode(casted, &resp); err != nil {
+ return nil, err
+ }
+
+ var vals []interface{}
+ switch casted.(type) {
+ case map[string]interface{}:
+ vals = casted.(map[string]interface{})["instances"].([]interface{})
+ case map[string][]interface{}:
+ vals = casted.(map[string][]interface{})["instances"]
+ default:
+ return resp.Instances, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i, v := range vals {
+ val := v.(map[string]interface{})
+
+ if t, ok := val["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Instances, err
+ }
+ resp.Instances[i].Created = creationTime
+ }
+
+ if t, ok := val["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return resp.Instances, err
+ }
+ resp.Instances[i].Updated = updatedTime
+ }
+ }
+
+ return resp.Instances, nil
+}
diff --git a/rackspace/db/v1/instances/urls.go b/rackspace/db/v1/instances/urls.go
new file mode 100644
index 0000000..5955f4c
--- /dev/null
+++ b/rackspace/db/v1/instances/urls.go
@@ -0,0 +1,23 @@
+package instances
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("instances")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return baseURL(c)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id)
+}
+
+func configURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id, "configuration")
+}
+
+func backupsURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("instances", id, "backups")
+}
diff --git a/rackspace/db/v1/users/delegate.go b/rackspace/db/v1/users/delegate.go
new file mode 100644
index 0000000..8298c46
--- /dev/null
+++ b/rackspace/db/v1/users/delegate.go
@@ -0,0 +1,16 @@
+package users
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/users"
+)
+
+// Create will create a new database user for the specified database instance.
+func Create(client *gophercloud.ServiceClient, instanceID string, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(client, instanceID, opts)
+}
+
+// Delete will permanently remove a user from a specified database instance.
+func Delete(client *gophercloud.ServiceClient, instanceID, userName string) os.DeleteResult {
+ return os.Delete(client, instanceID, userName)
+}
diff --git a/rackspace/db/v1/users/delegate_test.go b/rackspace/db/v1/users/delegate_test.go
new file mode 100644
index 0000000..7a1b773
--- /dev/null
+++ b/rackspace/db/v1/users/delegate_test.go
@@ -0,0 +1,48 @@
+package users
+
+import (
+ "testing"
+
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const instanceID = "{instanceID}"
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreate(t)
+
+ opts := os.BatchCreateOpts{
+ os.CreateOpts{
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "databaseA"},
+ },
+ Name: "dbuser3",
+ Password: "secretsecret",
+ },
+ os.CreateOpts{
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "databaseB"},
+ db.CreateOpts{Name: "databaseC"},
+ },
+ Name: "dbuser4",
+ Password: "secretsecret",
+ },
+ }
+
+ res := Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDelete(t)
+
+ res := Delete(fake.ServiceClient(), instanceID, "{userName}")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/db/v1/users/doc.go b/rackspace/db/v1/users/doc.go
new file mode 100644
index 0000000..84f2eb3
--- /dev/null
+++ b/rackspace/db/v1/users/doc.go
@@ -0,0 +1,3 @@
+// Package users provides information and interaction with the user API
+// resource in the Rackspace Database service.
+package users
diff --git a/rackspace/db/v1/users/fixtures.go b/rackspace/db/v1/users/fixtures.go
new file mode 100644
index 0000000..5314e85
--- /dev/null
+++ b/rackspace/db/v1/users/fixtures.go
@@ -0,0 +1,77 @@
+package users
+
+const singleDB = `{"databases": [{"name": "databaseE"}]}`
+
+var changePwdReq = `
+{
+ "users": [
+ {
+ "name": "dbuser1",
+ "password": "newpassword"
+ },
+ {
+ "name": "dbuser2",
+ "password": "anotherpassword"
+ }
+ ]
+}
+`
+
+var updateReq = `
+{
+ "user": {
+ "name": "new_username",
+ "password": "new_password"
+ }
+}
+`
+
+var getResp = `
+{
+ "user": {
+ "name": "exampleuser",
+ "host": "foo",
+ "databases": [
+ {
+ "name": "databaseA"
+ },
+ {
+ "name": "databaseB"
+ }
+ ]
+ }
+}
+`
+
+var listResp = `
+{
+"users": [
+ {
+ "name": "dbuser1",
+ "host": "localhost",
+ "databases": [
+ {
+ "name": "databaseA"
+ }
+ ]
+ },
+ {
+ "name": "dbuser2",
+ "host": "localhost",
+ "databases": [
+ {
+ "name": "databaseB"
+ },
+ {
+ "name": "databaseC"
+ }
+ ]
+ }
+]
+}
+`
+
+var (
+ listUserAccessResp = singleDB
+ grantUserAccessReq = singleDB
+)
diff --git a/rackspace/db/v1/users/requests.go b/rackspace/db/v1/users/requests.go
new file mode 100644
index 0000000..74e47ab
--- /dev/null
+++ b/rackspace/db/v1/users/requests.go
@@ -0,0 +1,176 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List will list all available users for a specified database instance.
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+ createPageFn := func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, baseURL(client, instanceID), createPageFn)
+}
+
+/*
+ChangePassword changes the password for one or more users. For example, to
+change the respective passwords for two users:
+
+ opts := os.BatchCreateOpts{
+ os.CreateOpts{Name: "db_user_1", Password: "new_password_1"},
+ os.CreateOpts{Name: "db_user_2", Password: "new_password_2"},
+ }
+
+ ChangePassword(client, "instance_id", opts)
+*/
+func ChangePassword(client *gophercloud.ServiceClient, instanceID string, opts os.CreateOptsBuilder) UpdatePasswordsResult {
+ var res UpdatePasswordsResult
+
+ reqBody, err := opts.ToUserCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("PUT", baseURL(client, instanceID), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// UpdateOpts is the struct responsible for updating an existing user.
+type UpdateOpts struct {
+ // [OPTIONAL] Specifies a name for the user. Valid names can be composed
+ // of the following characters: letters (either case); numbers; these
+ // characters '@', '?', '#', ' ' but NEVER beginning a name string; '_' is
+ // permitted anywhere. Prohibited characters that are forbidden include:
+ // single quotes, double quotes, back quotes, semicolons, commas, backslashes,
+ // and forward slashes. Spaces at the front or end of a user name are also
+ // not permitted.
+ Name string
+
+ // [OPTIONAL] Specifies a password for the user.
+ Password string
+
+ // [OPTIONAL] Specifies the host from which a user is allowed to connect to
+ // the database. Possible values are a string containing an IPv4 address or
+ // "%" to allow connecting from any host. Optional; the default is "%".
+ Host string
+}
+
+// ToMap is a convenience function for creating sub-maps for individual users.
+func (opts UpdateOpts) ToMap() (map[string]interface{}, error) {
+ if opts.Name == "root" {
+ return nil, errors.New("root is a reserved user name and cannot be used")
+ }
+
+ user := map[string]interface{}{}
+
+ if opts.Name != "" {
+ user["name"] = opts.Name
+ }
+
+ if opts.Password != "" {
+ user["password"] = opts.Password
+ }
+
+ if opts.Host != "" {
+ user["host"] = opts.Host
+ }
+
+ return user, nil
+}
+
+// Update will modify the attributes of a specified user. Attributes that can
+// be updated are: user name, password, and host.
+func Update(client *gophercloud.ServiceClient, instanceID, userName string, opts UpdateOpts) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+ reqBody = map[string]interface{}{"user": reqBody}
+
+ _, res.Err = client.Request("PUT", userURL(client, instanceID, userName), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+// Get will retrieve the details for a particular user.
+func Get(client *gophercloud.ServiceClient, instanceID, userName string) GetResult {
+ var res GetResult
+
+ _, res.Err = client.Request("GET", userURL(client, instanceID, userName), gophercloud.RequestOpts{
+ JSONResponse: &res.Body,
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// ListAccess will list all of the databases a user has access to.
+func ListAccess(client *gophercloud.ServiceClient, instanceID, userName string) pagination.Pager {
+ pageFn := func(r pagination.PageResult) pagination.Page {
+ return AccessPage{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.NewPager(client, dbsURL(client, instanceID, userName), pageFn)
+}
+
+/*
+GrantAccess for the specified user to one or more databases on a specified
+instance. For example, to add a user to multiple databases:
+
+ opts := db.BatchCreateOpts{
+ db.CreateOpts{Name: "database_1"},
+ db.CreateOpts{Name: "database_3"},
+ db.CreateOpts{Name: "database_19"},
+ }
+
+ GrantAccess(client, "instance_id", "user_name", opts)
+*/
+func GrantAccess(client *gophercloud.ServiceClient, instanceID, userName string, opts db.CreateOptsBuilder) GrantAccessResult {
+ var res GrantAccessResult
+
+ reqBody, err := opts.ToDBCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Request("PUT", dbsURL(client, instanceID, userName), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{202},
+ })
+
+ return res
+}
+
+/*
+RevokeAccess will revoke access for the specified user to one or more databases
+on a specified instance. For example:
+
+ RevokeAccess(client, "instance_id", "user_name", "db_name")
+*/
+func RevokeAccess(client *gophercloud.ServiceClient, instanceID, userName, dbName string) RevokeAccessResult {
+ var res RevokeAccessResult
+
+ _, res.Err = client.Request("DELETE", dbURL(client, instanceID, userName, dbName), gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+
+ return res
+}
diff --git a/rackspace/db/v1/users/requests_test.go b/rackspace/db/v1/users/requests_test.go
new file mode 100644
index 0000000..2f2dca7
--- /dev/null
+++ b/rackspace/db/v1/users/requests_test.go
@@ -0,0 +1,156 @@
+package users
+
+import (
+ "testing"
+
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ os "github.com/rackspace/gophercloud/openstack/db/v1/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+ "github.com/rackspace/gophercloud/testhelper/fixture"
+)
+
+var (
+ userName = "{userName}"
+ _rootURL = "/instances/" + instanceID + "/users"
+ _userURL = _rootURL + "/" + userName
+ _dbURL = _userURL + "/databases"
+)
+
+func TestChangeUserPassword(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _rootURL, "PUT", changePwdReq, "", 202)
+
+ opts := os.BatchCreateOpts{
+ os.CreateOpts{Name: "dbuser1", Password: "newpassword"},
+ os.CreateOpts{Name: "dbuser2", Password: "anotherpassword"},
+ }
+
+ err := ChangePassword(fake.ServiceClient(), instanceID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestUpdateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _userURL, "PUT", updateReq, "", 202)
+
+ opts := UpdateOpts{
+ Name: "new_username",
+ Password: "new_password",
+ }
+
+ err := Update(fake.ServiceClient(), instanceID, userName, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGetUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _userURL, "GET", "", getResp, 200)
+
+ user, err := Get(fake.ServiceClient(), instanceID, userName).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "exampleuser",
+ Host: "foo",
+ Databases: []db.Database{
+ db.Database{Name: "databaseA"},
+ db.Database{Name: "databaseB"},
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestUserAccessList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _userURL+"/databases", "GET", "", listUserAccessResp, 200)
+
+ expectedDBs := []db.Database{
+ db.Database{Name: "databaseE"},
+ }
+
+ pages := 0
+ err := ListAccess(fake.ServiceClient(), instanceID, userName).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractDBs(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, expectedDBs, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestUserList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ fixture.SetupHandler(t, "/instances/"+instanceID+"/users", "GET", "", listResp, 200)
+
+ expectedUsers := []User{
+ User{
+ Databases: []db.Database{
+ db.Database{Name: "databaseA"},
+ },
+ Name: "dbuser1",
+ Host: "localhost",
+ },
+ User{
+ Databases: []db.Database{
+ db.Database{Name: "databaseB"},
+ db.Database{Name: "databaseC"},
+ },
+ Name: "dbuser2",
+ Host: "localhost",
+ },
+ }
+
+ pages := 0
+ err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := ExtractUsers(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, expectedUsers, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+}
+
+func TestGrantAccess(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _dbURL, "PUT", grantUserAccessReq, "", 202)
+
+ opts := db.BatchCreateOpts{db.CreateOpts{Name: "databaseE"}}
+ err := GrantAccess(fake.ServiceClient(), instanceID, userName, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestRevokeAccess(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, _dbURL+"/{dbName}", "DELETE", "", "", 202)
+
+ err := RevokeAccess(fake.ServiceClient(), instanceID, userName, "{dbName}").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/db/v1/users/results.go b/rackspace/db/v1/users/results.go
new file mode 100644
index 0000000..85b3a7a
--- /dev/null
+++ b/rackspace/db/v1/users/results.go
@@ -0,0 +1,149 @@
+package users
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// User represents a database user
+type User struct {
+ // The user name
+ Name string
+
+ // The user password
+ Password string
+
+ // Specifies the host from which a user is allowed to connect to the database.
+ // Possible values are a string containing an IPv4 address or "%" to allow
+ // connecting from any host.
+ Host string
+
+ // The databases associated with this user
+ Databases []db.Database
+}
+
+// UpdatePasswordsResult represents the result of changing a user password.
+type UpdatePasswordsResult struct {
+ gophercloud.ErrResult
+}
+
+// UpdateResult represents the result of updating a user.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of getting a user.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract will retrieve a User struct from a getresult.
+func (r GetResult) Extract() (*User, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ User User `mapstructure:"user"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+ return &response.User, err
+}
+
+// AccessPage represents a single page of a paginated user collection.
+type AccessPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page AccessPage) IsEmpty() (bool, error) {
+ users, err := ExtractDBs(page)
+ if err != nil {
+ return true, err
+ }
+ return len(users) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page AccessPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"databases_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractDBs will convert a generic pagination struct into a more
+// relevant slice of DB structs.
+func ExtractDBs(page pagination.Page) ([]db.Database, error) {
+ casted := page.(AccessPage).Body
+
+ var response struct {
+ DBs []db.Database `mapstructure:"databases"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.DBs, err
+}
+
+// UserPage represents a single page of a paginated user collection.
+type UserPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page UserPage) IsEmpty() (bool, error) {
+ users, err := ExtractUsers(page)
+ if err != nil {
+ return true, err
+ }
+ return len(users) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page UserPage) NextPageURL() (string, error) {
+ type resp struct {
+ Links []gophercloud.Link `mapstructure:"users_links"`
+ }
+
+ var r resp
+ err := mapstructure.Decode(page.Body, &r)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractUsers will convert a generic pagination struct into a more
+// relevant slice of User structs.
+func ExtractUsers(page pagination.Page) ([]User, error) {
+ casted := page.(UserPage).Body
+
+ var response struct {
+ Users []User `mapstructure:"users"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+
+ return response.Users, err
+}
+
+// GrantAccessResult represents the result of granting access to a user.
+type GrantAccessResult struct {
+ gophercloud.ErrResult
+}
+
+// RevokeAccessResult represents the result of revoking access to a user.
+type RevokeAccessResult struct {
+ gophercloud.ErrResult
+}
diff --git a/rackspace/db/v1/users/urls.go b/rackspace/db/v1/users/urls.go
new file mode 100644
index 0000000..bac8788
--- /dev/null
+++ b/rackspace/db/v1/users/urls.go
@@ -0,0 +1,19 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+ return c.ServiceURL("instances", instanceID, "users")
+}
+
+func userURL(c *gophercloud.ServiceClient, instanceID, userName string) string {
+ return c.ServiceURL("instances", instanceID, "users", userName)
+}
+
+func dbsURL(c *gophercloud.ServiceClient, instanceID, userName string) string {
+ return c.ServiceURL("instances", instanceID, "users", userName, "databases")
+}
+
+func dbURL(c *gophercloud.ServiceClient, instanceID, userName, dbName string) string {
+ return c.ServiceURL("instances", instanceID, "users", userName, "databases", dbName)
+}
diff --git a/rackspace/identity/v2/extensions/delegate.go b/rackspace/identity/v2/extensions/delegate.go
new file mode 100644
index 0000000..fc547cd
--- /dev/null
+++ b/rackspace/identity/v2/extensions/delegate.go
@@ -0,0 +1,24 @@
+package extensions
+
+import (
+ "github.com/rackspace/gophercloud"
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of os.Extension structs.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+ return common.ExtractExtensions(page)
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+ return common.Get(c, alias)
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return common.List(c)
+}
diff --git a/rackspace/identity/v2/extensions/delegate_test.go b/rackspace/identity/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..e30f794
--- /dev/null
+++ b/rackspace/identity/v2/extensions/delegate_test.go
@@ -0,0 +1,39 @@
+package extensions
+
+import (
+ "testing"
+
+ common "github.com/rackspace/gophercloud/openstack/common/extensions"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ common.HandleListExtensionsSuccessfully(t)
+
+ count := 0
+
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractExtensions(page)
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, common.ExpectedExtensions, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ common.HandleGetExtensionSuccessfully(t)
+
+ actual, err := Get(fake.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, common.SingleExtension, actual)
+}
diff --git a/rackspace/identity/v2/extensions/doc.go b/rackspace/identity/v2/extensions/doc.go
new file mode 100644
index 0000000..b02a95b
--- /dev/null
+++ b/rackspace/identity/v2/extensions/doc.go
@@ -0,0 +1,3 @@
+// Package extensions provides information and interaction with the all the
+// extensions available for the Rackspace Identity service.
+package extensions
diff --git a/rackspace/identity/v2/roles/delegate.go b/rackspace/identity/v2/roles/delegate.go
new file mode 100644
index 0000000..a6ee851
--- /dev/null
+++ b/rackspace/identity/v2/roles/delegate.go
@@ -0,0 +1,50 @@
+package roles
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+)
+
+// List is the operation responsible for listing all available global roles
+// that a user can adopt.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// AddUserRole is the operation responsible for assigning a particular role to
+// a user. This is confined to the scope of the user's tenant - so the tenant
+// ID is a required argument.
+func AddUserRole(client *gophercloud.ServiceClient, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = client.Request("PUT", userRoleURL(client, userID, roleID), gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+
+ return result
+}
+
+// DeleteUserRole is the operation responsible for deleting a particular role
+// from a user. This is confined to the scope of the user's tenant - so the
+// tenant ID is a required argument.
+func DeleteUserRole(client *gophercloud.ServiceClient, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = client.Request("DELETE", userRoleURL(client, userID, roleID), gophercloud.RequestOpts{
+ OkCodes: []int{204},
+ })
+
+ return result
+}
+
+// UserRoleResult represents the result of either an AddUserRole or
+// a DeleteUserRole operation.
+type UserRoleResult struct {
+ gophercloud.ErrResult
+}
+
+func userRoleURL(c *gophercloud.ServiceClient, userID, roleID string) string {
+ return c.ServiceURL(os.UserPath, userID, os.RolePath, os.ExtPath, roleID)
+}
diff --git a/rackspace/identity/v2/roles/delegate_test.go b/rackspace/identity/v2/roles/delegate_test.go
new file mode 100644
index 0000000..fcee97d
--- /dev/null
+++ b/rackspace/identity/v2/roles/delegate_test.go
@@ -0,0 +1,66 @@
+package roles
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListRoleResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractRoles(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []os.Role{
+ os.Role{
+ ID: "123",
+ Name: "compute:admin",
+ Description: "Nova Administrator",
+ ServiceID: "cke5372ebabeeabb70a0e702a4626977x4406e5",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestAddUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAddUserRoleResponse(t)
+
+ err := AddUserRole(client.ServiceClient(), "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteUserRoleResponse(t)
+
+ err := DeleteUserRole(client.ServiceClient(), "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/identity/v2/roles/fixtures.go b/rackspace/identity/v2/roles/fixtures.go
new file mode 100644
index 0000000..5f22d0f
--- /dev/null
+++ b/rackspace/identity/v2/roles/fixtures.go
@@ -0,0 +1,49 @@
+package roles
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/OS-KSADM/roles", 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, `
+{
+ "roles": [
+ {
+ "id": "123",
+ "name": "compute:admin",
+ "description": "Nova Administrator",
+ "serviceId": "cke5372ebabeeabb70a0e702a4626977x4406e5"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func MockAddUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+func MockDeleteUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/rackspace/identity/v2/tenants/delegate.go b/rackspace/identity/v2/tenants/delegate.go
new file mode 100644
index 0000000..6cdd0cf
--- /dev/null
+++ b/rackspace/identity/v2/tenants/delegate.go
@@ -0,0 +1,17 @@
+package tenants
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractTenants interprets a page of List results as a more usable slice of Tenant structs.
+func ExtractTenants(page pagination.Page) ([]os.Tenant, error) {
+ return os.ExtractTenants(page)
+}
+
+// List enumerates the tenants to which the current token grants access.
+func List(client *gophercloud.ServiceClient, opts *os.ListOpts) pagination.Pager {
+ return os.List(client, opts)
+}
diff --git a/rackspace/identity/v2/tenants/delegate_test.go b/rackspace/identity/v2/tenants/delegate_test.go
new file mode 100644
index 0000000..eccbfe2
--- /dev/null
+++ b/rackspace/identity/v2/tenants/delegate_test.go
@@ -0,0 +1,28 @@
+package tenants
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListTenants(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListTenantsSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ actual, err := ExtractTenants(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, os.ExpectedTenantSlice, actual)
+
+ count++
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
diff --git a/rackspace/identity/v2/tenants/doc.go b/rackspace/identity/v2/tenants/doc.go
new file mode 100644
index 0000000..c1825c2
--- /dev/null
+++ b/rackspace/identity/v2/tenants/doc.go
@@ -0,0 +1,3 @@
+// Package tenants provides information and interaction with the tenant
+// API resource for the Rackspace Identity service.
+package tenants
diff --git a/rackspace/identity/v2/tokens/delegate.go b/rackspace/identity/v2/tokens/delegate.go
new file mode 100644
index 0000000..4f9885a
--- /dev/null
+++ b/rackspace/identity/v2/tokens/delegate.go
@@ -0,0 +1,60 @@
+package tokens
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+)
+
+var (
+ // ErrPasswordProvided is returned if both a password and an API key are provided to Create.
+ ErrPasswordProvided = errors.New("Please provide either a password or an API key.")
+)
+
+// AuthOptions wraps the OpenStack AuthOptions struct to be able to customize the request body
+// when API key authentication is used.
+type AuthOptions struct {
+ os.AuthOptions
+}
+
+// WrapOptions embeds a root AuthOptions struct in a package-specific one.
+func WrapOptions(original gophercloud.AuthOptions) AuthOptions {
+ return AuthOptions{AuthOptions: os.WrapOptions(original)}
+}
+
+// ToTokenCreateMap serializes an AuthOptions into a request body. If an API key is provided, it
+// will be used, otherwise
+func (auth AuthOptions) ToTokenCreateMap() (map[string]interface{}, error) {
+ if auth.APIKey == "" {
+ return auth.AuthOptions.ToTokenCreateMap()
+ }
+
+ // Verify that other required attributes are present.
+ if auth.Username == "" {
+ return nil, os.ErrUsernameRequired
+ }
+
+ authMap := make(map[string]interface{})
+
+ authMap["RAX-KSKEY:apiKeyCredentials"] = map[string]interface{}{
+ "username": auth.Username,
+ "apiKey": auth.APIKey,
+ }
+
+ if auth.TenantID != "" {
+ authMap["tenantId"] = auth.TenantID
+ }
+ if auth.TenantName != "" {
+ authMap["tenantName"] = auth.TenantName
+ }
+
+ return map[string]interface{}{"auth": authMap}, nil
+}
+
+// Create authenticates to Rackspace's identity service and attempts to acquire a Token. Rather
+// than interact with this service directly, users should generally call
+// rackspace.AuthenticatedClient().
+func Create(client *gophercloud.ServiceClient, auth AuthOptions) os.CreateResult {
+ return os.Create(client, auth)
+}
diff --git a/rackspace/identity/v2/tokens/delegate_test.go b/rackspace/identity/v2/tokens/delegate_test.go
new file mode 100644
index 0000000..6678ff4
--- /dev/null
+++ b/rackspace/identity/v2/tokens/delegate_test.go
@@ -0,0 +1,36 @@
+package tokens
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/tokens"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) os.CreateResult {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleTokenPost(t, requestJSON)
+
+ return Create(client.ServiceClient(), WrapOptions(options))
+}
+
+func TestCreateTokenWithAPIKey(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ APIKey: "1234567890abcdef",
+ }
+
+ os.IsSuccessful(t, tokenPost(t, options, `
+ {
+ "auth": {
+ "RAX-KSKEY:apiKeyCredentials": {
+ "username": "me",
+ "apiKey": "1234567890abcdef"
+ }
+ }
+ }
+ `))
+}
diff --git a/rackspace/identity/v2/tokens/doc.go b/rackspace/identity/v2/tokens/doc.go
new file mode 100644
index 0000000..44043e5
--- /dev/null
+++ b/rackspace/identity/v2/tokens/doc.go
@@ -0,0 +1,3 @@
+// Package tokens provides information and interaction with the token
+// API resource for the Rackspace Identity service.
+package tokens
diff --git a/rackspace/identity/v2/users/delegate.go b/rackspace/identity/v2/users/delegate.go
new file mode 100644
index 0000000..6135bec
--- /dev/null
+++ b/rackspace/identity/v2/users/delegate.go
@@ -0,0 +1,142 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a pager that allows traversal over a collection of users.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// CommonOpts are the options which are shared between CreateOpts and
+// UpdateOpts
+type CommonOpts struct {
+ // Required. The username to assign to the user. When provided, the username
+ // must:
+ // - start with an alphabetical (A-Za-z) character
+ // - have a minimum length of 1 character
+ //
+ // The username may contain upper and lowercase characters, as well as any of
+ // the following special character: . - @ _
+ Username string
+
+ // Required. Email address for the user account.
+ Email string
+
+ // Required. Indicates whether the user can authenticate after the user
+ // account is created. If no value is specified, the default value is true.
+ Enabled os.EnabledState
+
+ // Optional. The password to assign to the user. If provided, the password
+ // must:
+ // - start with an alphabetical (A-Za-z) character
+ // - have a minimum length of 8 characters
+ // - contain at least one uppercase character, one lowercase character, and
+ // one numeric character.
+ //
+ // The password may contain any of the following special characters: . - @ _
+ Password string
+}
+
+// CreateOpts represents the options needed when creating new users.
+type CreateOpts CommonOpts
+
+// ToUserCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+ m := make(map[string]interface{})
+
+ if opts.Username == "" {
+ return m, errors.New("Username is a required field")
+ }
+ if opts.Enabled == nil {
+ return m, errors.New("Enabled is a required field")
+ }
+ if opts.Email == "" {
+ return m, errors.New("Email is a required field")
+ }
+
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = opts.Enabled
+ }
+ if opts.Password != "" {
+ m["OS-KSADM:password"] = opts.Password
+ }
+
+ return map[string]interface{}{"user": m}, nil
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) CreateResult {
+ return CreateResult{os.Create(client, opts)}
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToUserUpdateMap() map[string]interface{}
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts CommonOpts
+
+// ToUserUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToUserUpdateMap() map[string]interface{} {
+ m := make(map[string]interface{})
+
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+
+ return map[string]interface{}{"user": m}
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+
+ _, result.Err = client.Request("POST", os.ResourceURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &result.Body,
+ JSONBody: opts.ToUserUpdateMap(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
+
+// ResetAPIKey resets the User's API key.
+func ResetAPIKey(client *gophercloud.ServiceClient, id string) ResetAPIKeyResult {
+ var result ResetAPIKeyResult
+
+ _, result.Err = client.Request("POST", resetAPIKeyURL(client, id), gophercloud.RequestOpts{
+ JSONResponse: &result.Body,
+ OkCodes: []int{200},
+ })
+
+ return result
+}
diff --git a/rackspace/identity/v2/users/delegate_test.go b/rackspace/identity/v2/users/delegate_test.go
new file mode 100644
index 0000000..62faf0c
--- /dev/null
+++ b/rackspace/identity/v2/users/delegate_test.go
@@ -0,0 +1,111 @@
+package users
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ users, err := os.ExtractUsers(page)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "u1000", users[0].ID)
+ th.AssertEquals(t, "u1001", users[1].ID)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateUser(t)
+
+ opts := CreateOpts{
+ Username: "new_user",
+ Enabled: os.Disabled,
+ Email: "new_user@foo.com",
+ Password: "foo",
+ }
+
+ user, err := Create(client.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "123456", user.ID)
+ th.AssertEquals(t, "5830280", user.DomainID)
+ th.AssertEquals(t, "DFW", user.DefaultRegion)
+}
+
+func TestGetUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetUser(t)
+
+ user, err := Get(client.ServiceClient(), "new_user").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, true, user.Enabled)
+ th.AssertEquals(t, true, user.MultiFactorEnabled)
+}
+
+func TestUpdateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateUser(t)
+
+ id := "c39e3de9be2d4c779f1dfd6abacc176d"
+
+ opts := UpdateOpts{
+ Enabled: os.Enabled,
+ Email: "new_email@foo.com",
+ }
+
+ user, err := Update(client.ServiceClient(), id, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, true, user.Enabled)
+ th.AssertEquals(t, "new_email@foo.com", user.Email)
+}
+
+func TestDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteUser(t)
+
+ res := Delete(client.ServiceClient(), "c39e3de9be2d4c779f1dfd6abacc176d")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResetAPIKey(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockResetAPIKey(t)
+
+ apiKey, err := ResetAPIKey(client.ServiceClient(), "99").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "joesmith", apiKey.Username)
+ th.AssertEquals(t, "mooH1eiLahd5ahYood7r", apiKey.APIKey)
+}
diff --git a/rackspace/identity/v2/users/fixtures.go b/rackspace/identity/v2/users/fixtures.go
new file mode 100644
index 0000000..973f39e
--- /dev/null
+++ b/rackspace/identity/v2/users/fixtures.go
@@ -0,0 +1,154 @@
+package users
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func mockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", 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, `
+{
+ "users":[
+ {
+ "id": "u1000",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true
+ },
+ {
+ "id": "u1001",
+ "username": "jqsmith",
+ "email": "jane.smith@example.org",
+ "enabled": true
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateUser(t *testing.T) {
+ th.Mux.HandleFunc("/users", 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, `
+{
+ "user": {
+ "username": "new_user",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "OS-KSADM:password": "foo"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "id": "123456",
+ "username": "new_user",
+ "email": "new_user@foo.com",
+ "enabled": false
+ }
+}
+`)
+ })
+}
+
+func mockGetUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/new_user", 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, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "RAX-AUTH:multiFactorEnabled": "true",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true
+ }
+}
+`)
+ })
+}
+
+func mockUpdateUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", 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, `
+{
+ "user": {
+ "email": "new_email@foo.com",
+ "enabled": true
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "RAX-AUTH:multiFactorEnabled": "true",
+ "id": "123456",
+ "username": "jqsmith",
+ "email": "new_email@foo.com",
+ "enabled": true
+ }
+}
+`)
+ })
+}
+
+func mockDeleteUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+func mockResetAPIKey(t *testing.T) {
+ th.Mux.HandleFunc("/users/99/OS-KSADM/credentials/RAX-KSKEY:apiKeyCredentials/RAX-AUTH/reset", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+{
+ "RAX-KSKEY:apiKeyCredentials": {
+ "username": "joesmith",
+ "apiKey": "mooH1eiLahd5ahYood7r"
+ }
+}`)
+ })
+}
diff --git a/rackspace/identity/v2/users/results.go b/rackspace/identity/v2/users/results.go
new file mode 100644
index 0000000..6936ecb
--- /dev/null
+++ b/rackspace/identity/v2/users/results.go
@@ -0,0 +1,129 @@
+package users
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// User represents a user resource that exists on the API.
+type User struct {
+ // The UUID for this user.
+ ID string
+
+ // The human name for this user.
+ Name string
+
+ // The username for this user.
+ Username string
+
+ // Indicates whether the user is enabled (true) or disabled (false).
+ Enabled bool
+
+ // The email address for this user.
+ Email string
+
+ // The ID of the tenant to which this user belongs.
+ TenantID string `mapstructure:"tenant_id"`
+
+ // Specifies the default region for the user account. This value is inherited
+ // from the user administrator when the account is created.
+ DefaultRegion string `mapstructure:"RAX-AUTH:defaultRegion"`
+
+ // Identifies the domain that contains the user account. This value is
+ // inherited from the user administrator when the account is created.
+ DomainID string `mapstructure:"RAX-AUTH:domainId"`
+
+ // The password value that the user needs for authentication. If the Add user
+ // request included a password value, this attribute is not included in the
+ // response.
+ Password string `mapstructure:"OS-KSADM:password"`
+
+ // Indicates whether the user has enabled multi-factor authentication.
+ MultiFactorEnabled bool `mapstructure:"RAX-AUTH:multiFactorEnabled"`
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+ os.CreateResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+ os.GetResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+ os.UpdateResult
+}
+
+func commonExtract(resp interface{}, err error) (*User, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var respStruct struct {
+ User *User `json:"user"`
+ }
+
+ // Since the API returns a string instead of a bool, we need to hack the JSON
+ json := resp.(map[string]interface{})
+ user := json["user"].(map[string]interface{})
+ if s, ok := user["RAX-AUTH:multiFactorEnabled"].(string); ok && s != "" {
+ if b, err := strconv.ParseBool(s); err == nil {
+ user["RAX-AUTH:multiFactorEnabled"] = b
+ }
+ }
+
+ err = mapstructure.Decode(json, &respStruct)
+
+ return respStruct.User, err
+}
+
+// Extract will get the Snapshot object out of the GetResult object.
+func (r GetResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the CreateResult object.
+func (r CreateResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the UpdateResult object.
+func (r UpdateResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// ResetAPIKeyResult represents the server response to the ResetAPIKey method.
+type ResetAPIKeyResult struct {
+ gophercloud.Result
+}
+
+// ResetAPIKeyValue represents an API Key that has been reset.
+type ResetAPIKeyValue struct {
+ // The Username for this API Key reset.
+ Username string `mapstructure:"username"`
+
+ // The new API Key for this user.
+ APIKey string `mapstructure:"apiKey"`
+}
+
+// Extract will get the Error or ResetAPIKeyValue object out of the ResetAPIKeyResult object.
+func (r ResetAPIKeyResult) Extract() (*ResetAPIKeyValue, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ ResetAPIKeyValue ResetAPIKeyValue `mapstructure:"RAX-KSKEY:apiKeyCredentials"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.ResetAPIKeyValue, err
+}
diff --git a/rackspace/identity/v2/users/urls.go b/rackspace/identity/v2/users/urls.go
new file mode 100644
index 0000000..bc1aaef
--- /dev/null
+++ b/rackspace/identity/v2/users/urls.go
@@ -0,0 +1,7 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+func resetAPIKeyURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("users", id, "OS-KSADM", "credentials", "RAX-KSKEY:apiKeyCredentials", "RAX-AUTH", "reset")
+}
diff --git a/rackspace/lb/v1/acl/doc.go b/rackspace/lb/v1/acl/doc.go
new file mode 100644
index 0000000..42325fe
--- /dev/null
+++ b/rackspace/lb/v1/acl/doc.go
@@ -0,0 +1,12 @@
+/*
+Package acl provides information and interaction with the access lists feature
+of the Rackspace Cloud Load Balancer service.
+
+The access list management feature allows fine-grained network access controls
+to be applied to the load balancer's virtual IP address. A single IP address,
+multiple IP addresses, or entire network subnets can be added. Items that are
+configured with the ALLOW type always takes precedence over items with the DENY
+type. To reject traffic from all items except for those with the ALLOW type,
+add a networkItem with an address of "0.0.0.0/0" and a DENY type.
+*/
+package acl
diff --git a/rackspace/lb/v1/acl/fixtures.go b/rackspace/lb/v1/acl/fixtures.go
new file mode 100644
index 0000000..e3c941c
--- /dev/null
+++ b/rackspace/lb/v1/acl/fixtures.go
@@ -0,0 +1,109 @@
+package acl
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(lbID int) string {
+ return "/loadbalancers/" + strconv.Itoa(lbID) + "/accesslist"
+}
+
+func mockListResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc(_rootURL(id), 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, `
+{
+ "accessList": [
+ {
+ "address": "206.160.163.21",
+ "id": 21,
+ "type": "DENY"
+ },
+ {
+ "address": "206.160.163.22",
+ "id": 22,
+ "type": "DENY"
+ },
+ {
+ "address": "206.160.163.23",
+ "id": 23,
+ "type": "DENY"
+ },
+ {
+ "address": "206.160.163.24",
+ "id": 24,
+ "type": "DENY"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "accessList": [
+ {
+ "address": "206.160.163.21",
+ "type": "DENY"
+ },
+ {
+ "address": "206.160.165.11",
+ "type": "DENY"
+ }
+ ]
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteAllResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockBatchDeleteResponse(t *testing.T, lbID int, ids []int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ r.ParseForm()
+
+ for k, v := range ids {
+ fids := r.Form["id"]
+ th.AssertEquals(t, strconv.Itoa(v), fids[k])
+ }
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID, networkID int) {
+ th.Mux.HandleFunc(_rootURL(lbID)+"/"+strconv.Itoa(networkID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/acl/requests.go b/rackspace/lb/v1/acl/requests.go
new file mode 100644
index 0000000..d4ce7c0
--- /dev/null
+++ b/rackspace/lb/v1/acl/requests.go
@@ -0,0 +1,111 @@
+package acl
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List is the operation responsible for returning a paginated collection of
+// network items that define a load balancer's access list.
+func List(client *gophercloud.ServiceClient, lbID int) pagination.Pager {
+ url := rootURL(client, lbID)
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return AccessListPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder is the interface responsible for generating the JSON
+// for a Create operation.
+type CreateOptsBuilder interface {
+ ToAccessListCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is a slice of CreateOpt structs, that allow the user to create
+// multiple nodes in a single operation (one node per CreateOpt).
+type CreateOpts []CreateOpt
+
+// CreateOpt represents the options to create a single node.
+type CreateOpt struct {
+ // Required - the IP address or CIDR for item to add to access list.
+ Address string
+
+ // Required - the type of the node. Either ALLOW or DENY.
+ Type Type
+}
+
+// ToAccessListCreateMap converts a slice of options into a map that can be
+// used for the JSON.
+func (opts CreateOpts) ToAccessListCreateMap() (map[string]interface{}, error) {
+ type itemMap map[string]interface{}
+ items := []itemMap{}
+
+ for k, v := range opts {
+ if v.Address == "" {
+ return itemMap{}, fmt.Errorf("Address is a required attribute, none provided for %d CreateOpt element", k)
+ }
+ if v.Type != ALLOW && v.Type != DENY {
+ return itemMap{}, fmt.Errorf("Type must be ALLOW or DENY")
+ }
+
+ item := make(itemMap)
+ item["address"] = v.Address
+ item["type"] = v.Type
+
+ items = append(items, item)
+ }
+
+ return itemMap{"accessList": items}, nil
+}
+
+// Create is the operation responsible for adding network items to the access
+// rules for a particular load balancer. If network items already exist, the
+// new item will be appended. A single IP address or subnet range is considered
+// unique and cannot be duplicated.
+func Create(client *gophercloud.ServiceClient, loadBalancerID int, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToAccessListCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = client.Post(rootURL(client, loadBalancerID), reqBody, nil, nil)
+ return res
+}
+
+// BulkDelete will delete multiple network items from a load balancer's access
+// list in a single operation.
+func BulkDelete(c *gophercloud.ServiceClient, loadBalancerID int, itemIDs []int) DeleteResult {
+ var res DeleteResult
+
+ if len(itemIDs) > 10 || len(itemIDs) == 0 {
+ res.Err = errors.New("You must provide a minimum of 1 and a maximum of 10 item IDs")
+ return res
+ }
+
+ url := rootURL(c, loadBalancerID)
+ url += gophercloud.IDSliceToQueryString("id", itemIDs)
+
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
+
+// Delete will remove a single network item from a load balancer's access list.
+func Delete(c *gophercloud.ServiceClient, lbID, itemID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, lbID, itemID), nil)
+ return res
+}
+
+// DeleteAll will delete the entire contents of a load balancer's access list,
+// effectively resetting it and allowing all traffic.
+func DeleteAll(c *gophercloud.ServiceClient, lbID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(rootURL(c, lbID), nil)
+ return res
+}
diff --git a/rackspace/lb/v1/acl/requests_test.go b/rackspace/lb/v1/acl/requests_test.go
new file mode 100644
index 0000000..c4961a3
--- /dev/null
+++ b/rackspace/lb/v1/acl/requests_test.go
@@ -0,0 +1,91 @@
+package acl
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ lbID = 12345
+ itemID1 = 67890
+ itemID2 = 67891
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListResponse(t, lbID)
+
+ count := 0
+
+ err := List(client.ServiceClient(), lbID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractAccessList(page)
+ th.AssertNoErr(t, err)
+
+ expected := AccessList{
+ NetworkItem{Address: "206.160.163.21", ID: 21, Type: DENY},
+ NetworkItem{Address: "206.160.163.22", ID: 22, Type: DENY},
+ NetworkItem{Address: "206.160.163.23", ID: 23, Type: DENY},
+ NetworkItem{Address: "206.160.163.24", ID: 24, Type: DENY},
+ }
+
+ 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()
+
+ mockCreateResponse(t, lbID)
+
+ opts := CreateOpts{
+ CreateOpt{Address: "206.160.163.21", Type: DENY},
+ CreateOpt{Address: "206.160.165.11", Type: DENY},
+ }
+
+ err := Create(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestBulkDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ ids := []int{itemID1, itemID2}
+
+ mockBatchDeleteResponse(t, lbID, ids)
+
+ err := BulkDelete(client.ServiceClient(), lbID, ids).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID, itemID1)
+
+ err := Delete(client.ServiceClient(), lbID, itemID1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteAll(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteAllResponse(t, lbID)
+
+ err := DeleteAll(client.ServiceClient(), lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/acl/results.go b/rackspace/lb/v1/acl/results.go
new file mode 100644
index 0000000..9ea5ea2
--- /dev/null
+++ b/rackspace/lb/v1/acl/results.go
@@ -0,0 +1,72 @@
+package acl
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// AccessList represents the rules of network access to a particular load
+// balancer.
+type AccessList []NetworkItem
+
+// NetworkItem describes how an IP address or entire subnet may interact with a
+// load balancer.
+type NetworkItem struct {
+ // The IP address or subnet (CIDR) that defines the network item.
+ Address string
+
+ // The numeric unique ID for this item.
+ ID int
+
+ // Either ALLOW or DENY.
+ Type Type
+}
+
+// Type defines how an item may connect to the load balancer.
+type Type string
+
+// Convenience consts.
+const (
+ ALLOW Type = "ALLOW"
+ DENY Type = "DENY"
+)
+
+// AccessListPage is the page returned by a pager for traversing over a
+// collection of network items in an access list.
+type AccessListPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an AccessListPage struct is empty.
+func (p AccessListPage) IsEmpty() (bool, error) {
+ is, err := ExtractAccessList(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractAccessList accepts a Page struct, specifically an AccessListPage
+// struct, and extracts the elements into a slice of NetworkItem structs. In
+// other words, a generic collection is mapped into a relevant slice.
+func ExtractAccessList(page pagination.Page) (AccessList, error) {
+ var resp struct {
+ List AccessList `mapstructure:"accessList" json:"accessList"`
+ }
+
+ err := mapstructure.Decode(page.(AccessListPage).Body, &resp)
+
+ return resp.List, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/rackspace/lb/v1/acl/urls.go b/rackspace/lb/v1/acl/urls.go
new file mode 100644
index 0000000..e373fa1
--- /dev/null
+++ b/rackspace/lb/v1/acl/urls.go
@@ -0,0 +1,20 @@
+package acl
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ aclPath = "accesslist"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, lbID, networkID int) string {
+ return c.ServiceURL(path, strconv.Itoa(lbID), aclPath, strconv.Itoa(networkID))
+}
+
+func rootURL(c *gophercloud.ServiceClient, lbID int) string {
+ return c.ServiceURL(path, strconv.Itoa(lbID), aclPath)
+}
diff --git a/rackspace/lb/v1/lbs/doc.go b/rackspace/lb/v1/lbs/doc.go
new file mode 100644
index 0000000..05f0032
--- /dev/null
+++ b/rackspace/lb/v1/lbs/doc.go
@@ -0,0 +1,44 @@
+/*
+Package lbs provides information and interaction with the Load Balancer API
+resource for the Rackspace Cloud Load Balancer service.
+
+A load balancer is a logical device which belongs to a cloud account. It is
+used to distribute workloads between multiple back-end systems or services,
+based on the criteria defined as part of its configuration. This configuration
+is defined using the Create operation, and can be updated with Update.
+
+To conserve IPv4 address space, it is highly recommended that you share Virtual
+IPs between load balancers. If you have at least one load balancer, you may
+create subsequent ones that share a single virtual IPv4 and/or a single IPv6 by
+passing in a virtual IP ID to the Update operation (instead of a type). This
+feature is also highly desirable if you wish to load balance both an insecure
+and secure protocol using one IP or DNS name. In order to share a virtual IP,
+each Load Balancer must utilize a unique port.
+
+All load balancers have a Status attribute that shows the current configuration
+status of the device. This status is immutable by the caller and is updated
+automatically based on state changes within the service. When a load balancer
+is first created, it is placed into a BUILD state while the configuration is
+being generated and applied based on the request. Once the configuration is
+applied and finalized, it is in an ACTIVE status. In the event of a
+configuration change or update, the status of the load balancer changes to
+PENDING_UPDATE to signify configuration changes are in progress but have not yet
+been finalized. Load balancers in a SUSPENDED status are configured to reject
+traffic and do not forward requests to back-end nodes.
+
+An HTTP load balancer has the X-Forwarded-For (XFF) HTTP header set by default.
+This header contains the originating IP address of a client connecting to a web
+server through an HTTP proxy or load balancer, which many web applications are
+already designed to use when determining the source address for a request.
+
+It also includes the X-Forwarded-Proto (XFP) HTTP header, which has been added
+for identifying the originating protocol of an HTTP request as "http" or
+"https" depending on which protocol the client requested. This is useful when
+using SSL termination.
+
+Finally, it also includes the X-Forwarded-Port HTTP header, which has been
+added for being able to generate secure URLs containing the specified port.
+This header, along with the X-Forwarded-For header, provides the needed
+information to the underlying application servers.
+*/
+package lbs
diff --git a/rackspace/lb/v1/lbs/fixtures.go b/rackspace/lb/v1/lbs/fixtures.go
new file mode 100644
index 0000000..6325310
--- /dev/null
+++ b/rackspace/lb/v1/lbs/fixtures.go
@@ -0,0 +1,584 @@
+package lbs
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func mockListLBResponse(t *testing.T) {
+ th.Mux.HandleFunc("/loadbalancers", 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, `
+{
+ "loadBalancers":[
+ {
+ "name":"lb-site1",
+ "id":71,
+ "protocol":"HTTP",
+ "port":80,
+ "algorithm":"RANDOM",
+ "status":"ACTIVE",
+ "nodeCount":3,
+ "virtualIps":[
+ {
+ "id":403,
+ "address":"206.55.130.1",
+ "type":"PUBLIC",
+ "ipVersion":"IPV4"
+ }
+ ],
+ "created":{
+ "time":"2010-11-30T03:23:42Z"
+ },
+ "updated":{
+ "time":"2010-11-30T03:23:44Z"
+ }
+ },
+ {
+ "name":"lb-site2",
+ "id":72,
+ "created":{
+ "time":"2011-11-30T03:23:42Z"
+ },
+ "updated":{
+ "time":"2011-11-30T03:23:44Z"
+ }
+ },
+ {
+ "name":"lb-site3",
+ "id":73,
+ "created":{
+ "time":"2012-11-30T03:23:42Z"
+ },
+ "updated":{
+ "time":"2012-11-30T03:23:44Z"
+ }
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateLBResponse(t *testing.T) {
+ th.Mux.HandleFunc("/loadbalancers", 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, `
+{
+ "loadBalancer": {
+ "name": "a-new-loadbalancer",
+ "port": 80,
+ "protocol": "HTTP",
+ "virtualIps": [
+ {
+ "id": 2341
+ },
+ {
+ "id": 900001
+ }
+ ],
+ "nodes": [
+ {
+ "address": "10.1.1.1",
+ "port": 80,
+ "condition": "ENABLED"
+ }
+ ]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "loadBalancer": {
+ "name": "a-new-loadbalancer",
+ "id": 144,
+ "protocol": "HTTP",
+ "halfClosed": false,
+ "port": 83,
+ "algorithm": "RANDOM",
+ "status": "BUILD",
+ "timeout": 30,
+ "cluster": {
+ "name": "ztm-n01.staging1.lbaas.rackspace.net"
+ },
+ "nodes": [
+ {
+ "address": "10.1.1.1",
+ "id": 653,
+ "port": 80,
+ "status": "ONLINE",
+ "condition": "ENABLED",
+ "weight": 1
+ }
+ ],
+ "virtualIps": [
+ {
+ "address": "206.10.10.210",
+ "id": 39,
+ "type": "PUBLIC",
+ "ipVersion": "IPV4"
+ },
+ {
+ "address": "2001:4801:79f1:0002:711b:be4c:0000:0021",
+ "id": 900001,
+ "type": "PUBLIC",
+ "ipVersion": "IPV6"
+ }
+ ],
+ "created": {
+ "time": "2010-11-30T03:23:42Z"
+ },
+ "updated": {
+ "time": "2010-11-30T03:23:44Z"
+ },
+ "connectionLogging": {
+ "enabled": false
+ }
+ }
+}
+ `)
+ })
+}
+
+func mockBatchDeleteLBResponse(t *testing.T, ids []int) {
+ th.Mux.HandleFunc("/loadbalancers", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ r.ParseForm()
+
+ for k, v := range ids {
+ fids := r.Form["id"]
+ th.AssertEquals(t, strconv.Itoa(v), fids[k])
+ }
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteLBResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockGetLBResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id), 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, `
+{
+ "loadBalancer": {
+ "id": 2000,
+ "name": "sample-loadbalancer",
+ "protocol": "HTTP",
+ "port": 80,
+ "algorithm": "RANDOM",
+ "status": "ACTIVE",
+ "timeout": 30,
+ "connectionLogging": {
+ "enabled": true
+ },
+ "virtualIps": [
+ {
+ "id": 1000,
+ "address": "206.10.10.210",
+ "type": "PUBLIC",
+ "ipVersion": "IPV4"
+ }
+ ],
+ "nodes": [
+ {
+ "id": 1041,
+ "address": "10.1.1.1",
+ "port": 80,
+ "condition": "ENABLED",
+ "status": "ONLINE"
+ },
+ {
+ "id": 1411,
+ "address": "10.1.1.2",
+ "port": 80,
+ "condition": "ENABLED",
+ "status": "ONLINE"
+ }
+ ],
+ "sessionPersistence": {
+ "persistenceType": "HTTP_COOKIE"
+ },
+ "connectionThrottle": {
+ "maxConnections": 100
+ },
+ "cluster": {
+ "name": "c1.dfw1"
+ },
+ "created": {
+ "time": "2010-11-30T03:23:42Z"
+ },
+ "updated": {
+ "time": "2010-11-30T03:23:44Z"
+ },
+ "sourceAddresses": {
+ "ipv6Public": "2001:4801:79f1:1::1/64",
+ "ipv4Servicenet": "10.0.0.0",
+ "ipv4Public": "10.12.99.28"
+ }
+ }
+}
+ `)
+ })
+}
+
+func mockUpdateLBResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id), 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, `
+{
+ "loadBalancer": {
+ "name": "a-new-loadbalancer",
+ "protocol": "TCP",
+ "halfClosed": true,
+ "algorithm": "RANDOM",
+ "port": 8080,
+ "timeout": 100,
+ "httpsRedirect": false
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockListProtocolsResponse(t *testing.T) {
+ th.Mux.HandleFunc("/loadbalancers/protocols", 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, `
+{
+ "protocols": [
+ {
+ "name": "DNS_TCP",
+ "port": 53
+ },
+ {
+ "name": "DNS_UDP",
+ "port": 53
+ },
+ {
+ "name": "FTP",
+ "port": 21
+ },
+ {
+ "name": "HTTP",
+ "port": 80
+ },
+ {
+ "name": "HTTPS",
+ "port": 443
+ },
+ {
+ "name": "IMAPS",
+ "port": 993
+ },
+ {
+ "name": "IMAPv4",
+ "port": 143
+ },
+ {
+ "name": "LDAP",
+ "port": 389
+ },
+ {
+ "name": "LDAPS",
+ "port": 636
+ },
+ {
+ "name": "MYSQL",
+ "port": 3306
+ },
+ {
+ "name": "POP3",
+ "port": 110
+ },
+ {
+ "name": "POP3S",
+ "port": 995
+ },
+ {
+ "name": "SMTP",
+ "port": 25
+ },
+ {
+ "name": "TCP",
+ "port": 0
+ },
+ {
+ "name": "TCP_CLIENT_FIRST",
+ "port": 0
+ },
+ {
+ "name": "UDP",
+ "port": 0
+ },
+ {
+ "name": "UDP_STREAM",
+ "port": 0
+ },
+ {
+ "name": "SFTP",
+ "port": 22
+ },
+ {
+ "name": "TCP_STREAM",
+ "port": 0
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockListAlgorithmsResponse(t *testing.T) {
+ th.Mux.HandleFunc("/loadbalancers/algorithms", 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, `
+{
+ "algorithms": [
+ {
+ "name": "LEAST_CONNECTIONS"
+ },
+ {
+ "name": "RANDOM"
+ },
+ {
+ "name": "ROUND_ROBIN"
+ },
+ {
+ "name": "WEIGHTED_LEAST_CONNECTIONS"
+ },
+ {
+ "name": "WEIGHTED_ROUND_ROBIN"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockGetLoggingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/connectionlogging", 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, `
+{
+ "connectionLogging": {
+ "enabled": true
+ }
+}
+ `)
+ })
+}
+
+func mockEnableLoggingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/connectionlogging", 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, `
+{
+ "connectionLogging":{
+ "enabled":true
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDisableLoggingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/connectionlogging", 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, `
+{
+ "connectionLogging":{
+ "enabled":false
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockGetErrorPageResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/errorpage", 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, `
+{
+ "errorpage": {
+ "content": "<html>DEFAULT ERROR PAGE</html>"
+ }
+}
+ `)
+ })
+}
+
+func mockSetErrorPageResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/errorpage", 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, `
+{
+ "errorpage": {
+ "content": "<html>New error page</html>"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "errorpage": {
+ "content": "<html>New error page</html>"
+ }
+}
+ `)
+ })
+}
+
+func mockDeleteErrorPageResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/errorpage", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusOK)
+ })
+}
+
+func mockGetStatsResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/stats", 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, `
+{
+ "connectTimeOut": 10,
+ "connectError": 20,
+ "connectFailure": 30,
+ "dataTimedOut": 40,
+ "keepAliveTimedOut": 50,
+ "maxConn": 60,
+ "currentConn": 40,
+ "connectTimeOutSsl": 10,
+ "connectErrorSsl": 20,
+ "connectFailureSsl": 30,
+ "dataTimedOutSsl": 40,
+ "keepAliveTimedOutSsl": 50,
+ "maxConnSsl": 60,
+ "currentConnSsl": 40
+}
+ `)
+ })
+}
+
+func mockGetCachingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/contentcaching", 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, `
+{
+ "contentCaching": {
+ "enabled": true
+ }
+}
+ `)
+ })
+}
+
+func mockEnableCachingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/contentcaching", 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, `
+{
+ "contentCaching":{
+ "enabled":true
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDisableCachingResponse(t *testing.T, id int) {
+ th.Mux.HandleFunc("/loadbalancers/"+strconv.Itoa(id)+"/contentcaching", 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, `
+{
+ "contentCaching":{
+ "enabled":false
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/lbs/requests.go b/rackspace/lb/v1/lbs/requests.go
new file mode 100644
index 0000000..46f5f02
--- /dev/null
+++ b/rackspace/lb/v1/lbs/requests.go
@@ -0,0 +1,497 @@
+package lbs
+
+import (
+ "errors"
+
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/acl"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/monitors"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/nodes"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/sessions"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/throttle"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/vips"
+)
+
+var (
+ errNameRequired = errors.New("Name is a required attribute")
+ errTimeoutExceeded = errors.New("Timeout must be less than 120")
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToLBListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API.
+type ListOpts struct {
+ ChangesSince string `q:"changes-since"`
+ Status Status `q:"status"`
+ NodeAddr string `q:"nodeaddress"`
+ Marker string `q:"marker"`
+ Limit int `q:"limit"`
+}
+
+// ToLBListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToLBListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// List is the operation responsible for returning a paginated collection of
+// load balancers. You may pass in a ListOpts struct to filter results.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := rootURL(client)
+ if opts != nil {
+ query, err := opts.ToLBListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return LBPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToLBCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Required - name of the load balancer to create. The name must be 128
+ // characters or fewer in length, and all UTF-8 characters are valid.
+ Name string
+
+ // Optional - nodes to be added.
+ Nodes []nodes.Node
+
+ // Required - protocol of the service that is being load balanced.
+ // See http://docs.rackspace.com/loadbalancers/api/v1.0/clb-devguide/content/protocols.html
+ // for a full list of supported protocols.
+ Protocol string
+
+ // Optional - enables or disables Half-Closed support for the load balancer.
+ // Half-Closed support provides the ability for one end of the connection to
+ // terminate its output, while still receiving data from the other end. Only
+ // available for TCP/TCP_CLIENT_FIRST protocols.
+ HalfClosed gophercloud.EnabledState
+
+ // Optional - the type of virtual IPs you want associated with the load
+ // balancer.
+ VIPs []vips.VIP
+
+ // Optional - the access list management feature allows fine-grained network
+ // access controls to be applied to the load balancer virtual IP address.
+ AccessList *acl.AccessList
+
+ // Optional - algorithm that defines how traffic should be directed between
+ // back-end nodes.
+ Algorithm string
+
+ // Optional - current connection logging configuration.
+ ConnectionLogging *ConnectionLogging
+
+ // Optional - specifies a limit on the number of connections per IP address
+ // to help mitigate malicious or abusive traffic to your applications.
+ ConnThrottle *throttle.ConnectionThrottle
+
+ // Optional - the type of health monitor check to perform to ensure that the
+ // service is performing properly.
+ HealthMonitor *monitors.Monitor
+
+ // Optional - arbitrary information that can be associated with each LB.
+ Metadata map[string]interface{}
+
+ // Optional - port number for the service you are load balancing.
+ Port int
+
+ // Optional - the timeout value for the load balancer and communications with
+ // its nodes. Defaults to 30 seconds with a maximum of 120 seconds.
+ Timeout int
+
+ // Optional - specifies whether multiple requests from clients are directed
+ // to the same node.
+ SessionPersistence *sessions.SessionPersistence
+
+ // Optional - enables or disables HTTP to HTTPS redirection for the load
+ // balancer. When enabled, any HTTP request returns status code 301 (Moved
+ // Permanently), and the requester is redirected to the requested URL via the
+ // HTTPS protocol on port 443. For example, http://example.com/page.html
+ // would be redirected to https://example.com/page.html. Only available for
+ // HTTPS protocol (port=443), or HTTP protocol with a properly configured SSL
+ // termination (secureTrafficOnly=true, securePort=443).
+ HTTPSRedirect gophercloud.EnabledState
+}
+
+// ToLBCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToLBCreateMap() (map[string]interface{}, error) {
+ lb := make(map[string]interface{})
+
+ if opts.Name == "" {
+ return lb, errNameRequired
+ }
+ if opts.Timeout > 120 {
+ return lb, errTimeoutExceeded
+ }
+
+ lb["name"] = opts.Name
+
+ if len(opts.Nodes) > 0 {
+ nodes := []map[string]interface{}{}
+ for _, n := range opts.Nodes {
+ nodes = append(nodes, map[string]interface{}{
+ "address": n.Address,
+ "port": n.Port,
+ "condition": n.Condition,
+ })
+ }
+ lb["nodes"] = nodes
+ }
+
+ if opts.Protocol != "" {
+ lb["protocol"] = opts.Protocol
+ }
+ if opts.HalfClosed != nil {
+ lb["halfClosed"] = opts.HalfClosed
+ }
+ if len(opts.VIPs) > 0 {
+ lb["virtualIps"] = opts.VIPs
+ }
+ if opts.AccessList != nil {
+ lb["accessList"] = &opts.AccessList
+ }
+ if opts.Algorithm != "" {
+ lb["algorithm"] = opts.Algorithm
+ }
+ if opts.ConnectionLogging != nil {
+ lb["connectionLogging"] = &opts.ConnectionLogging
+ }
+ if opts.ConnThrottle != nil {
+ lb["connectionThrottle"] = &opts.ConnThrottle
+ }
+ if opts.HealthMonitor != nil {
+ lb["healthMonitor"] = &opts.HealthMonitor
+ }
+ if len(opts.Metadata) != 0 {
+ lb["metadata"] = opts.Metadata
+ }
+ if opts.Port > 0 {
+ lb["port"] = opts.Port
+ }
+ if opts.Timeout > 0 {
+ lb["timeout"] = opts.Timeout
+ }
+ if opts.SessionPersistence != nil {
+ lb["sessionPersistence"] = &opts.SessionPersistence
+ }
+ if opts.HTTPSRedirect != nil {
+ lb["httpsRedirect"] = &opts.HTTPSRedirect
+ }
+
+ return map[string]interface{}{"loadBalancer": lb}, nil
+}
+
+// Create is the operation responsible for asynchronously provisioning a new
+// load balancer based on the configuration defined in CreateOpts. Once the
+// request is validated and progress has started on the provisioning process, a
+// response struct is returned. When extracted (with Extract()), you have
+// to the load balancer's unique ID and status.
+//
+// Once an ID is attained, you can check on the progress of the operation by
+// calling Get and passing in the ID. If the corresponding request cannot be
+// fulfilled due to insufficient or invalid data, an HTTP 400 (Bad Request)
+// error response is returned with information regarding the nature of the
+// failure in the body of the response. Failures in the validation process are
+// non-recoverable and require the caller to correct the cause of the failure.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToLBCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(rootURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get is the operation responsible for providing detailed information
+// regarding a specific load balancer which is configured and associated with
+// your account. This operation is not capable of returning details for a load
+// balancer which has been deleted.
+func Get(c *gophercloud.ServiceClient, id int) GetResult {
+ var res GetResult
+
+ _, res.Err = c.Get(resourceURL(c, id), &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// BulkDelete removes all the load balancers referenced in the slice of IDs.
+// Any and all configuration data associated with these load balancers is
+// immediately purged and is not recoverable.
+//
+// If one of the items in the list cannot be removed due to its current status,
+// a 400 Bad Request error is returned along with the IDs of the ones the
+// system identified as potential failures for this request.
+func BulkDelete(c *gophercloud.ServiceClient, ids []int) DeleteResult {
+ var res DeleteResult
+
+ if len(ids) > 10 || len(ids) == 0 {
+ res.Err = errors.New("You must provide a minimum of 1 and a maximum of 10 LB IDs")
+ return res
+ }
+
+ url := rootURL(c)
+ url += gophercloud.IDSliceToQueryString("id", ids)
+
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
+
+// Delete removes a single load balancer.
+func Delete(c *gophercloud.ServiceClient, id int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, id), nil)
+ return res
+}
+
+// UpdateOptsBuilder represents a type that can be converted into a JSON-like
+// map structure.
+type UpdateOptsBuilder interface {
+ ToLBUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represents the options for updating an existing load balancer.
+type UpdateOpts struct {
+ // Optional - new name of the load balancer.
+ Name string
+
+ // Optional - the new protocol you want your load balancer to have.
+ // See http://docs.rackspace.com/loadbalancers/api/v1.0/clb-devguide/content/protocols.html
+ // for a full list of supported protocols.
+ Protocol string
+
+ // Optional - see the HalfClosed field in CreateOpts for more information.
+ HalfClosed gophercloud.EnabledState
+
+ // Optional - see the Algorithm field in CreateOpts for more information.
+ Algorithm string
+
+ // Optional - see the Port field in CreateOpts for more information.
+ Port int
+
+ // Optional - see the Timeout field in CreateOpts for more information.
+ Timeout int
+
+ // Optional - see the HTTPSRedirect field in CreateOpts for more information.
+ HTTPSRedirect gophercloud.EnabledState
+}
+
+// ToLBUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToLBUpdateMap() (map[string]interface{}, error) {
+ lb := make(map[string]interface{})
+
+ if opts.Name != "" {
+ lb["name"] = opts.Name
+ }
+ if opts.Protocol != "" {
+ lb["protocol"] = opts.Protocol
+ }
+ if opts.HalfClosed != nil {
+ lb["halfClosed"] = opts.HalfClosed
+ }
+ if opts.Algorithm != "" {
+ lb["algorithm"] = opts.Algorithm
+ }
+ if opts.Port > 0 {
+ lb["port"] = opts.Port
+ }
+ if opts.Timeout > 0 {
+ lb["timeout"] = opts.Timeout
+ }
+ if opts.HTTPSRedirect != nil {
+ lb["httpsRedirect"] = &opts.HTTPSRedirect
+ }
+
+ return map[string]interface{}{"loadBalancer": lb}, nil
+}
+
+// Update is the operation responsible for asynchronously updating the
+// attributes of a specific load balancer. Upon successful validation of the
+// request, the service returns a 202 Accepted response, and the load balancer
+// enters a PENDING_UPDATE state. A user can poll the load balancer with Get to
+// wait for the changes to be applied. When this happens, the load balancer will
+// return to an ACTIVE state.
+func Update(c *gophercloud.ServiceClient, id int, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToLBUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(resourceURL(c, id), reqBody, nil, nil)
+ return res
+}
+
+// ListProtocols is the operation responsible for returning a paginated
+// collection of load balancer protocols.
+func ListProtocols(client *gophercloud.ServiceClient) pagination.Pager {
+ url := protocolsURL(client)
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ProtocolPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// ListAlgorithms is the operation responsible for returning a paginated
+// collection of load balancer algorithms.
+func ListAlgorithms(client *gophercloud.ServiceClient) pagination.Pager {
+ url := algorithmsURL(client)
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return AlgorithmPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// IsLoggingEnabled returns true if the load balancer has connection logging
+// enabled and false if not.
+func IsLoggingEnabled(client *gophercloud.ServiceClient, id int) (bool, error) {
+ var body interface{}
+
+ _, err := client.Get(loggingURL(client, id), &body, nil)
+ if err != nil {
+ return false, err
+ }
+
+ var resp struct {
+ CL struct {
+ Enabled bool `mapstructure:"enabled"`
+ } `mapstructure:"connectionLogging"`
+ }
+
+ err = mapstructure.Decode(body, &resp)
+ return resp.CL.Enabled, err
+}
+
+func toConnLoggingMap(state bool) map[string]map[string]bool {
+ return map[string]map[string]bool{
+ "connectionLogging": map[string]bool{"enabled": state},
+ }
+}
+
+// EnableLogging will enable connection logging for a specified load balancer.
+func EnableLogging(client *gophercloud.ServiceClient, id int) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ _, res.Err = client.Put(loggingURL(client, id), toConnLoggingMap(true), nil, nil)
+ return res
+}
+
+// DisableLogging will disable connection logging for a specified load balancer.
+func DisableLogging(client *gophercloud.ServiceClient, id int) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ _, res.Err = client.Put(loggingURL(client, id), toConnLoggingMap(false), nil, nil)
+ return res
+}
+
+// GetErrorPage will retrieve the current error page for the load balancer.
+func GetErrorPage(client *gophercloud.ServiceClient, id int) ErrorPageResult {
+ var res ErrorPageResult
+ _, res.Err = client.Get(errorPageURL(client, id), &res.Body, nil)
+ return res
+}
+
+// SetErrorPage will set the HTML of the load balancer's error page to a
+// specific value.
+func SetErrorPage(client *gophercloud.ServiceClient, id int, html string) ErrorPageResult {
+ var res ErrorPageResult
+
+ type stringMap map[string]string
+ reqBody := map[string]stringMap{"errorpage": stringMap{"content": html}}
+
+ _, res.Err = client.Put(errorPageURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// DeleteErrorPage will delete the current error page for the load balancer.
+func DeleteErrorPage(client *gophercloud.ServiceClient, id int) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ _, res.Err = client.Delete(errorPageURL(client, id), &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// GetStats will retrieve detailed stats related to the load balancer's usage.
+func GetStats(client *gophercloud.ServiceClient, id int) StatsResult {
+ var res StatsResult
+ _, res.Err = client.Get(statsURL(client, id), &res.Body, nil)
+ return res
+}
+
+// IsContentCached will check to see whether the specified load balancer caches
+// content. When content caching is enabled, recently-accessed files are stored
+// on the load balancer for easy retrieval by web clients. Content caching
+// improves the performance of high traffic web sites by temporarily storing
+// data that was recently accessed. While it's cached, requests for that data
+// are served by the load balancer, which in turn reduces load off the back-end
+// nodes. The result is improved response times for those requests and less
+// load on the web server.
+func IsContentCached(client *gophercloud.ServiceClient, id int) (bool, error) {
+ var body interface{}
+
+ _, err := client.Get(cacheURL(client, id), &body, nil)
+ if err != nil {
+ return false, err
+ }
+
+ var resp struct {
+ CC struct {
+ Enabled bool `mapstructure:"enabled"`
+ } `mapstructure:"contentCaching"`
+ }
+
+ err = mapstructure.Decode(body, &resp)
+ return resp.CC.Enabled, err
+}
+
+func toCachingMap(state bool) map[string]map[string]bool {
+ return map[string]map[string]bool{
+ "contentCaching": map[string]bool{"enabled": state},
+ }
+}
+
+// EnableCaching will enable content-caching for the specified load balancer.
+func EnableCaching(client *gophercloud.ServiceClient, id int) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ _, res.Err = client.Put(cacheURL(client, id), toCachingMap(true), nil, nil)
+ return res
+}
+
+// DisableCaching will disable content-caching for the specified load balancer.
+func DisableCaching(client *gophercloud.ServiceClient, id int) gophercloud.ErrResult {
+ var res gophercloud.ErrResult
+ _, res.Err = client.Put(cacheURL(client, id), toCachingMap(false), nil, nil)
+ return res
+}
diff --git a/rackspace/lb/v1/lbs/requests_test.go b/rackspace/lb/v1/lbs/requests_test.go
new file mode 100644
index 0000000..a8ec19e
--- /dev/null
+++ b/rackspace/lb/v1/lbs/requests_test.go
@@ -0,0 +1,438 @@
+package lbs
+
+import (
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/nodes"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/sessions"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/throttle"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/vips"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ id1 = 12345
+ id2 = 67890
+ ts1 = "2010-11-30T03:23:42Z"
+ ts2 = "2010-11-30T03:23:44Z"
+)
+
+func toTime(t *testing.T, str string) time.Time {
+ ts, err := time.Parse(time.RFC3339, str)
+ if err != nil {
+ t.Fatalf("Could not parse time: %s", err.Error())
+ }
+ return ts
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListLBResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractLBs(page)
+ th.AssertNoErr(t, err)
+
+ expected := []LoadBalancer{
+ LoadBalancer{
+ Name: "lb-site1",
+ ID: 71,
+ Protocol: "HTTP",
+ Port: 80,
+ Algorithm: "RANDOM",
+ Status: ACTIVE,
+ NodeCount: 3,
+ VIPs: []vips.VIP{
+ vips.VIP{
+ ID: 403,
+ Address: "206.55.130.1",
+ Type: "PUBLIC",
+ Version: "IPV4",
+ },
+ },
+ Created: Datetime{Time: toTime(t, ts1)},
+ Updated: Datetime{Time: toTime(t, ts2)},
+ },
+ LoadBalancer{
+ ID: 72,
+ Name: "lb-site2",
+ Created: Datetime{Time: toTime(t, "2011-11-30T03:23:42Z")},
+ Updated: Datetime{Time: toTime(t, "2011-11-30T03:23:44Z")},
+ },
+ LoadBalancer{
+ ID: 73,
+ Name: "lb-site3",
+ Created: Datetime{Time: toTime(t, "2012-11-30T03:23:42Z")},
+ Updated: Datetime{Time: toTime(t, "2012-11-30T03:23:44Z")},
+ },
+ }
+
+ 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()
+
+ mockCreateLBResponse(t)
+
+ opts := CreateOpts{
+ Name: "a-new-loadbalancer",
+ Port: 80,
+ Protocol: "HTTP",
+ VIPs: []vips.VIP{
+ vips.VIP{ID: 2341},
+ vips.VIP{ID: 900001},
+ },
+ Nodes: []nodes.Node{
+ nodes.Node{Address: "10.1.1.1", Port: 80, Condition: "ENABLED"},
+ },
+ }
+
+ lb, err := Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &LoadBalancer{
+ Name: "a-new-loadbalancer",
+ ID: 144,
+ Protocol: "HTTP",
+ HalfClosed: false,
+ Port: 83,
+ Algorithm: "RANDOM",
+ Status: BUILD,
+ Timeout: 30,
+ Cluster: Cluster{Name: "ztm-n01.staging1.lbaas.rackspace.net"},
+ Nodes: []nodes.Node{
+ nodes.Node{
+ Address: "10.1.1.1",
+ ID: 653,
+ Port: 80,
+ Status: "ONLINE",
+ Condition: "ENABLED",
+ Weight: 1,
+ },
+ },
+ VIPs: []vips.VIP{
+ vips.VIP{
+ ID: 39,
+ Address: "206.10.10.210",
+ Type: vips.PUBLIC,
+ Version: vips.IPV4,
+ },
+ vips.VIP{
+ ID: 900001,
+ Address: "2001:4801:79f1:0002:711b:be4c:0000:0021",
+ Type: vips.PUBLIC,
+ Version: vips.IPV6,
+ },
+ },
+ Created: Datetime{Time: toTime(t, ts1)},
+ Updated: Datetime{Time: toTime(t, ts2)},
+ ConnectionLogging: ConnectionLogging{Enabled: false},
+ }
+
+ th.AssertDeepEquals(t, expected, lb)
+}
+
+func TestBulkDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ ids := []int{id1, id2}
+
+ mockBatchDeleteLBResponse(t, ids)
+
+ err := BulkDelete(client.ServiceClient(), ids).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteLBResponse(t, id1)
+
+ err := Delete(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetLBResponse(t, id1)
+
+ lb, err := Get(client.ServiceClient(), id1).Extract()
+
+ expected := &LoadBalancer{
+ Name: "sample-loadbalancer",
+ ID: 2000,
+ Protocol: "HTTP",
+ Port: 80,
+ Algorithm: "RANDOM",
+ Status: ACTIVE,
+ Timeout: 30,
+ ConnectionLogging: ConnectionLogging{Enabled: true},
+ VIPs: []vips.VIP{
+ vips.VIP{
+ ID: 1000,
+ Address: "206.10.10.210",
+ Type: "PUBLIC",
+ Version: "IPV4",
+ },
+ },
+ Nodes: []nodes.Node{
+ nodes.Node{
+ Address: "10.1.1.1",
+ ID: 1041,
+ Port: 80,
+ Status: "ONLINE",
+ Condition: "ENABLED",
+ },
+ nodes.Node{
+ Address: "10.1.1.2",
+ ID: 1411,
+ Port: 80,
+ Status: "ONLINE",
+ Condition: "ENABLED",
+ },
+ },
+ SessionPersistence: sessions.SessionPersistence{Type: "HTTP_COOKIE"},
+ ConnectionThrottle: throttle.ConnectionThrottle{MaxConnections: 100},
+ Cluster: Cluster{Name: "c1.dfw1"},
+ Created: Datetime{Time: toTime(t, ts1)},
+ Updated: Datetime{Time: toTime(t, ts2)},
+ SourceAddrs: SourceAddrs{
+ IPv4Public: "10.12.99.28",
+ IPv4Private: "10.0.0.0",
+ IPv6Public: "2001:4801:79f1:1::1/64",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, lb)
+ th.AssertNoErr(t, err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateLBResponse(t, id1)
+
+ opts := UpdateOpts{
+ Name: "a-new-loadbalancer",
+ Protocol: "TCP",
+ HalfClosed: gophercloud.Enabled,
+ Algorithm: "RANDOM",
+ Port: 8080,
+ Timeout: 100,
+ HTTPSRedirect: gophercloud.Disabled,
+ }
+
+ err := Update(client.ServiceClient(), id1, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListProtocols(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListProtocolsResponse(t)
+
+ count := 0
+
+ err := ListProtocols(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractProtocols(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Protocol{
+ Protocol{Name: "DNS_TCP", Port: 53},
+ Protocol{Name: "DNS_UDP", Port: 53},
+ Protocol{Name: "FTP", Port: 21},
+ Protocol{Name: "HTTP", Port: 80},
+ Protocol{Name: "HTTPS", Port: 443},
+ Protocol{Name: "IMAPS", Port: 993},
+ Protocol{Name: "IMAPv4", Port: 143},
+ }
+
+ th.CheckDeepEquals(t, expected[0:7], actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestListAlgorithms(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListAlgorithmsResponse(t)
+
+ count := 0
+
+ err := ListAlgorithms(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractAlgorithms(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Algorithm{
+ Algorithm{Name: "LEAST_CONNECTIONS"},
+ Algorithm{Name: "RANDOM"},
+ Algorithm{Name: "ROUND_ROBIN"},
+ Algorithm{Name: "WEIGHTED_LEAST_CONNECTIONS"},
+ Algorithm{Name: "WEIGHTED_ROUND_ROBIN"},
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestIsLoggingEnabled(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetLoggingResponse(t, id1)
+
+ res, err := IsLoggingEnabled(client.ServiceClient(), id1)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, res)
+}
+
+func TestEnablingLogging(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockEnableLoggingResponse(t, id1)
+
+ err := EnableLogging(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisablingLogging(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDisableLoggingResponse(t, id1)
+
+ err := DisableLogging(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGetErrorPage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetErrorPageResponse(t, id1)
+
+ content, err := GetErrorPage(client.ServiceClient(), id1).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &ErrorPage{Content: "<html>DEFAULT ERROR PAGE</html>"}
+ th.AssertDeepEquals(t, expected, content)
+}
+
+func TestSetErrorPage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockSetErrorPageResponse(t, id1)
+
+ html := "<html>New error page</html>"
+ content, err := SetErrorPage(client.ServiceClient(), id1, html).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &ErrorPage{Content: html}
+ th.AssertDeepEquals(t, expected, content)
+}
+
+func TestDeleteErrorPage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteErrorPageResponse(t, id1)
+
+ err := DeleteErrorPage(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGetStats(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetStatsResponse(t, id1)
+
+ content, err := GetStats(client.ServiceClient(), id1).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Stats{
+ ConnectTimeout: 10,
+ ConnectError: 20,
+ ConnectFailure: 30,
+ DataTimedOut: 40,
+ KeepAliveTimedOut: 50,
+ MaxConnections: 60,
+ CurrentConnections: 40,
+ SSLConnectTimeout: 10,
+ SSLConnectError: 20,
+ SSLConnectFailure: 30,
+ SSLDataTimedOut: 40,
+ SSLKeepAliveTimedOut: 50,
+ SSLMaxConnections: 60,
+ SSLCurrentConnections: 40,
+ }
+ th.AssertDeepEquals(t, expected, content)
+}
+
+func TestIsCached(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetCachingResponse(t, id1)
+
+ res, err := IsContentCached(client.ServiceClient(), id1)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, res)
+}
+
+func TestEnablingCaching(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockEnableCachingResponse(t, id1)
+
+ err := EnableCaching(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisablingCaching(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDisableCachingResponse(t, id1)
+
+ err := DisableCaching(client.ServiceClient(), id1).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/lbs/results.go b/rackspace/lb/v1/lbs/results.go
new file mode 100644
index 0000000..98f3962
--- /dev/null
+++ b/rackspace/lb/v1/lbs/results.go
@@ -0,0 +1,420 @@
+package lbs
+
+import (
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/acl"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/nodes"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/sessions"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/throttle"
+ "github.com/rackspace/gophercloud/rackspace/lb/v1/vips"
+)
+
+// Protocol represents the network protocol which the load balancer accepts.
+type Protocol struct {
+ // The name of the protocol, e.g. HTTP, LDAP, FTP, etc.
+ Name string
+
+ // The port number for the protocol.
+ Port int
+}
+
+// Algorithm defines how traffic should be directed between back-end nodes.
+type Algorithm struct {
+ // The name of the algorithm, e.g RANDOM, ROUND_ROBIN, etc.
+ Name string
+}
+
+// Status represents the potential state of a load balancer resource.
+type Status string
+
+const (
+ // ACTIVE indicates that the LB is configured properly and ready to serve
+ // traffic to incoming requests via the configured virtual IPs.
+ ACTIVE Status = "ACTIVE"
+
+ // BUILD indicates that the LB is being provisioned for the first time and
+ // configuration is being applied to bring the service online. The service
+ // cannot yet serve incoming requests.
+ BUILD Status = "BUILD"
+
+ // PENDINGUPDATE indicates that the LB is online but configuration changes
+ // are being applied to update the service based on a previous request.
+ PENDINGUPDATE Status = "PENDING_UPDATE"
+
+ // PENDINGDELETE indicates that the LB is online but configuration changes
+ // are being applied to begin deletion of the service based on a previous
+ // request.
+ PENDINGDELETE Status = "PENDING_DELETE"
+
+ // SUSPENDED indicates that the LB has been taken offline and disabled.
+ SUSPENDED Status = "SUSPENDED"
+
+ // ERROR indicates that the system encountered an error when attempting to
+ // configure the load balancer.
+ ERROR Status = "ERROR"
+
+ // DELETED indicates that the LB has been deleted.
+ DELETED Status = "DELETED"
+)
+
+// Datetime represents the structure of a Created or Updated field.
+type Datetime struct {
+ Time time.Time `mapstructure:"-"`
+}
+
+// LoadBalancer represents a load balancer API resource.
+type LoadBalancer struct {
+ // Human-readable name for the load balancer.
+ Name string
+
+ // The unique ID for the load balancer.
+ ID int
+
+ // Represents the service protocol being load balanced. See Protocol type for
+ // a list of accepted values.
+ // See http://docs.rackspace.com/loadbalancers/api/v1.0/clb-devguide/content/protocols.html
+ // for a full list of supported protocols.
+ Protocol string
+
+ // Defines how traffic should be directed between back-end nodes. The default
+ // algorithm is RANDOM. See Algorithm type for a list of accepted values.
+ Algorithm string
+
+ // The current status of the load balancer.
+ Status Status
+
+ // The number of load balancer nodes.
+ NodeCount int `mapstructure:"nodeCount"`
+
+ // Slice of virtual IPs associated with this load balancer.
+ VIPs []vips.VIP `mapstructure:"virtualIps"`
+
+ // Datetime when the LB was created.
+ Created Datetime
+
+ // Datetime when the LB was created.
+ Updated Datetime
+
+ // Port number for the service you are load balancing.
+ Port int
+
+ // HalfClosed provides the ability for one end of the connection to
+ // terminate its output while still receiving data from the other end. This
+ // is only available on TCP/TCP_CLIENT_FIRST protocols.
+ HalfClosed bool
+
+ // Timeout represents the timeout value between a load balancer and its
+ // nodes. Defaults to 30 seconds with a maximum of 120 seconds.
+ Timeout int
+
+ // The cluster name.
+ Cluster Cluster
+
+ // Nodes shows all the back-end nodes which are associated with the load
+ // balancer. These are the devices which are delivered traffic.
+ Nodes []nodes.Node
+
+ // Current connection logging configuration.
+ ConnectionLogging ConnectionLogging
+
+ // SessionPersistence specifies whether multiple requests from clients are
+ // directed to the same node.
+ SessionPersistence sessions.SessionPersistence
+
+ // ConnectionThrottle specifies a limit on the number of connections per IP
+ // address to help mitigate malicious or abusive traffic to your applications.
+ ConnectionThrottle throttle.ConnectionThrottle
+
+ // The source public and private IP addresses.
+ SourceAddrs SourceAddrs `mapstructure:"sourceAddresses"`
+
+ // Represents the access rules for this particular load balancer. IP addresses
+ // or subnet ranges, depending on their type (ALLOW or DENY), can be permitted
+ // or blocked.
+ AccessList acl.AccessList
+}
+
+// SourceAddrs represents the source public and private IP addresses.
+type SourceAddrs struct {
+ IPv4Public string `json:"ipv4Public" mapstructure:"ipv4Public"`
+ IPv4Private string `json:"ipv4Servicenet" mapstructure:"ipv4Servicenet"`
+ IPv6Public string `json:"ipv6Public" mapstructure:"ipv6Public"`
+ IPv6Private string `json:"ipv6Servicenet" mapstructure:"ipv6Servicenet"`
+}
+
+// ConnectionLogging - temp
+type ConnectionLogging struct {
+ Enabled bool
+}
+
+// Cluster - temp
+type Cluster struct {
+ Name string
+}
+
+// LBPage is the page returned by a pager when traversing over a collection of
+// LBs.
+type LBPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (p LBPage) IsEmpty() (bool, error) {
+ is, err := ExtractLBs(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractLBs accepts a Page struct, specifically a LBPage struct, and extracts
+// the elements into a slice of LoadBalancer structs. In other words, a generic
+// collection is mapped into a relevant slice.
+func ExtractLBs(page pagination.Page) ([]LoadBalancer, error) {
+ var resp struct {
+ LBs []LoadBalancer `mapstructure:"loadBalancers" json:"loadBalancers"`
+ }
+
+ coll := page.(LBPage).Body
+ err := mapstructure.Decode(coll, &resp)
+
+ s := reflect.ValueOf(coll.(map[string]interface{})["loadBalancers"])
+
+ for i := 0; i < s.Len(); i++ {
+ val := (s.Index(i).Interface()).(map[string]interface{})
+
+ ts, err := extractTS(val, "created")
+ if err != nil {
+ return resp.LBs, err
+ }
+ resp.LBs[i].Created.Time = ts
+
+ ts, err = extractTS(val, "updated")
+ if err != nil {
+ return resp.LBs, err
+ }
+ resp.LBs[i].Updated.Time = ts
+ }
+
+ return resp.LBs, err
+}
+
+func extractTS(body map[string]interface{}, key string) (time.Time, error) {
+ val := body[key].(map[string]interface{})
+ return time.Parse(time.RFC3339, val["time"].(string))
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as a LB, if possible.
+func (r commonResult) Extract() (*LoadBalancer, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ LB LoadBalancer `mapstructure:"loadBalancer"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ json := r.Body.(map[string]interface{})
+ lb := json["loadBalancer"].(map[string]interface{})
+
+ ts, err := extractTS(lb, "created")
+ if err != nil {
+ return nil, err
+ }
+ response.LB.Created.Time = ts
+
+ ts, err = extractTS(lb, "updated")
+ if err != nil {
+ return nil, err
+ }
+ response.LB.Updated.Time = ts
+
+ return &response.LB, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// ProtocolPage is the page returned by a pager when traversing over a
+// collection of LB protocols.
+type ProtocolPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether a ProtocolPage struct is empty.
+func (p ProtocolPage) IsEmpty() (bool, error) {
+ is, err := ExtractProtocols(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractProtocols accepts a Page struct, specifically a ProtocolPage struct,
+// and extracts the elements into a slice of Protocol structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractProtocols(page pagination.Page) ([]Protocol, error) {
+ var resp struct {
+ Protocols []Protocol `mapstructure:"protocols" json:"protocols"`
+ }
+ err := mapstructure.Decode(page.(ProtocolPage).Body, &resp)
+ return resp.Protocols, err
+}
+
+// AlgorithmPage is the page returned by a pager when traversing over a
+// collection of LB algorithms.
+type AlgorithmPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an AlgorithmPage struct is empty.
+func (p AlgorithmPage) IsEmpty() (bool, error) {
+ is, err := ExtractAlgorithms(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractAlgorithms accepts a Page struct, specifically an AlgorithmPage struct,
+// and extracts the elements into a slice of Algorithm structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractAlgorithms(page pagination.Page) ([]Algorithm, error) {
+ var resp struct {
+ Algorithms []Algorithm `mapstructure:"algorithms" json:"algorithms"`
+ }
+ err := mapstructure.Decode(page.(AlgorithmPage).Body, &resp)
+ return resp.Algorithms, err
+}
+
+// ErrorPage represents the HTML file that is shown to an end user who is
+// attempting to access a load balancer node that is offline/unavailable.
+//
+// During provisioning, every load balancer is configured with a default error
+// page that gets displayed when traffic is requested for an offline node.
+//
+// You can add a single custom error page with an HTTP-based protocol to a load
+// balancer. Page updates override existing content. If a custom error page is
+// deleted, or the load balancer is changed to a non-HTTP protocol, the default
+// error page is restored.
+type ErrorPage struct {
+ Content string
+}
+
+// ErrorPageResult represents the result of an error page operation -
+// specifically getting or creating one.
+type ErrorPageResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as an ErrorPage, if possible.
+func (r ErrorPageResult) Extract() (*ErrorPage, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ ErrorPage ErrorPage `mapstructure:"errorpage"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.ErrorPage, err
+}
+
+// Stats represents all the key information about a load balancer's usage.
+type Stats struct {
+ // The number of connections closed by this load balancer because its
+ // ConnectTimeout interval was exceeded.
+ ConnectTimeout int `mapstructure:"connectTimeOut"`
+
+ // The number of transaction or protocol errors for this load balancer.
+ ConnectError int
+
+ // Number of connection failures for this load balancer.
+ ConnectFailure int
+
+ // Number of connections closed by this load balancer because its Timeout
+ // interval was exceeded.
+ DataTimedOut int
+
+ // Number of connections closed by this load balancer because the
+ // 'keepalive_timeout' interval was exceeded.
+ KeepAliveTimedOut int
+
+ // The maximum number of simultaneous TCP connections this load balancer has
+ // processed at any one time.
+ MaxConnections int `mapstructure:"maxConn"`
+
+ // Number of simultaneous connections active at the time of the request.
+ CurrentConnections int `mapstructure:"currentConn"`
+
+ // Number of SSL connections closed by this load balancer because the
+ // ConnectTimeout interval was exceeded.
+ SSLConnectTimeout int `mapstructure:"connectTimeOutSsl"`
+
+ // Number of SSL transaction or protocol erros in this load balancer.
+ SSLConnectError int `mapstructure:"connectErrorSsl"`
+
+ // Number of SSL connection failures in this load balancer.
+ SSLConnectFailure int `mapstructure:"connectFailureSsl"`
+
+ // Number of SSL connections closed by this load balancer because the
+ // Timeout interval was exceeded.
+ SSLDataTimedOut int `mapstructure:"dataTimedOutSsl"`
+
+ // Number of SSL connections closed by this load balancer because the
+ // 'keepalive_timeout' interval was exceeded.
+ SSLKeepAliveTimedOut int `mapstructure:"keepAliveTimedOutSsl"`
+
+ // Maximum number of simultaneous SSL connections this load balancer has
+ // processed at any one time.
+ SSLMaxConnections int `mapstructure:"maxConnSsl"`
+
+ // Number of simultaneous SSL connections active at the time of the request.
+ SSLCurrentConnections int `mapstructure:"currentConnSsl"`
+}
+
+// StatsResult represents the result of a Stats operation.
+type StatsResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as a Stats struct, if possible.
+func (r StatsResult) Extract() (*Stats, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ res := &Stats{}
+ err := mapstructure.Decode(r.Body, res)
+ return res, err
+}
diff --git a/rackspace/lb/v1/lbs/urls.go b/rackspace/lb/v1/lbs/urls.go
new file mode 100644
index 0000000..471a86b
--- /dev/null
+++ b/rackspace/lb/v1/lbs/urls.go
@@ -0,0 +1,49 @@
+package lbs
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ protocolsPath = "protocols"
+ algorithmsPath = "algorithms"
+ logPath = "connectionlogging"
+ epPath = "errorpage"
+ stPath = "stats"
+ cachePath = "contentcaching"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id))
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(path)
+}
+
+func protocolsURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(path, protocolsPath)
+}
+
+func algorithmsURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(path, algorithmsPath)
+}
+
+func loggingURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), logPath)
+}
+
+func errorPageURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), epPath)
+}
+
+func statsURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), stPath)
+}
+
+func cacheURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), cachePath)
+}
diff --git a/rackspace/lb/v1/monitors/doc.go b/rackspace/lb/v1/monitors/doc.go
new file mode 100644
index 0000000..2c5be75
--- /dev/null
+++ b/rackspace/lb/v1/monitors/doc.go
@@ -0,0 +1,21 @@
+/*
+Package monitors provides information and interaction with the Health Monitor
+API resource for the Rackspace Cloud Load Balancer service.
+
+The load balancing service includes a health monitoring resource that
+periodically checks your back-end nodes to ensure they are responding correctly.
+If a node does not respond, it is removed from rotation until the health monitor
+determines that the node is functional. In addition to being performed
+periodically, a health check also executes against every new node that is
+added, to ensure that the node is operating properly before allowing it to
+service traffic. Only one health monitor is allowed to be enabled on a load
+balancer at a time.
+
+As part of a good strategy for monitoring connections, secondary nodes should
+also be created which provide failover for effectively routing traffic in case
+the primary node fails. This is an additional feature that ensures that you
+remain up in case your primary node fails.
+
+There are three types of health monitor: CONNECT, HTTP and HTTPS.
+*/
+package monitors
diff --git a/rackspace/lb/v1/monitors/fixtures.go b/rackspace/lb/v1/monitors/fixtures.go
new file mode 100644
index 0000000..a565abc
--- /dev/null
+++ b/rackspace/lb/v1/monitors/fixtures.go
@@ -0,0 +1,87 @@
+package monitors
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(lbID int) string {
+ return "/loadbalancers/" + strconv.Itoa(lbID) + "/healthmonitor"
+}
+
+func mockGetResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "healthMonitor": {
+ "type": "CONNECT",
+ "delay": 10,
+ "timeout": 10,
+ "attemptsBeforeDeactivation": 3
+ }
+}
+ `)
+ })
+}
+
+func mockUpdateConnectResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "healthMonitor": {
+ "type": "CONNECT",
+ "delay": 10,
+ "timeout": 10,
+ "attemptsBeforeDeactivation": 3
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockUpdateHTTPResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "healthMonitor": {
+ "attemptsBeforeDeactivation": 3,
+ "bodyRegex": "{regex}",
+ "delay": 10,
+ "path": "/foo",
+ "statusRegex": "200",
+ "timeout": 10,
+ "type": "HTTPS"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/monitors/requests.go b/rackspace/lb/v1/monitors/requests.go
new file mode 100644
index 0000000..d4ba276
--- /dev/null
+++ b/rackspace/lb/v1/monitors/requests.go
@@ -0,0 +1,160 @@
+package monitors
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+)
+
+var (
+ errAttemptLimit = errors.New("AttemptLimit field must be an int greater than 1 and less than 10")
+ errDelay = errors.New("Delay field must be an int greater than 1 and less than 10")
+ errTimeout = errors.New("Timeout field must be an int greater than 1 and less than 10")
+)
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package.
+type UpdateOptsBuilder interface {
+ ToMonitorUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateConnectMonitorOpts represents the options needed to update a CONNECT
+// monitor.
+type UpdateConnectMonitorOpts struct {
+ // Required - number of permissible monitor failures before removing a node
+ // from rotation. Must be a number between 1 and 10.
+ AttemptLimit int
+
+ // Required - the minimum number of seconds to wait before executing the
+ // health monitor. Must be a number between 1 and 3600.
+ Delay int
+
+ // Required - maximum number of seconds to wait for a connection to be
+ // established before timing out. Must be a number between 1 and 300.
+ Timeout int
+}
+
+// ToMonitorUpdateMap produces a map for updating CONNECT monitors.
+func (opts UpdateConnectMonitorOpts) ToMonitorUpdateMap() (map[string]interface{}, error) {
+ type m map[string]interface{}
+
+ if !gophercloud.IntWithinRange(opts.AttemptLimit, 1, 10) {
+ return m{}, errAttemptLimit
+ }
+ if !gophercloud.IntWithinRange(opts.Delay, 1, 3600) {
+ return m{}, errDelay
+ }
+ if !gophercloud.IntWithinRange(opts.Timeout, 1, 300) {
+ return m{}, errTimeout
+ }
+
+ return m{"healthMonitor": m{
+ "attemptsBeforeDeactivation": opts.AttemptLimit,
+ "delay": opts.Delay,
+ "timeout": opts.Timeout,
+ "type": CONNECT,
+ }}, nil
+}
+
+// UpdateHTTPMonitorOpts represents the options needed to update a HTTP monitor.
+type UpdateHTTPMonitorOpts struct {
+ // Required - number of permissible monitor failures before removing a node
+ // from rotation. Must be a number between 1 and 10.
+ AttemptLimit int `mapstructure:"attemptsBeforeDeactivation"`
+
+ // Required - the minimum number of seconds to wait before executing the
+ // health monitor. Must be a number between 1 and 3600.
+ Delay int
+
+ // Required - maximum number of seconds to wait for a connection to be
+ // established before timing out. Must be a number between 1 and 300.
+ Timeout int
+
+ // Required - a regular expression that will be used to evaluate the contents
+ // of the body of the response.
+ BodyRegex string
+
+ // Required - the HTTP path that will be used in the sample request.
+ Path string
+
+ // Required - a regular expression that will be used to evaluate the HTTP
+ // status code returned in the response.
+ StatusRegex string
+
+ // Optional - the name of a host for which the health monitors will check.
+ HostHeader string
+
+ // Required - either HTTP or HTTPS
+ Type Type
+}
+
+// ToMonitorUpdateMap produces a map for updating HTTP(S) monitors.
+func (opts UpdateHTTPMonitorOpts) ToMonitorUpdateMap() (map[string]interface{}, error) {
+ type m map[string]interface{}
+
+ if !gophercloud.IntWithinRange(opts.AttemptLimit, 1, 10) {
+ return m{}, errAttemptLimit
+ }
+ if !gophercloud.IntWithinRange(opts.Delay, 1, 3600) {
+ return m{}, errDelay
+ }
+ if !gophercloud.IntWithinRange(opts.Timeout, 1, 300) {
+ return m{}, errTimeout
+ }
+ if opts.Type != HTTP && opts.Type != HTTPS {
+ return m{}, errors.New("Type must either by HTTP or HTTPS")
+ }
+ if opts.BodyRegex == "" {
+ return m{}, errors.New("BodyRegex is a required field")
+ }
+ if opts.Path == "" {
+ return m{}, errors.New("Path is a required field")
+ }
+ if opts.StatusRegex == "" {
+ return m{}, errors.New("StatusRegex is a required field")
+ }
+
+ json := m{
+ "attemptsBeforeDeactivation": opts.AttemptLimit,
+ "delay": opts.Delay,
+ "timeout": opts.Timeout,
+ "type": opts.Type,
+ "bodyRegex": opts.BodyRegex,
+ "path": opts.Path,
+ "statusRegex": opts.StatusRegex,
+ }
+
+ if opts.HostHeader != "" {
+ json["hostHeader"] = opts.HostHeader
+ }
+
+ return m{"healthMonitor": json}, nil
+}
+
+// Update is the operation responsible for updating a health monitor.
+func Update(c *gophercloud.ServiceClient, id int, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToMonitorUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(rootURL(c, id), reqBody, nil, nil)
+ return res
+}
+
+// Get is the operation responsible for showing details of a health monitor.
+func Get(c *gophercloud.ServiceClient, id int) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(rootURL(c, id), &res.Body, nil)
+ return res
+}
+
+// Delete is the operation responsible for deleting a health monitor.
+func Delete(c *gophercloud.ServiceClient, id int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(rootURL(c, id), nil)
+ return res
+}
diff --git a/rackspace/lb/v1/monitors/requests_test.go b/rackspace/lb/v1/monitors/requests_test.go
new file mode 100644
index 0000000..76a60db
--- /dev/null
+++ b/rackspace/lb/v1/monitors/requests_test.go
@@ -0,0 +1,75 @@
+package monitors
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const lbID = 12345
+
+func TestUpdateCONNECT(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateConnectResponse(t, lbID)
+
+ opts := UpdateConnectMonitorOpts{
+ AttemptLimit: 3,
+ Delay: 10,
+ Timeout: 10,
+ }
+
+ err := Update(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestUpdateHTTP(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateHTTPResponse(t, lbID)
+
+ opts := UpdateHTTPMonitorOpts{
+ AttemptLimit: 3,
+ Delay: 10,
+ Timeout: 10,
+ BodyRegex: "{regex}",
+ Path: "/foo",
+ StatusRegex: "200",
+ Type: HTTPS,
+ }
+
+ err := Update(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetResponse(t, lbID)
+
+ m, err := Get(client.ServiceClient(), lbID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Monitor{
+ Type: CONNECT,
+ Delay: 10,
+ Timeout: 10,
+ AttemptLimit: 3,
+ }
+
+ th.AssertDeepEquals(t, expected, m)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID)
+
+ err := Delete(client.ServiceClient(), lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/monitors/results.go b/rackspace/lb/v1/monitors/results.go
new file mode 100644
index 0000000..eec556f
--- /dev/null
+++ b/rackspace/lb/v1/monitors/results.go
@@ -0,0 +1,90 @@
+package monitors
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// Type represents the type of Monitor.
+type Type string
+
+// Useful constants.
+const (
+ CONNECT Type = "CONNECT"
+ HTTP Type = "HTTP"
+ HTTPS Type = "HTTPS"
+)
+
+// Monitor represents a health monitor API resource. A monitor comes in three
+// forms: CONNECT, HTTP or HTTPS.
+//
+// A CONNECT monitor establishes a basic connection to each node on its defined
+// port to ensure that the service is listening properly. The connect monitor
+// is the most basic type of health check and does no post-processing or
+// protocol-specific health checks.
+//
+// HTTP and HTTPS health monitors are generally considered more intelligent and
+// powerful than CONNECT. It is capable of processing an HTTP or HTTPS response
+// to determine the condition of a node. It supports the same basic properties
+// as CONNECT and includes additional attributes that are used to evaluate the
+// HTTP response.
+type Monitor struct {
+ // Number of permissible monitor failures before removing a node from
+ // rotation.
+ AttemptLimit int `mapstructure:"attemptsBeforeDeactivation"`
+
+ // The minimum number of seconds to wait before executing the health monitor.
+ Delay int
+
+ // Maximum number of seconds to wait for a connection to be established
+ // before timing out.
+ Timeout int
+
+ // Type of the health monitor.
+ Type Type
+
+ // A regular expression that will be used to evaluate the contents of the
+ // body of the response.
+ BodyRegex string
+
+ // The name of a host for which the health monitors will check.
+ HostHeader string
+
+ // The HTTP path that will be used in the sample request.
+ Path string
+
+ // A regular expression that will be used to evaluate the HTTP status code
+ // returned in the response.
+ StatusRegex string
+}
+
+// UpdateResult represents the result of an Update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// DeleteResult represents the result of an Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// Extract interprets any GetResult as a Monitor.
+func (r GetResult) Extract() (*Monitor, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ M Monitor `mapstructure:"healthMonitor"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.M, err
+}
diff --git a/rackspace/lb/v1/monitors/urls.go b/rackspace/lb/v1/monitors/urls.go
new file mode 100644
index 0000000..0a1e6df
--- /dev/null
+++ b/rackspace/lb/v1/monitors/urls.go
@@ -0,0 +1,16 @@
+package monitors
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ monitorPath = "healthmonitor"
+)
+
+func rootURL(c *gophercloud.ServiceClient, lbID int) string {
+ return c.ServiceURL(path, strconv.Itoa(lbID), monitorPath)
+}
diff --git a/rackspace/lb/v1/nodes/doc.go b/rackspace/lb/v1/nodes/doc.go
new file mode 100644
index 0000000..49c4318
--- /dev/null
+++ b/rackspace/lb/v1/nodes/doc.go
@@ -0,0 +1,35 @@
+/*
+Package nodes provides information and interaction with the Node API resource
+for the Rackspace Cloud Load Balancer service.
+
+Nodes are responsible for servicing the requests received through the load
+balancer's virtual IP. A node is usually a virtual machine. By default, the
+load balancer employs a basic health check that ensures the node is listening
+on its defined port. The node is checked at the time of addition and at regular
+intervals as defined by the load balancer's health check configuration. If a
+back-end node is not listening on its port, or does not meet the conditions of
+the defined check, then connections will not be forwarded to the node, and its
+status is changed to OFFLINE. Only nodes that are in an ONLINE status receive
+and can service traffic from the load balancer.
+
+All nodes have an associated status that indicates whether the node is
+ONLINE, OFFLINE, or DRAINING. Only nodes that are in ONLINE status can receive
+and service traffic from the load balancer. The OFFLINE status represents a
+node that cannot accept or service traffic. A node in DRAINING status
+represents a node that stops the traffic manager from sending any additional
+new connections to the node, but honors established sessions. If the traffic
+manager receives a request and session persistence requires that the node is
+used, the traffic manager uses it. The status is determined by the passive or
+active health monitors.
+
+If the WEIGHTED_ROUND_ROBIN load balancer algorithm mode is selected, then the
+caller should assign the relevant weights to the node as part of the weight
+attribute of the node element. When the algorithm of the load balancer is
+changed to WEIGHTED_ROUND_ROBIN and the nodes do not already have an assigned
+weight, the service automatically sets the weight to 1 for all nodes.
+
+One or more secondary nodes can be added to a specified load balancer so that
+if all the primary nodes fail, traffic can be redirected to secondary nodes.
+The type attribute allows configuring the node as either PRIMARY or SECONDARY.
+*/
+package nodes
diff --git a/rackspace/lb/v1/nodes/fixtures.go b/rackspace/lb/v1/nodes/fixtures.go
new file mode 100644
index 0000000..8899fc5
--- /dev/null
+++ b/rackspace/lb/v1/nodes/fixtures.go
@@ -0,0 +1,243 @@
+package nodes
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(lbID int) string {
+ return "/loadbalancers/" + strconv.Itoa(lbID) + "/nodes"
+}
+
+func _nodeURL(lbID, nodeID int) string {
+ return _rootURL(lbID) + "/" + strconv.Itoa(nodeID)
+}
+
+func mockListResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "nodes": [
+ {
+ "id": 410,
+ "address": "10.1.1.1",
+ "port": 80,
+ "condition": "ENABLED",
+ "status": "ONLINE",
+ "weight": 3,
+ "type": "PRIMARY"
+ },
+ {
+ "id": 411,
+ "address": "10.1.1.2",
+ "port": 80,
+ "condition": "ENABLED",
+ "status": "ONLINE",
+ "weight": 8,
+ "type": "SECONDARY"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "nodes": [
+ {
+ "address": "10.2.2.3",
+ "port": 80,
+ "condition": "ENABLED",
+ "type": "PRIMARY"
+ },
+ {
+ "address": "10.2.2.4",
+ "port": 81,
+ "condition": "ENABLED",
+ "type": "SECONDARY"
+ }
+ ]
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "nodes": [
+ {
+ "address": "10.2.2.3",
+ "id": 185,
+ "port": 80,
+ "status": "ONLINE",
+ "condition": "ENABLED",
+ "weight": 1,
+ "type": "PRIMARY"
+ },
+ {
+ "address": "10.2.2.4",
+ "id": 186,
+ "port": 81,
+ "status": "ONLINE",
+ "condition": "ENABLED",
+ "weight": 1,
+ "type": "SECONDARY"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateErrResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "nodes": [
+ {
+ "address": "10.2.2.3",
+ "port": 80,
+ "condition": "ENABLED",
+ "type": "PRIMARY"
+ },
+ {
+ "address": "10.2.2.4",
+ "port": 81,
+ "condition": "ENABLED",
+ "type": "SECONDARY"
+ }
+ ]
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(422) // Unprocessable Entity
+
+ fmt.Fprintf(w, `
+{
+ "code": 422,
+ "message": "Load Balancer '%d' has a status of 'PENDING_UPDATE' and is considered immutable."
+}
+ `, lbID)
+ })
+}
+
+func mockBatchDeleteResponse(t *testing.T, lbID int, ids []int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ r.ParseForm()
+
+ for k, v := range ids {
+ fids := r.Form["id"]
+ th.AssertEquals(t, strconv.Itoa(v), fids[k])
+ }
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID, nodeID int) {
+ th.Mux.HandleFunc(_nodeURL(lbID, nodeID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockGetResponse(t *testing.T, lbID, nodeID int) {
+ th.Mux.HandleFunc(_nodeURL(lbID, nodeID), 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, `
+{
+ "node": {
+ "id": 410,
+ "address": "10.1.1.1",
+ "port": 80,
+ "condition": "ENABLED",
+ "status": "ONLINE",
+ "weight": 12,
+ "type": "PRIMARY"
+ }
+}
+ `)
+ })
+}
+
+func mockUpdateResponse(t *testing.T, lbID, nodeID int) {
+ th.Mux.HandleFunc(_nodeURL(lbID, nodeID), 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, `
+{
+ "node": {
+ "condition": "DRAINING",
+ "weight": 10,
+ "type": "SECONDARY"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockListEventsResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID)+"/events", 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, `
+{
+ "nodeServiceEvents": [
+ {
+ "detailedMessage": "Node is ok",
+ "nodeId": 373,
+ "id": 7,
+ "type": "UPDATE_NODE",
+ "description": "Node '373' status changed to 'ONLINE' for load balancer '323'",
+ "category": "UPDATE",
+ "severity": "INFO",
+ "relativeUri": "/406271/loadbalancers/323/nodes/373/events",
+ "accountId": 406271,
+ "loadbalancerId": 323,
+ "title": "Node Status Updated",
+ "author": "Rackspace Cloud",
+ "created": "10-30-2012 10:18:23"
+ }
+ ]
+}
+`)
+ })
+}
diff --git a/rackspace/lb/v1/nodes/requests.go b/rackspace/lb/v1/nodes/requests.go
new file mode 100644
index 0000000..dc2d46c
--- /dev/null
+++ b/rackspace/lb/v1/nodes/requests.go
@@ -0,0 +1,251 @@
+package nodes
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List is the operation responsible for returning a paginated collection of
+// load balancer nodes. It requires the node ID, its parent load balancer ID,
+// and optional limit integer (passed in either as a pointer or a nil poitner).
+func List(client *gophercloud.ServiceClient, loadBalancerID int, limit *int) pagination.Pager {
+ url := rootURL(client, loadBalancerID)
+ if limit != nil {
+ url += fmt.Sprintf("?limit=%d", limit)
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return NodePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder is the interface responsible for generating the JSON
+// for a Create operation.
+type CreateOptsBuilder interface {
+ ToNodeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is a slice of CreateOpt structs, that allow the user to create
+// multiple nodes in a single operation (one node per CreateOpt).
+type CreateOpts []CreateOpt
+
+// CreateOpt represents the options to create a single node.
+type CreateOpt struct {
+ // Required - the IP address or CIDR for this back-end node. It can either be
+ // a private IP (ServiceNet) or a public IP.
+ Address string
+
+ // Optional - the port on which traffic is sent and received.
+ Port int
+
+ // Optional - the condition of the node. See the consts in Results.go.
+ Condition Condition
+
+ // Optional - the type of the node. See the consts in Results.go.
+ Type Type
+
+ // Optional - a pointer to an integer between 0 and 100.
+ Weight *int
+}
+
+func validateWeight(weight *int) error {
+ if weight != nil && (*weight > 100 || *weight < 0) {
+ return errors.New("Weight must be a valid int between 0 and 100")
+ }
+ return nil
+}
+
+// ToNodeCreateMap converts a slice of options into a map that can be used for
+// the JSON.
+func (opts CreateOpts) ToNodeCreateMap() (map[string]interface{}, error) {
+ type nodeMap map[string]interface{}
+ nodes := []nodeMap{}
+
+ for k, v := range opts {
+ if v.Address == "" {
+ return nodeMap{}, fmt.Errorf("ID is a required attribute, none provided for %d CreateOpt element", k)
+ }
+ if weightErr := validateWeight(v.Weight); weightErr != nil {
+ return nodeMap{}, weightErr
+ }
+
+ node := make(map[string]interface{})
+ node["address"] = v.Address
+
+ if v.Port > 0 {
+ node["port"] = v.Port
+ }
+ if v.Condition != "" {
+ node["condition"] = v.Condition
+ }
+ if v.Type != "" {
+ node["type"] = v.Type
+ }
+ if v.Weight != nil {
+ node["weight"] = &v.Weight
+ }
+
+ nodes = append(nodes, node)
+ }
+
+ return nodeMap{"nodes": nodes}, nil
+}
+
+// Create is the operation responsible for creating a new node on a load
+// balancer. Since every load balancer exists in both ServiceNet and the public
+// Internet, both private and public IP addresses can be used for nodes.
+//
+// If nodes need time to boot up services before they become operational, you
+// can temporarily prevent traffic from being sent to that node by setting the
+// Condition field to DRAINING. Health checks will still be performed; but once
+// your node is ready, you can update its condition to ENABLED and have it
+// handle traffic.
+func Create(client *gophercloud.ServiceClient, loadBalancerID int, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToNodeCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ resp, err := client.Post(rootURL(client, loadBalancerID), reqBody, &res.Body, nil)
+
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ pr := pagination.PageResultFromParsed(resp, res.Body)
+ return CreateResult{pagination.SinglePageBase(pr)}
+}
+
+// BulkDelete is the operation responsible for batch deleting multiple nodes in
+// a single operation. It accepts a slice of integer IDs and will remove them
+// from the load balancer. The maximum limit is 10 node removals at once.
+func BulkDelete(c *gophercloud.ServiceClient, loadBalancerID int, nodeIDs []int) DeleteResult {
+ var res DeleteResult
+
+ if len(nodeIDs) > 10 || len(nodeIDs) == 0 {
+ res.Err = errors.New("You must provide a minimum of 1 and a maximum of 10 node IDs")
+ return res
+ }
+
+ url := rootURL(c, loadBalancerID)
+ url += gophercloud.IDSliceToQueryString("id", nodeIDs)
+
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
+
+// Get is the operation responsible for showing details for a single node.
+func Get(c *gophercloud.ServiceClient, lbID, nodeID int) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(resourceURL(c, lbID, nodeID), &res.Body, nil)
+ return res
+}
+
+// UpdateOptsBuilder represents a type that can be converted into a JSON-like
+// map structure.
+type UpdateOptsBuilder interface {
+ ToNodeUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represent the options for updating an existing node.
+type UpdateOpts struct {
+ // Optional - the condition of the node. See the consts in Results.go.
+ Condition Condition
+
+ // Optional - the type of the node. See the consts in Results.go.
+ Type Type
+
+ // Optional - a pointer to an integer between 0 and 100.
+ Weight *int
+}
+
+// ToNodeUpdateMap converts an options struct into a JSON-like map.
+func (opts UpdateOpts) ToNodeUpdateMap() (map[string]interface{}, error) {
+ node := make(map[string]interface{})
+
+ if opts.Condition != "" {
+ node["condition"] = opts.Condition
+ }
+ if opts.Weight != nil {
+ if weightErr := validateWeight(opts.Weight); weightErr != nil {
+ return node, weightErr
+ }
+ node["weight"] = &opts.Weight
+ }
+ if opts.Type != "" {
+ node["type"] = opts.Type
+ }
+
+ return map[string]interface{}{"node": node}, nil
+}
+
+// Update is the operation responsible for updating an existing node. A node's
+// IP, port, and status are immutable attributes and cannot be modified.
+func Update(c *gophercloud.ServiceClient, lbID, nodeID int, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToNodeUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(resourceURL(c, lbID, nodeID), reqBody, nil, nil)
+ return res
+}
+
+// Delete is the operation responsible for permanently deleting a node.
+func Delete(c *gophercloud.ServiceClient, lbID, nodeID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, lbID, nodeID), nil)
+ return res
+}
+
+// ListEventsOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListEventsOptsBuilder interface {
+ ToEventsListQuery() (string, error)
+}
+
+// ListEventsOpts allows the filtering and sorting of paginated collections through
+// the API.
+type ListEventsOpts struct {
+ Marker string `q:"marker"`
+ Limit int `q:"limit"`
+}
+
+// ToEventsListQuery formats a ListOpts into a query string.
+func (opts ListEventsOpts) ToEventsListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return "", err
+ }
+ return q.String(), nil
+}
+
+// ListEvents is the operation responsible for listing all the events
+// associated with the activity between the node and the load balancer. The
+// events report errors found with the node. The detailedMessage provides the
+// detailed reason for the error.
+func ListEvents(client *gophercloud.ServiceClient, loadBalancerID int, opts ListEventsOptsBuilder) pagination.Pager {
+ url := eventsURL(client, loadBalancerID)
+
+ if opts != nil {
+ query, err := opts.ToEventsListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return NodeEventPage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/rackspace/lb/v1/nodes/requests_test.go b/rackspace/lb/v1/nodes/requests_test.go
new file mode 100644
index 0000000..a964af8
--- /dev/null
+++ b/rackspace/lb/v1/nodes/requests_test.go
@@ -0,0 +1,243 @@
+package nodes
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ lbID = 12345
+ nodeID = 67890
+ nodeID2 = 67891
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListResponse(t, lbID)
+
+ count := 0
+
+ err := List(client.ServiceClient(), lbID, nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNodes(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Node{
+ Node{
+ ID: 410,
+ Address: "10.1.1.1",
+ Port: 80,
+ Condition: ENABLED,
+ Status: ONLINE,
+ Weight: 3,
+ Type: PRIMARY,
+ },
+ Node{
+ ID: 411,
+ Address: "10.1.1.2",
+ Port: 80,
+ Condition: ENABLED,
+ Status: ONLINE,
+ Weight: 8,
+ Type: SECONDARY,
+ },
+ }
+
+ 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()
+
+ mockCreateResponse(t, lbID)
+
+ opts := CreateOpts{
+ CreateOpt{
+ Address: "10.2.2.3",
+ Port: 80,
+ Condition: ENABLED,
+ Type: PRIMARY,
+ },
+ CreateOpt{
+ Address: "10.2.2.4",
+ Port: 81,
+ Condition: ENABLED,
+ Type: SECONDARY,
+ },
+ }
+
+ page := Create(client.ServiceClient(), lbID, opts)
+
+ actual, err := page.ExtractNodes()
+ th.AssertNoErr(t, err)
+
+ expected := []Node{
+ Node{
+ ID: 185,
+ Address: "10.2.2.3",
+ Port: 80,
+ Condition: ENABLED,
+ Status: ONLINE,
+ Weight: 1,
+ Type: PRIMARY,
+ },
+ Node{
+ ID: 186,
+ Address: "10.2.2.4",
+ Port: 81,
+ Condition: ENABLED,
+ Status: ONLINE,
+ Weight: 1,
+ Type: SECONDARY,
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestCreateErr(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateErrResponse(t, lbID)
+
+ opts := CreateOpts{
+ CreateOpt{
+ Address: "10.2.2.3",
+ Port: 80,
+ Condition: ENABLED,
+ Type: PRIMARY,
+ },
+ CreateOpt{
+ Address: "10.2.2.4",
+ Port: 81,
+ Condition: ENABLED,
+ Type: SECONDARY,
+ },
+ }
+
+ page := Create(client.ServiceClient(), lbID, opts)
+
+ actual, err := page.ExtractNodes()
+ if err == nil {
+ t.Fatal("Did not receive expected error from ExtractNodes")
+ }
+ if actual != nil {
+ t.Fatalf("Received non-nil result from failed ExtractNodes: %#v", actual)
+ }
+}
+
+func TestBulkDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ ids := []int{nodeID, nodeID2}
+
+ mockBatchDeleteResponse(t, lbID, ids)
+
+ err := BulkDelete(client.ServiceClient(), lbID, ids).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetResponse(t, lbID, nodeID)
+
+ node, err := Get(client.ServiceClient(), lbID, nodeID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Node{
+ ID: 410,
+ Address: "10.1.1.1",
+ Port: 80,
+ Condition: ENABLED,
+ Status: ONLINE,
+ Weight: 12,
+ Type: PRIMARY,
+ }
+
+ th.AssertDeepEquals(t, expected, node)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateResponse(t, lbID, nodeID)
+
+ opts := UpdateOpts{
+ Weight: gophercloud.IntToPointer(10),
+ Condition: DRAINING,
+ Type: SECONDARY,
+ }
+
+ err := Update(client.ServiceClient(), lbID, nodeID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID, nodeID)
+
+ err := Delete(client.ServiceClient(), lbID, nodeID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListEventsResponse(t, lbID)
+
+ count := 0
+
+ pager := ListEvents(client.ServiceClient(), lbID, ListEventsOpts{})
+
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNodeEvents(page)
+ th.AssertNoErr(t, err)
+
+ expected := []NodeEvent{
+ NodeEvent{
+ DetailedMessage: "Node is ok",
+ NodeID: 373,
+ ID: 7,
+ Type: "UPDATE_NODE",
+ Description: "Node '373' status changed to 'ONLINE' for load balancer '323'",
+ Category: "UPDATE",
+ Severity: "INFO",
+ RelativeURI: "/406271/loadbalancers/323/nodes/373/events",
+ AccountID: 406271,
+ LoadBalancerID: 323,
+ Title: "Node Status Updated",
+ Author: "Rackspace Cloud",
+ Created: "10-30-2012 10:18:23",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
diff --git a/rackspace/lb/v1/nodes/results.go b/rackspace/lb/v1/nodes/results.go
new file mode 100644
index 0000000..57835dc
--- /dev/null
+++ b/rackspace/lb/v1/nodes/results.go
@@ -0,0 +1,213 @@
+package nodes
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Node represents a back-end device, usually a virtual machine, that can
+// handle traffic. It is assigned traffic based on its parent load balancer.
+type Node struct {
+ // The IP address or CIDR for this back-end node.
+ Address string
+
+ // The unique ID for this node.
+ ID int
+
+ // The port on which traffic is sent and received.
+ Port int
+
+ // The node's status.
+ Status Status
+
+ // The node's condition.
+ Condition Condition
+
+ // The priority at which this node will receive traffic if a weighted
+ // algorithm is used by its parent load balancer. Ranges from 1 to 100.
+ Weight int
+
+ // Type of node.
+ Type Type
+}
+
+// Type indicates whether the node is of a PRIMARY or SECONDARY nature.
+type Type string
+
+const (
+ // PRIMARY nodes are in the normal rotation to receive traffic from the load
+ // balancer.
+ PRIMARY Type = "PRIMARY"
+
+ // SECONDARY nodes are only in the rotation to receive traffic from the load
+ // balancer when all the primary nodes fail. This provides a failover feature
+ // that automatically routes traffic to the secondary node in the event that
+ // the primary node is disabled or in a failing state. Note that active
+ // health monitoring must be enabled on the load balancer to enable the
+ // failover feature to the secondary node.
+ SECONDARY Type = "SECONDARY"
+)
+
+// Condition represents the condition of a node.
+type Condition string
+
+const (
+ // ENABLED indicates that the node is permitted to accept new connections.
+ ENABLED Condition = "ENABLED"
+
+ // DISABLED indicates that the node is not permitted to accept any new
+ // connections regardless of session persistence configuration. Existing
+ // connections are forcibly terminated.
+ DISABLED Condition = "DISABLED"
+
+ // DRAINING indicates that the node is allowed to service existing
+ // established connections and connections that are being directed to it as a
+ // result of the session persistence configuration.
+ DRAINING Condition = "DRAINING"
+)
+
+// Status indicates whether the node can accept service traffic. If a node is
+// not listening on its port or does not meet the conditions of the defined
+// active health check for the load balancer, then the load balancer does not
+// forward connections, and its status is listed as OFFLINE.
+type Status string
+
+const (
+ // ONLINE indicates that the node is healthy and capable of receiving traffic
+ // from the load balancer.
+ ONLINE Status = "ONLINE"
+
+ // OFFLINE indicates that the node is not in a position to receive service
+ // traffic. It is usually switched into this state when a health check is not
+ // satisfied with the node's response time.
+ OFFLINE Status = "OFFLINE"
+)
+
+// NodePage is the page returned by a pager when traversing over a collection
+// of nodes.
+type NodePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether a NodePage struct is empty.
+func (p NodePage) IsEmpty() (bool, error) {
+ is, err := ExtractNodes(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+func commonExtractNodes(body interface{}) ([]Node, error) {
+ var resp struct {
+ Nodes []Node `mapstructure:"nodes" json:"nodes"`
+ }
+
+ err := mapstructure.Decode(body, &resp)
+
+ return resp.Nodes, err
+}
+
+// ExtractNodes accepts a Page struct, specifically a NodePage struct, and
+// extracts the elements into a slice of Node structs. In other words, a
+// generic collection is mapped into a relevant slice.
+func ExtractNodes(page pagination.Page) ([]Node, error) {
+ return commonExtractNodes(page.(NodePage).Body)
+}
+
+// CreateResult represents the result of a create operation. Since multiple
+// nodes can be added in one operation, this result represents multiple nodes
+// and should be treated as a typical pagination Page. Use its ExtractNodes
+// method to get out a slice of Node structs.
+type CreateResult struct {
+ pagination.SinglePageBase
+}
+
+// ExtractNodes extracts a slice of Node structs from a CreateResult.
+func (res CreateResult) ExtractNodes() ([]Node, error) {
+ if res.Err != nil {
+ return nil, res.Err
+ }
+ return commonExtractNodes(res.Body)
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+func (r commonResult) Extract() (*Node, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Node Node `mapstructure:"node"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.Node, err
+}
+
+// NodeEvent represents a service event that occurred between a node and a
+// load balancer.
+type NodeEvent struct {
+ ID int
+ DetailedMessage string
+ NodeID int
+ Type string
+ Description string
+ Category string
+ Severity string
+ RelativeURI string
+ AccountID int
+ LoadBalancerID int
+ Title string
+ Author string
+ Created string
+}
+
+// NodeEventPage is a concrete type which embeds the common SinglePageBase
+// struct, and is used when traversing node event collections.
+type NodeEventPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty is a concrete function which indicates whether an NodeEventPage is
+// empty or not.
+func (r NodeEventPage) IsEmpty() (bool, error) {
+ is, err := ExtractNodeEvents(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractNodeEvents accepts a Page struct, specifically a NodeEventPage
+// struct, and extracts the elements into a slice of NodeEvent structs. In
+// other words, the collection is mapped into a relevant slice.
+func ExtractNodeEvents(page pagination.Page) ([]NodeEvent, error) {
+ var resp struct {
+ Events []NodeEvent `mapstructure:"nodeServiceEvents" json:"nodeServiceEvents"`
+ }
+
+ err := mapstructure.Decode(page.(NodeEventPage).Body, &resp)
+
+ return resp.Events, err
+}
diff --git a/rackspace/lb/v1/nodes/urls.go b/rackspace/lb/v1/nodes/urls.go
new file mode 100644
index 0000000..2cefee2
--- /dev/null
+++ b/rackspace/lb/v1/nodes/urls.go
@@ -0,0 +1,25 @@
+package nodes
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ lbPath = "loadbalancers"
+ nodePath = "nodes"
+ eventPath = "events"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, lbID, nodeID int) string {
+ return c.ServiceURL(lbPath, strconv.Itoa(lbID), nodePath, strconv.Itoa(nodeID))
+}
+
+func rootURL(c *gophercloud.ServiceClient, lbID int) string {
+ return c.ServiceURL(lbPath, strconv.Itoa(lbID), nodePath)
+}
+
+func eventsURL(c *gophercloud.ServiceClient, lbID int) string {
+ return c.ServiceURL(lbPath, strconv.Itoa(lbID), nodePath, eventPath)
+}
diff --git a/rackspace/lb/v1/sessions/doc.go b/rackspace/lb/v1/sessions/doc.go
new file mode 100644
index 0000000..dcec0a8
--- /dev/null
+++ b/rackspace/lb/v1/sessions/doc.go
@@ -0,0 +1,30 @@
+/*
+Package sessions provides information and interaction with the Session
+Persistence feature of the Rackspace Cloud Load Balancer service.
+
+Session persistence is a feature of the load balancing service that forces
+multiple requests from clients (of the same protocol) to be directed to the
+same node. This is common with many web applications that do not inherently
+share application state between back-end servers.
+
+There are two modes to choose from: HTTP_COOKIE and SOURCE_IP. You can only set
+one of the session persistence modes on a load balancer, and it can only
+support one protocol. If you set HTTP_COOKIE mode for an HTTP load balancer, it
+supports session persistence for HTTP requests only. Likewise, if you set
+SOURCE_IP mode for an HTTPS load balancer, it supports session persistence for
+only HTTPS requests.
+
+To support session persistence for both HTTP and HTTPS requests concurrently,
+choose one of the following options:
+
+- Use two load balancers, one configured for session persistence for HTTP
+requests and the other configured for session persistence for HTTPS requests.
+That way, the load balancers support session persistence for both HTTP and
+HTTPS requests concurrently, with each load balancer supporting one of the
+protocols.
+
+- Use one load balancer, configure it for session persistence for HTTP requests,
+and then enable SSL termination for that load balancer. The load balancer
+supports session persistence for both HTTP and HTTPS requests concurrently.
+*/
+package sessions
diff --git a/rackspace/lb/v1/sessions/fixtures.go b/rackspace/lb/v1/sessions/fixtures.go
new file mode 100644
index 0000000..077ef04
--- /dev/null
+++ b/rackspace/lb/v1/sessions/fixtures.go
@@ -0,0 +1,59 @@
+package sessions
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(id int) string {
+ return "/loadbalancers/" + strconv.Itoa(id) + "/sessionpersistence"
+}
+
+func mockGetResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "sessionPersistence": {
+ "persistenceType": "HTTP_COOKIE"
+ }
+}
+`)
+ })
+}
+
+func mockEnableResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "sessionPersistence": {
+ "persistenceType": "HTTP_COOKIE"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func mockDisableResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/sessions/requests.go b/rackspace/lb/v1/sessions/requests.go
new file mode 100644
index 0000000..a93d766
--- /dev/null
+++ b/rackspace/lb/v1/sessions/requests.go
@@ -0,0 +1,63 @@
+package sessions
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package.
+type CreateOptsBuilder interface {
+ ToSPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Required - can either be HTTPCOOKIE or SOURCEIP
+ Type Type
+}
+
+// ToSPCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToSPCreateMap() (map[string]interface{}, error) {
+ sp := make(map[string]interface{})
+
+ if opts.Type == "" {
+ return sp, errors.New("Type is a required field")
+ }
+
+ sp["persistenceType"] = opts.Type
+ return map[string]interface{}{"sessionPersistence": sp}, nil
+}
+
+// Enable is the operation responsible for enabling session persistence for a
+// particular load balancer.
+func Enable(c *gophercloud.ServiceClient, lbID int, opts CreateOptsBuilder) EnableResult {
+ var res EnableResult
+
+ reqBody, err := opts.ToSPCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(rootURL(c, lbID), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get is the operation responsible for showing details of the session
+// persistence configuration for a particular load balancer.
+func Get(c *gophercloud.ServiceClient, lbID int) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(rootURL(c, lbID), &res.Body, nil)
+ return res
+}
+
+// Disable is the operation responsible for disabling session persistence for a
+// particular load balancer.
+func Disable(c *gophercloud.ServiceClient, lbID int) DisableResult {
+ var res DisableResult
+ _, res.Err = c.Delete(rootURL(c, lbID), nil)
+ return res
+}
diff --git a/rackspace/lb/v1/sessions/requests_test.go b/rackspace/lb/v1/sessions/requests_test.go
new file mode 100644
index 0000000..f319e54
--- /dev/null
+++ b/rackspace/lb/v1/sessions/requests_test.go
@@ -0,0 +1,44 @@
+package sessions
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const lbID = 12345
+
+func TestEnable(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockEnableResponse(t, lbID)
+
+ opts := CreateOpts{Type: HTTPCOOKIE}
+ err := Enable(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetResponse(t, lbID)
+
+ sp, err := Get(client.ServiceClient(), lbID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SessionPersistence{Type: HTTPCOOKIE}
+ th.AssertDeepEquals(t, expected, sp)
+}
+
+func TestDisable(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDisableResponse(t, lbID)
+
+ err := Disable(client.ServiceClient(), lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/sessions/results.go b/rackspace/lb/v1/sessions/results.go
new file mode 100644
index 0000000..fe90e72
--- /dev/null
+++ b/rackspace/lb/v1/sessions/results.go
@@ -0,0 +1,58 @@
+package sessions
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// Type represents the type of session persistence being used.
+type Type string
+
+const (
+ // HTTPCOOKIE is a session persistence mechanism that inserts an HTTP cookie
+ // and is used to determine the destination back-end node. This is supported
+ // for HTTP load balancing only.
+ HTTPCOOKIE Type = "HTTP_COOKIE"
+
+ // SOURCEIP is a session persistence mechanism that keeps track of the source
+ // IP address that is mapped and is able to determine the destination
+ // back-end node. This is supported for HTTPS pass-through and non-HTTP load
+ // balancing only.
+ SOURCEIP Type = "SOURCE_IP"
+)
+
+// SessionPersistence indicates how a load balancer is using session persistence
+type SessionPersistence struct {
+ Type Type `mapstructure:"persistenceType"`
+}
+
+// EnableResult represents the result of an enable operation.
+type EnableResult struct {
+ gophercloud.ErrResult
+}
+
+// DisableResult represents the result of a disable operation.
+type DisableResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult as an SP, if possible.
+func (r GetResult) Extract() (*SessionPersistence, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ SP SessionPersistence `mapstructure:"sessionPersistence"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.SP, err
+}
diff --git a/rackspace/lb/v1/sessions/urls.go b/rackspace/lb/v1/sessions/urls.go
new file mode 100644
index 0000000..c4a896d
--- /dev/null
+++ b/rackspace/lb/v1/sessions/urls.go
@@ -0,0 +1,16 @@
+package sessions
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ spPath = "sessionpersistence"
+)
+
+func rootURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), spPath)
+}
diff --git a/rackspace/lb/v1/ssl/doc.go b/rackspace/lb/v1/ssl/doc.go
new file mode 100644
index 0000000..6a2c174
--- /dev/null
+++ b/rackspace/lb/v1/ssl/doc.go
@@ -0,0 +1,22 @@
+/*
+Package ssl provides information and interaction with the SSL Termination
+feature of the Rackspace Cloud Load Balancer service.
+
+You may only enable and configure SSL termination on load balancers with
+non-secure protocols, such as HTTP, but not HTTPS.
+
+SSL-terminated load balancers decrypt the traffic at the traffic manager and
+pass unencrypted traffic to the back-end node. Because of this, the customer's
+back-end nodes don't know what protocol the client requested. For this reason,
+the X-Forwarded-Proto (XFP) header has been added for identifying the
+originating protocol of an HTTP request as "http" or "https" depending on what
+protocol the client requested.
+
+Not every service returns certificates in the proper order. Please verify that
+your chain of certificates matches that of walking up the chain from the domain
+to the CA root.
+
+If used for HTTP to HTTPS redirection, the LoadBalancer's securePort attribute
+must be set to 443, and its secureTrafficOnly attribute must be true.
+*/
+package ssl
diff --git a/rackspace/lb/v1/ssl/fixtures.go b/rackspace/lb/v1/ssl/fixtures.go
new file mode 100644
index 0000000..5a52962
--- /dev/null
+++ b/rackspace/lb/v1/ssl/fixtures.go
@@ -0,0 +1,196 @@
+package ssl
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(id int) string {
+ return "/loadbalancers/" + strconv.Itoa(id) + "/ssltermination"
+}
+
+func _certURL(id, certID int) string {
+ url := _rootURL(id) + "/certificatemappings"
+ if certID > 0 {
+ url += "/" + strconv.Itoa(certID)
+ }
+ return url
+}
+
+func mockGetResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "sslTermination": {
+ "certificate": "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "enabled": true,
+ "secureTrafficOnly": false,
+ "intermediateCertificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ "securePort": 443
+ }
+}
+`)
+ })
+}
+
+func mockUpdateResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "sslTermination": {
+ "enabled": true,
+ "securePort": 443,
+ "secureTrafficOnly": false,
+ "privateKey": "foo",
+ "certificate": "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusOK)
+ })
+}
+
+func mockListCertsResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_certURL(lbID, 0), 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, `
+{
+ "certificateMappings": [
+ {
+ "certificateMapping": {
+ "id": 123,
+ "hostName": "rackspace.com"
+ }
+ },
+ {
+ "certificateMapping": {
+ "id": 124,
+ "hostName": "*.rackspace.com"
+ }
+ }
+ ]
+}
+`)
+ })
+}
+
+func mockAddCertResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_certURL(lbID, 0), 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, `
+{
+ "certificateMapping": {
+ "hostName": "rackspace.com",
+ "privateKey":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAwIudSMpRZx7TS0/AtDVX3DgXwLD9g+XrNaoazlhwhpYALgzJ\nLAbAnOxT6OT0gTpkPus/B7QhW6y6Auf2cdBeW31XoIwPsSoyNhxgErGBxzNARRB9\nlI1HCa1ojFrcULluj4W6rpaOycI5soDBJiJHin/hbZBPZq6vhPCuNP7Ya48Zd/2X\nCQ9ft3XKfmbs1SdrdROIhigse/SGRbMrCorn/vhNIuohr7yOlHG3GcVcUI9k6ZSZ\nBbqF+ZA4ApSF/Q6/cumieEgofhkYbx5fg02s9Jwr4IWnIR2bSHs7UQ6sVgKYzjs7\nPd3Unpa74jFw6/H6shABoO2CIYLotGmQbFgnpwIDAQABAoIBAQCBCQ+PCIclJHNV\ntUzfeCA5ZR4F9JbxHdRTUnxEbOB8UWotckQfTScoAvj4yvdQ42DrCZxj/UOdvFOs\nPufZvlp91bIz1alugWjE+p8n5+2hIaegoTyHoWZKBfxak0myj5KYfHZvKlbmv1ML\nXV4TwEVRfAIG+v87QTY/UUxuF5vR+BpKIbgUJLfPUFFvJUdl84qsJ44pToxaYUd/\nh5YAGC00U4ay1KVSAUnTkkPNZ0lPG/rWU6w6WcTvNRLMd8DzFLTKLOgQfHhbExAF\n+sXPWjWSzbBRP1O7fHqq96QQh4VFiY/7w9W+sDKQyV6Ul17OSXs6aZ4f+lq4rJTI\n1FG96YiBAoGBAO1tiH0h1oWDBYfJB3KJJ6CQQsDGwtHo/DEgznFVP4XwEVbZ98Ha\nBfBCn3sAybbaikyCV1Hwj7kfHMZPDHbrcUSFX7quu/2zPK+wO3lZKXSyu4YsguSa\nRedInN33PpdnlPhLyQdWSuD5sVHJDF6xn22vlyxeILH3ooLg2WOFMPmVAoGBAM+b\nUG/a7iyfpAQKYyuFAsXz6SeFaDY+ZYeX45L112H8Pu+Ie/qzon+bzLB9FIH8GP6+\nQpQgmm/p37U2gD1zChUv7iW6OfQBKk9rWvMpfRF6d7YHquElejhizfTZ+ntBV/VY\ndOYEczxhrdW7keLpatYaaWUy/VboRZmlz/9JGqVLAoGAHfqNmFc0cgk4IowEj7a3\ntTNh6ltub/i+FynwRykfazcDyXaeLPDtfQe8gVh5H8h6W+y9P9BjJVnDVVrX1RAn\nbiJ1EupLPF5sVDapW8ohTOXgfbGTGXBNUUW+4Nv+IDno+mz/RhjkPYHpnM0I7c/5\ntGzOZsC/2hjNgT8I0+MWav0CgYEAuULdJeQVlKalI6HtW2Gn1uRRVJ49H+LQkY6e\nW3+cw2jo9LI0CMWSphNvNrN3wIMp/vHj0fHCP0pSApDvIWbuQXfzKaGko7UCf7rK\nf6GvZRCHkV4IREBAb97j8bMvThxClMNqFfU0rFZyXP+0MOyhFQyertswrgQ6T+Fi\n2mnvKD8CgYAmJHP3NTDRMoMRyAzonJ6nEaGUbAgNmivTaUWMe0+leCvAdwD89gzC\nTKbm3eDUg/6Va3X6ANh3wsfIOe4RXXxcbcFDk9R4zO2M5gfLSjYm5Q87EBZ2hrdj\nM2gLI7dt6thx0J8lR8xRHBEMrVBdgwp0g1gQzo5dAV88/kpkZVps8Q==\n-----END RSA PRIVATE KEY-----\n",
+ "certificate":"-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate":"-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "certificateMapping": {
+ "id": 123,
+ "hostName": "rackspace.com",
+ "certificate":"-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate":"-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+ `)
+ })
+}
+
+func mockGetCertResponse(t *testing.T, lbID, certID int) {
+ th.Mux.HandleFunc(_certURL(lbID, certID), 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, `
+{
+ "certificateMapping": {
+ "id": 123,
+ "hostName": "rackspace.com",
+ "certificate":"-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate":"-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+`)
+ })
+}
+
+func mockUpdateCertResponse(t *testing.T, lbID, certID int) {
+ th.Mux.HandleFunc(_certURL(lbID, certID), 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, `
+{
+ "certificateMapping": {
+ "hostName": "rackspace.com",
+ "privateKey":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAwIudSMpRZx7TS0/AtDVX3DgXwLD9g+XrNaoazlhwhpYALgzJ\nLAbAnOxT6OT0gTpkPus/B7QhW6y6Auf2cdBeW31XoIwPsSoyNhxgErGBxzNARRB9\nlI1HCa1ojFrcULluj4W6rpaOycI5soDBJiJHin/hbZBPZq6vhPCuNP7Ya48Zd/2X\nCQ9ft3XKfmbs1SdrdROIhigse/SGRbMrCorn/vhNIuohr7yOlHG3GcVcUI9k6ZSZ\nBbqF+ZA4ApSF/Q6/cumieEgofhkYbx5fg02s9Jwr4IWnIR2bSHs7UQ6sVgKYzjs7\nPd3Unpa74jFw6/H6shABoO2CIYLotGmQbFgnpwIDAQABAoIBAQCBCQ+PCIclJHNV\ntUzfeCA5ZR4F9JbxHdRTUnxEbOB8UWotckQfTScoAvj4yvdQ42DrCZxj/UOdvFOs\nPufZvlp91bIz1alugWjE+p8n5+2hIaegoTyHoWZKBfxak0myj5KYfHZvKlbmv1ML\nXV4TwEVRfAIG+v87QTY/UUxuF5vR+BpKIbgUJLfPUFFvJUdl84qsJ44pToxaYUd/\nh5YAGC00U4ay1KVSAUnTkkPNZ0lPG/rWU6w6WcTvNRLMd8DzFLTKLOgQfHhbExAF\n+sXPWjWSzbBRP1O7fHqq96QQh4VFiY/7w9W+sDKQyV6Ul17OSXs6aZ4f+lq4rJTI\n1FG96YiBAoGBAO1tiH0h1oWDBYfJB3KJJ6CQQsDGwtHo/DEgznFVP4XwEVbZ98Ha\nBfBCn3sAybbaikyCV1Hwj7kfHMZPDHbrcUSFX7quu/2zPK+wO3lZKXSyu4YsguSa\nRedInN33PpdnlPhLyQdWSuD5sVHJDF6xn22vlyxeILH3ooLg2WOFMPmVAoGBAM+b\nUG/a7iyfpAQKYyuFAsXz6SeFaDY+ZYeX45L112H8Pu+Ie/qzon+bzLB9FIH8GP6+\nQpQgmm/p37U2gD1zChUv7iW6OfQBKk9rWvMpfRF6d7YHquElejhizfTZ+ntBV/VY\ndOYEczxhrdW7keLpatYaaWUy/VboRZmlz/9JGqVLAoGAHfqNmFc0cgk4IowEj7a3\ntTNh6ltub/i+FynwRykfazcDyXaeLPDtfQe8gVh5H8h6W+y9P9BjJVnDVVrX1RAn\nbiJ1EupLPF5sVDapW8ohTOXgfbGTGXBNUUW+4Nv+IDno+mz/RhjkPYHpnM0I7c/5\ntGzOZsC/2hjNgT8I0+MWav0CgYEAuULdJeQVlKalI6HtW2Gn1uRRVJ49H+LQkY6e\nW3+cw2jo9LI0CMWSphNvNrN3wIMp/vHj0fHCP0pSApDvIWbuQXfzKaGko7UCf7rK\nf6GvZRCHkV4IREBAb97j8bMvThxClMNqFfU0rFZyXP+0MOyhFQyertswrgQ6T+Fi\n2mnvKD8CgYAmJHP3NTDRMoMRyAzonJ6nEaGUbAgNmivTaUWMe0+leCvAdwD89gzC\nTKbm3eDUg/6Va3X6ANh3wsfIOe4RXXxcbcFDk9R4zO2M5gfLSjYm5Q87EBZ2hrdj\nM2gLI7dt6thx0J8lR8xRHBEMrVBdgwp0g1gQzo5dAV88/kpkZVps8Q==\n-----END RSA PRIVATE KEY-----\n",
+ "certificate":"-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate":"-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "certificateMapping": {
+ "id": 123,
+ "hostName": "rackspace.com",
+ "certificate":"-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ "intermediateCertificate":"-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n"
+ }
+}
+ `)
+ })
+}
+
+func mockDeleteCertResponse(t *testing.T, lbID, certID int) {
+ th.Mux.HandleFunc(_certURL(lbID, certID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusOK)
+ })
+}
diff --git a/rackspace/lb/v1/ssl/requests.go b/rackspace/lb/v1/ssl/requests.go
new file mode 100644
index 0000000..bb53ef8
--- /dev/null
+++ b/rackspace/lb/v1/ssl/requests.go
@@ -0,0 +1,247 @@
+package ssl
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+var (
+ errPrivateKey = errors.New("PrivateKey is a required field")
+ errCertificate = errors.New("Certificate is a required field")
+ errIntCertificate = errors.New("IntCertificate is a required field")
+)
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package.
+type UpdateOptsBuilder interface {
+ ToSSLUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ // Required - consult the SSLTermConfig struct for more info.
+ SecurePort int
+
+ // Required - consult the SSLTermConfig struct for more info.
+ PrivateKey string
+
+ // Required - consult the SSLTermConfig struct for more info.
+ Certificate string
+
+ // Required - consult the SSLTermConfig struct for more info.
+ IntCertificate string
+
+ // Optional - consult the SSLTermConfig struct for more info.
+ Enabled *bool
+
+ // Optional - consult the SSLTermConfig struct for more info.
+ SecureTrafficOnly *bool
+}
+
+// ToSSLUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToSSLUpdateMap() (map[string]interface{}, error) {
+ ssl := make(map[string]interface{})
+
+ if opts.SecurePort == 0 {
+ return ssl, errors.New("SecurePort needs to be an integer greater than 0")
+ }
+ if opts.PrivateKey == "" {
+ return ssl, errPrivateKey
+ }
+ if opts.Certificate == "" {
+ return ssl, errCertificate
+ }
+ if opts.IntCertificate == "" {
+ return ssl, errIntCertificate
+ }
+
+ ssl["securePort"] = opts.SecurePort
+ ssl["privateKey"] = opts.PrivateKey
+ ssl["certificate"] = opts.Certificate
+ ssl["intermediateCertificate"] = opts.IntCertificate
+
+ if opts.Enabled != nil {
+ ssl["enabled"] = &opts.Enabled
+ }
+
+ if opts.SecureTrafficOnly != nil {
+ ssl["secureTrafficOnly"] = &opts.SecureTrafficOnly
+ }
+
+ return map[string]interface{}{"sslTermination": ssl}, nil
+}
+
+// Update is the operation responsible for updating the SSL Termination
+// configuration for a load balancer.
+func Update(c *gophercloud.ServiceClient, lbID int, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+
+ reqBody, err := opts.ToSSLUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(rootURL(c, lbID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// Get is the operation responsible for showing the details of the SSL
+// Termination configuration for a load balancer.
+func Get(c *gophercloud.ServiceClient, lbID int) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(rootURL(c, lbID), &res.Body, nil)
+ return res
+}
+
+// Delete is the operation responsible for deleting the SSL Termination
+// configuration for a load balancer.
+func Delete(c *gophercloud.ServiceClient, lbID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(rootURL(c, lbID), &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return res
+}
+
+// ListCerts will list all of the certificate mappings associated with a
+// SSL-terminated HTTP load balancer.
+func ListCerts(c *gophercloud.ServiceClient, lbID int) pagination.Pager {
+ url := certURL(c, lbID)
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return CertPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateCertOptsBuilder is the interface options structs have to satisfy in
+// order to be used in the AddCert operation in this package.
+type CreateCertOptsBuilder interface {
+ ToCertCreateMap() (map[string]interface{}, error)
+}
+
+// CreateCertOpts represents the options used when adding a new certificate mapping.
+type CreateCertOpts struct {
+ HostName string
+ PrivateKey string
+ Certificate string
+ IntCertificate string
+}
+
+// ToCertCreateMap will cast an CreateCertOpts struct to a map for JSON serialization.
+func (opts CreateCertOpts) ToCertCreateMap() (map[string]interface{}, error) {
+ cm := make(map[string]interface{})
+
+ if opts.HostName == "" {
+ return cm, errors.New("HostName is a required option")
+ }
+ if opts.PrivateKey == "" {
+ return cm, errPrivateKey
+ }
+ if opts.Certificate == "" {
+ return cm, errCertificate
+ }
+
+ cm["hostName"] = opts.HostName
+ cm["privateKey"] = opts.PrivateKey
+ cm["certificate"] = opts.Certificate
+
+ if opts.IntCertificate != "" {
+ cm["intermediateCertificate"] = opts.IntCertificate
+ }
+
+ return map[string]interface{}{"certificateMapping": cm}, nil
+}
+
+// CreateCert will add a new SSL certificate and allow an SSL-terminated HTTP
+// load balancer to use it. This feature is useful because it allows multiple
+// certificates to be used. The maximum number of certificates that can be
+// stored per LB is 20.
+func CreateCert(c *gophercloud.ServiceClient, lbID int, opts CreateCertOptsBuilder) CreateCertResult {
+ var res CreateCertResult
+
+ reqBody, err := opts.ToCertCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(certURL(c, lbID), reqBody, &res.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
+
+// GetCert will show the details of an existing SSL certificate.
+func GetCert(c *gophercloud.ServiceClient, lbID, certID int) GetCertResult {
+ var res GetCertResult
+ _, res.Err = c.Get(certResourceURL(c, lbID, certID), &res.Body, nil)
+ return res
+}
+
+// UpdateCertOptsBuilder is the interface options structs have to satisfy in
+// order to be used in the UpdateCert operation in this package.
+type UpdateCertOptsBuilder interface {
+ ToCertUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateCertOpts represents the options needed to update a SSL certificate.
+type UpdateCertOpts struct {
+ HostName string
+ PrivateKey string
+ Certificate string
+ IntCertificate string
+}
+
+// ToCertUpdateMap will cast an UpdateCertOpts struct into a map for JSON
+// seralization.
+func (opts UpdateCertOpts) ToCertUpdateMap() (map[string]interface{}, error) {
+ cm := make(map[string]interface{})
+
+ if opts.HostName != "" {
+ cm["hostName"] = opts.HostName
+ }
+ if opts.PrivateKey != "" {
+ cm["privateKey"] = opts.PrivateKey
+ }
+ if opts.Certificate != "" {
+ cm["certificate"] = opts.Certificate
+ }
+ if opts.IntCertificate != "" {
+ cm["intermediateCertificate"] = opts.IntCertificate
+ }
+
+ return map[string]interface{}{"certificateMapping": cm}, nil
+}
+
+// UpdateCert is the operation responsible for updating the details of an
+// existing SSL certificate.
+func UpdateCert(c *gophercloud.ServiceClient, lbID, certID int, opts UpdateCertOptsBuilder) UpdateCertResult {
+ var res UpdateCertResult
+
+ reqBody, err := opts.ToCertUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(certResourceURL(c, lbID, certID), reqBody, &res.Body, nil)
+ return res
+}
+
+// DeleteCert is the operation responsible for permanently removing a SSL
+// certificate.
+func DeleteCert(c *gophercloud.ServiceClient, lbID, certID int) DeleteResult {
+ var res DeleteResult
+
+ _, res.Err = c.Delete(certResourceURL(c, lbID, certID), &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+
+ return res
+}
diff --git a/rackspace/lb/v1/ssl/requests_test.go b/rackspace/lb/v1/ssl/requests_test.go
new file mode 100644
index 0000000..fb14c4a
--- /dev/null
+++ b/rackspace/lb/v1/ssl/requests_test.go
@@ -0,0 +1,167 @@
+package ssl
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ lbID = 12345
+ certID = 67890
+)
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetResponse(t, lbID)
+
+ sp, err := Get(client.ServiceClient(), lbID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &SSLTermConfig{
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ Enabled: true,
+ SecureTrafficOnly: false,
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ SecurePort: 443,
+ }
+ th.AssertDeepEquals(t, expected, sp)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateResponse(t, lbID)
+
+ opts := UpdateOpts{
+ Enabled: gophercloud.Enabled,
+ SecurePort: 443,
+ SecureTrafficOnly: gophercloud.Disabled,
+ PrivateKey: "foo",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+ err := Update(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID)
+
+ err := Delete(client.ServiceClient(), lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListCerts(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListCertsResponse(t, lbID)
+
+ count := 0
+
+ err := ListCerts(client.ServiceClient(), lbID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractCerts(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Certificate{
+ Certificate{ID: 123, HostName: "rackspace.com"},
+ Certificate{ID: 124, HostName: "*.rackspace.com"},
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreateCert(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddCertResponse(t, lbID)
+
+ opts := CreateCertOpts{
+ HostName: "rackspace.com",
+ PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAwIudSMpRZx7TS0/AtDVX3DgXwLD9g+XrNaoazlhwhpYALgzJ\nLAbAnOxT6OT0gTpkPus/B7QhW6y6Auf2cdBeW31XoIwPsSoyNhxgErGBxzNARRB9\nlI1HCa1ojFrcULluj4W6rpaOycI5soDBJiJHin/hbZBPZq6vhPCuNP7Ya48Zd/2X\nCQ9ft3XKfmbs1SdrdROIhigse/SGRbMrCorn/vhNIuohr7yOlHG3GcVcUI9k6ZSZ\nBbqF+ZA4ApSF/Q6/cumieEgofhkYbx5fg02s9Jwr4IWnIR2bSHs7UQ6sVgKYzjs7\nPd3Unpa74jFw6/H6shABoO2CIYLotGmQbFgnpwIDAQABAoIBAQCBCQ+PCIclJHNV\ntUzfeCA5ZR4F9JbxHdRTUnxEbOB8UWotckQfTScoAvj4yvdQ42DrCZxj/UOdvFOs\nPufZvlp91bIz1alugWjE+p8n5+2hIaegoTyHoWZKBfxak0myj5KYfHZvKlbmv1ML\nXV4TwEVRfAIG+v87QTY/UUxuF5vR+BpKIbgUJLfPUFFvJUdl84qsJ44pToxaYUd/\nh5YAGC00U4ay1KVSAUnTkkPNZ0lPG/rWU6w6WcTvNRLMd8DzFLTKLOgQfHhbExAF\n+sXPWjWSzbBRP1O7fHqq96QQh4VFiY/7w9W+sDKQyV6Ul17OSXs6aZ4f+lq4rJTI\n1FG96YiBAoGBAO1tiH0h1oWDBYfJB3KJJ6CQQsDGwtHo/DEgznFVP4XwEVbZ98Ha\nBfBCn3sAybbaikyCV1Hwj7kfHMZPDHbrcUSFX7quu/2zPK+wO3lZKXSyu4YsguSa\nRedInN33PpdnlPhLyQdWSuD5sVHJDF6xn22vlyxeILH3ooLg2WOFMPmVAoGBAM+b\nUG/a7iyfpAQKYyuFAsXz6SeFaDY+ZYeX45L112H8Pu+Ie/qzon+bzLB9FIH8GP6+\nQpQgmm/p37U2gD1zChUv7iW6OfQBKk9rWvMpfRF6d7YHquElejhizfTZ+ntBV/VY\ndOYEczxhrdW7keLpatYaaWUy/VboRZmlz/9JGqVLAoGAHfqNmFc0cgk4IowEj7a3\ntTNh6ltub/i+FynwRykfazcDyXaeLPDtfQe8gVh5H8h6W+y9P9BjJVnDVVrX1RAn\nbiJ1EupLPF5sVDapW8ohTOXgfbGTGXBNUUW+4Nv+IDno+mz/RhjkPYHpnM0I7c/5\ntGzOZsC/2hjNgT8I0+MWav0CgYEAuULdJeQVlKalI6HtW2Gn1uRRVJ49H+LQkY6e\nW3+cw2jo9LI0CMWSphNvNrN3wIMp/vHj0fHCP0pSApDvIWbuQXfzKaGko7UCf7rK\nf6GvZRCHkV4IREBAb97j8bMvThxClMNqFfU0rFZyXP+0MOyhFQyertswrgQ6T+Fi\n2mnvKD8CgYAmJHP3NTDRMoMRyAzonJ6nEaGUbAgNmivTaUWMe0+leCvAdwD89gzC\nTKbm3eDUg/6Va3X6ANh3wsfIOe4RXXxcbcFDk9R4zO2M5gfLSjYm5Q87EBZ2hrdj\nM2gLI7dt6thx0J8lR8xRHBEMrVBdgwp0g1gQzo5dAV88/kpkZVps8Q==\n-----END RSA PRIVATE KEY-----\n",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+
+ cm, err := CreateCert(client.ServiceClient(), lbID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Certificate{
+ ID: 123,
+ HostName: "rackspace.com",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+ th.AssertDeepEquals(t, expected, cm)
+}
+
+func TestGetCertMapping(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetCertResponse(t, lbID, certID)
+
+ sp, err := GetCert(client.ServiceClient(), lbID, certID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Certificate{
+ ID: 123,
+ HostName: "rackspace.com",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+ th.AssertDeepEquals(t, expected, sp)
+}
+
+func TestUpdateCert(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateCertResponse(t, lbID, certID)
+
+ opts := UpdateCertOpts{
+ HostName: "rackspace.com",
+ PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAwIudSMpRZx7TS0/AtDVX3DgXwLD9g+XrNaoazlhwhpYALgzJ\nLAbAnOxT6OT0gTpkPus/B7QhW6y6Auf2cdBeW31XoIwPsSoyNhxgErGBxzNARRB9\nlI1HCa1ojFrcULluj4W6rpaOycI5soDBJiJHin/hbZBPZq6vhPCuNP7Ya48Zd/2X\nCQ9ft3XKfmbs1SdrdROIhigse/SGRbMrCorn/vhNIuohr7yOlHG3GcVcUI9k6ZSZ\nBbqF+ZA4ApSF/Q6/cumieEgofhkYbx5fg02s9Jwr4IWnIR2bSHs7UQ6sVgKYzjs7\nPd3Unpa74jFw6/H6shABoO2CIYLotGmQbFgnpwIDAQABAoIBAQCBCQ+PCIclJHNV\ntUzfeCA5ZR4F9JbxHdRTUnxEbOB8UWotckQfTScoAvj4yvdQ42DrCZxj/UOdvFOs\nPufZvlp91bIz1alugWjE+p8n5+2hIaegoTyHoWZKBfxak0myj5KYfHZvKlbmv1ML\nXV4TwEVRfAIG+v87QTY/UUxuF5vR+BpKIbgUJLfPUFFvJUdl84qsJ44pToxaYUd/\nh5YAGC00U4ay1KVSAUnTkkPNZ0lPG/rWU6w6WcTvNRLMd8DzFLTKLOgQfHhbExAF\n+sXPWjWSzbBRP1O7fHqq96QQh4VFiY/7w9W+sDKQyV6Ul17OSXs6aZ4f+lq4rJTI\n1FG96YiBAoGBAO1tiH0h1oWDBYfJB3KJJ6CQQsDGwtHo/DEgznFVP4XwEVbZ98Ha\nBfBCn3sAybbaikyCV1Hwj7kfHMZPDHbrcUSFX7quu/2zPK+wO3lZKXSyu4YsguSa\nRedInN33PpdnlPhLyQdWSuD5sVHJDF6xn22vlyxeILH3ooLg2WOFMPmVAoGBAM+b\nUG/a7iyfpAQKYyuFAsXz6SeFaDY+ZYeX45L112H8Pu+Ie/qzon+bzLB9FIH8GP6+\nQpQgmm/p37U2gD1zChUv7iW6OfQBKk9rWvMpfRF6d7YHquElejhizfTZ+ntBV/VY\ndOYEczxhrdW7keLpatYaaWUy/VboRZmlz/9JGqVLAoGAHfqNmFc0cgk4IowEj7a3\ntTNh6ltub/i+FynwRykfazcDyXaeLPDtfQe8gVh5H8h6W+y9P9BjJVnDVVrX1RAn\nbiJ1EupLPF5sVDapW8ohTOXgfbGTGXBNUUW+4Nv+IDno+mz/RhjkPYHpnM0I7c/5\ntGzOZsC/2hjNgT8I0+MWav0CgYEAuULdJeQVlKalI6HtW2Gn1uRRVJ49H+LQkY6e\nW3+cw2jo9LI0CMWSphNvNrN3wIMp/vHj0fHCP0pSApDvIWbuQXfzKaGko7UCf7rK\nf6GvZRCHkV4IREBAb97j8bMvThxClMNqFfU0rFZyXP+0MOyhFQyertswrgQ6T+Fi\n2mnvKD8CgYAmJHP3NTDRMoMRyAzonJ6nEaGUbAgNmivTaUWMe0+leCvAdwD89gzC\nTKbm3eDUg/6Va3X6ANh3wsfIOe4RXXxcbcFDk9R4zO2M5gfLSjYm5Q87EBZ2hrdj\nM2gLI7dt6thx0J8lR8xRHBEMrVBdgwp0g1gQzo5dAV88/kpkZVps8Q==\n-----END RSA PRIVATE KEY-----\n",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+
+ cm, err := UpdateCert(client.ServiceClient(), lbID, certID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &Certificate{
+ ID: 123,
+ HostName: "rackspace.com",
+ Certificate: "-----BEGIN CERTIFICATE-----\nMIIEXTCCA0WgAwIBAgIGATTEAjK3MA0GCSqGSIb3DQEBBQUAMIGDMRkwFwYDVQQD\nExBUZXN0IENBIFNUdWIgS2V5MRcwFQYDVQQLEw5QbGF0Zm9ybSBMYmFhczEaMBgG\nA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFDASBgNVBAcTC1NhbiBBbnRvbmlvMQ4w\nDAYDVQQIEwVUZXhhczELMAkGA1UEBhMCVVMwHhcNMTIwMTA5MTk0NjQ1WhcNMTQw\nMTA4MTk0NjQ1WjCBgjELMAkGA1UEBhMCVVMxDjAMBgNVBAgTBVRleGFzMRQwEgYD\nVQQHEwtTYW4gQW50b25pbzEaMBgGA1UEChMRUmFja3NwYWNlIEhvc3RpbmcxFzAV\nBgNVBAsTDlBsYXRmb3JtIExiYWFzMRgwFgYDVQQDEw9UZXN0IENsaWVudCBLZXkw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAi51IylFnHtNLT8C0NVfc\nOBfAsP2D5es1qhrOWHCGlgAuDMksBsCc7FPo5PSBOmQ+6z8HtCFbrLoC5/Zx0F5b\nfVegjA+xKjI2HGASsYHHM0BFEH2UjUcJrWiMWtxQuW6Phbqulo7JwjmygMEmIkeK\nf+FtkE9mrq+E8K40/thrjxl3/ZcJD1+3dcp+ZuzVJ2t1E4iGKCx79IZFsysKiuf+\n+E0i6iGvvI6UcbcZxVxQj2TplJkFuoX5kDgClIX9Dr9y6aJ4SCh+GRhvHl+DTaz0\nnCvghachHZtIeztRDqxWApjOOzs93dSelrviMXDr8fqyEAGg7YIhgui0aZBsWCen\nAgMBAAGjgdUwgdIwgbAGA1UdIwSBqDCBpYAUNpx1Pc6cGA7KqEwHMmHBTZMA7lSh\ngYmkgYYwgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVU4IBATAd\nBgNVHQ4EFgQULueOfsjZZOHwJHZwBy6u0swnpccwDQYJKoZIhvcNAQEFBQADggEB\nAFNuqSVUaotUJoWDv4z7Kbi6JFpTjDht5ORw4BdVYlRD4h9DACAFzPrPV2ym/Osp\nhNMdZq6msZku7MdOSQVhdeGWrSNk3M8O9Hg7cVzPNXOF3iNoo3irQ5tURut44xs4\nWw5YWQqS9WyUY5snD8tm7Y1rQTPfhg+678xIq/zWCv/u+FSnfVv1nlhLVQkEeG/Y\ngh1uMaTIpUKTGEjIAGtpGP7wwIcXptR/HyfzhTUSTaWc1Ef7zoKT9LL5z3IV1hC2\njVWz+RwYs98LjMuksJFoHqRfWyYhCIym0jb6GTwaEmpxAjc+d7OLNQdnoEGoUYGP\nYjtfkRYg265ESMA+Kww4Xy8=\n-----END CERTIFICATE-----\n",
+ IntCertificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzEZMBcGA1UEAxMQVGVz\ndCBDQSBTVHViIEtleTEXMBUGA1UECxMOUGxhdGZvcm0gTGJhYXMxGjAYBgNVBAoT\nEVJhY2tzcGFjZSBIb3N0aW5nMRQwEgYDVQQHEwtTYW4gQW50b25pbzEOMAwGA1UE\nCBMFVGV4YXMxCzAJBgNVBAYTAlVTMB4XDTEyMDEwOTE5NDU0OVoXDTE0MDEwODE5\nNDU0OVowgYMxGTAXBgNVBAMTEFRlc3QgQ0EgU1R1YiBLZXkxFzAVBgNVBAsTDlBs\nYXRmb3JtIExiYWFzMRowGAYDVQQKExFSYWNrc3BhY2UgSG9zdGluZzEUMBIGA1UE\nBxMLU2FuIEFudG9uaW8xDjAMBgNVBAgTBVRleGFzMQswCQYDVQQGEwJVUzCCASIw\nDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNh55lwTVwQvNoEZjq1zGdYz9jA\nXXdjizn8AJhjHLOAallfPtvCfTEgKanhdoyz5FnhQE8HbDAop/KNS1lN2UMvdl5f\nZNLTSjJrNtedqxQwxN/i3bpyBxNVejUH2NjV1mmyj+5CJYwCzWalvI/gLPq/A3as\nO2EQqtf3U8unRgn0zXLRdYxV9MrUzNAmdipPNvNrsVdrCgA42rgF/8KsyRVQfJCX\nfN7PGCfrsC3YaUvhymraWxNnXIzMYTNa9wEeBZLUw8SlEtpa1Zsvui+TPXu3USNZ\nVnWH8Lb6ENlnoX0VBwo62fjOG3JzhNKoJawi3bRqyDdINOvafr7iPrrs/T8CAwEA\nAaMyMDAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUNpx1Pc6cGA7KqEwHMmHB\nTZMA7lQwDQYJKoZIhvcNAQEFBQADggEBAMoRgH3iTG3t317viLKoY+lNMHUgHuR7\nb3mn9MidJKyYVewe6hCDIN6WY4fUojmMW9wFJWJIo0hRMNHL3n3tq8HP2j20Mxy8\nacPdfGZJa+jiBw72CrIGdobKaFduIlIEDBA1pNdZIJ+EulrtqrMesnIt92WaypIS\n8JycbIgDMCiyC0ENHEk8UWlC6429c7OZAsplMTbHME/1R4btxjkdfrYZJjdJ2yL2\n8cjZDUDMCPTdW/ycP07Gkq30RB5tACB5aZdaCn2YaKC8FsEdhff4X7xEOfOEHWEq\nSRxADDj8Lx1MT6QpR07hCiDyHfTCtbqzI0iGjX63Oh7xXSa0f+JVTa8=\n-----END CERTIFICATE-----\n",
+ }
+ th.AssertDeepEquals(t, expected, cm)
+}
+
+func TestDeleteCert(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteCertResponse(t, lbID, certID)
+
+ err := DeleteCert(client.ServiceClient(), lbID, certID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/ssl/results.go b/rackspace/lb/v1/ssl/results.go
new file mode 100644
index 0000000..ead9fcd
--- /dev/null
+++ b/rackspace/lb/v1/ssl/results.go
@@ -0,0 +1,148 @@
+package ssl
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// SSLTermConfig represents the SSL configuration for a particular load balancer.
+type SSLTermConfig struct {
+ // The port on which the SSL termination load balancer listens for secure
+ // traffic. The value must be unique to the existing LB protocol/port
+ // combination
+ SecurePort int `mapstructure:"securePort"`
+
+ // The private key for the SSL certificate which is validated and verified
+ // against the provided certificates.
+ PrivateKey string `mapstructure:"privatekey"`
+
+ // The certificate used for SSL termination, which is validated and verified
+ // against the key and intermediate certificate if provided.
+ Certificate string
+
+ // The intermediate certificate (for the user). The intermediate certificate
+ // is validated and verified against the key and certificate credentials
+ // provided. A user may only provide this value when accompanied by a
+ // Certificate, PrivateKey, and SecurePort. It may not be added or updated as
+ // a single attribute in a future operation.
+ IntCertificate string `mapstructure:"intermediatecertificate"`
+
+ // Determines if the load balancer is enabled to terminate SSL traffic or not.
+ // If this is set to false, the load balancer retains its specified SSL
+ // attributes but does not terminate SSL traffic.
+ Enabled bool
+
+ // Determines if the load balancer can only accept secure traffic. If set to
+ // true, the load balancer will not accept non-secure traffic.
+ SecureTrafficOnly bool
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult as a SSLTermConfig struct, if possible.
+func (r GetResult) Extract() (*SSLTermConfig, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ SSL SSLTermConfig `mapstructure:"sslTermination"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.SSL, err
+}
+
+// Certificate represents an SSL certificate associated with an SSL-terminated
+// HTTP load balancer.
+type Certificate struct {
+ ID int
+ HostName string
+ Certificate string
+ IntCertificate string `mapstructure:"intermediateCertificate"`
+}
+
+// CertPage represents a page of certificates.
+type CertPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty checks whether a CertMappingPage struct is empty.
+func (p CertPage) IsEmpty() (bool, error) {
+ is, err := ExtractCerts(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractCerts accepts a Page struct, specifically a CertPage struct, and
+// extracts the elements into a slice of Cert structs. In other words, a generic
+// collection is mapped into a relevant slice.
+func ExtractCerts(page pagination.Page) ([]Certificate, error) {
+ type NestedMap struct {
+ Cert Certificate `mapstructure:"certificateMapping" json:"certificateMapping"`
+ }
+ var resp struct {
+ Certs []NestedMap `mapstructure:"certificateMappings" json:"certificateMappings"`
+ }
+
+ err := mapstructure.Decode(page.(CertPage).Body, &resp)
+
+ slice := []Certificate{}
+ for _, cert := range resp.Certs {
+ slice = append(slice, cert.Cert)
+ }
+
+ return slice, err
+}
+
+type certResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a result as a CertMapping struct, if possible.
+func (r certResult) Extract() (*Certificate, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ Cert Certificate `mapstructure:"certificateMapping"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.Cert, err
+}
+
+// CreateCertResult represents the result of an CreateCert operation.
+type CreateCertResult struct {
+ certResult
+}
+
+// GetCertResult represents the result of a GetCert operation.
+type GetCertResult struct {
+ certResult
+}
+
+// UpdateCertResult represents the result of an UpdateCert operation.
+type UpdateCertResult struct {
+ certResult
+}
diff --git a/rackspace/lb/v1/ssl/urls.go b/rackspace/lb/v1/ssl/urls.go
new file mode 100644
index 0000000..aa814b3
--- /dev/null
+++ b/rackspace/lb/v1/ssl/urls.go
@@ -0,0 +1,25 @@
+package ssl
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ sslPath = "ssltermination"
+ certPath = "certificatemappings"
+)
+
+func rootURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), sslPath)
+}
+
+func certURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), sslPath, certPath)
+}
+
+func certResourceURL(c *gophercloud.ServiceClient, id, certID int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), sslPath, certPath, strconv.Itoa(certID))
+}
diff --git a/rackspace/lb/v1/throttle/doc.go b/rackspace/lb/v1/throttle/doc.go
new file mode 100644
index 0000000..1ed605d
--- /dev/null
+++ b/rackspace/lb/v1/throttle/doc.go
@@ -0,0 +1,5 @@
+/*
+Package throttle provides information and interaction with the Connection
+Throttling feature of the Rackspace Cloud Load Balancer service.
+*/
+package throttle
diff --git a/rackspace/lb/v1/throttle/fixtures.go b/rackspace/lb/v1/throttle/fixtures.go
new file mode 100644
index 0000000..f3e49fa
--- /dev/null
+++ b/rackspace/lb/v1/throttle/fixtures.go
@@ -0,0 +1,62 @@
+package throttle
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(id int) string {
+ return "/loadbalancers/" + strconv.Itoa(id) + "/connectionthrottle"
+}
+
+func mockGetResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "connectionThrottle": {
+ "maxConnections": 100
+ }
+}
+`)
+ })
+}
+
+func mockCreateResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "connectionThrottle": {
+ "maxConnectionRate": 0,
+ "maxConnections": 200,
+ "minConnections": 0,
+ "rateInterval": 0
+ }
+}
+ `)
+
+ w.WriteHeader(http.StatusAccepted)
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/throttle/requests.go b/rackspace/lb/v1/throttle/requests.go
new file mode 100644
index 0000000..0446b97
--- /dev/null
+++ b/rackspace/lb/v1/throttle/requests.go
@@ -0,0 +1,76 @@
+package throttle
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package.
+type CreateOptsBuilder interface {
+ ToCTCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Required - the maximum amount of connections per IP address to allow per LB.
+ MaxConnections int
+
+ // Deprecated as of v1.22.
+ MaxConnectionRate int
+
+ // Deprecated as of v1.22.
+ MinConnections int
+
+ // Deprecated as of v1.22.
+ RateInterval int
+}
+
+// ToCTCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToCTCreateMap() (map[string]interface{}, error) {
+ ct := make(map[string]interface{})
+
+ if opts.MaxConnections < 0 || opts.MaxConnections > 100000 {
+ return ct, errors.New("MaxConnections must be an int between 0 and 100000")
+ }
+
+ ct["maxConnections"] = opts.MaxConnections
+ ct["maxConnectionRate"] = opts.MaxConnectionRate
+ ct["minConnections"] = opts.MinConnections
+ ct["rateInterval"] = opts.RateInterval
+
+ return map[string]interface{}{"connectionThrottle": ct}, nil
+}
+
+// Create is the operation responsible for creating or updating the connection
+// throttling configuration for a load balancer.
+func Create(c *gophercloud.ServiceClient, lbID int, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToCTCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Put(rootURL(c, lbID), reqBody, &res.Body, nil)
+ return res
+}
+
+// Get is the operation responsible for showing the details of the connection
+// throttling configuration for a load balancer.
+func Get(c *gophercloud.ServiceClient, lbID int) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(rootURL(c, lbID), &res.Body, nil)
+ return res
+}
+
+// Delete is the operation responsible for deleting the connection throttling
+// configuration for a load balancer.
+func Delete(c *gophercloud.ServiceClient, lbID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(rootURL(c, lbID), nil)
+ return res
+}
diff --git a/rackspace/lb/v1/throttle/requests_test.go b/rackspace/lb/v1/throttle/requests_test.go
new file mode 100644
index 0000000..6e9703f
--- /dev/null
+++ b/rackspace/lb/v1/throttle/requests_test.go
@@ -0,0 +1,44 @@
+package throttle
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const lbID = 12345
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateResponse(t, lbID)
+
+ opts := CreateOpts{MaxConnections: 200}
+ err := Create(client.ServiceClient(), lbID, opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetResponse(t, lbID)
+
+ sp, err := Get(client.ServiceClient(), lbID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &ConnectionThrottle{MaxConnections: 100}
+ th.AssertDeepEquals(t, expected, sp)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID)
+
+ err := Delete(client.ServiceClient(), lbID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/throttle/results.go b/rackspace/lb/v1/throttle/results.go
new file mode 100644
index 0000000..db93c6f
--- /dev/null
+++ b/rackspace/lb/v1/throttle/results.go
@@ -0,0 +1,43 @@
+package throttle
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// ConnectionThrottle represents the connection throttle configuration for a
+// particular load balancer.
+type ConnectionThrottle struct {
+ MaxConnections int
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult as a SP, if possible.
+func (r GetResult) Extract() (*ConnectionThrottle, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ CT ConnectionThrottle `mapstructure:"connectionThrottle"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.CT, err
+}
diff --git a/rackspace/lb/v1/throttle/urls.go b/rackspace/lb/v1/throttle/urls.go
new file mode 100644
index 0000000..b77f0ac
--- /dev/null
+++ b/rackspace/lb/v1/throttle/urls.go
@@ -0,0 +1,16 @@
+package throttle
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ path = "loadbalancers"
+ ctPath = "connectionthrottle"
+)
+
+func rootURL(c *gophercloud.ServiceClient, id int) string {
+ return c.ServiceURL(path, strconv.Itoa(id), ctPath)
+}
diff --git a/rackspace/lb/v1/vips/doc.go b/rackspace/lb/v1/vips/doc.go
new file mode 100644
index 0000000..5c3846d
--- /dev/null
+++ b/rackspace/lb/v1/vips/doc.go
@@ -0,0 +1,13 @@
+/*
+Package vips provides information and interaction with the Virtual IP API
+resource for the Rackspace Cloud Load Balancer service.
+
+A virtual IP (VIP) makes a load balancer accessible by clients. The load
+balancing service supports either a public VIP, routable on the public Internet,
+or a ServiceNet address, routable only within the region in which the load
+balancer resides.
+
+All load balancers must have at least one virtual IP associated with them at
+all times.
+*/
+package vips
diff --git a/rackspace/lb/v1/vips/fixtures.go b/rackspace/lb/v1/vips/fixtures.go
new file mode 100644
index 0000000..158759f
--- /dev/null
+++ b/rackspace/lb/v1/vips/fixtures.go
@@ -0,0 +1,88 @@
+package vips
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func _rootURL(lbID int) string {
+ return "/loadbalancers/" + strconv.Itoa(lbID) + "/virtualips"
+}
+
+func mockListResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "virtualIps": [
+ {
+ "id": 1000,
+ "address": "206.10.10.210",
+ "type": "PUBLIC"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateResponse(t *testing.T, lbID int) {
+ th.Mux.HandleFunc(_rootURL(lbID), 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, `
+{
+ "type":"PUBLIC",
+ "ipVersion":"IPV6"
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "address":"fd24:f480:ce44:91bc:1af2:15ff:0000:0002",
+ "id":9000134,
+ "type":"PUBLIC",
+ "ipVersion":"IPV6"
+}
+ `)
+ })
+}
+
+func mockBatchDeleteResponse(t *testing.T, lbID int, ids []int) {
+ th.Mux.HandleFunc(_rootURL(lbID), func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ r.ParseForm()
+
+ for k, v := range ids {
+ fids := r.Form["id"]
+ th.AssertEquals(t, strconv.Itoa(v), fids[k])
+ }
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+func mockDeleteResponse(t *testing.T, lbID, vipID int) {
+ url := _rootURL(lbID) + "/" + strconv.Itoa(vipID)
+ 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.WriteHeader(http.StatusAccepted)
+ })
+}
diff --git a/rackspace/lb/v1/vips/requests.go b/rackspace/lb/v1/vips/requests.go
new file mode 100644
index 0000000..2bc924f
--- /dev/null
+++ b/rackspace/lb/v1/vips/requests.go
@@ -0,0 +1,97 @@
+package vips
+
+import (
+ "errors"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List is the operation responsible for returning a paginated collection of
+// load balancer virtual IP addresses.
+func List(client *gophercloud.ServiceClient, loadBalancerID int) pagination.Pager {
+ url := rootURL(client, loadBalancerID)
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return VIPPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+ ToVIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Optional - the ID of an existing virtual IP. By doing this, you are
+ // allowing load balancers to share IPV6 addresses.
+ ID string
+
+ // Optional - the type of address.
+ Type Type
+
+ // Optional - the version of address.
+ Version Version
+}
+
+// ToVIPCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToVIPCreateMap() (map[string]interface{}, error) {
+ lb := make(map[string]interface{})
+
+ if opts.ID != "" {
+ lb["id"] = opts.ID
+ }
+ if opts.Type != "" {
+ lb["type"] = opts.Type
+ }
+ if opts.Version != "" {
+ lb["ipVersion"] = opts.Version
+ }
+
+ return lb, nil
+}
+
+// Create is the operation responsible for assigning a new Virtual IP to an
+// existing load balancer resource. Currently, only version 6 IP addresses may
+// be added.
+func Create(c *gophercloud.ServiceClient, lbID int, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToVIPCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(rootURL(c, lbID), reqBody, &res.Body, nil)
+ return res
+}
+
+// BulkDelete is the operation responsible for batch deleting multiple VIPs in
+// a single operation. It accepts a slice of integer IDs and will remove them
+// from the load balancer. The maximum limit is 10 VIP removals at once.
+func BulkDelete(c *gophercloud.ServiceClient, loadBalancerID int, vipIDs []int) DeleteResult {
+ var res DeleteResult
+
+ if len(vipIDs) > 10 || len(vipIDs) == 0 {
+ res.Err = errors.New("You must provide a minimum of 1 and a maximum of 10 VIP IDs")
+ return res
+ }
+
+ url := rootURL(c, loadBalancerID)
+ url += gophercloud.IDSliceToQueryString("id", vipIDs)
+
+ _, res.Err = c.Delete(url, nil)
+ return res
+}
+
+// Delete is the operation responsible for permanently deleting a VIP.
+func Delete(c *gophercloud.ServiceClient, lbID, vipID int) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(resourceURL(c, lbID, vipID), nil)
+ return res
+}
diff --git a/rackspace/lb/v1/vips/requests_test.go b/rackspace/lb/v1/vips/requests_test.go
new file mode 100644
index 0000000..74ac461
--- /dev/null
+++ b/rackspace/lb/v1/vips/requests_test.go
@@ -0,0 +1,87 @@
+package vips
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const (
+ lbID = 12345
+ vipID = 67890
+ vipID2 = 67891
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListResponse(t, lbID)
+
+ count := 0
+
+ err := List(client.ServiceClient(), lbID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractVIPs(page)
+ th.AssertNoErr(t, err)
+
+ expected := []VIP{
+ VIP{ID: 1000, Address: "206.10.10.210", Type: "PUBLIC"},
+ }
+
+ 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()
+
+ mockCreateResponse(t, lbID)
+
+ opts := CreateOpts{
+ Type: "PUBLIC",
+ Version: "IPV6",
+ }
+
+ vip, err := Create(client.ServiceClient(), lbID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &VIP{
+ Address: "fd24:f480:ce44:91bc:1af2:15ff:0000:0002",
+ ID: 9000134,
+ Type: "PUBLIC",
+ Version: "IPV6",
+ }
+
+ th.CheckDeepEquals(t, expected, vip)
+}
+
+func TestBulkDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ ids := []int{vipID, vipID2}
+
+ mockBatchDeleteResponse(t, lbID, ids)
+
+ err := BulkDelete(client.ServiceClient(), lbID, ids).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteResponse(t, lbID, vipID)
+
+ err := Delete(client.ServiceClient(), lbID, vipID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/lb/v1/vips/results.go b/rackspace/lb/v1/vips/results.go
new file mode 100644
index 0000000..678b2af
--- /dev/null
+++ b/rackspace/lb/v1/vips/results.go
@@ -0,0 +1,89 @@
+package vips
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// VIP represents a Virtual IP API resource.
+type VIP struct {
+ Address string `json:"address,omitempty"`
+ ID int `json:"id,omitempty"`
+ Type Type `json:"type,omitempty"`
+ Version Version `json:"ipVersion,omitempty" mapstructure:"ipVersion"`
+}
+
+// Version represents the version of a VIP.
+type Version string
+
+// Convenient constants to use for type
+const (
+ IPV4 Version = "IPV4"
+ IPV6 Version = "IPV6"
+)
+
+// Type represents the type of a VIP.
+type Type string
+
+const (
+ // PUBLIC indicates a VIP type that is routable on the public Internet.
+ PUBLIC Type = "PUBLIC"
+
+ // PRIVATE indicates a VIP type that is routable only on ServiceNet.
+ PRIVATE Type = "SERVICENET"
+)
+
+// VIPPage is the page returned by a pager when traversing over a collection
+// of VIPs.
+type VIPPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty checks whether a VIPPage struct is empty.
+func (p VIPPage) IsEmpty() (bool, error) {
+ is, err := ExtractVIPs(p)
+ if err != nil {
+ return true, nil
+ }
+ return len(is) == 0, nil
+}
+
+// ExtractVIPs accepts a Page struct, specifically a VIPPage struct, and
+// extracts the elements into a slice of VIP structs. In other words, a
+// generic collection is mapped into a relevant slice.
+func ExtractVIPs(page pagination.Page) ([]VIP, error) {
+ var resp struct {
+ VIPs []VIP `mapstructure:"virtualIps" json:"virtualIps"`
+ }
+
+ err := mapstructure.Decode(page.(VIPPage).Body, &resp)
+
+ return resp.VIPs, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+func (r commonResult) Extract() (*VIP, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ resp := &VIP{}
+ err := mapstructure.Decode(r.Body, resp)
+
+ return resp, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/rackspace/lb/v1/vips/urls.go b/rackspace/lb/v1/vips/urls.go
new file mode 100644
index 0000000..28f063a
--- /dev/null
+++ b/rackspace/lb/v1/vips/urls.go
@@ -0,0 +1,20 @@
+package vips
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+const (
+ lbPath = "loadbalancers"
+ vipPath = "virtualips"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, lbID, nodeID int) string {
+ return c.ServiceURL(lbPath, strconv.Itoa(lbID), vipPath, strconv.Itoa(nodeID))
+}
+
+func rootURL(c *gophercloud.ServiceClient, lbID int) string {
+ return c.ServiceURL(lbPath, strconv.Itoa(lbID), vipPath)
+}
diff --git a/rackspace/networking/v2/common/common_tests.go b/rackspace/networking/v2/common/common_tests.go
new file mode 100644
index 0000000..129cd63
--- /dev/null
+++ b/rackspace/networking/v2/common/common_tests.go
@@ -0,0 +1,12 @@
+package common
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const TokenID = client.TokenID
+
+func ServiceClient() *gophercloud.ServiceClient {
+ return client.ServiceClient()
+}
diff --git a/rackspace/networking/v2/networks/delegate.go b/rackspace/networking/v2/networks/delegate.go
new file mode 100644
index 0000000..dcb0855
--- /dev/null
+++ b/rackspace/networking/v2/networks/delegate.go
@@ -0,0 +1,41 @@
+package networks
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Get retrieves a specific network based on its unique ID.
+func Get(c *gophercloud.ServiceClient, networkID string) os.GetResult {
+ return os.Get(c, networkID)
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. This operation does not actually require a request body, i.e. the
+// CreateOpts struct argument can be empty.
+//
+// The tenant ID that is contained in the URI is the tenant that creates the
+// network. An admin user, however, has the option of specifying another tenant
+// ID in the CreateOpts struct.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Update accepts a UpdateOpts struct and updates an existing network using the
+// values provided. For more information, see the Create function.
+func Update(c *gophercloud.ServiceClient, networkID string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, networkID, opts)
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) os.DeleteResult {
+ return os.Delete(c, networkID)
+}
diff --git a/rackspace/networking/v2/networks/delegate_test.go b/rackspace/networking/v2/networks/delegate_test.go
new file mode 100644
index 0000000..0b3a6b1
--- /dev/null
+++ b/rackspace/networking/v2/networks/delegate_test.go
@@ -0,0 +1,285 @@
+package networks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+ "github.com/rackspace/gophercloud/pagination"
+ fake "github.com/rackspace/gophercloud/rackspace/networking/v2/common"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks", 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, `
+{
+ "networks": [
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ },
+ {
+ "status": "ACTIVE",
+ "subnets": [
+ "08eae331-0402-425a-923c-34f7cfe39c1b"
+ ],
+ "name": "private",
+ "admin_state_up": true,
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "shared": true,
+ "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324"
+ }
+ ]
+}
+ `)
+ })
+
+ client := fake.ServiceClient()
+ count := 0
+
+ List(client, os.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []os.Network{
+ os.Network{
+ Status: "ACTIVE",
+ Subnets: []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+ Name: "private-network",
+ AdminStateUp: true,
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ Shared: true,
+ ID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ },
+ os.Network{
+ Status: "ACTIVE",
+ Subnets: []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+ Name: "private",
+ AdminStateUp: true,
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ Shared: true,
+ ID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", 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, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [
+ "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ ],
+ "name": "private-network",
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"})
+ th.AssertEquals(t, n.Name, "private-network")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertEquals(t, n.Shared, true)
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [],
+ "name": "net1",
+ "admin_state_up": true,
+ "tenant_id": "9bacb3c5d39d41a79512987f338cf177",
+ "shared": false,
+ "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+ }
+}
+ `)
+ })
+
+ iTrue := true
+ options := os.CreateOpts{Name: "sample_network", AdminStateUp: &iTrue}
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.Subnets, []string{})
+ th.AssertEquals(t, n.Name, "net1")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.TenantID, "9bacb3c5d39d41a79512987f338cf177")
+ th.AssertEquals(t, n.Shared, false)
+ th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestCreateWithOptionalFields(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true,
+ "shared": true,
+ "tenant_id": "12345"
+ }
+}
+ `)
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "name": "sample_network",
+ "admin_state_up": true,
+ "shared": true,
+ "tenant_id": "12345"
+ }
+}
+ `)
+ })
+
+ iTrue := true
+ options := os.CreateOpts{Name: "sample_network", AdminStateUp: &iTrue, Shared: &iTrue, TenantID: "12345"}
+ _, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "network": {
+ "name": "new_network_name",
+ "admin_state_up": false,
+ "shared": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "network": {
+ "status": "ACTIVE",
+ "subnets": [],
+ "name": "new_network_name",
+ "admin_state_up": false,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "shared": true,
+ "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+ }
+}
+ `)
+ })
+
+ iTrue := true
+ options := os.UpdateOpts{Name: "new_network_name", AdminStateUp: os.Down, Shared: &iTrue}
+ n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Name, "new_network_name")
+ th.AssertEquals(t, n.AdminStateUp, false)
+ th.AssertEquals(t, n.Shared, true)
+ th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/networking/v2/ports/delegate.go b/rackspace/networking/v2/ports/delegate.go
new file mode 100644
index 0000000..95728d1
--- /dev/null
+++ b/rackspace/networking/v2/ports/delegate.go
@@ -0,0 +1,43 @@
+package ports
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// ports. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those ports that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, networkID string) os.GetResult {
+ return os.Get(c, networkID)
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. You must remember to provide a NetworkID value.
+//
+// NOTE: Currently the SecurityGroup option is not implemented to work with
+// Rackspace.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, networkID string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, networkID, opts)
+}
+
+// Delete accepts a unique ID and deletes the port associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) os.DeleteResult {
+ return os.Delete(c, networkID)
+}
diff --git a/rackspace/networking/v2/ports/delegate_test.go b/rackspace/networking/v2/ports/delegate_test.go
new file mode 100644
index 0000000..f53ff59
--- /dev/null
+++ b/rackspace/networking/v2/ports/delegate_test.go
@@ -0,0 +1,322 @@
+package ports
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+ "github.com/rackspace/gophercloud/pagination"
+ fake "github.com/rackspace/gophercloud/rackspace/networking/v2/common"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/ports", 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, `
+{
+ "ports": [
+ {
+ "status": "ACTIVE",
+ "binding:host_id": "devstack",
+ "name": "",
+ "admin_state_up": true,
+ "network_id": "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+ "tenant_id": "",
+ "device_owner": "network:router_gateway",
+ "mac_address": "fa:16:3e:58:42:ed",
+ "fixed_ips": [
+ {
+ "subnet_id": "008ba151-0b8c-4a67-98b5-0d2b87666062",
+ "ip_address": "172.24.4.2"
+ }
+ ],
+ "id": "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+ "security_groups": [],
+ "device_id": "9ae135f4-b6e0-4dad-9e91-3c223e385824"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), os.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractPorts(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []os.Port{
+ os.Port{
+ Status: "ACTIVE",
+ Name: "",
+ AdminStateUp: true,
+ NetworkID: "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+ TenantID: "",
+ DeviceOwner: "network:router_gateway",
+ MACAddress: "fa:16:3e:58:42:ed",
+ FixedIPs: []os.IP{
+ os.IP{
+ SubnetID: "008ba151-0b8c-4a67-98b5-0d2b87666062",
+ IPAddress: "172.24.4.2",
+ },
+ },
+ ID: "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+ SecurityGroups: []string{},
+ DeviceID: "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/ports/46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2", 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, `
+{
+ "port": {
+ "status": "ACTIVE",
+ "name": "",
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "7e02058126cc4950b75f9970368ba177",
+ "device_owner": "network:router_interface",
+ "mac_address": "fa:16:3e:23:fd:d7",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.1"
+ }
+ ],
+ "id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
+ "security_groups": [],
+ "device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e"
+ }
+}
+ `)
+ })
+
+ n, err := Get(fake.ServiceClient(), "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertEquals(t, n.Name, "")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+ th.AssertEquals(t, n.TenantID, "7e02058126cc4950b75f9970368ba177")
+ th.AssertEquals(t, n.DeviceOwner, "network:router_interface")
+ th.AssertEquals(t, n.MACAddress, "fa:16:3e:23:fd:d7")
+ th.AssertDeepEquals(t, n.FixedIPs, []os.IP{
+ os.IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.1"},
+ })
+ th.AssertEquals(t, n.ID, "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2")
+ th.AssertDeepEquals(t, n.SecurityGroups, []string{})
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertEquals(t, n.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/ports", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "port": {
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "name": "private-port",
+ "admin_state_up": true,
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.2"
+ }
+ ],
+ "security_groups": ["foo"]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "port": {
+ "status": "DOWN",
+ "name": "private-port",
+ "allowed_address_pairs": [],
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+ "device_owner": "",
+ "mac_address": "fa:16:3e:c9:cb:f0",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.2"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ asu := true
+ options := os.CreateOpts{
+ Name: "private-port",
+ AdminStateUp: &asu,
+ NetworkID: "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ FixedIPs: []os.IP{
+ os.IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ },
+ SecurityGroups: []string{"foo"},
+ }
+ n, err := Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "DOWN")
+ th.AssertEquals(t, n.Name, "private-port")
+ th.AssertEquals(t, n.AdminStateUp, true)
+ th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+ th.AssertEquals(t, n.TenantID, "d6700c0c9ffa4f1cb322cd4a1f3906fa")
+ th.AssertEquals(t, n.DeviceOwner, "")
+ th.AssertEquals(t, n.MACAddress, "fa:16:3e:c9:cb:f0")
+ th.AssertDeepEquals(t, n.FixedIPs, []os.IP{
+ os.IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ })
+ th.AssertEquals(t, n.ID, "65c0ee9f-d634-4522-8954-51021b570b0d")
+ th.AssertDeepEquals(t, n.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), os.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "port": {
+ "name": "new_port_name",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.3"
+ }
+ ],
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "port": {
+ "status": "DOWN",
+ "name": "new_port_name",
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+ "device_owner": "",
+ "mac_address": "fa:16:3e:c9:cb:f0",
+ "fixed_ips": [
+ {
+ "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+ "ip_address": "10.0.0.3"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ options := os.UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []os.IP{
+ os.IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+ }
+
+ s, err := Update(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "new_port_name")
+ th.AssertDeepEquals(t, s.FixedIPs, []os.IP{
+ os.IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/networking/v2/security/doc.go b/rackspace/networking/v2/security/doc.go
new file mode 100644
index 0000000..31f744c
--- /dev/null
+++ b/rackspace/networking/v2/security/doc.go
@@ -0,0 +1,32 @@
+// Package security contains functionality to work with security group and
+// security group rules Neutron resources.
+//
+// Security groups and security group rules allows administrators and tenants
+// the ability to specify the type of traffic and direction (ingress/egress)
+// that is allowed to pass through a port. A security group is a container for
+// security group rules.
+//
+// When a port is created in Networking it is associated with a security group.
+// If a security group is not specified the port is associated with a 'default'
+// security group. By default, this group drops all ingress traffic and allows
+// all egress. Rules can be added to this group in order to change the behaviour.
+//
+// The basic characteristics of Neutron Security Groups are:
+//
+// For ingress traffic (to an instance)
+// - Only traffic matched with security group rules are allowed.
+// - When there is no rule defined, all traffic is dropped.
+//
+// For egress traffic (from an instance)
+// - Only traffic matched with security group rules are allowed.
+// - When there is no rule defined, all egress traffic are dropped.
+// - When a new security group is created, rules to allow all egress traffic
+// is automatically added.
+//
+// "default security group" is defined for each tenant.
+// - For the default security group a rule which allows intercommunication
+// among hosts associated with the default security group is defined by default.
+// - As a result, all egress traffic and intercommunication in the default
+// group are allowed and all ingress from outside of the default group is
+// dropped by default (in the default security group).
+package security
diff --git a/rackspace/networking/v2/security/groups/delegate.go b/rackspace/networking/v2/security/groups/delegate.go
new file mode 100644
index 0000000..1e9a23a
--- /dev/null
+++ b/rackspace/networking/v2/security/groups/delegate.go
@@ -0,0 +1,30 @@
+package groups
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/groups"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// security groups. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts os.ListOpts) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Create is an operation which provisions a new security group with default
+// security group rules for the IPv4 and IPv6 ether types.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOpts) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Get retrieves a particular security group based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(c, id)
+}
+
+// Delete will permanently delete a particular security group based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(c, id)
+}
diff --git a/rackspace/networking/v2/security/groups/delegate_test.go b/rackspace/networking/v2/security/groups/delegate_test.go
new file mode 100644
index 0000000..45cd3ba
--- /dev/null
+++ b/rackspace/networking/v2/security/groups/delegate_test.go
@@ -0,0 +1,206 @@
+package groups
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ osGroups "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/groups"
+ osRules "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups", 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_groups": [
+ {
+ "description": "default",
+ "id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "name": "default",
+ "security_group_rules": [],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ]
+ }
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), osGroups.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := osGroups.ExtractGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract secgroups: %v", err)
+ return false, err
+ }
+
+ expected := []osGroups.SecGroup{
+ osGroups.SecGroup{
+ Description: "default",
+ ID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ Name: "default",
+ Rules: []osRules.SecGroupRule{},
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "security_group": {
+ "name": "new-webservers",
+ "description": "security group for webservers"
+ }
+ }
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+ {
+ "security_group": {
+ "description": "security group for webservers",
+ "id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "name": "new-webservers",
+ "security_group_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "38ce2d8e-e8f1-48bd-83c2-d33cb9f50c3d",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "565b9502-12de-4ffd-91e9-68885cff6ae1",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ }
+ `)
+ })
+
+ opts := osGroups.CreateOpts{Name: "new-webservers", Description: "security group for webservers"}
+ _, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups/85cc3048-abc3-43cc-89b3-377341426ac5", 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": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "name": "default",
+ "security_group_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ],
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ }
+ `)
+ })
+
+ sg, err := Get(fake.ServiceClient(), "85cc3048-abc3-43cc-89b3-377341426ac5").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "default", sg.Description)
+ th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sg.ID)
+ th.AssertEquals(t, "default", sg.Name)
+ th.AssertEquals(t, 2, len(sg.Rules))
+ th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sg.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-groups/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/networking/v2/security/rules/delegate.go b/rackspace/networking/v2/security/rules/delegate.go
new file mode 100644
index 0000000..23b4b31
--- /dev/null
+++ b/rackspace/networking/v2/security/rules/delegate.go
@@ -0,0 +1,30 @@
+package rules
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// security group rules. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts os.ListOpts) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Create is an operation which provisions a new security group with default
+// security group rules for the IPv4 and IPv6 ether types.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOpts) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Get retrieves a particular security group based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) os.GetResult {
+ return os.Get(c, id)
+}
+
+// Delete will permanently delete a particular security group based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(c, id)
+}
diff --git a/rackspace/networking/v2/security/rules/delegate_test.go b/rackspace/networking/v2/security/rules/delegate_test.go
new file mode 100644
index 0000000..3563fbe
--- /dev/null
+++ b/rackspace/networking/v2/security/rules/delegate_test.go
@@ -0,0 +1,236 @@
+package rules
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+ osRules "github.com/rackspace/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules", 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_rules": [
+ {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ },
+ {
+ "direction": "egress",
+ "ethertype": "IPv4",
+ "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ ]
+ }
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), osRules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := osRules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract secrules: %v", err)
+ return false, err
+ }
+
+ expected := []osRules.SecGroupRule{
+ osRules.SecGroupRule{
+ Direction: "egress",
+ EtherType: "IPv6",
+ ID: "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ PortRangeMax: 0,
+ PortRangeMin: 0,
+ Protocol: "",
+ RemoteGroupID: "",
+ RemoteIPPrefix: "",
+ SecGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ osRules.SecGroupRule{
+ Direction: "egress",
+ EtherType: "IPv4",
+ ID: "93aa42e5-80db-4581-9391-3a608bd0e448",
+ PortRangeMax: 0,
+ PortRangeMin: 0,
+ Protocol: "",
+ RemoteGroupID: "",
+ RemoteIPPrefix: "",
+ SecGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ TenantID: "e4f50856753b4dc6afee5fa6b9b6c550",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/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.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "security_group_rule": {
+ "direction": "ingress",
+ "port_range_min": 80,
+ "ethertype": "IPv4",
+ "port_range_max": 80,
+ "protocol": "tcp",
+ "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a"
+ }
+ }
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+ {
+ "security_group_rule": {
+ "direction": "ingress",
+ "ethertype": "IPv4",
+ "id": "2bc0accf-312e-429a-956e-e4407625eb62",
+ "port_range_max": 80,
+ "port_range_min": 80,
+ "protocol": "tcp",
+ "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "remote_ip_prefix": null,
+ "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ }
+ `)
+ })
+
+ opts := osRules.CreateOpts{
+ Direction: "ingress",
+ PortRangeMin: 80,
+ EtherType: "IPv4",
+ PortRangeMax: 80,
+ Protocol: "tcp",
+ RemoteGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a",
+ }
+ _, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), osRules.CreateOpts{Direction: "something"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), osRules.CreateOpts{Direction: osRules.DirIngress, EtherType: "something"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), osRules.CreateOpts{Direction: osRules.DirIngress, EtherType: osRules.Ether4})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = Create(fake.ServiceClient(), osRules.CreateOpts{Direction: osRules.DirIngress, EtherType: osRules.Ether4, SecGroupID: "something", Protocol: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules/3c0e45ff-adaf-4124-b083-bf390e5482ff", 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_rule": {
+ "direction": "egress",
+ "ethertype": "IPv6",
+ "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+ "port_range_max": null,
+ "port_range_min": null,
+ "protocol": null,
+ "remote_group_id": null,
+ "remote_ip_prefix": null,
+ "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+ "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+ }
+ }
+ `)
+ })
+
+ sr, err := Get(fake.ServiceClient(), "3c0e45ff-adaf-4124-b083-bf390e5482ff").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "egress", sr.Direction)
+ th.AssertEquals(t, "IPv6", sr.EtherType)
+ th.AssertEquals(t, "3c0e45ff-adaf-4124-b083-bf390e5482ff", sr.ID)
+ th.AssertEquals(t, 0, sr.PortRangeMax)
+ th.AssertEquals(t, 0, sr.PortRangeMin)
+ th.AssertEquals(t, "", sr.Protocol)
+ th.AssertEquals(t, "", sr.RemoteGroupID)
+ th.AssertEquals(t, "", sr.RemoteIPPrefix)
+ th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sr.SecGroupID)
+ th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sr.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/v2.0/security-group-rules/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/networking/v2/subnets/delegate.go b/rackspace/networking/v2/subnets/delegate.go
new file mode 100644
index 0000000..a7fb7bb
--- /dev/null
+++ b/rackspace/networking/v2/subnets/delegate.go
@@ -0,0 +1,40 @@
+package subnets
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a Pager which allows you to iterate over a collection of
+// subnets. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those subnets that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Get retrieves a specific subnet based on its unique ID.
+func Get(c *gophercloud.ServiceClient, networkID string) os.GetResult {
+ return os.Get(c, networkID)
+}
+
+// Create accepts a CreateOpts struct and creates a new subnet using the values
+// provided. You must remember to provide a valid NetworkID, CIDR and IP version.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Update accepts a UpdateOpts struct and updates an existing subnet using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, networkID string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, networkID, opts)
+}
+
+// Delete accepts a unique ID and deletes the subnet associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) os.DeleteResult {
+ return os.Delete(c, networkID)
+}
diff --git a/rackspace/networking/v2/subnets/delegate_test.go b/rackspace/networking/v2/subnets/delegate_test.go
new file mode 100644
index 0000000..fafc6fb
--- /dev/null
+++ b/rackspace/networking/v2/subnets/delegate_test.go
@@ -0,0 +1,363 @@
+package subnets
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+ "github.com/rackspace/gophercloud/pagination"
+ fake "github.com/rackspace/gophercloud/rackspace/networking/v2/common"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/subnets", 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, `
+{
+ "subnets": [
+ {
+ "name": "private-subnet",
+ "enable_dhcp": true,
+ "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "10.0.0.2",
+ "end": "10.0.0.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "10.0.0.1",
+ "cidr": "10.0.0.0/24",
+ "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+ },
+ {
+ "name": "my_subnet",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.0.0.2",
+ "end": "192.255.255.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.0.0.1",
+ "cidr": "192.0.0.0/8",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ List(fake.ServiceClient(), os.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractSubnets(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []os.Subnet{
+ os.Subnet{
+ Name: "private-subnet",
+ EnableDHCP: true,
+ NetworkID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ DNSNameservers: []string{},
+ AllocationPools: []os.AllocationPool{
+ os.AllocationPool{
+ Start: "10.0.0.2",
+ End: "10.0.0.254",
+ },
+ },
+ HostRoutes: []os.HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "10.0.0.1",
+ CIDR: "10.0.0.0/24",
+ ID: "08eae331-0402-425a-923c-34f7cfe39c1b",
+ },
+ os.Subnet{
+ Name: "my_subnet",
+ EnableDHCP: true,
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ DNSNameservers: []string{},
+ AllocationPools: []os.AllocationPool{
+ os.AllocationPool{
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ },
+ HostRoutes: []os.HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "192.0.0.1",
+ CIDR: "192.0.0.0/8",
+ ID: "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/subnets/54d6f61d-db07-451c-9ab3-b9609b6b6f0b", 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, `
+{
+ "subnet": {
+ "name": "my_subnet",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.0.0.2",
+ "end": "192.255.255.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.0.0.1",
+ "cidr": "192.0.0.0/8",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+ }
+}
+ `)
+ })
+
+ s, err := Get(fake.ServiceClient(), "54d6f61d-db07-451c-9ab3-b9609b6b6f0b").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "my_subnet")
+ th.AssertEquals(t, s.EnableDHCP, true)
+ th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+ th.AssertDeepEquals(t, s.AllocationPools, []os.AllocationPool{
+ os.AllocationPool{
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []os.HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "192.0.0.1")
+ th.AssertEquals(t, s.CIDR, "192.0.0.0/8")
+ th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0b")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/subnets", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet": {
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "ip_version": 4,
+ "cidr": "192.168.199.0/24",
+ "dns_nameservers": ["foo"],
+ "allocation_pools": [
+ {
+ "start": "192.168.199.2",
+ "end": "192.168.199.254"
+ }
+ ],
+ "host_routes": [{"destination":"","nexthop": "bar"}]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "subnet": {
+ "name": "",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.168.199.2",
+ "end": "192.168.199.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.168.199.1",
+ "cidr": "192.168.199.0/24",
+ "id": "3b80198d-4f7b-4f77-9ef5-774d54e17126"
+ }
+}
+ `)
+ })
+
+ opts := os.CreateOpts{
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ IPVersion: 4,
+ CIDR: "192.168.199.0/24",
+ AllocationPools: []os.AllocationPool{
+ os.AllocationPool{
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ },
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []os.HostRoute{
+ os.HostRoute{NextHop: "bar"},
+ },
+ }
+ s, err := Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "")
+ th.AssertEquals(t, s.EnableDHCP, true)
+ th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+ th.AssertDeepEquals(t, s.AllocationPools, []os.AllocationPool{
+ os.AllocationPool{
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []os.HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "192.168.199.1")
+ th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+ th.AssertEquals(t, s.ID, "3b80198d-4f7b-4f77-9ef5-774d54e17126")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := Create(fake.ServiceClient(), os.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = Create(fake.ServiceClient(), os.CreateOpts{NetworkID: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = Create(fake.ServiceClient(), os.CreateOpts{NetworkID: "foo", CIDR: "bar", IPVersion: 40})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Content-Type", "application/json")
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+{
+ "subnet": {
+ "name": "my_new_subnet",
+ "dns_nameservers": ["foo"],
+ "host_routes": [{"destination":"","nexthop": "bar"}]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "subnet": {
+ "name": "my_new_subnet",
+ "enable_dhcp": true,
+ "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "10.0.0.2",
+ "end": "10.0.0.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "10.0.0.1",
+ "cidr": "10.0.0.0/24",
+ "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+ }
+}
+ `)
+ })
+
+ opts := os.UpdateOpts{
+ Name: "my_new_subnet",
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []os.HostRoute{
+ os.HostRoute{NextHop: "bar"},
+ },
+ }
+ s, err := Update(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b", opts).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "my_new_subnet")
+ th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ res := Delete(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/objectstorage/v1/accounts/delegate.go b/rackspace/objectstorage/v1/accounts/delegate.go
new file mode 100644
index 0000000..9473930
--- /dev/null
+++ b/rackspace/objectstorage/v1/accounts/delegate.go
@@ -0,0 +1,39 @@
+package accounts
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/accounts"
+)
+
+// Get is a function that retrieves an account's metadata. To extract just the
+// custom metadata, call the ExtractMetadata method on the GetResult. To extract
+// all the headers that are returned (including the metadata), call the
+// ExtractHeader method on the GetResult.
+func Get(c *gophercloud.ServiceClient) os.GetResult {
+ return os.Get(c, nil)
+}
+
+// UpdateOpts is a structure that contains parameters for updating, creating, or
+// deleting an account's metadata.
+type UpdateOpts struct {
+ Metadata map[string]string
+ TempURLKey string `h:"X-Account-Meta-Temp-URL-Key"`
+ TempURLKey2 string `h:"X-Account-Meta-Temp-URL-Key-2"`
+}
+
+// ToAccountUpdateMap formats an UpdateOpts into a map[string]string of headers.
+func (opts UpdateOpts) ToAccountUpdateMap() (map[string]string, error) {
+ headers, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ headers["X-Account-Meta-"+k] = v
+ }
+ return headers, err
+}
+
+// Update will update an account's metadata with the Metadata in the UpdateOptsBuilder.
+func Update(c *gophercloud.ServiceClient, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, opts)
+}
diff --git a/rackspace/objectstorage/v1/accounts/delegate_test.go b/rackspace/objectstorage/v1/accounts/delegate_test.go
new file mode 100644
index 0000000..a1ea98b
--- /dev/null
+++ b/rackspace/objectstorage/v1/accounts/delegate_test.go
@@ -0,0 +1,32 @@
+package accounts
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/accounts"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestUpdateAccounts(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleUpdateAccountSuccessfully(t)
+
+ options := &UpdateOpts{Metadata: map[string]string{"gophercloud-test": "accounts"}}
+ res := Update(fake.ServiceClient(), options)
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestGetAccounts(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ os.HandleGetAccountSuccessfully(t)
+
+ expected := map[string]string{"Foo": "bar"}
+ actual, err := Get(fake.ServiceClient()).ExtractMetadata()
+ th.CheckNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/objectstorage/v1/accounts/doc.go b/rackspace/objectstorage/v1/accounts/doc.go
new file mode 100644
index 0000000..293a930
--- /dev/null
+++ b/rackspace/objectstorage/v1/accounts/doc.go
@@ -0,0 +1,3 @@
+// Package accounts provides information and interaction with the account
+// API resource for the Rackspace Cloud Files service.
+package accounts
diff --git a/rackspace/objectstorage/v1/bulk/doc.go b/rackspace/objectstorage/v1/bulk/doc.go
new file mode 100644
index 0000000..9c89e22
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/doc.go
@@ -0,0 +1,3 @@
+// Package bulk provides functionality for working with bulk operations in the
+// Rackspace Cloud Files service.
+package bulk
diff --git a/rackspace/objectstorage/v1/bulk/requests.go b/rackspace/objectstorage/v1/bulk/requests.go
new file mode 100644
index 0000000..0aeec15
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/requests.go
@@ -0,0 +1,51 @@
+package bulk
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// DeleteOptsBuilder allows extensions to add additional parameters to the
+// Delete request.
+type DeleteOptsBuilder interface {
+ ToBulkDeleteBody() (string, error)
+}
+
+// DeleteOpts is a structure that holds parameters for deleting an object.
+type DeleteOpts []string
+
+// ToBulkDeleteBody formats a DeleteOpts into a request body.
+func (opts DeleteOpts) ToBulkDeleteBody() (string, error) {
+ return url.QueryEscape(strings.Join(opts, "\n")), nil
+}
+
+// Delete will delete objects or containers in bulk.
+func Delete(c *gophercloud.ServiceClient, opts DeleteOptsBuilder) DeleteResult {
+ var res DeleteResult
+
+ if opts == nil {
+ return res
+ }
+
+ reqString, err := opts.ToBulkDeleteBody()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ reqBody := strings.NewReader(reqString)
+
+ resp, err := c.Request("DELETE", deleteURL(c), gophercloud.RequestOpts{
+ MoreHeaders: map[string]string{"Content-Type": "text/plain"},
+ OkCodes: []int{200},
+ JSONBody: reqBody,
+ JSONResponse: &res.Body,
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
diff --git a/rackspace/objectstorage/v1/bulk/requests_test.go b/rackspace/objectstorage/v1/bulk/requests_test.go
new file mode 100644
index 0000000..8b5578e
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/requests_test.go
@@ -0,0 +1,36 @@
+package bulk
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestBulkDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.AssertEquals(t, r.URL.RawQuery, "bulk-delete")
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `
+ {
+ "Number Not Found": 1,
+ "Response Status": "200 OK",
+ "Errors": [],
+ "Number Deleted": 1,
+ "Response Body": ""
+ }
+ `)
+ })
+
+ options := DeleteOpts{"gophercloud-testcontainer1", "gophercloud-testcontainer2"}
+ actual, err := Delete(fake.ServiceClient(), options).ExtractBody()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, actual.NumberDeleted, 1)
+}
diff --git a/rackspace/objectstorage/v1/bulk/results.go b/rackspace/objectstorage/v1/bulk/results.go
new file mode 100644
index 0000000..fddc125
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/results.go
@@ -0,0 +1,28 @@
+package bulk
+
+import (
+ "github.com/rackspace/gophercloud"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// DeleteResult represents the result of a bulk delete operation.
+type DeleteResult struct {
+ gophercloud.Result
+}
+
+// DeleteRespBody is the form of the response body returned by a bulk delete request.
+type DeleteRespBody struct {
+ NumberNotFound int `mapstructure:"Number Not Found"`
+ ResponseStatus string `mapstructure:"Response Status"`
+ Errors []string `mapstructure:"Errors"`
+ NumberDeleted int `mapstructure:"Number Deleted"`
+ ResponseBody string `mapstructure:"Response Body"`
+}
+
+// ExtractBody will extract the body returned by the bulk extract request.
+func (dr DeleteResult) ExtractBody() (DeleteRespBody, error) {
+ var resp DeleteRespBody
+ err := mapstructure.Decode(dr.Body, &resp)
+ return resp, err
+}
diff --git a/rackspace/objectstorage/v1/bulk/urls.go b/rackspace/objectstorage/v1/bulk/urls.go
new file mode 100644
index 0000000..2e11203
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/urls.go
@@ -0,0 +1,11 @@
+package bulk
+
+import "github.com/rackspace/gophercloud"
+
+func deleteURL(c *gophercloud.ServiceClient) string {
+ return c.Endpoint + "?bulk-delete"
+}
+
+func extractURL(c *gophercloud.ServiceClient, ext string) string {
+ return c.Endpoint + "?extract-archive=" + ext
+}
diff --git a/rackspace/objectstorage/v1/bulk/urls_test.go b/rackspace/objectstorage/v1/bulk/urls_test.go
new file mode 100644
index 0000000..9169e52
--- /dev/null
+++ b/rackspace/objectstorage/v1/bulk/urls_test.go
@@ -0,0 +1,26 @@
+package bulk
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestDeleteURL(t *testing.T) {
+ actual := deleteURL(endpointClient())
+ expected := endpoint + "?bulk-delete"
+ th.CheckEquals(t, expected, actual)
+}
+
+func TestExtractURL(t *testing.T) {
+ actual := extractURL(endpointClient(), "tar")
+ expected := endpoint + "?extract-archive=tar"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/delegate.go b/rackspace/objectstorage/v1/cdncontainers/delegate.go
new file mode 100644
index 0000000..89adb83
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/delegate.go
@@ -0,0 +1,38 @@
+package cdncontainers
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractNames interprets a page of List results when just the container
+// names are requested.
+func ExtractNames(page pagination.Page) ([]string, error) {
+ return os.ExtractNames(page)
+}
+
+// ListOpts are options for listing Rackspace CDN containers.
+type ListOpts struct {
+ EndMarker string `q:"end_marker"`
+ Format string `q:"format"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+}
+
+// ToContainerListParams formats a ListOpts into a query string and boolean
+// representing whether to list complete information for each container.
+func (opts ListOpts) ToContainerListParams() (bool, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return false, "", err
+ }
+ return false, q.String(), nil
+}
+
+// List is a function that retrieves containers associated with the account as
+// well as account metadata. It returns a pager which can be iterated with the
+// EachPage function.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/delegate_test.go b/rackspace/objectstorage/v1/cdncontainers/delegate_test.go
new file mode 100644
index 0000000..02c3c5e
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/delegate_test.go
@@ -0,0 +1,50 @@
+package cdncontainers
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListCDNContainers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListContainerNamesSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNames(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ExpectedListNames, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetCDNContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetContainerSuccessfully(t)
+
+ _, err := Get(fake.ServiceClient(), "testContainer").ExtractMetadata()
+ th.CheckNoErr(t, err)
+
+}
+
+func TestUpdateCDNContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleUpdateContainerSuccessfully(t)
+
+ options := &UpdateOpts{TTL: 3600}
+ res := Update(fake.ServiceClient(), "testContainer", options)
+ th.CheckNoErr(t, res.Err)
+
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/doc.go b/rackspace/objectstorage/v1/cdncontainers/doc.go
new file mode 100644
index 0000000..7b0930e
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/doc.go
@@ -0,0 +1,3 @@
+// Package cdncontainers provides information and interaction with the CDN
+// Container API resource for the Rackspace Cloud Files service.
+package cdncontainers
diff --git a/rackspace/objectstorage/v1/cdncontainers/requests.go b/rackspace/objectstorage/v1/cdncontainers/requests.go
new file mode 100644
index 0000000..6acebb0
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/requests.go
@@ -0,0 +1,161 @@
+package cdncontainers
+
+import (
+ "strconv"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// EnableOptsBuilder allows extensions to add additional parameters to the Enable
+// request.
+type EnableOptsBuilder interface {
+ ToCDNContainerEnableMap() (map[string]string, error)
+}
+
+// EnableOpts is a structure that holds options for enabling a CDN container.
+type EnableOpts struct {
+ // CDNEnabled indicates whether or not the container is CDN enabled. Set to
+ // `true` to enable the container. Note that changing this setting from true
+ // to false will disable the container in the CDN but only after the TTL has
+ // expired.
+ CDNEnabled bool `h:"X-Cdn-Enabled"`
+ // TTL is the time-to-live for the container (in seconds).
+ TTL int `h:"X-Ttl"`
+}
+
+// ToCDNContainerEnableMap formats an EnableOpts into a map of headers.
+func (opts EnableOpts) ToCDNContainerEnableMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ return h, nil
+}
+
+// Enable is a function that enables/disables a CDN container.
+func Enable(c *gophercloud.ServiceClient, containerName string, opts EnableOptsBuilder) EnableResult {
+ var res EnableResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToCDNContainerEnableMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("PUT", enableURL(c, containerName), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{201, 202, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// Get is a function that retrieves the metadata of a container. To extract just
+// the custom metadata, pass the GetResult response to the ExtractMetadata
+// function.
+func Get(c *gophercloud.ServiceClient, containerName string) GetResult {
+ var res GetResult
+ resp, err := c.Request("HEAD", getURL(c, containerName), gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
+
+// State is the state of an option. It is a pointer to a boolean to enable checking for
+// a zero-value of nil instead of false, which is a valid option.
+type State *bool
+
+var (
+ iTrue = true
+ iFalse = false
+
+ // Enabled is used for a true value for options in request bodies.
+ Enabled State = &iTrue
+ // Disabled is used for a false value for options in request bodies.
+ Disabled State = &iFalse
+)
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToContainerUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that holds parameters for updating, creating, or
+// deleting a container's metadata.
+type UpdateOpts struct {
+ // Whether or not to CDN-enable a container. Prefer using XCDNEnabled, which
+ // is of type *bool underneath.
+ // TODO v2.0: change type to Enabled/Disabled (*bool)
+ CDNEnabled bool `h:"X-Cdn-Enabled"`
+ // Whether or not to enable log retention. Prefer using XLogRetention, which
+ // is of type *bool underneath.
+ // TODO v2.0: change type to Enabled/Disabled (*bool)
+ LogRetention bool `h:"X-Log-Retention"`
+ XCDNEnabled *bool
+ XLogRetention *bool
+ TTL int `h:"X-Ttl"`
+}
+
+// ToContainerUpdateMap formats a CreateOpts into a map of headers.
+func (opts UpdateOpts) ToContainerUpdateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ h["X-Cdn-Enabled"] = strconv.FormatBool(opts.CDNEnabled)
+ h["X-Log-Retention"] = strconv.FormatBool(opts.LogRetention)
+
+ if opts.XCDNEnabled != nil {
+ h["X-Cdn-Enabled"] = strconv.FormatBool(*opts.XCDNEnabled)
+ }
+
+ if opts.XLogRetention != nil {
+ h["X-Log-Retention"] = strconv.FormatBool(*opts.XLogRetention)
+ }
+
+ return h, nil
+}
+
+// Update is a function that creates, updates, or deletes a container's
+// metadata.
+func Update(c *gophercloud.ServiceClient, containerName string, opts UpdateOptsBuilder) UpdateResult {
+ var res UpdateResult
+ h := c.AuthenticatedHeaders()
+
+ if opts != nil {
+ headers, err := opts.ToContainerUpdateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("POST", updateURL(c, containerName), gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{202, 204},
+ })
+ if resp != nil {
+ res.Header = resp.Header
+ }
+ res.Err = err
+ return res
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/requests_test.go b/rackspace/objectstorage/v1/cdncontainers/requests_test.go
new file mode 100644
index 0000000..28b963d
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/requests_test.go
@@ -0,0 +1,29 @@
+package cdncontainers
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestEnableCDNContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Add("X-Ttl", "259200")
+ w.Header().Add("X-Cdn-Enabled", "True")
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ options := &EnableOpts{CDNEnabled: true, TTL: 259200}
+ actual := Enable(fake.ServiceClient(), "testContainer", options)
+ th.AssertNoErr(t, actual.Err)
+ th.CheckEquals(t, actual.Header["X-Ttl"][0], "259200")
+ th.CheckEquals(t, actual.Header["X-Cdn-Enabled"][0], "True")
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/results.go b/rackspace/objectstorage/v1/cdncontainers/results.go
new file mode 100644
index 0000000..cb0ad30
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/results.go
@@ -0,0 +1,149 @@
+package cdncontainers
+
+import (
+ "strings"
+ "time"
+
+ "github.com/rackspace/gophercloud"
+)
+
+// EnableHeader represents the headers returned in the response from an Enable request.
+type EnableHeader struct {
+ CDNIosURI string `mapstructure:"X-Cdn-Ios-Uri"`
+ CDNSslURI string `mapstructure:"X-Cdn-Ssl-Uri"`
+ CDNStreamingURI string `mapstructure:"X-Cdn-Streaming-Uri"`
+ CDNUri string `mapstructure:"X-Cdn-Uri"`
+ ContentLength int `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// EnableResult represents the result of an Enable operation.
+type EnableResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return extract an EnableHeader from the response to an Enable
+// request. To obtain a map of headers, call the ExtractHeader method on the EnableResult.
+func (er EnableResult) Extract() (EnableHeader, error) {
+ var eh EnableHeader
+ if er.Err != nil {
+ return eh, er.Err
+ }
+
+ if err := gophercloud.DecodeHeader(er.Header, &eh); err != nil {
+ return eh, err
+ }
+
+ if date, ok := er.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, er.Header["Date"][0])
+ if err != nil {
+ return eh, err
+ }
+ eh.Date = t
+ }
+
+ return eh, nil
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ CDNEnabled bool `mapstructure:"X-Cdn-Enabled"`
+ CDNIosURI string `mapstructure:"X-Cdn-Ios-Uri"`
+ CDNSslURI string `mapstructure:"X-Cdn-Ssl-Uri"`
+ CDNStreamingURI string `mapstructure:"X-Cdn-Streaming-Uri"`
+ CDNUri string `mapstructure:"X-Cdn-Uri"`
+ ContentLength int `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ LogRetention bool `mapstructure:"X-Log-Retention"`
+ TransID string `mapstructure:"X-Trans-Id"`
+ TTL int `mapstructure:"X-Ttl"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (gr GetResult) Extract() (GetHeader, error) {
+ var gh GetHeader
+ if gr.Err != nil {
+ return gh, gr.Err
+ }
+
+ if err := gophercloud.DecodeHeader(gr.Header, &gh); err != nil {
+ return gh, err
+ }
+
+ if date, ok := gr.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, gr.Header["Date"][0])
+ if err != nil {
+ return gh, err
+ }
+ gh.Date = t
+ }
+
+ return gh, nil
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the container.
+func (gr GetResult) ExtractMetadata() (map[string]string, error) {
+ if gr.Err != nil {
+ return nil, gr.Err
+ }
+ metadata := make(map[string]string)
+ for k, v := range gr.Header {
+ if strings.HasPrefix(k, "X-Container-Meta-") {
+ key := strings.TrimPrefix(k, "X-Container-Meta-")
+ metadata[key] = v[0]
+ }
+ }
+ return metadata, nil
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+ CDNIosURI string `mapstructure:"X-Cdn-Ios-Uri"`
+ CDNSslURI string `mapstructure:"X-Cdn-Ssl-Uri"`
+ CDNStreamingURI string `mapstructure:"X-Cdn-Streaming-Uri"`
+ CDNUri string `mapstructure:"X-Cdn-Uri"`
+ ContentLength int `mapstructure:"Content-Length"`
+ ContentType string `mapstructure:"Content-Type"`
+ Date time.Time `mapstructure:"-"`
+ TransID string `mapstructure:"X-Trans-Id"`
+}
+
+// UpdateResult represents the result of an update operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type UpdateResult struct {
+ gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Update. To obtain
+// a map of headers, call the ExtractHeader method on the UpdateResult.
+func (ur UpdateResult) Extract() (UpdateHeader, error) {
+ var uh UpdateHeader
+ if ur.Err != nil {
+ return uh, ur.Err
+ }
+
+ if err := gophercloud.DecodeHeader(ur.Header, &uh); err != nil {
+ return uh, err
+ }
+
+ if date, ok := ur.Header["Date"]; ok && len(date) > 0 {
+ t, err := time.Parse(time.RFC1123, ur.Header["Date"][0])
+ if err != nil {
+ return uh, err
+ }
+ uh.Date = t
+ }
+
+ return uh, nil
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/urls.go b/rackspace/objectstorage/v1/cdncontainers/urls.go
new file mode 100644
index 0000000..541249a
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/urls.go
@@ -0,0 +1,15 @@
+package cdncontainers
+
+import "github.com/rackspace/gophercloud"
+
+func enableURL(c *gophercloud.ServiceClient, containerName string) string {
+ return c.ServiceURL(containerName)
+}
+
+func getURL(c *gophercloud.ServiceClient, container string) string {
+ return c.ServiceURL(container)
+}
+
+func updateURL(c *gophercloud.ServiceClient, container string) string {
+ return getURL(c, container)
+}
diff --git a/rackspace/objectstorage/v1/cdncontainers/urls_test.go b/rackspace/objectstorage/v1/cdncontainers/urls_test.go
new file mode 100644
index 0000000..aa5bfe6
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdncontainers/urls_test.go
@@ -0,0 +1,20 @@
+package cdncontainers
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{Endpoint: endpoint}
+}
+
+func TestEnableURL(t *testing.T) {
+ actual := enableURL(endpointClient(), "foo")
+ expected := endpoint + "foo"
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/rackspace/objectstorage/v1/cdnobjects/delegate.go b/rackspace/objectstorage/v1/cdnobjects/delegate.go
new file mode 100644
index 0000000..e9d2ff1
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdnobjects/delegate.go
@@ -0,0 +1,11 @@
+package cdnobjects
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+)
+
+// Delete is a function that deletes an object from the CDN.
+func Delete(c *gophercloud.ServiceClient, containerName, objectName string, opts os.DeleteOptsBuilder) os.DeleteResult {
+ return os.Delete(c, containerName, objectName, nil)
+}
diff --git a/rackspace/objectstorage/v1/cdnobjects/delegate_test.go b/rackspace/objectstorage/v1/cdnobjects/delegate_test.go
new file mode 100644
index 0000000..b5e04a9
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdnobjects/delegate_test.go
@@ -0,0 +1,19 @@
+package cdnobjects
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestDeleteCDNObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDeleteObjectSuccessfully(t)
+
+ res := Delete(fake.ServiceClient(), "testContainer", "testObject", nil)
+ th.AssertNoErr(t, res.Err)
+
+}
diff --git a/rackspace/objectstorage/v1/cdnobjects/doc.go b/rackspace/objectstorage/v1/cdnobjects/doc.go
new file mode 100644
index 0000000..90cd5c9
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdnobjects/doc.go
@@ -0,0 +1,3 @@
+// Package cdnobjects provides information and interaction with the CDN
+// Object API resource for the Rackspace Cloud Files service.
+package cdnobjects
diff --git a/rackspace/objectstorage/v1/cdnobjects/request.go b/rackspace/objectstorage/v1/cdnobjects/request.go
new file mode 100644
index 0000000..540e0cd
--- /dev/null
+++ b/rackspace/objectstorage/v1/cdnobjects/request.go
@@ -0,0 +1,15 @@
+package cdnobjects
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/rackspace/objectstorage/v1/cdncontainers"
+)
+
+// CDNURL returns the unique CDN URI for the given container and object.
+func CDNURL(c *gophercloud.ServiceClient, containerName, objectName string) (string, error) {
+ h, err := cdncontainers.Get(c, containerName).Extract()
+ if err != nil {
+ return "", err
+ }
+ return h.CDNUri + "/" + objectName, nil
+}
diff --git a/rackspace/objectstorage/v1/containers/delegate.go b/rackspace/objectstorage/v1/containers/delegate.go
new file mode 100644
index 0000000..77ed002
--- /dev/null
+++ b/rackspace/objectstorage/v1/containers/delegate.go
@@ -0,0 +1,93 @@
+package containers
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractInfo interprets a page of List results when full container info
+// is requested.
+func ExtractInfo(page pagination.Page) ([]os.Container, error) {
+ return os.ExtractInfo(page)
+}
+
+// ExtractNames interprets a page of List results when just the container
+// names are requested.
+func ExtractNames(page pagination.Page) ([]string, error) {
+ return os.ExtractNames(page)
+}
+
+// List is a function that retrieves containers associated with the account as
+// well as account metadata. It returns a pager which can be iterated with the
+// EachPage function.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// CreateOpts is a structure that holds parameters for creating a container.
+type CreateOpts struct {
+ Metadata map[string]string
+ ContainerRead string `h:"X-Container-Read"`
+ ContainerWrite string `h:"X-Container-Write"`
+ VersionsLocation string `h:"X-Versions-Location"`
+}
+
+// ToContainerCreateMap formats a CreateOpts into a map of headers.
+func (opts CreateOpts) ToContainerCreateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Container-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Create is a function that creates a new container.
+func Create(c *gophercloud.ServiceClient, containerName string, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, containerName, opts)
+}
+
+// Delete is a function that deletes a container.
+func Delete(c *gophercloud.ServiceClient, containerName string) os.DeleteResult {
+ return os.Delete(c, containerName)
+}
+
+// UpdateOpts is a structure that holds parameters for updating or creating a
+// container's metadata.
+type UpdateOpts struct {
+ Metadata map[string]string
+ ContainerRead string `h:"X-Container-Read"`
+ ContainerWrite string `h:"X-Container-Write"`
+ ContentType string `h:"Content-Type"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+ RemoveVersionsLocation string `h:"X-Remove-Versions-Location"`
+ VersionsLocation string `h:"X-Versions-Location"`
+}
+
+// ToContainerUpdateMap formats a CreateOpts into a map of headers.
+func (opts UpdateOpts) ToContainerUpdateMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Container-Meta-"+k] = v
+ }
+ return h, nil
+}
+
+// Update is a function that creates, updates, or deletes a container's
+// metadata.
+func Update(c *gophercloud.ServiceClient, containerName string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, containerName, opts)
+}
+
+// Get is a function that retrieves the metadata of a container. To extract just
+// the custom metadata, pass the GetResult response to the ExtractMetadata
+// function.
+func Get(c *gophercloud.ServiceClient, containerName string) os.GetResult {
+ return os.Get(c, containerName)
+}
diff --git a/rackspace/objectstorage/v1/containers/delegate_test.go b/rackspace/objectstorage/v1/containers/delegate_test.go
new file mode 100644
index 0000000..7ba4eb2
--- /dev/null
+++ b/rackspace/objectstorage/v1/containers/delegate_test.go
@@ -0,0 +1,91 @@
+package containers
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListContainerInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListContainerInfoSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), &os.ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ExpectedListInfo, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListContainerNames(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListContainerNamesSuccessfully(t)
+
+ count := 0
+ err := List(fake.ServiceClient(), &os.ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNames(page)
+ if err != nil {
+ t.Errorf("Failed to extract container names: %v", err)
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, os.ExpectedListNames, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestCreateContainers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreateContainerSuccessfully(t)
+
+ options := os.CreateOpts{ContentType: "application/json", Metadata: map[string]string{"foo": "bar"}}
+ res := Create(fake.ServiceClient(), "testContainer", options)
+ th.CheckNoErr(t, res.Err)
+ th.CheckEquals(t, "bar", res.Header["X-Container-Meta-Foo"][0])
+
+}
+
+func TestDeleteContainers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDeleteContainerSuccessfully(t)
+
+ res := Delete(fake.ServiceClient(), "testContainer")
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestUpdateContainers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleUpdateContainerSuccessfully(t)
+
+ options := &os.UpdateOpts{Metadata: map[string]string{"foo": "bar"}}
+ res := Update(fake.ServiceClient(), "testContainer", options)
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestGetContainers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetContainerSuccessfully(t)
+
+ _, err := Get(fake.ServiceClient(), "testContainer").ExtractMetadata()
+ th.CheckNoErr(t, err)
+}
diff --git a/rackspace/objectstorage/v1/containers/doc.go b/rackspace/objectstorage/v1/containers/doc.go
new file mode 100644
index 0000000..d132a07
--- /dev/null
+++ b/rackspace/objectstorage/v1/containers/doc.go
@@ -0,0 +1,3 @@
+// Package containers provides information and interaction with the Container
+// API resource for the Rackspace Cloud Files service.
+package containers
diff --git a/rackspace/objectstorage/v1/objects/delegate.go b/rackspace/objectstorage/v1/objects/delegate.go
new file mode 100644
index 0000000..94c820b
--- /dev/null
+++ b/rackspace/objectstorage/v1/objects/delegate.go
@@ -0,0 +1,94 @@
+package objects
+
+import (
+ "io"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// ExtractInfo is a function that takes a page of objects and returns their full information.
+func ExtractInfo(page pagination.Page) ([]os.Object, error) {
+ return os.ExtractInfo(page)
+}
+
+// ExtractNames is a function that takes a page of objects and returns only their names.
+func ExtractNames(page pagination.Page) ([]string, error) {
+ return os.ExtractNames(page)
+}
+
+// List is a function that retrieves objects in the container as
+// well as container metadata. It returns a pager which can be iterated with the
+// EachPage function.
+func List(c *gophercloud.ServiceClient, containerName string, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, containerName, opts)
+}
+
+// Download is a function that retrieves the content and metadata for an object.
+// To extract just the content, pass the DownloadResult response to the
+// ExtractContent function.
+func Download(c *gophercloud.ServiceClient, containerName, objectName string, opts os.DownloadOptsBuilder) os.DownloadResult {
+ return os.Download(c, containerName, objectName, opts)
+}
+
+// Create is a function that creates a new object or replaces an existing object.
+func Create(c *gophercloud.ServiceClient, containerName, objectName string, content io.ReadSeeker, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, containerName, objectName, content, opts)
+}
+
+// CopyOpts is a structure that holds parameters for copying one object to
+// another.
+type CopyOpts struct {
+ Metadata map[string]string
+ ContentDisposition string `h:"Content-Disposition"`
+ ContentEncoding string `h:"Content-Encoding"`
+ ContentLength int `h:"Content-Length"`
+ ContentType string `h:"Content-Type"`
+ CopyFrom string `h:"X-Copy_From"`
+ Destination string `h:"Destination"`
+ DetectContentType bool `h:"X-Detect-Content-Type"`
+}
+
+// ToObjectCopyMap formats a CopyOpts into a map of headers.
+func (opts CopyOpts) ToObjectCopyMap() (map[string]string, error) {
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range opts.Metadata {
+ h["X-Object-Meta-"+k] = v
+ }
+ // `Content-Length` is required and a value of "0" is acceptable, but calling `gophercloud.BuildHeaders`
+ // will remove the `Content-Length` header if it's set to 0 (or equivalently not set). This will add
+ // the header if it's not already set.
+ if _, ok := h["Content-Length"]; !ok {
+ h["Content-Length"] = "0"
+ }
+ return h, nil
+}
+
+// Copy is a function that copies one object to another.
+func Copy(c *gophercloud.ServiceClient, containerName, objectName string, opts os.CopyOptsBuilder) os.CopyResult {
+ return os.Copy(c, containerName, objectName, opts)
+}
+
+// Delete is a function that deletes an object.
+func Delete(c *gophercloud.ServiceClient, containerName, objectName string, opts os.DeleteOptsBuilder) os.DeleteResult {
+ return os.Delete(c, containerName, objectName, opts)
+}
+
+// Get is a function that retrieves the metadata of an object. To extract just the custom
+// metadata, pass the GetResult response to the ExtractMetadata function.
+func Get(c *gophercloud.ServiceClient, containerName, objectName string, opts os.GetOptsBuilder) os.GetResult {
+ return os.Get(c, containerName, objectName, opts)
+}
+
+// Update is a function that creates, updates, or deletes an object's metadata.
+func Update(c *gophercloud.ServiceClient, containerName, objectName string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, containerName, objectName, opts)
+}
+
+func CreateTempURL(c *gophercloud.ServiceClient, containerName, objectName string, opts os.CreateTempURLOpts) (string, error) {
+ return os.CreateTempURL(c, containerName, objectName, opts)
+}
diff --git a/rackspace/objectstorage/v1/objects/delegate_test.go b/rackspace/objectstorage/v1/objects/delegate_test.go
new file mode 100644
index 0000000..21cd417
--- /dev/null
+++ b/rackspace/objectstorage/v1/objects/delegate_test.go
@@ -0,0 +1,127 @@
+package objects
+
+import (
+ "strings"
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestDownloadObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDownloadObjectSuccessfully(t)
+
+ content, err := Download(fake.ServiceClient(), "testContainer", "testObject", nil).ExtractContent()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, string(content), "Successful download with Gophercloud")
+}
+
+func TestListObjectsInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListObjectsInfoSuccessfully(t)
+
+ count := 0
+ options := &os.ListOpts{Full: true}
+ err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractInfo(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ExpectedListInfo, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListObjectNames(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListObjectNamesSuccessfully(t)
+
+ count := 0
+ options := &os.ListOpts{Full: false}
+ err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNames(page)
+ if err != nil {
+ t.Errorf("Failed to extract container names: %v", err)
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, os.ExpectedListNames, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestCreateObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ content := "Did gyre and gimble in the wabe"
+ os.HandleCreateTextObjectSuccessfully(t, content)
+
+ options := &os.CreateOpts{ContentType: "text/plain"}
+ res := Create(fake.ServiceClient(), "testContainer", "testObject", strings.NewReader(content), options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateObjectWithoutContentType(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ content := "The sky was the color of television, tuned to a dead channel."
+ os.HandleCreateTypelessObjectSuccessfully(t, content)
+
+ res := Create(fake.ServiceClient(), "testContainer", "testObject", strings.NewReader(content), &os.CreateOpts{})
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCopyObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCopyObjectSuccessfully(t)
+
+ options := &CopyOpts{Destination: "/newTestContainer/newTestObject"}
+ res := Copy(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestDeleteObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleDeleteObjectSuccessfully(t)
+
+ res := Delete(fake.ServiceClient(), "testContainer", "testObject", nil)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleUpdateObjectSuccessfully(t)
+
+ options := &os.UpdateOpts{Metadata: map[string]string{"Gophercloud-Test": "objects"}}
+ res := Update(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetObjectSuccessfully(t)
+
+ expected := map[string]string{"Gophercloud-Test": "objects"}
+ actual, err := Get(fake.ServiceClient(), "testContainer", "testObject", nil).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/objectstorage/v1/objects/doc.go b/rackspace/objectstorage/v1/objects/doc.go
new file mode 100644
index 0000000..781984b
--- /dev/null
+++ b/rackspace/objectstorage/v1/objects/doc.go
@@ -0,0 +1,3 @@
+// Package objects provides information and interaction with the Object
+// API resource for the Rackspace Cloud Files service.
+package objects
diff --git a/rackspace/orchestration/v1/buildinfo/delegate.go b/rackspace/orchestration/v1/buildinfo/delegate.go
new file mode 100644
index 0000000..c834e5c
--- /dev/null
+++ b/rackspace/orchestration/v1/buildinfo/delegate.go
@@ -0,0 +1,11 @@
+package buildinfo
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/buildinfo"
+)
+
+// Get retreives build info data for the Heat deployment.
+func Get(c *gophercloud.ServiceClient) os.GetResult {
+ return os.Get(c)
+}
diff --git a/rackspace/orchestration/v1/buildinfo/delegate_test.go b/rackspace/orchestration/v1/buildinfo/delegate_test.go
new file mode 100644
index 0000000..b25a690
--- /dev/null
+++ b/rackspace/orchestration/v1/buildinfo/delegate_test.go
@@ -0,0 +1,21 @@
+package buildinfo
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/buildinfo"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t, os.GetOutput)
+
+ actual, err := Get(fake.ServiceClient()).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/orchestration/v1/buildinfo/doc.go b/rackspace/orchestration/v1/buildinfo/doc.go
new file mode 100644
index 0000000..183e8df
--- /dev/null
+++ b/rackspace/orchestration/v1/buildinfo/doc.go
@@ -0,0 +1,2 @@
+// Package buildinfo provides build information about heat deployments.
+package buildinfo
diff --git a/rackspace/orchestration/v1/stackevents/delegate.go b/rackspace/orchestration/v1/stackevents/delegate.go
new file mode 100644
index 0000000..08675de
--- /dev/null
+++ b/rackspace/orchestration/v1/stackevents/delegate.go
@@ -0,0 +1,27 @@
+package stackevents
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackevents"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Find retreives stack events for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) os.FindResult {
+ return os.Find(c, stackName)
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(c *gophercloud.ServiceClient, stackName, stackID string, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, stackName, stackID, opts)
+}
+
+// ListResourceEvents makes a request against the API to list resources for the given stack.
+func ListResourceEvents(c *gophercloud.ServiceClient, stackName, stackID, resourceName string, opts os.ListResourceEventsOptsBuilder) pagination.Pager {
+ return os.ListResourceEvents(c, stackName, stackID, resourceName, opts)
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) os.GetResult {
+ return os.Get(c, stackName, stackID, resourceName, eventID)
+}
diff --git a/rackspace/orchestration/v1/stackevents/delegate_test.go b/rackspace/orchestration/v1/stackevents/delegate_test.go
new file mode 100644
index 0000000..e1c0bc8
--- /dev/null
+++ b/rackspace/orchestration/v1/stackevents/delegate_test.go
@@ -0,0 +1,72 @@
+package stackevents
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackevents"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestFindEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleFindSuccessfully(t, os.FindOutput)
+
+ actual, err := Find(fake.ServiceClient(), "postman_stack").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.FindExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListSuccessfully(t, os.ListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractEvents(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestListResourceEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListResourceEventsSuccessfully(t, os.ListResourceEventsOutput)
+
+ count := 0
+ err := ListResourceEvents(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractEvents(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ListResourceEventsExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetEvent(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t, os.GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", "93940999-7d40-44ae-8de4-19624e7b8d18").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/orchestration/v1/stackevents/doc.go b/rackspace/orchestration/v1/stackevents/doc.go
new file mode 100644
index 0000000..dfd6ef6
--- /dev/null
+++ b/rackspace/orchestration/v1/stackevents/doc.go
@@ -0,0 +1,3 @@
+// Package stackevents provides operations for finding, listing, and retrieving
+// stack events.
+package stackevents
diff --git a/rackspace/orchestration/v1/stackresources/delegate.go b/rackspace/orchestration/v1/stackresources/delegate.go
new file mode 100644
index 0000000..cb7be28
--- /dev/null
+++ b/rackspace/orchestration/v1/stackresources/delegate.go
@@ -0,0 +1,42 @@
+package stackresources
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackresources"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Find retreives stack resources for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) os.FindResult {
+ return os.Find(c, stackName)
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(c *gophercloud.ServiceClient, stackName, stackID string, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, stackName, stackID, opts)
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) os.GetResult {
+ return os.Get(c, stackName, stackID, resourceName)
+}
+
+// Metadata retreives the metadata for the given stack resource.
+func Metadata(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) os.MetadataResult {
+ return os.Metadata(c, stackName, stackID, resourceName)
+}
+
+// ListTypes makes a request against the API to list resource types.
+func ListTypes(c *gophercloud.ServiceClient) pagination.Pager {
+ return os.ListTypes(c)
+}
+
+// Schema retreives the schema for the given resource type.
+func Schema(c *gophercloud.ServiceClient, resourceType string) os.SchemaResult {
+ return os.Schema(c, resourceType)
+}
+
+// Template retreives the template representation for the given resource type.
+func Template(c *gophercloud.ServiceClient, resourceType string) os.TemplateResult {
+ return os.Template(c, resourceType)
+}
diff --git a/rackspace/orchestration/v1/stackresources/delegate_test.go b/rackspace/orchestration/v1/stackresources/delegate_test.go
new file mode 100644
index 0000000..116e44c
--- /dev/null
+++ b/rackspace/orchestration/v1/stackresources/delegate_test.go
@@ -0,0 +1,108 @@
+package stackresources
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stackresources"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestFindResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleFindSuccessfully(t, os.FindOutput)
+
+ actual, err := Find(fake.ServiceClient(), "hello_world").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.FindExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListSuccessfully(t, os.ListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractResources(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetResource(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t, os.GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestResourceMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleMetadataSuccessfully(t, os.MetadataOutput)
+
+ actual, err := Metadata(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.MetadataExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResourceTypes(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleListTypesSuccessfully(t, os.ListTypesOutput)
+
+ count := 0
+ err := ListTypes(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractResourceTypes(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ListTypesExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestGetResourceSchema(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSchemaSuccessfully(t, os.GetSchemaOutput)
+
+ actual, err := Schema(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetSchemaExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestGetResourceTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetTemplateSuccessfully(t, os.GetTemplateOutput)
+
+ actual, err := Template(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetTemplateExpected
+ th.AssertDeepEquals(t, expected, string(actual))
+}
diff --git a/rackspace/orchestration/v1/stackresources/doc.go b/rackspace/orchestration/v1/stackresources/doc.go
new file mode 100644
index 0000000..e4f8b08
--- /dev/null
+++ b/rackspace/orchestration/v1/stackresources/doc.go
@@ -0,0 +1,5 @@
+// Package stackresources provides operations for working with stack resources.
+// A resource is a template artifact that represents some component of your
+// desired architecture (a Cloud Server, a group of scaled Cloud Servers, a load
+// balancer, some configuration management system, and so forth).
+package stackresources
diff --git a/rackspace/orchestration/v1/stacks/delegate.go b/rackspace/orchestration/v1/stacks/delegate.go
new file mode 100644
index 0000000..f7e387f
--- /dev/null
+++ b/rackspace/orchestration/v1/stacks/delegate.go
@@ -0,0 +1,49 @@
+package stacks
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Create accepts an os.CreateOpts struct and creates a new stack using the values
+// provided.
+func Create(c *gophercloud.ServiceClient, opts os.CreateOptsBuilder) os.CreateResult {
+ return os.Create(c, opts)
+}
+
+// Adopt accepts an os.AdoptOpts struct and creates a new stack from existing stack
+// resources using the values provided.
+func Adopt(c *gophercloud.ServiceClient, opts os.AdoptOptsBuilder) os.AdoptResult {
+ return os.Adopt(c, opts)
+}
+
+// List accepts an os.ListOpts struct and lists stacks based on the options provided.
+func List(c *gophercloud.ServiceClient, opts os.ListOptsBuilder) pagination.Pager {
+ return os.List(c, opts)
+}
+
+// Get retreives a stack based on the stack name and stack ID.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) os.GetResult {
+ return os.Get(c, stackName, stackID)
+}
+
+// Update accepts an os.UpdateOpts struct and updates a stack based on the options provided.
+func Update(c *gophercloud.ServiceClient, stackName, stackID string, opts os.UpdateOptsBuilder) os.UpdateResult {
+ return os.Update(c, stackName, stackID, opts)
+}
+
+// Delete deletes a stack based on the stack name and stack ID provided.
+func Delete(c *gophercloud.ServiceClient, stackName, stackID string) os.DeleteResult {
+ return os.Delete(c, stackName, stackID)
+}
+
+// Preview provides a preview of a stack based on the options provided.
+func Preview(c *gophercloud.ServiceClient, opts os.PreviewOptsBuilder) os.PreviewResult {
+ return os.Preview(c, opts)
+}
+
+// Abandon abandons a stack, keeping the resources available.
+func Abandon(c *gophercloud.ServiceClient, stackName, stackID string) os.AbandonResult {
+ return os.Abandon(c, stackName, stackID)
+}
diff --git a/rackspace/orchestration/v1/stacks/delegate_test.go b/rackspace/orchestration/v1/stacks/delegate_test.go
new file mode 100644
index 0000000..553ae94
--- /dev/null
+++ b/rackspace/orchestration/v1/stacks/delegate_test.go
@@ -0,0 +1,870 @@
+package stacks
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestCreateStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleCreateSuccessfully(t, CreateOutput)
+
+ createOpts := os.CreateOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ Template: `{
+ "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"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }`,
+ DisableRollback: os.Disable,
+ }
+ actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ 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()
+ os.HandleCreateSuccessfully(t, CreateOutput)
+
+ 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,
+ Template: `{
+ "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"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }`,
+ DisableRollback: os.Disable,
+ }
+ actual, err := Adopt(fake.ServiceClient(), adoptOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := CreateExpected
+ 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()
+ os.HandleListSuccessfully(t, os.FullListOutput)
+
+ count := 0
+ err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractStacks(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, os.ListExpected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestUpdateStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleUpdateSuccessfully(t)
+
+ updateOpts := os.UpdateOpts{
+ 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 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()
+ os.HandleDeleteSuccessfully(t)
+
+ err := Delete(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestPreviewStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandlePreviewSuccessfully(t, os.GetOutput)
+
+ previewOpts := os.PreviewOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ Template: `
+ {
+ "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"
+ }
+ }
+ }
+ }
+ }`,
+ DisableRollback: os.Disable,
+ }
+ actual, err := Preview(fake.ServiceClient(), previewOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.PreviewExpected
+ 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.AbandonOutput)
+
+ 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)
+}
diff --git a/rackspace/orchestration/v1/stacks/doc.go b/rackspace/orchestration/v1/stacks/doc.go
new file mode 100644
index 0000000..19231b5
--- /dev/null
+++ b/rackspace/orchestration/v1/stacks/doc.go
@@ -0,0 +1,8 @@
+// Package stacks provides operation for working with Heat stacks. A stack is a
+// group of resources (servers, load balancers, databases, and so forth)
+// combined to fulfill a useful purpose. Based on a template, Heat orchestration
+// engine creates an instantiated set of resources (a stack) to run the
+// application framework or component specified (in the template). A stack is a
+// running instance of a template. The result of creating a stack is a deployment
+// of the application framework or component.
+package stacks
diff --git a/rackspace/orchestration/v1/stacks/fixtures.go b/rackspace/orchestration/v1/stacks/fixtures.go
new file mode 100644
index 0000000..c9afeb1
--- /dev/null
+++ b/rackspace/orchestration/v1/stacks/fixtures.go
@@ -0,0 +1,32 @@
+package stacks
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacks"
+)
+
+// CreateExpected represents the expected object from a Create request.
+var CreateExpected = &os.CreatedStack{
+ ID: "b663e18a-4767-4cdf-9db5-9c8cc13cc38a",
+ Links: []gophercloud.Link{
+ gophercloud.Link{
+ Href: "https://ord.orchestration.api.rackspacecloud.com/v1/864477/stacks/stackcreated/b663e18a-4767-4cdf-9db5-9c8cc13cc38a",
+ Rel: "self",
+ },
+ },
+}
+
+// CreateOutput represents the response body from a Create request.
+const CreateOutput = `
+{
+ "stack": {
+ "id": "b663e18a-4767-4cdf-9db5-9c8cc13cc38a",
+ "links": [
+ {
+ "href": "https://ord.orchestration.api.rackspacecloud.com/v1/864477/stacks/stackcreated/b663e18a-4767-4cdf-9db5-9c8cc13cc38a",
+ "rel": "self"
+ }
+ ]
+ }
+}
+`
diff --git a/rackspace/orchestration/v1/stacktemplates/delegate.go b/rackspace/orchestration/v1/stacktemplates/delegate.go
new file mode 100644
index 0000000..3b5d46e
--- /dev/null
+++ b/rackspace/orchestration/v1/stacktemplates/delegate.go
@@ -0,0 +1,16 @@
+package stacktemplates
+
+import (
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacktemplates"
+)
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) os.GetResult {
+ return os.Get(c, stackName, stackID)
+}
+
+// Validate validates the given stack template.
+func Validate(c *gophercloud.ServiceClient, opts os.ValidateOptsBuilder) os.ValidateResult {
+ return os.Validate(c, opts)
+}
diff --git a/rackspace/orchestration/v1/stacktemplates/delegate_test.go b/rackspace/orchestration/v1/stacktemplates/delegate_test.go
new file mode 100644
index 0000000..d4d0f8f
--- /dev/null
+++ b/rackspace/orchestration/v1/stacktemplates/delegate_test.go
@@ -0,0 +1,47 @@
+package stacktemplates
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/orchestration/v1/stacktemplates"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleGetSuccessfully(t, os.GetOutput)
+
+ actual, err := Get(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := os.GetExpected
+ th.AssertDeepEquals(t, expected, string(actual))
+}
+
+func TestValidateTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ os.HandleValidateSuccessfully(t, os.ValidateOutput)
+
+ opts := os.ValidateOpts{
+ 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)
+
+ expected := os.ValidateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/orchestration/v1/stacktemplates/doc.go b/rackspace/orchestration/v1/stacktemplates/doc.go
new file mode 100644
index 0000000..5af0bd6
--- /dev/null
+++ b/rackspace/orchestration/v1/stacktemplates/doc.go
@@ -0,0 +1,8 @@
+// Package stacktemplates provides operations for working with Heat templates.
+// A Cloud Orchestration template is a portable file, written in a user-readable
+// language, that describes how a set of resources should be assembled and what
+// software should be installed in order to produce a working stack. The template
+// specifies what resources should be used, what attributes can be set, and other
+// parameters that are critical to the successful, repeatable automation of a
+// specific application stack.
+package stacktemplates
diff --git a/rackspace/rackconnect/v3/cloudnetworks/requests.go b/rackspace/rackconnect/v3/cloudnetworks/requests.go
new file mode 100644
index 0000000..5884303
--- /dev/null
+++ b/rackspace/rackconnect/v3/cloudnetworks/requests.go
@@ -0,0 +1,24 @@
+package cloudnetworks
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns all cloud networks that are associated with RackConnect. The ID
+// returned for each network is the same as the ID returned by the networks package.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(c)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return CloudNetworkPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Get retrieves a specific cloud network (that is associated with RackConnect)
+// based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
diff --git a/rackspace/rackconnect/v3/cloudnetworks/requests_test.go b/rackspace/rackconnect/v3/cloudnetworks/requests_test.go
new file mode 100644
index 0000000..10d15dd
--- /dev/null
+++ b/rackspace/rackconnect/v3/cloudnetworks/requests_test.go
@@ -0,0 +1,87 @@
+package cloudnetworks
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListCloudNetworks(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/cloud_networks", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[{
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "updated": "2014-05-25T02:28:44Z"
+ }]`)
+ })
+
+ expected := []CloudNetwork{
+ CloudNetwork{
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ Name: "RC-CLOUD",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ }
+
+ count := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractCloudNetworks(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetCloudNetwork(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/cloud_networks/07426958-1ebf-4c38-b032-d456820ca21a", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, `{
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "updated": "2014-05-25T02:28:44Z"
+ }`)
+ })
+
+ expected := &CloudNetwork{
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ Name: "RC-CLOUD",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ }
+
+ actual, err := Get(fake.ServiceClient(), "07426958-1ebf-4c38-b032-d456820ca21a").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/rackspace/rackconnect/v3/cloudnetworks/results.go b/rackspace/rackconnect/v3/cloudnetworks/results.go
new file mode 100644
index 0000000..f554a0d
--- /dev/null
+++ b/rackspace/rackconnect/v3/cloudnetworks/results.go
@@ -0,0 +1,113 @@
+package cloudnetworks
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// CloudNetwork represents a network associated with a RackConnect configuration.
+type CloudNetwork struct {
+ // Specifies the ID of the newtork.
+ ID string `mapstructure:"id"`
+ // Specifies the user-provided name of the network.
+ Name string `mapstructure:"name"`
+ // Specifies the IP range for this network.
+ CIDR string `mapstructure:"cidr"`
+ // Specifies the time the network was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // Specifies the time the network was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+}
+
+// CloudNetworkPage is the page returned by a pager when traversing over a
+// collection of CloudNetworks.
+type CloudNetworkPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a CloudNetworkPage contains no CloudNetworks.
+func (r CloudNetworkPage) IsEmpty() (bool, error) {
+ cns, err := ExtractCloudNetworks(r)
+ if err != nil {
+ return true, err
+ }
+ return len(cns) == 0, nil
+}
+
+// ExtractCloudNetworks extracts and returns CloudNetworks. It is used while iterating over
+// a cloudnetworks.List call.
+func ExtractCloudNetworks(page pagination.Page) ([]CloudNetwork, error) {
+ var res []CloudNetwork
+ casted := page.(CloudNetworkPage).Body
+ err := mapstructure.Decode(casted, &res)
+
+ var rawNets []interface{}
+ switch casted.(type) {
+ case interface{}:
+ rawNets = casted.([]interface{})
+ default:
+ return res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawNets {
+ thisNet := (rawNets[i]).(map[string]interface{})
+
+ if t, ok := thisNet["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CreatedAt = creationTime
+ }
+
+ if t, ok := thisNet["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].UpdatedAt = updatedTime
+ }
+ }
+
+ return res, err
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts a CloudNetwork from a GetResult.
+func (r GetResult) Extract() (*CloudNetwork, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res CloudNetwork
+
+ err := mapstructure.Decode(r.Body, &res)
+
+ b := r.Body.(map[string]interface{})
+
+ if date, ok := b["created"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.CreatedAt = t
+ }
+
+ if date, ok := b["updated"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.UpdatedAt = t
+ }
+
+ return &res, err
+}
diff --git a/rackspace/rackconnect/v3/cloudnetworks/urls.go b/rackspace/rackconnect/v3/cloudnetworks/urls.go
new file mode 100644
index 0000000..bd6b098
--- /dev/null
+++ b/rackspace/rackconnect/v3/cloudnetworks/urls.go
@@ -0,0 +1,11 @@
+package cloudnetworks
+
+import "github.com/rackspace/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("cloud_networks")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("cloud_networks", id)
+}
diff --git a/rackspace/rackconnect/v3/doc.go b/rackspace/rackconnect/v3/doc.go
new file mode 100644
index 0000000..3a8279e
--- /dev/null
+++ b/rackspace/rackconnect/v3/doc.go
@@ -0,0 +1,4 @@
+// Package rackconnect allows Rackspace cloud accounts to leverage version 3 of
+// RackConnect, Rackspace's hybrid connectivity solution connecting dedicated
+// and cloud servers.
+package rackconnect
diff --git a/rackspace/rackconnect/v3/lbpools/doc.go b/rackspace/rackconnect/v3/lbpools/doc.go
new file mode 100644
index 0000000..f4319b8
--- /dev/null
+++ b/rackspace/rackconnect/v3/lbpools/doc.go
@@ -0,0 +1,14 @@
+// Package lbpools provides access to load balancer pools associated with a
+// RackConnect configuration. Load Balancer Pools must be configured in advance
+// by your Network Security team to be eligible for use with RackConnect.
+// If you do not see a pool that you expect to see, contact your Support team
+// for further assistance. The Load Balancer Pool id returned by these calls is
+// automatically generated by the RackConnect automation and will remain constant
+// unless the Load Balancer Pool is renamed on your hardware load balancer.
+// All Load Balancer Pools will currently return a status of ACTIVE. Future
+// features may introduce additional statuses.
+// Node status values are ADDING, ACTIVE, REMOVING, ADD_FAILED, and REMOVE_FAILED.
+// The cloud_servers node count will only include Cloud Servers from the specified
+// cloud account. Any dedicated servers or cloud servers from another cloud account
+// on the same RackConnect Configuration will be counted as external nodes.
+package lbpools
diff --git a/rackspace/rackconnect/v3/lbpools/requests.go b/rackspace/rackconnect/v3/lbpools/requests.go
new file mode 100644
index 0000000..c300c56
--- /dev/null
+++ b/rackspace/rackconnect/v3/lbpools/requests.go
@@ -0,0 +1,146 @@
+package lbpools
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns all load balancer pools that are associated with RackConnect.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(c)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return PoolPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Get retrieves a specific load balancer pool (that is associated with RackConnect)
+// based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// ListNodes returns all load balancer pool nodes that are associated with RackConnect
+// for the given LB pool ID.
+func ListNodes(c *gophercloud.ServiceClient, id string) pagination.Pager {
+ url := listNodesURL(c, id)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NodePage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// CreateNode adds the cloud server with the given serverID to the load balancer
+// pool with the given poolID.
+func CreateNode(c *gophercloud.ServiceClient, poolID, serverID string) CreateNodeResult {
+ var res CreateNodeResult
+ reqBody := map[string]interface{}{
+ "cloud_server": map[string]string{
+ "id": serverID,
+ },
+ }
+ _, res.Err = c.Post(createNodeURL(c, poolID), reqBody, &res.Body, nil)
+ return res
+}
+
+// ListNodesDetails returns all load balancer pool nodes that are associated with RackConnect
+// for the given LB pool ID with all their details.
+func ListNodesDetails(c *gophercloud.ServiceClient, id string) pagination.Pager {
+ url := listNodesDetailsURL(c, id)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NodeDetailsPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// GetNode retrieves a specific LB pool node (that is associated with RackConnect)
+// based on its unique ID and the LB pool's unique ID.
+func GetNode(c *gophercloud.ServiceClient, poolID, nodeID string) GetNodeResult {
+ var res GetNodeResult
+ _, res.Err = c.Get(nodeURL(c, poolID, nodeID), &res.Body, nil)
+ return res
+}
+
+// DeleteNode removes the node with the given nodeID from the LB pool with the
+// given poolID.
+func DeleteNode(c *gophercloud.ServiceClient, poolID, nodeID string) DeleteNodeResult {
+ var res DeleteNodeResult
+ _, res.Err = c.Delete(deleteNodeURL(c, poolID, nodeID), nil)
+ return res
+}
+
+// GetNodeDetails retrieves a specific LB pool node's details based on its unique
+// ID and the LB pool's unique ID.
+func GetNodeDetails(c *gophercloud.ServiceClient, poolID, nodeID string) GetNodeDetailsResult {
+ var res GetNodeDetailsResult
+ _, res.Err = c.Get(nodeDetailsURL(c, poolID, nodeID), &res.Body, nil)
+ return res
+}
+
+// NodeOpts are options for bulk adding/deleting nodes to LB pools.
+type NodeOpts struct {
+ ServerID string
+ PoolID string
+}
+
+// NodesOpts are a slice of NodeOpts, passed as options for bulk operations.
+type NodesOpts []NodeOpts
+
+// ToLBPoolCreateNodesMap serializes a NodesOpts into a map to send in the request.
+func (o NodesOpts) ToLBPoolCreateNodesMap() ([]map[string]interface{}, error) {
+ m := make([]map[string]interface{}, len(o))
+ for i := range o {
+ m[i] = map[string]interface{}{
+ "cloud_server": map[string]string{
+ "id": o[i].ServerID,
+ },
+ "load_balancer_pool": map[string]string{
+ "id": o[i].PoolID,
+ },
+ }
+ }
+ return m, nil
+}
+
+// CreateNodes adds the cloud servers with the given serverIDs to the corresponding
+// load balancer pools with the given poolIDs.
+func CreateNodes(c *gophercloud.ServiceClient, opts NodesOpts) CreateNodesResult {
+ var res CreateNodesResult
+ reqBody, err := opts.ToLBPoolCreateNodesMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Post(createNodesURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// DeleteNodes removes the cloud servers with the given serverIDs to the corresponding
+// load balancer pools with the given poolIDs.
+func DeleteNodes(c *gophercloud.ServiceClient, opts NodesOpts) DeleteNodesResult {
+ var res DeleteNodesResult
+ reqBody, err := opts.ToLBPoolCreateNodesMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = c.Request("DELETE", createNodesURL(c), gophercloud.RequestOpts{
+ JSONBody: &reqBody,
+ OkCodes: []int{204},
+ })
+ return res
+}
+
+// ListNodesDetailsForServer is similar to ListNodesDetails but only returns nodes
+// for the given serverID.
+func ListNodesDetailsForServer(c *gophercloud.ServiceClient, serverID string) pagination.Pager {
+ url := listNodesForServerURL(c, serverID)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return NodeDetailsForServerPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
diff --git a/rackspace/rackconnect/v3/lbpools/requests_test.go b/rackspace/rackconnect/v3/lbpools/requests_test.go
new file mode 100644
index 0000000..48ebcec
--- /dev/null
+++ b/rackspace/rackconnect/v3/lbpools/requests_test.go
@@ -0,0 +1,876 @@
+package lbpools
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListPools(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[
+ {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ "name": "RCv3Test",
+ "node_counts": {
+ "cloud_servers": 3,
+ "external": 4,
+ "total": 7
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.5"
+ },
+ {
+ "id": "33021100-4abf-4836-9080-465a6d87ab68",
+ "name": "RCv3Test2",
+ "node_counts": {
+ "cloud_servers": 1,
+ "external": 0,
+ "total": 1
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.7"
+ },
+ {
+ "id": "b644350a-301b-47b5-a411-c6e0f933c347",
+ "name": "RCv3Test3",
+ "node_counts": {
+ "cloud_servers": 2,
+ "external": 3,
+ "total": 5
+ },
+ "port": 443,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.15"
+ }
+ ]`)
+ })
+
+ expected := []Pool{
+ Pool{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ Name: "RCv3Test",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 3,
+ External: 4,
+ Total: 7,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.5",
+ },
+ Pool{
+ ID: "33021100-4abf-4836-9080-465a6d87ab68",
+ Name: "RCv3Test2",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 1,
+ External: 0,
+ Total: 1,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.7",
+ },
+ Pool{
+ ID: "b644350a-301b-47b5-a411-c6e0f933c347",
+ Name: "RCv3Test3",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 2,
+ External: 3,
+ Total: 5,
+ },
+ Port: 443,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.15",
+ },
+ }
+
+ count := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPools(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetLBPool(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, `{
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ "name": "RCv3Test",
+ "node_counts": {
+ "cloud_servers": 3,
+ "external": 4,
+ "total": 7
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.5"
+ }`)
+ })
+
+ expected := &Pool{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ Name: "RCv3Test",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 3,
+ External: 4,
+ Total: 7,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.5",
+ }
+
+ actual, err := Get(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListNodes(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ACTIVE",
+ "updated": "2014-05-30T03:24:18Z"
+ },
+ {
+ "created": "2014-05-31T08:23:12Z",
+ "cloud_server": {
+ "id": "f28b870f-a063-498a-8b12-7025e5b1caa6"
+ },
+ "id": "b70481dd-7edf-4dbb-a44b-41cc7679d4fb",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ADDING",
+ "updated": "2014-05-31T08:23:26Z"
+ },
+ {
+ "created": "2014-05-31T08:23:18Z",
+ "cloud_server": {
+ "id": "a3d3a6b3-e4e4-496f-9a3d-5c987163e458"
+ },
+ "id": "ced9ddc8-6fae-4e72-9457-16ead52b5515",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ADD_FAILED",
+ "status_detail": "Unable to communicate with network device",
+ "updated": "2014-05-31T08:24:36Z"
+ }
+ ]`)
+ })
+
+ expected := []Node{
+ Node{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ },
+ Node{
+ CreatedAt: time.Date(2014, 5, 31, 8, 23, 12, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "f28b870f-a063-498a-8b12-7025e5b1caa6",
+ },
+ ID: "b70481dd-7edf-4dbb-a44b-41cc7679d4fb",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ADDING",
+ UpdatedAt: time.Date(2014, 5, 31, 8, 23, 26, 0, time.UTC),
+ },
+ Node{
+ CreatedAt: time.Date(2014, 5, 31, 8, 23, 18, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "a3d3a6b3-e4e4-496f-9a3d-5c987163e458",
+ },
+ ID: "ced9ddc8-6fae-4e72-9457-16ead52b5515",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ADD_FAILED",
+ StatusDetail: "Unable to communicate with network device",
+ UpdatedAt: time.Date(2014, 5, 31, 8, 24, 36, 0, time.UTC),
+ },
+ }
+
+ count := 0
+ err := ListNodes(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNodes(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestCreateNode(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ }
+ }
+ `)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ `)
+ })
+
+ expected := &Node{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ }
+
+ actual, err := CreateNode(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", "d95ae0c4-6ab8-4873-b82f-f8433840cff2").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListNodesDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes/details", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ [
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ "name": "RCv3Test",
+ "node_counts": {
+ "cloud_servers": 3,
+ "external": 4,
+ "total": 7
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.5"
+ },
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ ]
+ `)
+ })
+
+ expected := []NodeDetails{
+ NodeDetails{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: Pool{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ Name: "RCv3Test",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 3,
+ External: 4,
+ Total: 7,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.5",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ },
+ }
+ count := 0
+ err := ListNodesDetails(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNodesDetails(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetNode(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes/1860451d-fb89-45b8-b54e-151afceb50e5", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, `
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ `)
+ })
+
+ expected := &Node{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ }
+
+ actual, err := GetNode(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", "1860451d-fb89-45b8-b54e-151afceb50e5").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteNode(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes/1860451d-fb89-45b8-b54e-151afceb50e5", 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.StatusNoContent)
+ })
+
+ err := DeleteNode(client.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", "1860451d-fb89-45b8-b54e-151afceb50e5").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestGetNodeDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2/nodes/d95ae0c4-6ab8-4873-b82f-f8433840cff2/details", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ "name": "RCv3Test",
+ "node_counts": {
+ "cloud_servers": 3,
+ "external": 4,
+ "total": 7
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.5"
+ },
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ `)
+ })
+
+ expected := &NodeDetails{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: Pool{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ Name: "RCv3Test",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 3,
+ External: 4,
+ Total: 7,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.5",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ }
+
+ actual, err := GetNodeDetails(fake.ServiceClient(), "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2", "d95ae0c4-6ab8-4873-b82f-f8433840cff2").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestCreateNodes(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/nodes", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ [
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ }
+ },
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "load_balancer_pool": {
+ "id": "33021100-4abf-4836-9080-465a6d87ab68"
+ }
+ }
+ ]
+ `)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ [
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ },
+ "status": "ADDING",
+ "status_detail": null,
+ "updated": null
+ },
+ {
+ "created": "2014-05-31T08:23:12Z",
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "id": "b70481dd-7edf-4dbb-a44b-41cc7679d4fb",
+ "load_balancer_pool": {
+ "id": "33021100-4abf-4836-9080-465a6d87ab68"
+ },
+ "status": "ADDING",
+ "status_detail": null,
+ "updated": null
+ }
+ ]
+ `)
+ })
+
+ expected := []Node{
+ Node{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ },
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ Status: "ADDING",
+ },
+ Node{
+ CreatedAt: time.Date(2014, 5, 31, 8, 23, 12, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ },
+ ID: "b70481dd-7edf-4dbb-a44b-41cc7679d4fb",
+ LoadBalancerPool: struct {
+ ID string `mapstructure:"id"`
+ }{
+ ID: "33021100-4abf-4836-9080-465a6d87ab68",
+ },
+ Status: "ADDING",
+ },
+ }
+
+ opts := NodesOpts{
+ NodeOpts{
+ ServerID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ PoolID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ NodeOpts{
+ ServerID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ PoolID: "33021100-4abf-4836-9080-465a6d87ab68",
+ },
+ }
+ actual, err := CreateNodes(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteNodes(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/nodes", 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")
+ th.TestJSONRequest(t, r, `
+ [
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2"
+ }
+ },
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ },
+ "load_balancer_pool": {
+ "id": "33021100-4abf-4836-9080-465a6d87ab68"
+ }
+ }
+ ]
+ `)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ opts := NodesOpts{
+ NodeOpts{
+ ServerID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ PoolID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ },
+ NodeOpts{
+ ServerID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ PoolID: "33021100-4abf-4836-9080-465a6d87ab68",
+ },
+ }
+ err := DeleteNodes(client.ServiceClient(), opts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListNodesForServerDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/load_balancer_pools/nodes/details", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ [
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "id": "1860451d-fb89-45b8-b54e-151afceb50e5",
+ "load_balancer_pool": {
+ "id": "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ "name": "RCv3Test",
+ "node_counts": {
+ "cloud_servers": 3,
+ "external": 4,
+ "total": 7
+ },
+ "port": 80,
+ "status": "ACTIVE",
+ "status_detail": null,
+ "virtual_ip": "203.0.113.5"
+ },
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ ]
+ `)
+ })
+
+ expected := []NodeDetailsForServer{
+ NodeDetailsForServer{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ ID: "1860451d-fb89-45b8-b54e-151afceb50e5",
+ LoadBalancerPool: Pool{
+ ID: "d6d3aa7c-dfa5-4e61-96ee-1d54ac1075d2",
+ Name: "RCv3Test",
+ NodeCounts: struct {
+ CloudServers int `mapstructure:"cloud_servers"`
+ External int `mapstructure:"external"`
+ Total int `mapstructure:"total"`
+ }{
+ CloudServers: 3,
+ External: 4,
+ Total: 7,
+ },
+ Port: 80,
+ Status: "ACTIVE",
+ VirtualIP: "203.0.113.5",
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ },
+ }
+ count := 0
+ err := ListNodesDetailsForServer(fake.ServiceClient(), "07426958-1ebf-4c38-b032-d456820ca21a").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractNodesDetailsForServer(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
diff --git a/rackspace/rackconnect/v3/lbpools/results.go b/rackspace/rackconnect/v3/lbpools/results.go
new file mode 100644
index 0000000..e5e914b
--- /dev/null
+++ b/rackspace/rackconnect/v3/lbpools/results.go
@@ -0,0 +1,505 @@
+package lbpools
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Pool represents a load balancer pool associated with a RackConnect configuration.
+type Pool struct {
+ // The unique ID of the load balancer pool.
+ ID string `mapstructure:"id"`
+ // The name of the load balancer pool.
+ Name string `mapstructure:"name"`
+ // The node counts associated witht the load balancer pool.
+ NodeCounts struct {
+ // The number of nodes associated with this LB pool for this account.
+ CloudServers int `mapstructure:"cloud_servers"`
+ // The number of nodes associated with this LB pool from other accounts.
+ External int `mapstructure:"external"`
+ // The total number of nodes associated with this LB pool.
+ Total int `mapstructure:"total"`
+ } `mapstructure:"node_counts"`
+ // The port of the LB pool
+ Port int `mapstructure:"port"`
+ // The status of the LB pool
+ Status string `mapstructure:"status"`
+ // The details of the status of the LB pool
+ StatusDetail string `mapstructure:"status_detail"`
+ // The virtual IP of the LB pool
+ VirtualIP string `mapstructure:"virtual_ip"`
+}
+
+// PoolPage is the page returned by a pager when traversing over a
+// collection of Pools.
+type PoolPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a PoolPage contains no Pools.
+func (r PoolPage) IsEmpty() (bool, error) {
+ cns, err := ExtractPools(r)
+ if err != nil {
+ return true, err
+ }
+ return len(cns) == 0, nil
+}
+
+// ExtractPools extracts and returns Pools. It is used while iterating over
+// an lbpools.List call.
+func ExtractPools(page pagination.Page) ([]Pool, error) {
+ var res []Pool
+ err := mapstructure.Decode(page.(PoolPage).Body, &res)
+ return res, err
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts an LBPool from a GetResult.
+func (r GetResult) Extract() (*Pool, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res Pool
+ err := mapstructure.Decode(r.Body, &res)
+ return &res, err
+}
+
+// Node represents a load balancer pool node associated with a RackConnect configuration.
+type Node struct {
+ // The unique ID of the LB node.
+ ID string `mapstructure:"id"`
+ // The cloud server (node) of the load balancer pool.
+ CloudServer struct {
+ // The cloud server ID.
+ ID string `mapstructure:"id"`
+ } `mapstructure:"cloud_server"`
+ // The load balancer pool.
+ LoadBalancerPool struct {
+ // The LB pool ID.
+ ID string `mapstructure:"id"`
+ } `mapstructure:"load_balancer_pool"`
+ // The status of the LB pool.
+ Status string `mapstructure:"status"`
+ // The details of the status of the LB pool.
+ StatusDetail string `mapstructure:"status_detail"`
+ // The time the LB node was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The time the LB node was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+}
+
+// NodePage is the page returned by a pager when traversing over a
+// collection of Nodes.
+type NodePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a NodePage contains no Nodes.
+func (r NodePage) IsEmpty() (bool, error) {
+ n, err := ExtractNodes(r)
+ if err != nil {
+ return true, err
+ }
+ return len(n) == 0, nil
+}
+
+// ExtractNodes extracts and returns a slice of Nodes. It is used while iterating over
+// an lbpools.ListNodes call.
+func ExtractNodes(page pagination.Page) ([]Node, error) {
+ var res []Node
+ casted := page.(NodePage).Body
+ err := mapstructure.Decode(casted, &res)
+
+ var rawNodes []interface{}
+ switch casted.(type) {
+ case interface{}:
+ rawNodes = casted.([]interface{})
+ default:
+ return res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawNodes {
+ thisNode := (rawNodes[i]).(map[string]interface{})
+
+ if t, ok := thisNode["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CreatedAt = creationTime
+ }
+
+ if t, ok := thisNode["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].UpdatedAt = updatedTime
+ }
+ }
+
+ return res, err
+}
+
+// NodeResult represents a result that can be extracted as a Node.
+type NodeResult struct {
+ gophercloud.Result
+}
+
+// CreateNodeResult represents the result of an CreateNode operation.
+type CreateNodeResult struct {
+ NodeResult
+}
+
+// GetNodeResult represents the result of an GetNode operation.
+type GetNodeResult struct {
+ NodeResult
+}
+
+// Extract is a function that extracts a Node from a NodeResult.
+func (r NodeResult) Extract() (*Node, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res Node
+ err := mapstructure.Decode(r.Body, &res)
+
+ b := r.Body.(map[string]interface{})
+
+ if date, ok := b["created"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.CreatedAt = t
+ }
+
+ if date, ok := b["updated"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.UpdatedAt = t
+ }
+
+ return &res, err
+}
+
+// NodeDetails represents a load balancer pool node associated with a RackConnect configuration
+// with all its details.
+type NodeDetails struct {
+ // The unique ID of the LB node.
+ ID string `mapstructure:"id"`
+ // The cloud server (node) of the load balancer pool.
+ CloudServer struct {
+ // The cloud server ID.
+ ID string `mapstructure:"id"`
+ // The name of the server.
+ Name string `mapstructure:"name"`
+ // The cloud network for the cloud server.
+ CloudNetwork struct {
+ // The network ID.
+ ID string `mapstructure:"id"`
+ // The network name.
+ Name string `mapstructure:"name"`
+ // The network's private IPv4 address.
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ // The IP range for the network.
+ CIDR string `mapstructure:"cidr"`
+ // The datetime the network was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The last datetime the network was updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ // The datetime the server was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The datetime the server was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_server"`
+ // The load balancer pool.
+ LoadBalancerPool Pool `mapstructure:"load_balancer_pool"`
+ // The status of the LB pool.
+ Status string `mapstructure:"status"`
+ // The details of the status of the LB pool.
+ StatusDetail string `mapstructure:"status_detail"`
+ // The time the LB node was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The time the LB node was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+}
+
+// NodeDetailsPage is the page returned by a pager when traversing over a
+// collection of NodeDetails.
+type NodeDetailsPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a NodeDetailsPage contains no NodeDetails.
+func (r NodeDetailsPage) IsEmpty() (bool, error) {
+ n, err := ExtractNodesDetails(r)
+ if err != nil {
+ return true, err
+ }
+ return len(n) == 0, nil
+}
+
+// ExtractNodesDetails extracts and returns a slice of NodeDetails. It is used while iterating over
+// an lbpools.ListNodesDetails call.
+func ExtractNodesDetails(page pagination.Page) ([]NodeDetails, error) {
+ var res []NodeDetails
+ casted := page.(NodeDetailsPage).Body
+ err := mapstructure.Decode(casted, &res)
+
+ var rawNodesDetails []interface{}
+ switch casted.(type) {
+ case interface{}:
+ rawNodesDetails = casted.([]interface{})
+ default:
+ return res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawNodesDetails {
+ thisNodeDetails := (rawNodesDetails[i]).(map[string]interface{})
+
+ if t, ok := thisNodeDetails["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CreatedAt = creationTime
+ }
+
+ if t, ok := thisNodeDetails["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].UpdatedAt = updatedTime
+ }
+
+ if cs, ok := thisNodeDetails["cloud_server"].(map[string]interface{}); ok {
+ if t, ok := cs["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CreatedAt = creationTime
+ }
+ if t, ok := cs["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.UpdatedAt = updatedTime
+ }
+ if cn, ok := cs["cloud_network"].(map[string]interface{}); ok {
+ if t, ok := cn["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CloudNetwork.CreatedAt = creationTime
+ }
+ if t, ok := cn["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CloudNetwork.UpdatedAt = updatedTime
+ }
+ }
+ }
+ }
+
+ return res, err
+}
+
+// GetNodeDetailsResult represents the result of an NodeDetails operation.
+type GetNodeDetailsResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts a NodeDetails from a NodeDetailsResult.
+func (r GetNodeDetailsResult) Extract() (*NodeDetails, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res NodeDetails
+ err := mapstructure.Decode(r.Body, &res)
+
+ b := r.Body.(map[string]interface{})
+
+ if date, ok := b["created"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.CreatedAt = t
+ }
+
+ if date, ok := b["updated"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.UpdatedAt = t
+ }
+
+ if cs, ok := b["cloud_server"].(map[string]interface{}); ok {
+ if t, ok := cs["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CreatedAt = creationTime
+ }
+ if t, ok := cs["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.UpdatedAt = updatedTime
+ }
+ if cn, ok := cs["cloud_network"].(map[string]interface{}); ok {
+ if t, ok := cn["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CloudNetwork.CreatedAt = creationTime
+ }
+ if t, ok := cn["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CloudNetwork.UpdatedAt = updatedTime
+ }
+ }
+ }
+
+ return &res, err
+}
+
+// DeleteNodeResult represents the result of a DeleteNode operation.
+type DeleteNodeResult struct {
+ gophercloud.ErrResult
+}
+
+// CreateNodesResult represents the result of a CreateNodes operation.
+type CreateNodesResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that extracts a slice of Nodes from a CreateNodesResult.
+func (r CreateNodesResult) Extract() ([]Node, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res []Node
+ err := mapstructure.Decode(r.Body, &res)
+
+ b := r.Body.([]interface{})
+ for i := range b {
+ if date, ok := b[i].(map[string]interface{})["created"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res[i].CreatedAt = t
+ }
+ if date, ok := b[i].(map[string]interface{})["updated"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res[i].UpdatedAt = t
+ }
+ }
+
+ return res, err
+}
+
+// DeleteNodesResult represents the result of a DeleteNodes operation.
+type DeleteNodesResult struct {
+ gophercloud.ErrResult
+}
+
+// NodeDetailsForServer represents a load balancer pool node associated with a RackConnect configuration
+// with all its details for a particular server.
+type NodeDetailsForServer struct {
+ // The unique ID of the LB node.
+ ID string `mapstructure:"id"`
+ // The load balancer pool.
+ LoadBalancerPool Pool `mapstructure:"load_balancer_pool"`
+ // The status of the LB pool.
+ Status string `mapstructure:"status"`
+ // The details of the status of the LB pool.
+ StatusDetail string `mapstructure:"status_detail"`
+ // The time the LB node was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The time the LB node was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+}
+
+// NodeDetailsForServerPage is the page returned by a pager when traversing over a
+// collection of NodeDetailsForServer.
+type NodeDetailsForServerPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a NodeDetailsForServerPage contains no NodeDetailsForServer.
+func (r NodeDetailsForServerPage) IsEmpty() (bool, error) {
+ n, err := ExtractNodesDetailsForServer(r)
+ if err != nil {
+ return true, err
+ }
+ return len(n) == 0, nil
+}
+
+// ExtractNodesDetailsForServer extracts and returns a slice of NodeDetailsForServer. It is used while iterating over
+// an lbpools.ListNodesDetailsForServer call.
+func ExtractNodesDetailsForServer(page pagination.Page) ([]NodeDetailsForServer, error) {
+ var res []NodeDetailsForServer
+ casted := page.(NodeDetailsForServerPage).Body
+ err := mapstructure.Decode(casted, &res)
+
+ var rawNodesDetails []interface{}
+ switch casted.(type) {
+ case interface{}:
+ rawNodesDetails = casted.([]interface{})
+ default:
+ return res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawNodesDetails {
+ thisNodeDetails := (rawNodesDetails[i]).(map[string]interface{})
+
+ if t, ok := thisNodeDetails["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CreatedAt = creationTime
+ }
+
+ if t, ok := thisNodeDetails["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].UpdatedAt = updatedTime
+ }
+ }
+
+ return res, err
+}
diff --git a/rackspace/rackconnect/v3/lbpools/urls.go b/rackspace/rackconnect/v3/lbpools/urls.go
new file mode 100644
index 0000000..c238239
--- /dev/null
+++ b/rackspace/rackconnect/v3/lbpools/urls.go
@@ -0,0 +1,49 @@
+package lbpools
+
+import "github.com/rackspace/gophercloud"
+
+var root = "load_balancer_pools"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(root)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(root, id)
+}
+
+func listNodesURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(root, id, "nodes")
+}
+
+func createNodeURL(c *gophercloud.ServiceClient, id string) string {
+ return listNodesURL(c, id)
+}
+
+func listNodesDetailsURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(root, id, "nodes", "details")
+}
+
+func nodeURL(c *gophercloud.ServiceClient, poolID, nodeID string) string {
+ return c.ServiceURL(root, poolID, "nodes", nodeID)
+}
+
+func deleteNodeURL(c *gophercloud.ServiceClient, poolID, nodeID string) string {
+ return nodeURL(c, poolID, nodeID)
+}
+
+func nodeDetailsURL(c *gophercloud.ServiceClient, poolID, nodeID string) string {
+ return c.ServiceURL(root, poolID, "nodes", nodeID, "details")
+}
+
+func createNodesURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(root, "nodes")
+}
+
+func deleteNodesURL(c *gophercloud.ServiceClient) string {
+ return createNodesURL(c)
+}
+
+func listNodesForServerURL(c *gophercloud.ServiceClient, serverID string) string {
+ return c.ServiceURL(root, "nodes", "details?cloud_server_id="+serverID)
+}
diff --git a/rackspace/rackconnect/v3/publicips/requests.go b/rackspace/rackconnect/v3/publicips/requests.go
new file mode 100644
index 0000000..1164260
--- /dev/null
+++ b/rackspace/rackconnect/v3/publicips/requests.go
@@ -0,0 +1,50 @@
+package publicips
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns all public IPs.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ url := listURL(c)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return PublicIPPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Create adds a public IP to the server with the given serverID.
+func Create(c *gophercloud.ServiceClient, serverID string) CreateResult {
+ var res CreateResult
+ reqBody := map[string]interface{}{
+ "cloud_server": map[string]string{
+ "id": serverID,
+ },
+ }
+ _, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+ return res
+}
+
+// ListForServer returns all public IPs for the server with the given serverID.
+func ListForServer(c *gophercloud.ServiceClient, serverID string) pagination.Pager {
+ url := listForServerURL(c, serverID)
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return PublicIPPage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(c, url, createPage)
+}
+
+// Get retrieves the public IP with the given id.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+ var res GetResult
+ _, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+ return res
+}
+
+// Delete removes the public IP with the given id.
+func Delete(c *gophercloud.ServiceClient, id string) DeleteResult {
+ var res DeleteResult
+ _, res.Err = c.Delete(deleteURL(c, id), nil)
+ return res
+}
diff --git a/rackspace/rackconnect/v3/publicips/requests_test.go b/rackspace/rackconnect/v3/publicips/requests_test.go
new file mode 100644
index 0000000..61da2b0
--- /dev/null
+++ b/rackspace/rackconnect/v3/publicips/requests_test.go
@@ -0,0 +1,378 @@
+package publicips
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestListIPs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/public_ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `[
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ "public_ip_v4": "203.0.113.110",
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ ]`)
+ })
+
+ expected := []PublicIP{
+ PublicIP{
+ ID: "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ PublicIPv4: "203.0.113.110",
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ Status: "ACTIVE",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ },
+ }
+
+ count := 0
+ err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPublicIPs(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestCreateIP(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/public_ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+ th.TestJSONRequest(t, r, `
+ {
+ "cloud_server": {
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2"
+ }
+ }
+ `)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ "status": "ADDING"
+ }`)
+ })
+
+ expected := &PublicIP{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ ID: "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ Status: "ADDING",
+ }
+
+ actual, err := Create(fake.ServiceClient(), "d95ae0c4-6ab8-4873-b82f-f8433840cff2").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestGetIP(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/public_ips/2d0f586b-37a7-4ae0-adac-2743d5feb450", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ 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, `
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ "public_ip_v4": "203.0.113.110",
+ "status": "ACTIVE",
+ "status_detail": null,
+ "updated": "2014-05-30T03:24:18Z"
+ }`)
+ })
+
+ expected := &PublicIP{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ ID: "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ Status: "ACTIVE",
+ PublicIPv4: "203.0.113.110",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ }
+
+ actual, err := Get(fake.ServiceClient(), "2d0f586b-37a7-4ae0-adac-2743d5feb450").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteIP(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/public_ips/2d0f586b-37a7-4ae0-adac-2743d5feb450", 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.StatusNoContent)
+ })
+
+ err := Delete(client.ServiceClient(), "2d0f586b-37a7-4ae0-adac-2743d5feb450").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestListForServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ th.Mux.HandleFunc("/public_ips", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ th.TestHeader(t, r, "Accept", "application/json")
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ [
+ {
+ "created": "2014-05-30T03:23:42Z",
+ "cloud_server": {
+ "cloud_network": {
+ "cidr": "192.168.100.0/24",
+ "created": "2014-05-25T01:23:42Z",
+ "id": "07426958-1ebf-4c38-b032-d456820ca21a",
+ "name": "RC-CLOUD",
+ "private_ip_v4": "192.168.100.5",
+ "updated": "2014-05-25T02:28:44Z"
+ },
+ "created": "2014-05-30T02:18:42Z",
+ "id": "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ "name": "RCv3TestServer1",
+ "updated": "2014-05-30T02:19:18Z"
+ },
+ "id": "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ "public_ip_v4": "203.0.113.110",
+ "status": "ACTIVE",
+ "updated": "2014-05-30T03:24:18Z"
+ }
+ ]`)
+ })
+
+ expected := []PublicIP{
+ PublicIP{
+ CreatedAt: time.Date(2014, 5, 30, 3, 23, 42, 0, time.UTC),
+ CloudServer: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ CloudNetwork struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "d95ae0c4-6ab8-4873-b82f-f8433840cff2",
+ CloudNetwork: struct {
+ ID string `mapstructure:"id"`
+ Name string `mapstructure:"name"`
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ CIDR string `mapstructure:"cidr"`
+ CreatedAt time.Time `mapstructure:"-"`
+ UpdatedAt time.Time `mapstructure:"-"`
+ }{
+ ID: "07426958-1ebf-4c38-b032-d456820ca21a",
+ CIDR: "192.168.100.0/24",
+ CreatedAt: time.Date(2014, 5, 25, 1, 23, 42, 0, time.UTC),
+ Name: "RC-CLOUD",
+ PrivateIPv4: "192.168.100.5",
+ UpdatedAt: time.Date(2014, 5, 25, 2, 28, 44, 0, time.UTC),
+ },
+ CreatedAt: time.Date(2014, 5, 30, 2, 18, 42, 0, time.UTC),
+ Name: "RCv3TestServer1",
+ UpdatedAt: time.Date(2014, 5, 30, 2, 19, 18, 0, time.UTC),
+ },
+ ID: "2d0f586b-37a7-4ae0-adac-2743d5feb450",
+ Status: "ACTIVE",
+ PublicIPv4: "203.0.113.110",
+ UpdatedAt: time.Date(2014, 5, 30, 3, 24, 18, 0, time.UTC),
+ },
+ }
+ count := 0
+ err := ListForServer(fake.ServiceClient(), "d95ae0c4-6ab8-4873-b82f-f8433840cff2").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractPublicIPs(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
diff --git a/rackspace/rackconnect/v3/publicips/results.go b/rackspace/rackconnect/v3/publicips/results.go
new file mode 100644
index 0000000..132cf77
--- /dev/null
+++ b/rackspace/rackconnect/v3/publicips/results.go
@@ -0,0 +1,221 @@
+package publicips
+
+import (
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// PublicIP represents a public IP address.
+type PublicIP struct {
+ // The unique ID of the public IP.
+ ID string `mapstructure:"id"`
+ // The IPv4 address of the public IP.
+ PublicIPv4 string `mapstructure:"public_ip_v4"`
+ // The cloud server (node) of the public IP.
+ CloudServer struct {
+ // The cloud server ID.
+ ID string `mapstructure:"id"`
+ // The name of the server.
+ Name string `mapstructure:"name"`
+ // The cloud network for the cloud server.
+ CloudNetwork struct {
+ // The network ID.
+ ID string `mapstructure:"id"`
+ // The network name.
+ Name string `mapstructure:"name"`
+ // The network's private IPv4 address.
+ PrivateIPv4 string `mapstructure:"private_ip_v4"`
+ // The IP range for the network.
+ CIDR string `mapstructure:"cidr"`
+ // The datetime the network was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The last datetime the network was updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_network"`
+ // The datetime the server was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The datetime the server was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+ } `mapstructure:"cloud_server"`
+ // The status of the public IP.
+ Status string `mapstructure:"status"`
+ // The details of the status of the public IP.
+ StatusDetail string `mapstructure:"status_detail"`
+ // The time the public IP was created.
+ CreatedAt time.Time `mapstructure:"-"`
+ // The time the public IP was last updated.
+ UpdatedAt time.Time `mapstructure:"-"`
+}
+
+// PublicIPPage is the page returned by a pager when traversing over a
+// collection of PublicIPs.
+type PublicIPPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a PublicIPPage contains no PublicIPs.
+func (r PublicIPPage) IsEmpty() (bool, error) {
+ n, err := ExtractPublicIPs(r)
+ if err != nil {
+ return true, err
+ }
+ return len(n) == 0, nil
+}
+
+// ExtractPublicIPs extracts and returns a slice of PublicIPs. It is used while iterating over
+// a publicips.List call.
+func ExtractPublicIPs(page pagination.Page) ([]PublicIP, error) {
+ var res []PublicIP
+ casted := page.(PublicIPPage).Body
+ err := mapstructure.Decode(casted, &res)
+
+ var rawNodesDetails []interface{}
+ switch casted.(type) {
+ case interface{}:
+ rawNodesDetails = casted.([]interface{})
+ default:
+ return res, fmt.Errorf("Unknown type: %v", reflect.TypeOf(casted))
+ }
+
+ for i := range rawNodesDetails {
+ thisNodeDetails := (rawNodesDetails[i]).(map[string]interface{})
+
+ if t, ok := thisNodeDetails["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CreatedAt = creationTime
+ }
+
+ if t, ok := thisNodeDetails["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].UpdatedAt = updatedTime
+ }
+
+ if cs, ok := thisNodeDetails["cloud_server"].(map[string]interface{}); ok {
+ if t, ok := cs["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CreatedAt = creationTime
+ }
+ if t, ok := cs["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.UpdatedAt = updatedTime
+ }
+ if cn, ok := cs["cloud_network"].(map[string]interface{}); ok {
+ if t, ok := cn["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CloudNetwork.CreatedAt = creationTime
+ }
+ if t, ok := cn["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return res, err
+ }
+ res[i].CloudServer.CloudNetwork.UpdatedAt = updatedTime
+ }
+ }
+ }
+ }
+
+ return res, err
+}
+
+// PublicIPResult represents a result that can be extracted into a PublicIP.
+type PublicIPResult struct {
+ gophercloud.Result
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+ PublicIPResult
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+ PublicIPResult
+}
+
+// Extract is a function that extracts a PublicIP from a PublicIPResult.
+func (r PublicIPResult) Extract() (*PublicIP, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ var res PublicIP
+ err := mapstructure.Decode(r.Body, &res)
+
+ b := r.Body.(map[string]interface{})
+
+ if date, ok := b["created"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.CreatedAt = t
+ }
+
+ if date, ok := b["updated"]; ok && date != nil {
+ t, err := time.Parse(time.RFC3339, date.(string))
+ if err != nil {
+ return nil, err
+ }
+ res.UpdatedAt = t
+ }
+
+ if cs, ok := b["cloud_server"].(map[string]interface{}); ok {
+ if t, ok := cs["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CreatedAt = creationTime
+ }
+ if t, ok := cs["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.UpdatedAt = updatedTime
+ }
+ if cn, ok := cs["cloud_network"].(map[string]interface{}); ok {
+ if t, ok := cn["created"].(string); ok && t != "" {
+ creationTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CloudNetwork.CreatedAt = creationTime
+ }
+ if t, ok := cn["updated"].(string); ok && t != "" {
+ updatedTime, err := time.Parse(time.RFC3339, t)
+ if err != nil {
+ return &res, err
+ }
+ res.CloudServer.CloudNetwork.UpdatedAt = updatedTime
+ }
+ }
+ }
+
+ return &res, err
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/rackspace/rackconnect/v3/publicips/urls.go b/rackspace/rackconnect/v3/publicips/urls.go
new file mode 100644
index 0000000..6f310be
--- /dev/null
+++ b/rackspace/rackconnect/v3/publicips/urls.go
@@ -0,0 +1,25 @@
+package publicips
+
+import "github.com/rackspace/gophercloud"
+
+var root = "public_ips"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(root)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(root)
+}
+
+func listForServerURL(c *gophercloud.ServiceClient, serverID string) string {
+ return c.ServiceURL(root + "?cloud_server_id=" + serverID)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(root, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return getURL(c, id)
+}
diff --git a/results.go b/results.go
new file mode 100644
index 0000000..27fd1b6
--- /dev/null
+++ b/results.go
@@ -0,0 +1,153 @@
+package gophercloud
+
+import (
+ "encoding/json"
+ "net/http"
+ "reflect"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+/*
+Result is an internal type to be used by individual resource packages, but its
+methods will be available on a wide variety of user-facing embedding types.
+
+It acts as a base struct that other Result types, returned from request
+functions, can embed for convenience. All Results capture basic information
+from the HTTP transaction that was performed, including the response body,
+HTTP headers, and any errors that happened.
+
+Generally, each Result type will have an Extract method that can be used to
+further interpret the result's payload in a specific context. Extensions or
+providers can then provide additional extraction functions to pull out
+provider- or extension-specific information as well.
+*/
+type Result struct {
+ // Body is the payload of the HTTP response from the server. In most cases,
+ // this will be the deserialized JSON structure.
+ Body interface{}
+
+ // Header contains the HTTP header structure from the original response.
+ Header http.Header
+
+ // Err is an error that occurred during the operation. It's deferred until
+ // extraction to make it easier to chain the Extract call.
+ Err error
+}
+
+// PrettyPrintJSON creates a string containing the full response body as
+// pretty-printed JSON. It's useful for capturing test fixtures and for
+// debugging extraction bugs. If you include its output in an issue related to
+// a buggy extraction function, we will all love you forever.
+func (r Result) PrettyPrintJSON() string {
+ pretty, err := json.MarshalIndent(r.Body, "", " ")
+ if err != nil {
+ panic(err.Error())
+ }
+ return string(pretty)
+}
+
+// ErrResult is an internal type to be used by individual resource packages, but
+// its methods will be available on a wide variety of user-facing embedding
+// types.
+//
+// It represents results that only contain a potential error and
+// nothing else. Usually, if the operation executed successfully, the Err field
+// will be nil; otherwise it will be stocked with a relevant error. Use the
+// ExtractErr method
+// to cleanly pull it out.
+type ErrResult struct {
+ Result
+}
+
+// ExtractErr is a function that extracts error information, or nil, from a result.
+func (r ErrResult) ExtractErr() error {
+ return r.Err
+}
+
+/*
+HeaderResult is an internal type to be used by individual resource packages, but
+its methods will be available on a wide variety of user-facing embedding types.
+
+It represents a result that only contains an error (possibly nil) and an
+http.Header. This is used, for example, by the objectstorage packages in
+openstack, because most of the operations don't return response bodies, but do
+have relevant information in headers.
+*/
+type HeaderResult struct {
+ Result
+}
+
+// ExtractHeader will return the http.Header and error from the HeaderResult.
+//
+// header, err := objects.Create(client, "my_container", objects.CreateOpts{}).ExtractHeader()
+func (hr HeaderResult) ExtractHeader() (http.Header, error) {
+ return hr.Header, hr.Err
+}
+
+// DecodeHeader is a function that decodes a header (usually of type map[string]interface{}) to
+// another type (usually a struct). This function is used by the objectstorage package to give
+// users access to response headers without having to query a map. A DecodeHookFunction is used,
+// because OpenStack-based clients return header values as arrays (Go slices).
+func DecodeHeader(from, to interface{}) error {
+ config := &mapstructure.DecoderConfig{
+ DecodeHook: func(from, to reflect.Kind, data interface{}) (interface{}, error) {
+ if from == reflect.Slice {
+ return data.([]string)[0], nil
+ }
+ return data, nil
+ },
+ Result: to,
+ WeaklyTypedInput: true,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return err
+ }
+ if err := decoder.Decode(from); err != nil {
+ return err
+ }
+ return nil
+}
+
+// RFC3339Milli describes a common time format used by some API responses.
+const RFC3339Milli = "2006-01-02T15:04:05.999999Z"
+
+// Time format used in cloud orchestration
+const STACK_TIME_FMT = "2006-01-02T15:04:05"
+
+/*
+Link is an internal type to be used in packages of collection resources that are
+paginated in a certain way.
+
+It's a response substructure common to many paginated collection results that is
+used to point to related pages. Usually, the one we care about is the one with
+Rel field set to "next".
+*/
+type Link struct {
+ Href string `mapstructure:"href"`
+ Rel string `mapstructure:"rel"`
+}
+
+/*
+ExtractNextURL is an internal function useful for packages of collection
+resources that are paginated in a certain way.
+
+It attempts attempts to extract the "next" URL from slice of Link structs, or
+"" if no such URL is present.
+*/
+func ExtractNextURL(links []Link) (string, error) {
+ var url string
+
+ for _, l := range links {
+ if l.Rel == "next" {
+ url = l.Href
+ }
+ }
+
+ if url == "" {
+ return "", nil
+ }
+
+ return url, nil
+}
diff --git a/script/acceptancetest b/script/acceptancetest
new file mode 100755
index 0000000..f9c89f4
--- /dev/null
+++ b/script/acceptancetest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the acceptance tests.
+
+exec go test -p=1 -tags 'acceptance fixtures' github.com/rackspace/gophercloud/acceptance/... $@
diff --git a/script/bootstrap b/script/bootstrap
new file mode 100755
index 0000000..6bae6e8
--- /dev/null
+++ b/script/bootstrap
@@ -0,0 +1,26 @@
+#!/bin/bash
+#
+# This script helps new contributors set up their local workstation for
+# gophercloud development and contributions.
+
+# Create the environment
+export GOPATH=$HOME/go/gophercloud
+mkdir -p $GOPATH
+
+# Download gophercloud into that environment
+go get github.com/rackspace/gophercloud
+cd $GOPATH/src/github.com/rackspace/gophercloud
+git checkout master
+
+# Write out the env.sh convenience file.
+cd $GOPATH
+cat <<EOF >env.sh
+#!/bin/bash
+export GOPATH=$(pwd)
+export GOPHERCLOUD=$GOPATH/src/github.com/rackspace/gophercloud
+EOF
+chmod a+x env.sh
+
+# Make changes immediately available as a convenience.
+. ./env.sh
+
diff --git a/script/cibuild b/script/cibuild
new file mode 100755
index 0000000..1cb389e
--- /dev/null
+++ b/script/cibuild
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Test script to be invoked by Travis.
+
+exec script/unittest -v
diff --git a/script/test b/script/test
new file mode 100755
index 0000000..1e03dff
--- /dev/null
+++ b/script/test
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run all the tests.
+
+exec go test -tags 'acceptance fixtures' ./... $@
diff --git a/script/unittest b/script/unittest
new file mode 100755
index 0000000..d3440a9
--- /dev/null
+++ b/script/unittest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the unit tests.
+
+exec go test -tags fixtures ./... $@
diff --git a/service_client.go b/service_client.go
new file mode 100644
index 0000000..3490da0
--- /dev/null
+++ b/service_client.go
@@ -0,0 +1,32 @@
+package gophercloud
+
+import "strings"
+
+// ServiceClient stores details required to interact with a specific service API implemented by a provider.
+// Generally, you'll acquire these by calling the appropriate `New` method on a ProviderClient.
+type ServiceClient struct {
+ // ProviderClient is a reference to the provider that implements this service.
+ *ProviderClient
+
+ // Endpoint is the base URL of the service's API, acquired from a service catalog.
+ // It MUST end with a /.
+ Endpoint string
+
+ // ResourceBase is the base URL shared by the resources within a service's API. It should include
+ // the API version and, like Endpoint, MUST end with a / if set. If not set, the Endpoint is used
+ // as-is, instead.
+ ResourceBase string
+}
+
+// ResourceBaseURL returns the base URL of any resources used by this service. It MUST end with a /.
+func (client *ServiceClient) ResourceBaseURL() string {
+ if client.ResourceBase != "" {
+ return client.ResourceBase
+ }
+ return client.Endpoint
+}
+
+// ServiceURL constructs a URL for a resource belonging to this provider.
+func (client *ServiceClient) ServiceURL(parts ...string) string {
+ return client.ResourceBaseURL() + strings.Join(parts, "/")
+}
diff --git a/service_client_test.go b/service_client_test.go
new file mode 100644
index 0000000..84beb3f
--- /dev/null
+++ b/service_client_test.go
@@ -0,0 +1,14 @@
+package gophercloud
+
+import (
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestServiceURL(t *testing.T) {
+ c := &ServiceClient{Endpoint: "http://123.45.67.8/"}
+ expected := "http://123.45.67.8/more/parts/here"
+ actual := c.ServiceURL("more", "parts", "here")
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/testhelper/client/fake.go b/testhelper/client/fake.go
new file mode 100644
index 0000000..5b69b05
--- /dev/null
+++ b/testhelper/client/fake.go
@@ -0,0 +1,17 @@
+package client
+
+import (
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/testhelper"
+)
+
+// Fake token to use.
+const TokenID = "cbc36478b0bd8e67e89469c7749d4127"
+
+// ServiceClient returns a generic service client for use in tests.
+func ServiceClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{TokenID: TokenID},
+ Endpoint: testhelper.Endpoint(),
+ }
+}
diff --git a/testhelper/convenience.go b/testhelper/convenience.go
new file mode 100644
index 0000000..cf33e1a
--- /dev/null
+++ b/testhelper/convenience.go
@@ -0,0 +1,329 @@
+package testhelper
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+const (
+ logBodyFmt = "\033[1;31m%s %s\033[0m"
+ greenCode = "\033[0m\033[1;32m"
+ yellowCode = "\033[0m\033[1;33m"
+ resetCode = "\033[0m\033[1;31m"
+)
+
+func prefix(depth int) string {
+ _, file, line, _ := runtime.Caller(depth)
+ return fmt.Sprintf("Failure in %s, line %d:", filepath.Base(file), line)
+}
+
+func green(str interface{}) string {
+ return fmt.Sprintf("%s%#v%s", greenCode, str, resetCode)
+}
+
+func yellow(str interface{}) string {
+ return fmt.Sprintf("%s%#v%s", yellowCode, str, resetCode)
+}
+
+func logFatal(t *testing.T, str string) {
+ t.Fatalf(logBodyFmt, prefix(3), str)
+}
+
+func logError(t *testing.T, str string) {
+ t.Errorf(logBodyFmt, prefix(3), str)
+}
+
+type diffLogger func([]string, interface{}, interface{})
+
+type visit struct {
+ a1 uintptr
+ a2 uintptr
+ typ reflect.Type
+}
+
+// Recursively visits the structures of "expected" and "actual". The diffLogger function will be
+// invoked with each different value encountered, including the reference path that was followed
+// to get there.
+func deepDiffEqual(expected, actual reflect.Value, visited map[visit]bool, path []string, logDifference diffLogger) {
+ defer func() {
+ // Fall back to the regular reflect.DeepEquals function.
+ if r := recover(); r != nil {
+ var e, a interface{}
+ if expected.IsValid() {
+ e = expected.Interface()
+ }
+ if actual.IsValid() {
+ a = actual.Interface()
+ }
+
+ if !reflect.DeepEqual(e, a) {
+ logDifference(path, e, a)
+ }
+ }
+ }()
+
+ if !expected.IsValid() && actual.IsValid() {
+ logDifference(path, nil, actual.Interface())
+ return
+ }
+ if expected.IsValid() && !actual.IsValid() {
+ logDifference(path, expected.Interface(), nil)
+ return
+ }
+ if !expected.IsValid() && !actual.IsValid() {
+ return
+ }
+
+ hard := func(k reflect.Kind) bool {
+ switch k {
+ case reflect.Array, reflect.Map, reflect.Slice, reflect.Struct:
+ return true
+ }
+ return false
+ }
+
+ if expected.CanAddr() && actual.CanAddr() && hard(expected.Kind()) {
+ addr1 := expected.UnsafeAddr()
+ addr2 := actual.UnsafeAddr()
+
+ if addr1 > addr2 {
+ addr1, addr2 = addr2, addr1
+ }
+
+ if addr1 == addr2 {
+ // References are identical. We can short-circuit
+ return
+ }
+
+ typ := expected.Type()
+ v := visit{addr1, addr2, typ}
+ if visited[v] {
+ // Already visited.
+ return
+ }
+
+ // Remember this visit for later.
+ visited[v] = true
+ }
+
+ switch expected.Kind() {
+ case reflect.Array:
+ for i := 0; i < expected.Len(); i++ {
+ hop := append(path, fmt.Sprintf("[%d]", i))
+ deepDiffEqual(expected.Index(i), actual.Index(i), visited, hop, logDifference)
+ }
+ return
+ case reflect.Slice:
+ if expected.IsNil() != actual.IsNil() {
+ logDifference(path, expected.Interface(), actual.Interface())
+ return
+ }
+ if expected.Len() == actual.Len() && expected.Pointer() == actual.Pointer() {
+ return
+ }
+ for i := 0; i < expected.Len(); i++ {
+ hop := append(path, fmt.Sprintf("[%d]", i))
+ deepDiffEqual(expected.Index(i), actual.Index(i), visited, hop, logDifference)
+ }
+ return
+ case reflect.Interface:
+ if expected.IsNil() != actual.IsNil() {
+ logDifference(path, expected.Interface(), actual.Interface())
+ return
+ }
+ deepDiffEqual(expected.Elem(), actual.Elem(), visited, path, logDifference)
+ return
+ case reflect.Ptr:
+ deepDiffEqual(expected.Elem(), actual.Elem(), visited, path, logDifference)
+ return
+ case reflect.Struct:
+ for i, n := 0, expected.NumField(); i < n; i++ {
+ field := expected.Type().Field(i)
+ hop := append(path, "."+field.Name)
+ deepDiffEqual(expected.Field(i), actual.Field(i), visited, hop, logDifference)
+ }
+ return
+ case reflect.Map:
+ if expected.IsNil() != actual.IsNil() {
+ logDifference(path, expected.Interface(), actual.Interface())
+ return
+ }
+ if expected.Len() == actual.Len() && expected.Pointer() == actual.Pointer() {
+ return
+ }
+
+ var keys []reflect.Value
+ if expected.Len() >= actual.Len() {
+ keys = expected.MapKeys()
+ } else {
+ keys = actual.MapKeys()
+ }
+
+ for _, k := range keys {
+ expectedValue := expected.MapIndex(k)
+ actualValue := expected.MapIndex(k)
+
+ if !expectedValue.IsValid() {
+ logDifference(path, nil, actual.Interface())
+ return
+ }
+ if !actualValue.IsValid() {
+ logDifference(path, expected.Interface(), nil)
+ return
+ }
+
+ hop := append(path, fmt.Sprintf("[%v]", k))
+ deepDiffEqual(expectedValue, actualValue, visited, hop, logDifference)
+ }
+ return
+ case reflect.Func:
+ if expected.IsNil() != actual.IsNil() {
+ logDifference(path, expected.Interface(), actual.Interface())
+ }
+ return
+ default:
+ if expected.Interface() != actual.Interface() {
+ logDifference(path, expected.Interface(), actual.Interface())
+ }
+ }
+}
+
+func deepDiff(expected, actual interface{}, logDifference diffLogger) {
+ if expected == nil || actual == nil {
+ logDifference([]string{}, expected, actual)
+ return
+ }
+
+ expectedValue := reflect.ValueOf(expected)
+ actualValue := reflect.ValueOf(actual)
+
+ if expectedValue.Type() != actualValue.Type() {
+ logDifference([]string{}, expected, actual)
+ return
+ }
+ deepDiffEqual(expectedValue, actualValue, map[visit]bool{}, []string{}, logDifference)
+}
+
+// AssertEquals compares two arbitrary values and performs a comparison. If the
+// comparison fails, a fatal error is raised that will fail the test
+func AssertEquals(t *testing.T, expected, actual interface{}) {
+ if expected != actual {
+ logFatal(t, fmt.Sprintf("expected %s but got %s", green(expected), yellow(actual)))
+ }
+}
+
+// CheckEquals is similar to AssertEquals, except with a non-fatal error
+func CheckEquals(t *testing.T, expected, actual interface{}) {
+ if expected != actual {
+ logError(t, fmt.Sprintf("expected %s but got %s", green(expected), yellow(actual)))
+ }
+}
+
+// AssertDeepEquals - like Equals - performs a comparison - but on more complex
+// structures that requires deeper inspection
+func AssertDeepEquals(t *testing.T, expected, actual interface{}) {
+ pre := prefix(2)
+
+ differed := false
+ deepDiff(expected, actual, func(path []string, expected, actual interface{}) {
+ differed = true
+ t.Errorf("\033[1;31m%sat %s expected %s, but got %s\033[0m",
+ pre,
+ strings.Join(path, ""),
+ green(expected),
+ yellow(actual))
+ })
+ if differed {
+ logFatal(t, "The structures were different.")
+ }
+}
+
+// CheckDeepEquals is similar to AssertDeepEquals, except with a non-fatal error
+func CheckDeepEquals(t *testing.T, expected, actual interface{}) {
+ pre := prefix(2)
+
+ deepDiff(expected, actual, func(path []string, expected, actual interface{}) {
+ t.Errorf("\033[1;31m%s at %s expected %s, but got %s\033[0m",
+ pre,
+ strings.Join(path, ""),
+ green(expected),
+ yellow(actual))
+ })
+}
+
+// isJSONEquals is a utility function that implements JSON comparison for AssertJSONEquals and
+// CheckJSONEquals.
+func isJSONEquals(t *testing.T, expectedJSON string, actual interface{}) bool {
+ var parsedExpected, parsedActual interface{}
+ err := json.Unmarshal([]byte(expectedJSON), &parsedExpected)
+ if err != nil {
+ t.Errorf("Unable to parse expected value as JSON: %v", err)
+ return false
+ }
+
+ jsonActual, err := json.Marshal(actual)
+ AssertNoErr(t, err)
+ err = json.Unmarshal(jsonActual, &parsedActual)
+ AssertNoErr(t, err)
+
+ if !reflect.DeepEqual(parsedExpected, parsedActual) {
+ prettyExpected, err := json.MarshalIndent(parsedExpected, "", " ")
+ if err != nil {
+ t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expectedJSON)
+ } else {
+ // We can't use green() here because %#v prints prettyExpected as a byte array literal, which
+ // is... unhelpful. Converting it to a string first leaves "\n" uninterpreted for some reason.
+ t.Logf("Expected JSON:\n%s%s%s", greenCode, prettyExpected, resetCode)
+ }
+
+ prettyActual, err := json.MarshalIndent(actual, "", " ")
+ if err != nil {
+ t.Logf("Unable to pretty-print actual JSON: %v\n%#v", err, actual)
+ } else {
+ // We can't use yellow() for the same reason.
+ t.Logf("Actual JSON:\n%s%s%s", yellowCode, prettyActual, resetCode)
+ }
+
+ return false
+ }
+ return true
+}
+
+// AssertJSONEquals serializes a value as JSON, parses an expected string as JSON, and ensures that
+// both are consistent. If they aren't, the expected and actual structures are pretty-printed and
+// shown for comparison.
+//
+// This is useful for comparing structures that are built as nested map[string]interface{} values,
+// which are a pain to construct as literals.
+func AssertJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+ if !isJSONEquals(t, expectedJSON, actual) {
+ logFatal(t, "The generated JSON structure differed.")
+ }
+}
+
+// CheckJSONEquals is similar to AssertJSONEquals, but nonfatal.
+func CheckJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+ if !isJSONEquals(t, expectedJSON, actual) {
+ logError(t, "The generated JSON structure differed.")
+ }
+}
+
+// AssertNoErr is a convenience function for checking whether an error value is
+// an actual error
+func AssertNoErr(t *testing.T, e error) {
+ if e != nil {
+ logFatal(t, fmt.Sprintf("unexpected error %s", yellow(e.Error())))
+ }
+}
+
+// CheckNoErr is similar to AssertNoErr, except with a non-fatal error
+func CheckNoErr(t *testing.T, e error) {
+ if e != nil {
+ logError(t, fmt.Sprintf("unexpected error %s", yellow(e.Error())))
+ }
+}
diff --git a/testhelper/doc.go b/testhelper/doc.go
new file mode 100644
index 0000000..25b4dfe
--- /dev/null
+++ b/testhelper/doc.go
@@ -0,0 +1,4 @@
+/*
+Package testhelper container methods that are useful for writing unit tests.
+*/
+package testhelper
diff --git a/testhelper/fixture/helper.go b/testhelper/fixture/helper.go
new file mode 100644
index 0000000..d54355d
--- /dev/null
+++ b/testhelper/fixture/helper.go
@@ -0,0 +1,31 @@
+package fixture
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func SetupHandler(t *testing.T, url, method, requestBody, responseBody string, status int) {
+ th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, method)
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+ if requestBody != "" {
+ th.TestJSONRequest(t, r, requestBody)
+ }
+
+ if responseBody != "" {
+ w.Header().Add("Content-Type", "application/json")
+ }
+
+ w.WriteHeader(status)
+
+ if responseBody != "" {
+ fmt.Fprintf(w, responseBody)
+ }
+ })
+}
diff --git a/testhelper/http_responses.go b/testhelper/http_responses.go
new file mode 100644
index 0000000..e1f1f9a
--- /dev/null
+++ b/testhelper/http_responses.go
@@ -0,0 +1,91 @@
+package testhelper
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "testing"
+)
+
+var (
+ // Mux is a multiplexer that can be used to register handlers.
+ Mux *http.ServeMux
+
+ // Server is an in-memory HTTP server for testing.
+ Server *httptest.Server
+)
+
+// SetupHTTP prepares the Mux and Server.
+func SetupHTTP() {
+ Mux = http.NewServeMux()
+ Server = httptest.NewServer(Mux)
+}
+
+// TeardownHTTP releases HTTP-related resources.
+func TeardownHTTP() {
+ Server.Close()
+}
+
+// Endpoint returns a fake endpoint that will actually target the Mux.
+func Endpoint() string {
+ return Server.URL + "/"
+}
+
+// TestFormValues ensures that all the URL parameters given to the http.Request are the same as values.
+func TestFormValues(t *testing.T, r *http.Request, values map[string]string) {
+ want := url.Values{}
+ for k, v := range values {
+ want.Add(k, v)
+ }
+
+ r.ParseForm()
+ if !reflect.DeepEqual(want, r.Form) {
+ t.Errorf("Request parameters = %v, want %v", r.Form, want)
+ }
+}
+
+// TestMethod checks that the Request has the expected method (e.g. GET, POST).
+func TestMethod(t *testing.T, r *http.Request, expected string) {
+ if expected != r.Method {
+ t.Errorf("Request method = %v, expected %v", r.Method, expected)
+ }
+}
+
+// TestHeader checks that the header on the http.Request matches the expected value.
+func TestHeader(t *testing.T, r *http.Request, header string, expected string) {
+ if actual := r.Header.Get(header); expected != actual {
+ t.Errorf("Header %s = %s, expected %s", header, actual, expected)
+ }
+}
+
+// TestBody verifies that the request body matches an expected body.
+func TestBody(t *testing.T, r *http.Request, expected string) {
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Unable to read body: %v", err)
+ }
+ str := string(b)
+ if expected != str {
+ t.Errorf("Body = %s, expected %s", str, expected)
+ }
+}
+
+// TestJSONRequest verifies that the JSON payload of a request matches an expected structure, without asserting things about
+// whitespace or ordering.
+func TestJSONRequest(t *testing.T, r *http.Request, expected string) {
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Unable to read request body: %v", err)
+ }
+
+ var actualJSON interface{}
+ err = json.Unmarshal(b, &actualJSON)
+ if err != nil {
+ t.Errorf("Unable to parse request body as JSON: %v", err)
+ }
+
+ CheckJSONEquals(t, expected, actualJSON)
+}
diff --git a/util.go b/util.go
new file mode 100644
index 0000000..3d6a4e4
--- /dev/null
+++ b/util.go
@@ -0,0 +1,82 @@
+package gophercloud
+
+import (
+ "errors"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// WaitFor polls a predicate function, once per second, up to a timeout limit.
+// It usually does this to wait for a resource to transition to a certain state.
+// Resource packages will wrap this in a more convenient function that's
+// specific to a certain resource, but it can also be useful on its own.
+func WaitFor(timeout int, predicate func() (bool, error)) error {
+ start := time.Now().Second()
+ for {
+ // Force a 1s sleep
+ time.Sleep(1 * time.Second)
+
+ // If a timeout is set, and that's been exceeded, shut it down
+ if timeout >= 0 && time.Now().Second()-start >= timeout {
+ return errors.New("A timeout occurred")
+ }
+
+ // Execute the function
+ satisfied, err := predicate()
+ if err != nil {
+ return err
+ }
+ if satisfied {
+ return nil
+ }
+ }
+}
+
+// NormalizeURL is an internal function to be used by provider clients.
+//
+// It ensures that each endpoint URL has a closing `/`, as expected by
+// ServiceClient's methods.
+func NormalizeURL(url string) string {
+ if !strings.HasSuffix(url, "/") {
+ return url + "/"
+ }
+ 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
new file mode 100644
index 0000000..dcec77f
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1,85 @@
+package gophercloud
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestWaitFor(t *testing.T) {
+ err := WaitFor(5, func() (bool, error) {
+ return true, nil
+ })
+ 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)
+
+}