Merge remote-tracking branch 'target/master'
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..e9d5560
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,235 @@
+# Contributing to Gophercloud
+
+- [Getting started](#getting-started)
+- [Tests](#tests)
+- [Style guide](#basic-style-guide)
+- [3 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) but add `-tags "fixtures acceptance"` to
+get dependencies for unit and acceptance tests.
+
+ ```bash
+ go get -tags "fixtures acceptance" github.com/gophercloud/gophercloud
+ ```
+
+2. Move into the directory that houses your local repository:
+
+ ```bash
+ cd ${GOPATH}/src/github.com/gophercloud/gophercloud
+ ```
+
+3. Fork the `gophercloud/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.git
+ ```
+
+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 behavior
+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/gophercloud/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/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+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 := networks.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 behavior. 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 -tags fixtures ./...
+```
+
+To run all tests with verbose output:
+
+```bash
+go test -v -tags fixtures ./...
+```
+
+To run tests that match certain [build tags]():
+
+```bash
+go test -tags "fixtures foo bar" ./...
+```
+
+To run tests for a particular sub-package:
+
+```bash
+cd ./path/to/package && go test -tags fixtures .
+```
+
+## Style guide
+
+See [here](/STYLEGUIDE.md)
+
+## 3 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 `gophercloud/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. 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/gophercloud/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.
+
+### 2. Improving documentation
+The best source of documentation is on [godoc.org](http://godoc.org). It is
+automatically generated from the source code.
+
+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!
+
+###3. 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/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE
new file mode 100644
index 0000000..1451b81
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE
@@ -0,0 +1 @@
+Before starting a PR, please read the [style guide](https://github.com/gophercloud/gophercloud/blob/master/STYLEGUIDE.md).
diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE
new file mode 100644
index 0000000..43aafa0
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE
@@ -0,0 +1,9 @@
+Prior to a PR being reviewed, there needs to be a Github issue that the PR
+addresses. Replace the brackets and text below with that issue number.
+
+For #[PUT ISSUE NUMBER HERE]
+
+Links to the line numbers/files in the OpenStack source code that support the
+code in this PR:
+
+[PUT URLS HERE]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ead8445
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+**/*.swp
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..b7cbdb9
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,17 @@
+language: go
+sudo: false
+install:
+- go get golang.org/x/crypto/ssh
+- go get -v -tags 'fixtures acceptance' ./...
+- go get github.com/wadey/gocovmerge
+- go get github.com/mattn/goveralls
+go:
+- 1.7
+- tip
+env:
+ global:
+ - secure: "xSQsAG5wlL9emjbCdxzz/hYQsSpJ/bABO1kkbwMSISVcJ3Nk0u4ywF+LS4bgeOnwPfmFvNTOqVDu3RwEvMeWXSI76t1piCPcObutb2faKLVD/hLoAS76gYX+Z8yGWGHrSB7Do5vTPj1ERe2UljdrnsSeOXzoDwFxYRaZLX4bBOB4AyoGvRniil5QXPATiA1tsWX1VMicj8a4F8X+xeESzjt1Q5Iy31e7vkptu71bhvXCaoo5QhYwT+pLR9dN0S1b7Ro0KVvkRefmr1lUOSYd2e74h6Lc34tC1h3uYZCS4h47t7v5cOXvMNxinEj2C51RvbjvZI1RLVdkuAEJD1Iz4+Ote46nXbZ//6XRZMZz/YxQ13l7ux1PFjgEB6HAapmF5Xd8PRsgeTU9LRJxpiTJ3P5QJ3leS1va8qnziM5kYipj/Rn+V8g2ad/rgkRox9LSiR9VYZD2Pe45YCb1mTKSl2aIJnV7nkOqsShY5LNB4JZSg7xIffA+9YVDktw8dJlATjZqt7WvJJ49g6A61mIUV4C15q2JPGKTkZzDiG81NtmS7hFa7k0yaE2ELgYocbcuyUcAahhxntYTC0i23nJmEHVNiZmBO3u7EgpWe4KGVfumU+lt12tIn5b3dZRBBUk3QakKKozSK1QPHGpk/AZGrhu7H6l8to6IICKWtDcyMPQ="
+script:
+- ./script/coverage
+after_success:
+- $HOME/gopath/bin/goveralls -service=travis-ci -coverprofile=cover.out
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/CHANGELOG.md
diff --git a/FAQ.md b/FAQ.md
new file mode 100644
index 0000000..88a366a
--- /dev/null
+++ b/FAQ.md
@@ -0,0 +1,148 @@
+# Tips
+
+## Implementing default logging and re-authentication attempts
+
+You can implement custom logging and/or limit re-auth attempts by creating a custom HTTP client
+like the following and setting it as the provider client's HTTP Client (via the
+`gophercloud.ProviderClient.HTTPClient` field):
+
+```go
+//...
+
+// LogRoundTripper satisfies the http.RoundTripper interface and is used to
+// customize the default Gophercloud RoundTripper to allow for logging.
+type LogRoundTripper struct {
+ rt http.RoundTripper
+ numReauthAttempts int
+}
+
+// newHTTPClient return a custom HTTP client that allows for logging relevant
+// information before and after the HTTP request.
+func newHTTPClient() http.Client {
+ return http.Client{
+ Transport: &LogRoundTripper{
+ rt: http.DefaultTransport,
+ },
+ }
+}
+
+// RoundTrip performs a round-trip HTTP request and logs relevant information about it.
+func (lrt *LogRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+ glog.Infof("Request URL: %s\n", request.URL)
+
+ response, err := lrt.rt.RoundTrip(request)
+ if response == nil {
+ return nil, err
+ }
+
+ if response.StatusCode == http.StatusUnauthorized {
+ if lrt.numReauthAttempts == 3 {
+ return response, fmt.Errorf("Tried to re-authenticate 3 times with no success.")
+ }
+ lrt.numReauthAttempts++
+ }
+
+ glog.Debugf("Response Status: %s\n", response.Status)
+
+ return response, nil
+}
+
+endpoint := "https://127.0.0.1/auth"
+pc := openstack.NewClient(endpoint)
+pc.HTTPClient = newHTTPClient()
+
+//...
+```
+
+
+## Implementing custom objects
+
+OpenStack request/response objects may differ among variable names or types.
+
+### Custom request objects
+
+To pass custom options to a request, implement the desired `<ACTION>OptsBuilder` interface. For
+example, to pass in
+
+```go
+type MyCreateServerOpts struct {
+ Name string
+ Size int
+}
+```
+
+to `servers.Create`, simply implement the `servers.CreateOptsBuilder` interface:
+
+```go
+func (o MyCreateServeropts) ToServerCreateMap() (map[string]interface{}, error) {
+ return map[string]interface{}{
+ "name": o.Name,
+ "size": o.Size,
+ }, nil
+}
+```
+
+create an instance of your custom options object, and pass it to `servers.Create`:
+
+```go
+// ...
+myOpts := MyCreateServerOpts{
+ Name: "s1",
+ Size: "100",
+}
+server, err := servers.Create(computeClient, myOpts).Extract()
+// ...
+```
+
+### Custom response objects
+
+Some OpenStack services have extensions. Extensions that are supported in Gophercloud can be
+combined to create a custom object:
+
+```go
+// ...
+type MyVolume struct {
+ volumes.Volume
+ tenantattr.VolumeExt
+}
+
+var v struct {
+ MyVolume `json:"volume"`
+}
+
+err := volumes.Get(client, volID).ExtractInto(&v)
+// ...
+```
+
+## Overriding default `UnmarshalJSON` method
+
+For some response objects, a field may be a custom type or may be allowed to take on
+different types. In these cases, overriding the default `UnmarshalJSON` method may be
+necessary. To do this, declare the JSON `struct` field tag as "-" and create an `UnmarshalJSON`
+method on the type:
+
+```go
+// ...
+type MyVolume struct {
+ ID string `json: "id"`
+ TimeCreated time.Time `json: "-"`
+}
+
+func (r *MyVolume) UnmarshalJSON(b []byte) error {
+ type tmp MyVolume
+ var s struct {
+ tmp
+ TimeCreated gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Volume(s.tmp)
+
+ r.TimeCreated = time.Time(s.CreatedAt)
+
+ return err
+}
+// ...
+```
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/MIGRATING.md b/MIGRATING.md
new file mode 100644
index 0000000..aa383c9
--- /dev/null
+++ b/MIGRATING.md
@@ -0,0 +1,32 @@
+# Compute
+
+## Floating IPs
+
+* `github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingip` is now `github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips`
+* `floatingips.Associate` and `floatingips.Disassociate` have been removed.
+* `floatingips.DisassociateOpts` is now required to disassociate a Floating IP.
+
+## Security Groups
+
+* `secgroups.AddServerToGroup` is now `secgroups.AddServer`.
+* `secgroups.RemoveServerFromGroup` is now `secgroups.RemoveServer`.
+
+## Servers
+
+* `servers.Reboot` now requires a `servers.RebootOpts` struct:
+
+ ```golang
+ rebootOpts := &servers.RebootOpts{
+ Type: servers.SoftReboot,
+ }
+ res := servers.Reboot(client, server.ID, rebootOpts)
+ ```
+
+# Identity
+
+## V3
+
+### Tokens
+
+* `Token.ExpiresAt` is now of type `gophercloud.JSONRFC3339Milli` instead of
+ `time.Time`
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..60ca479
--- /dev/null
+++ b/README.md
@@ -0,0 +1,143 @@
+# Gophercloud: an OpenStack SDK for Go
+[![Build Status](https://travis-ci.org/gophercloud/gophercloud.svg?branch=master)](https://travis-ci.org/gophercloud/gophercloud)
+[![Coverage Status](https://coveralls.io/repos/github/gophercloud/gophercloud/badge.svg?branch=master)](https://coveralls.io/github/gophercloud/gophercloud?branch=master)
+
+Gophercloud is an OpenStack Go SDK.
+
+## Useful links
+
+* [Reference documentation](http://godoc.org/github.com/gophercloud/gophercloud)
+* [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/gophercloud/gophercloud
+
+# Edit your code to import relevant packages from "github.com/gophercloud/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
+* 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/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ "github.com/gophercloud/gophercloud/openstack/utils"
+)
+
+// Option 1: Pass in the values yourself
+opts := gophercloud.AuthOptions{
+ IdentityEndpoint: "https://openstack.example.com:5000/v2.0",
+ Username: "{username}",
+ Password: "{password}",
+}
+
+// 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/gophercloud/gophercloud/openstack/compute/v2/servers"
+
+server, err := servers.Create(client, servers.CreateOpts{
+ Name: "My new server!",
+ FlavorRef: "flavor_id",
+ ImageRef: "image_id",
+}).Extract()
+```
+
+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/gophercloud/gophercloud) struct).
+
+## Advanced Usage
+
+Have a look at the [FAQ](./FAQ.md) for some tips on customizing the way Gophercloud works.
+
+## Backwards-Compatibility Guarantees
+
+None. Vendor it and write tests covering the parts you use.
+
+## Contributing
+
+See the [contributing guide](./.github/CONTRIBUTING.md).
+
+## 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).
diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md
new file mode 100644
index 0000000..5b49ef4
--- /dev/null
+++ b/STYLEGUIDE.md
@@ -0,0 +1,74 @@
+
+## On Pull Requests
+
+- Before you start a PR there needs to be a Github issue and a discussion about it
+ on that issue with a core contributor, even if it's just a 'SGTM'.
+
+- A PR's description must reference the issue it closes with a `For <ISSUE NUMBER>` (e.g. For #293).
+
+- A PR's description must contain link(s) to the line(s) in the OpenStack
+ source code (on Github) that prove(s) the PR code to be valid. Links to documentation
+ are not good enough. The link(s) should be to a non-`master` branch. For example,
+ a pull request implementing the creation of a Neutron v2 subnet might put the
+ following link in the description:
+
+ https://github.com/openstack/neutron/blob/stable/mitaka/neutron/api/v2/attributes.py#L749
+
+ From that link, a reviewer (or user) can verify the fields in the request/response
+ objects in the PR.
+
+- A PR that is in-progress should have `[wip]` in front of the PR's title. When
+ ready for review, remove the `[wip]` and ping a core contributor with an `@`.
+
+- Forcing PRs to be small can have the effect of users submitting PRs in a hierarchical chain, with
+ one depending on the next. If a PR depends on another one, it should have a [Pending #PRNUM]
+ prefix in the PR title. In addition, it will be the PR submitter's responsibility to remove the
+ [Pending #PRNUM] tag once the PR has been updated with the merged, dependent PR. That will
+ let reviewers know it is ready to review.
+
+- A PR should be small. Even if you intend on implementing an entire
+ service, a PR should only be one route of that service
+ (e.g. create server or get server, but not both).
+
+- Unless explicitly asked, do not squash commits in the middle of a review; only
+ append. It makes it difficult for the reviewer to see what's changed from one
+ review to the next.
+
+## On Code
+
+- In re design: follow as closely as is reasonable the code already in the library.
+ Most operations (e.g. create, delete) admit the same design.
+
+- Unit tests and acceptance (integration) tests must be written to cover each PR.
+ Tests for operations with several options (e.g. list, create) should include all
+ the options in the tests. This will allow users to verify an operation on their
+ own infrastructure and see an example of usage.
+
+- If in doubt, ask in-line on the PR.
+
+### File Structure
+
+- The following should be used in most cases:
+
+ - `requests.go`: contains all the functions that make HTTP requests and the
+ types associated with the HTTP request (parameters for URL, body, etc)
+ - `results.go`: contains all the response objects and their methods
+ - `urls.go`: contains the endpoints to which the requests are made
+
+### Naming
+
+- For methods on a type in `response.go`, the receiver should be named `r` and the
+ variable into which it will be unmarshalled `s`.
+
+- Functions in `requests.go`, with the exception of functions that return a
+ `pagination.Pager`, should be named returns of the name `r`.
+
+- Functions in `requests.go` that accept request bodies should accept as their
+ last parameter an `interface` named `<Action>OptsBuilder` (eg `CreateOptsBuilder`).
+ This `interface` should have at the least a method named `To<Resource><Action>Map`
+ (eg `ToPortCreateMap`).
+
+- Functions in `requests.go` that accept query strings should accept as their
+ last parameter an `interface` named `<Action>OptsBuilder` (eg `ListOptsBuilder`).
+ This `interface` should have at the least a method named `To<Resource><Action>Query`
+ (eg `ToServerListQuery`).
diff --git a/acceptance/README.md b/acceptance/README.md
new file mode 100644
index 0000000..620bdf8
--- /dev/null
+++ b/acceptance/README.md
@@ -0,0 +1,86 @@
+# 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|
+
+#### General
+
+|Name|Description|
+|---|---|
+|`OS_REGION_NAME`|The region you want your resources 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|
+|`OS_POOL_NAME`|The Pool from where to obtain Floating IPs|
+|`OS_NETWORK_NAME`|The network to launch instances on|
+
+#### Shared file systems
+|Name|Description|
+|---|---|
+|`OS_SHARE_NETWORK_ID`| The share network ID to use when creating shares|
+
+### 2. Run the test suite
+
+From the root directory, run:
+
+```
+./script/acceptancetest
+```
+
+Alternatively, add the following to your `.bashrc`:
+
+```bash
+gophercloudtest() {
+ if [[ -n $1 ]] && [[ -n $2 ]]; then
+ pushd ~/go/src/github.com/gophercloud/gophercloud
+ go test -v -tags "fixtures acceptance" -run "$1" github.com/gophercloud/gophercloud/acceptance/openstack/$2 | tee ~/gophercloud.log
+ popd
+fi
+}
+```
+
+Then run either groups or individual tests by doing:
+
+```shell
+$ gophercloudtest TestFlavorsList compute/v2
+$ gophercloudtest TestFlavors compute/v2
+$ gophercloudtest Test compute/v2
+```
+
+### 3. Notes
+
+#### Compute Tests
+
+* In order to run the `TestBootFromVolumeMultiEphemeral` test, a flavor with ephemeral disk space must be used.
+* The `TestDefSecRules` tests require a compatible network driver and admin privileges.
diff --git a/acceptance/clients/clients.go b/acceptance/clients/clients.go
new file mode 100644
index 0000000..aa57497
--- /dev/null
+++ b/acceptance/clients/clients.go
@@ -0,0 +1,324 @@
+// Package clients contains functions for creating OpenStack service clients
+// for use in acceptance tests. It also manages the required environment
+// variables to run the tests.
+package clients
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+)
+
+// AcceptanceTestChoices contains image and flavor selections for use by the acceptance tests.
+type AcceptanceTestChoices 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
+
+ // FloatingIPPool contains the name of the pool from where to obtain floating IPs.
+ FloatingIPPoolName string
+
+ // NetworkName is the name of a network to launch the instance on.
+ NetworkName string
+
+ // ExternalNetworkID is the network ID of the external network.
+ ExternalNetworkID string
+
+ // ShareNetworkID is the Manila Share network ID
+ ShareNetworkID string
+}
+
+// AcceptanceTestChoicesFromEnv populates a ComputeChoices struct from environment variables.
+// If any required state is missing, an `error` will be returned that enumerates the missing properties.
+func AcceptanceTestChoicesFromEnv() (*AcceptanceTestChoices, 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")
+ floatingIPPoolName := os.Getenv("OS_POOL_NAME")
+ externalNetworkID := os.Getenv("OS_EXTGW_ID")
+ shareNetworkID := os.Getenv("OS_SHARE_NETWORK_ID")
+
+ 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 floatingIPPoolName == "" {
+ missing = append(missing, "OS_POOL_NAME")
+ }
+ if externalNetworkID == "" {
+ missing = append(missing, "OS_EXTGW_ID")
+ }
+ if networkName == "" {
+ networkName = "private"
+ }
+ if shareNetworkID == "" {
+ missing = append(missing, "OS_SHARE_NETWORK_ID")
+ }
+ 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 &AcceptanceTestChoices{
+ ImageID: imageID,
+ FlavorID: flavorID,
+ FlavorIDResize: flavorIDResize,
+ FloatingIPPoolName: floatingIPPoolName,
+ NetworkName: networkName,
+ ExternalNetworkID: externalNetworkID,
+ ShareNetworkID: shareNetworkID,
+ }, nil
+}
+
+// NewBlockStorageV1Client returns a *ServiceClient for making calls
+// to the OpenStack Block Storage v1 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewBlockStorageV1Client() (*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.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewBlockStorageV2Client returns a *ServiceClient for making calls
+// to the OpenStack Block Storage v2 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewBlockStorageV2Client() (*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.NewBlockStorageV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewSharedFileSystemV2Client returns a *ServiceClient for making calls
+// to the OpenStack Shared File System v2 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewSharedFileSystemV2Client() (*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.NewSharedFileSystemV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewComputeV2Client returns a *ServiceClient for making calls
+// to the OpenStack Compute v2 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewComputeV2Client() (*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"),
+ })
+}
+
+// NewDNSV2Client returns a *ServiceClient for making calls
+// to the OpenStack Compute v2 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewDNSV2Client() (*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.NewDNSV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewIdentityV2Client returns a *ServiceClient for making calls
+// to the OpenStack Identity v2 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewIdentityV2Client() (*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.NewIdentityV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewIdentityV2AdminClient returns a *ServiceClient for making calls
+// to the Admin Endpoint of the OpenStack Identity v2 API. An error
+// will be returned if authentication or client creation was not possible.
+func NewIdentityV2AdminClient() (*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.NewIdentityV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ Availability: gophercloud.AvailabilityAdmin,
+ })
+}
+
+// NewIdentityV2UnauthenticatedClient returns an unauthenticated *ServiceClient
+// for the OpenStack Identity v2 API. An error will be returned if
+// authentication or client creation was not possible.
+func NewIdentityV2UnauthenticatedClient() (*gophercloud.ServiceClient, error) {
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ client, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ return openstack.NewIdentityV2(client, gophercloud.EndpointOpts{})
+}
+
+// NewIdentityV3Client returns a *ServiceClient for making calls
+// to the OpenStack Identity v3 API. An error will be returned
+// if authentication or client creation was not possible.
+func NewIdentityV3Client() (*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.NewIdentityV3(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewIdentityV3UnauthenticatedClient returns an unauthenticated *ServiceClient
+// for the OpenStack Identity v3 API. An error will be returned if
+// authentication or client creation was not possible.
+func NewIdentityV3UnauthenticatedClient() (*gophercloud.ServiceClient, error) {
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ return nil, err
+ }
+
+ client, err := openstack.NewClient(ao.IdentityEndpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ return openstack.NewIdentityV3(client, gophercloud.EndpointOpts{})
+}
+
+// NewImageServiceV2Client returns a *ServiceClient for making calls to the
+// OpenStack Image v2 API. An error will be returned if authentication or
+// client creation was not possible.
+func NewImageServiceV2Client() (*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.NewImageServiceV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
+
+// NewNetworkV2Client returns a *ServiceClient for making calls to the
+// OpenStack Networking v2 API. An error will be returned if authentication
+// or client creation was not possible.
+func NewNetworkV2Client() (*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.NewNetworkV2(client, gophercloud.EndpointOpts{
+ Region: os.Getenv("OS_REGION_NAME"),
+ })
+}
diff --git a/acceptance/openstack/blockstorage/extensions/extensions.go b/acceptance/openstack/blockstorage/extensions/extensions.go
new file mode 100644
index 0000000..3a859c9
--- /dev/null
+++ b/acceptance/openstack/blockstorage/extensions/extensions.go
@@ -0,0 +1,152 @@
+// Package extensions contains common functions for creating block storage
+// resources that are extensions of the block storage API. See the `*_test.go`
+// files for example usages.
+package extensions
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/extensions/volumeactions"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v2/volumes"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+// CreateUploadImage will upload volume it as volume-baked image. An name of new image or err will be
+// returned
+func CreateUploadImage(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) (string, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume-backed image uploading in short mode.")
+ }
+
+ imageName := tools.RandomString("ACPTTEST", 16)
+ uploadImageOpts := volumeactions.UploadImageOpts{
+ ImageName: imageName,
+ Force: true,
+ }
+
+ if err := volumeactions.UploadImage(client, volume.ID, uploadImageOpts).ExtractErr(); err != nil {
+ return "", err
+ }
+
+ t.Logf("Uploading volume %s as volume-backed image %s", volume.ID, imageName)
+
+ if err := volumes.WaitForStatus(client, volume.ID, "available", 60); err != nil {
+ return "", err
+ }
+
+ t.Logf("Uploaded volume %s as volume-backed image %s", volume.ID, imageName)
+
+ return imageName, nil
+
+}
+
+// DeleteUploadedImage deletes uploaded image. An error will be returned
+// if the deletion request failed.
+func DeleteUploadedImage(t *testing.T, client *gophercloud.ServiceClient, imageName string) error {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume-backed image removing in short mode.")
+ }
+
+ t.Logf("Getting image id for image name %s", imageName)
+
+ imageID, err := images.IDFromName(client, imageName)
+ if err != nil {
+ return err
+ }
+
+ t.Logf("Removing image %s", imageID)
+
+ err = images.Delete(client, imageID).ExtractErr()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CreateVolumeAttach will attach a volume to an instance. An error will be
+// returned if the attachment failed.
+func CreateVolumeAttach(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume, server *servers.Server) error {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume attachment in short mode.")
+ }
+
+ attachOpts := volumeactions.AttachOpts{
+ MountPoint: "/mnt",
+ Mode: "rw",
+ InstanceUUID: server.ID,
+ }
+
+ t.Logf("Attempting to attach volume %s to server %s", volume.ID, server.ID)
+
+ if err := volumeactions.Attach(client, volume.ID, attachOpts).ExtractErr(); err != nil {
+ return err
+ }
+
+ if err := volumes.WaitForStatus(client, volume.ID, "in-use", 60); err != nil {
+ return err
+ }
+
+ t.Logf("Attached volume %s to server %s", volume.ID, server.ID)
+
+ return nil
+}
+
+// CreateVolumeReserve creates a volume reservation. An error will be returned
+// if the reservation failed.
+func CreateVolumeReserve(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) error {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume reservation in short mode.")
+ }
+
+ t.Logf("Attempting to reserve volume %s", volume.ID)
+
+ if err := volumeactions.Reserve(client, volume.ID).ExtractErr(); err != nil {
+ return err
+ }
+
+ t.Logf("Reserved volume %s", volume.ID)
+
+ return nil
+}
+
+// DeleteVolumeAttach will detach a volume from an instance. A fatal error will
+// occur if the snapshot failed to be deleted. This works best when used as a
+// deferred function.
+func DeleteVolumeAttach(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) {
+ t.Logf("Attepting to detach volume volume: %s", volume.ID)
+
+ detachOpts := volumeactions.DetachOpts{
+ AttachmentID: volume.Attachments[0].AttachmentID,
+ }
+
+ if err := volumeactions.Detach(client, volume.ID, detachOpts).ExtractErr(); err != nil {
+ t.Fatalf("Unable to detach volume %s: %v", volume.ID, err)
+ }
+
+ if err := volumes.WaitForStatus(client, volume.ID, "available", 60); err != nil {
+ t.Fatalf("Volume %s failed to become unavailable in 60 seconds: %v", volume.ID, err)
+ }
+
+ t.Logf("Detached volume: %s", volume.ID)
+}
+
+// DeleteVolumeReserve deletes a volume reservation. A fatal error will occur
+// if the deletion request failed. This works best when used as a deferred
+// function.
+func DeleteVolumeReserve(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume reservation in short mode.")
+ }
+
+ t.Logf("Attempting to unreserve volume %s", volume.ID)
+
+ if err := volumeactions.Unreserve(client, volume.ID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to unreserve volume %s: %v", volume.ID, err)
+ }
+
+ t.Logf("Unreserved volume %s", volume.ID)
+}
diff --git a/acceptance/openstack/blockstorage/extensions/pkg.go b/acceptance/openstack/blockstorage/extensions/pkg.go
new file mode 100644
index 0000000..f18039d
--- /dev/null
+++ b/acceptance/openstack/blockstorage/extensions/pkg.go
@@ -0,0 +1,3 @@
+// The extensions package contains acceptance tests for the Openstack Cinder extensions service.
+
+package extensions
diff --git a/acceptance/openstack/blockstorage/extensions/volumeactions_test.go b/acceptance/openstack/blockstorage/extensions/volumeactions_test.go
new file mode 100644
index 0000000..d15d17a
--- /dev/null
+++ b/acceptance/openstack/blockstorage/extensions/volumeactions_test.go
@@ -0,0 +1,140 @@
+// +build acceptance blockstorage
+
+package extensions
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v2/volumes"
+
+ blockstorage "github.com/gophercloud/gophercloud/acceptance/openstack/blockstorage/v2"
+ compute "github.com/gophercloud/gophercloud/acceptance/openstack/compute/v2"
+)
+
+func TestVolumeActionsUploadImageDestroy(t *testing.T) {
+ blockClient, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+ computeClient, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ volume, err := blockstorage.CreateVolume(t, blockClient)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer blockstorage.DeleteVolume(t, blockClient, volume)
+
+ imageName, err := CreateUploadImage(t, blockClient, volume)
+ if err != nil {
+ t.Fatalf("Unable to upload volume-backed image: %v", err)
+ }
+
+ err = DeleteUploadedImage(t, computeClient, imageName)
+ if err != nil {
+ t.Fatalf("Unable to delete volume-backed image: %v", err)
+ }
+}
+
+func TestVolumeActionsAttachCreateDestroy(t *testing.T) {
+ blockClient, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ computeClient, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := compute.CreateServer(t, computeClient)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer compute.DeleteServer(t, computeClient, server)
+
+ volume, err := blockstorage.CreateVolume(t, blockClient)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer blockstorage.DeleteVolume(t, blockClient, volume)
+
+ err = CreateVolumeAttach(t, blockClient, volume, server)
+ if err != nil {
+ t.Fatalf("Unable to attach volume: %v", err)
+ }
+
+ newVolume, err := volumes.Get(blockClient, volume.ID).Extract()
+ if err != nil {
+ t.Fatal("Unable to get updated volume information: %v", err)
+ }
+
+ DeleteVolumeAttach(t, blockClient, newVolume)
+}
+
+func TestVolumeActionsReserveUnreserve(t *testing.T) {
+ client, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create blockstorage client: %v", err)
+ }
+
+ volume, err := blockstorage.CreateVolume(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer blockstorage.DeleteVolume(t, client, volume)
+
+ err = CreateVolumeReserve(t, client, volume)
+ if err != nil {
+ t.Fatalf("Unable to create volume reserve: %v", err)
+ }
+ defer DeleteVolumeReserve(t, client, volume)
+}
+
+// Note(jtopjian): I plan to work on this at some point, but it requires
+// setting up a server with iscsi utils.
+/*
+func TestVolumeConns(t *testing.T) {
+ client, err := newClient()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Creating volume")
+ cv, err := volumes.Create(client, &volumes.CreateOpts{
+ Size: 1,
+ Name: "blockv2-volume",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ defer func() {
+ err = volumes.WaitForStatus(client, cv.ID, "available", 60)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Deleting volume")
+ err = volumes.Delete(client, cv.ID).ExtractErr()
+ th.AssertNoErr(t, err)
+ }()
+
+ err = volumes.WaitForStatus(client, cv.ID, "available", 60)
+ th.AssertNoErr(t, err)
+
+ connOpts := &volumeactions.ConnectorOpts{
+ IP: "127.0.0.1",
+ Host: "stack",
+ Initiator: "iqn.1994-05.com.redhat:17cf566367d2",
+ Multipath: false,
+ Platform: "x86_64",
+ OSType: "linux2",
+ }
+
+ t.Logf("Initializing connection")
+ _, err = volumeactions.InitializeConnection(client, cv.ID, connOpts).Extract()
+ th.AssertNoErr(t, err)
+
+ t.Logf("Terminating connection")
+ err = volumeactions.TerminateConnection(client, cv.ID, connOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+*/
diff --git a/acceptance/openstack/blockstorage/v1/blockstorage.go b/acceptance/openstack/blockstorage/v1/blockstorage.go
new file mode 100644
index 0000000..41f24e1
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/blockstorage.go
@@ -0,0 +1,142 @@
+// Package v1 contains common functions for creating block storage based
+// resources for use in acceptance tests. See the `*_test.go` files for
+// example usages.
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/snapshots"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumetypes"
+)
+
+// CreateSnapshot will create a volume snapshot based off of a given volume and
+// with a random name. An error will be returned if the snapshot failed to be
+// created.
+func CreateSnapshot(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) (*snapshots.Snapshot, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires snapshot creation in short mode.")
+ }
+
+ snapshotName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create snapshot %s based on volume %s", snapshotName, volume.ID)
+
+ createOpts := snapshots.CreateOpts{
+ Name: snapshotName,
+ VolumeID: volume.ID,
+ }
+
+ snapshot, err := snapshots.Create(client, createOpts).Extract()
+ if err != nil {
+ return snapshot, err
+ }
+
+ err = snapshots.WaitForStatus(client, snapshot.ID, "available", 60)
+ if err != nil {
+ return snapshot, err
+ }
+
+ return snapshot, nil
+}
+
+// CreateVolume will create a volume with a random name and size of 1GB. An
+// error will be returned if the volume was unable to be created.
+func CreateVolume(t *testing.T, client *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume creation in short mode.")
+ }
+
+ volumeName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create volume: %s", volumeName)
+
+ createOpts := volumes.CreateOpts{
+ Size: 1,
+ Name: volumeName,
+ }
+
+ volume, err := volumes.Create(client, createOpts).Extract()
+ if err != nil {
+ return volume, err
+ }
+
+ err = volumes.WaitForStatus(client, volume.ID, "available", 60)
+ if err != nil {
+ return volume, err
+ }
+
+ return volume, nil
+}
+
+// CreateVolumeType will create a volume type with a random name. An error will
+// be returned if the volume type was unable to be created.
+func CreateVolumeType(t *testing.T, client *gophercloud.ServiceClient) (*volumetypes.VolumeType, error) {
+ volumeTypeName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create volume type: %s", volumeTypeName)
+
+ createOpts := volumetypes.CreateOpts{
+ Name: volumeTypeName,
+ ExtraSpecs: map[string]interface{}{
+ "capabilities": "ssd",
+ "priority": 3,
+ },
+ }
+
+ volumeType, err := volumetypes.Create(client, createOpts).Extract()
+ if err != nil {
+ return volumeType, err
+ }
+
+ return volumeType, nil
+}
+
+// DeleteSnapshot will delete a snapshot. A fatal error will occur if the
+// snapshot failed to be deleted. This works best when used as a deferred
+// function.
+func DeleteSnapshotshot(t *testing.T, client *gophercloud.ServiceClient, snapshot *snapshots.Snapshot) {
+ err := snapshots.Delete(client, snapshot.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete snapshot %s: %v", snapshot.ID, err)
+ }
+
+ // Volumes can't be deleted until their snapshots have been,
+ // so block up to 120 seconds for the snapshot to delete.
+ err = gophercloud.WaitFor(120, func() (bool, error) {
+ _, err := snapshots.Get(client, snapshot.ID).Extract()
+ if err != nil {
+ return true, nil
+ }
+
+ return false, nil
+ })
+ if err != nil {
+ t.Fatalf("Unable to wait for snapshot to delete: %v", err)
+ }
+
+ t.Logf("Deleted snapshot: %s", snapshot.ID)
+}
+
+// DeleteVolume will delete a volume. A fatal error will occur if the volume
+// failed to be deleted. This works best when used as a deferred function.
+func DeleteVolume(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) {
+ err := volumes.Delete(client, volume.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete volume %s: %v", volume.ID, err)
+ }
+
+ t.Logf("Deleted volume: %s", volume.ID)
+}
+
+// DeleteVolumeType will delete a volume type. A fatal error will occur if the
+// volume type failed to be deleted. This works best when used as a deferred
+// function.
+func DeleteVolumeType(t *testing.T, client *gophercloud.ServiceClient, volumeType *volumetypes.VolumeType) {
+ err := volumetypes.Delete(client, volumeType.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete volume type %s: %v", volumeType.ID, err)
+ }
+
+ t.Logf("Deleted volume type: %s", volumeType.ID)
+}
diff --git a/acceptance/openstack/blockstorage/v1/pkg.go b/acceptance/openstack/blockstorage/v1/pkg.go
new file mode 100644
index 0000000..4efa6fb
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/pkg.go
@@ -0,0 +1,2 @@
+// Package v1 contains openstack cinder acceptance tests
+package v1
diff --git a/acceptance/openstack/blockstorage/v1/snapshots_test.go b/acceptance/openstack/blockstorage/v1/snapshots_test.go
new file mode 100644
index 0000000..3545371
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/snapshots_test.go
@@ -0,0 +1,58 @@
+// +build acceptance blockstorage
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/snapshots"
+)
+
+func TestSnapshotsList(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ allPages, err := snapshots.List(client, snapshots.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve snapshots: %v", err)
+ }
+
+ allSnapshots, err := snapshots.ExtractSnapshots(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract snapshots: %v", err)
+ }
+
+ for _, snapshot := range allSnapshots {
+ tools.PrintResource(t, snapshot)
+ }
+}
+
+func TestSnapshotsCreateDelete(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ volume, err := CreateVolume(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer DeleteVolume(t, client, volume)
+
+ snapshot, err := CreateSnapshot(t, client, volume)
+ if err != nil {
+ t.Fatalf("Unable to create snapshot: %v", err)
+ }
+ defer DeleteSnapshotshot(t, client, snapshot)
+
+ newSnapshot, err := snapshots.Get(client, snapshot.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve snapshot: %v", err)
+ }
+
+ tools.PrintResource(t, newSnapshot)
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumes_test.go b/acceptance/openstack/blockstorage/v1/volumes_test.go
new file mode 100644
index 0000000..9a55500
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumes_test.go
@@ -0,0 +1,52 @@
+// +build acceptance blockstorage
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+)
+
+func TestVolumesList(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ allPages, err := volumes.List(client, volumes.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve volumes: %v", err)
+ }
+
+ allVolumes, err := volumes.ExtractVolumes(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract volumes: %v", err)
+ }
+
+ for _, volume := range allVolumes {
+ tools.PrintResource(t, volume)
+ }
+}
+
+func TestVolumesCreateDestroy(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create blockstorage client: %v", err)
+ }
+
+ volume, err := CreateVolume(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer DeleteVolume(t, client, volume)
+
+ newVolume, err := volumes.Get(client, volume.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve volume: %v", err)
+ }
+
+ tools.PrintResource(t, newVolume)
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumetypes_test.go b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
new file mode 100644
index 0000000..ace09bc
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
@@ -0,0 +1,47 @@
+// +build acceptance blockstorage
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumetypes"
+)
+
+func TestVolumeTypesList(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ allPages, err := volumetypes.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve volume types: %v", err)
+ }
+
+ allVolumeTypes, err := volumetypes.ExtractVolumeTypes(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract volume types: %v", err)
+ }
+
+ for _, volumeType := range allVolumeTypes {
+ tools.PrintResource(t, volumeType)
+ }
+}
+
+func TestVolumeTypesCreateDestroy(t *testing.T) {
+ client, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ volumeType, err := CreateVolumeType(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create volume type: %v", err)
+ }
+ defer DeleteVolumeType(t, client, volumeType)
+
+ tools.PrintResource(t, volumeType)
+}
diff --git a/acceptance/openstack/blockstorage/v2/blockstorage.go b/acceptance/openstack/blockstorage/v2/blockstorage.go
new file mode 100644
index 0000000..39fb7da
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v2/blockstorage.go
@@ -0,0 +1,86 @@
+// Package v2 contains common functions for creating block storage based
+// resources for use in acceptance tests. See the `*_test.go` files for
+// example usages.
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v2/volumes"
+)
+
+// CreateVolume will create a volume with a random name and size of 1GB. An
+// error will be returned if the volume was unable to be created.
+func CreateVolume(t *testing.T, client *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume creation in short mode.")
+ }
+
+ volumeName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create volume: %s", volumeName)
+
+ createOpts := volumes.CreateOpts{
+ Size: 1,
+ Name: volumeName,
+ }
+
+ volume, err := volumes.Create(client, createOpts).Extract()
+ if err != nil {
+ return volume, err
+ }
+
+ err = volumes.WaitForStatus(client, volume.ID, "available", 60)
+ if err != nil {
+ return volume, err
+ }
+
+ return volume, nil
+}
+
+// CreateVolumeFromImage will create a volume from with a random name and size of
+// 1GB. An error will be returned if the volume was unable to be created.
+func CreateVolumeFromImage(t *testing.T, client *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires volume creation in short mode.")
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ volumeName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create volume: %s", volumeName)
+
+ createOpts := volumes.CreateOpts{
+ Size: 1,
+ Name: volumeName,
+ ImageID: choices.ImageID,
+ }
+
+ volume, err := volumes.Create(client, createOpts).Extract()
+ if err != nil {
+ return volume, err
+ }
+
+ err = volumes.WaitForStatus(client, volume.ID, "available", 60)
+ if err != nil {
+ return volume, err
+ }
+
+ return volume, nil
+}
+
+// DeleteVolume will delete a volume. A fatal error will occur if the volume
+// failed to be deleted. This works best when used as a deferred function.
+func DeleteVolume(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) {
+ err := volumes.Delete(client, volume.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete volume %s: %v", volume.ID, err)
+ }
+
+ t.Logf("Deleted volume: %s", volume.ID)
+}
diff --git a/acceptance/openstack/blockstorage/v2/pkg.go b/acceptance/openstack/blockstorage/v2/pkg.go
new file mode 100644
index 0000000..31dd0ff
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v2/pkg.go
@@ -0,0 +1,3 @@
+// The v2 package contains acceptance tests for the Openstack Cinder V2 service.
+
+package v2
diff --git a/acceptance/openstack/blockstorage/v2/volumes_test.go b/acceptance/openstack/blockstorage/v2/volumes_test.go
new file mode 100644
index 0000000..9003ca7
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v2/volumes_test.go
@@ -0,0 +1,52 @@
+// +build acceptance blockstorage
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v2/volumes"
+)
+
+func TestVolumesList(t *testing.T) {
+ client, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ allPages, err := volumes.List(client, volumes.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve volumes: %v", err)
+ }
+
+ allVolumes, err := volumes.ExtractVolumes(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract volumes: %v", err)
+ }
+
+ for _, volume := range allVolumes {
+ tools.PrintResource(t, volume)
+ }
+}
+
+func TestVolumesCreateDestroy(t *testing.T) {
+ client, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create blockstorage client: %v", err)
+ }
+
+ volume, err := CreateVolume(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+ defer DeleteVolume(t, client, volume)
+
+ newVolume, err := volumes.Get(client, volume.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve volume: %v", err)
+ }
+
+ tools.PrintResource(t, newVolume)
+}
diff --git a/acceptance/openstack/client_test.go b/acceptance/openstack/client_test.go
new file mode 100644
index 0000000..2758f60
--- /dev/null
+++ b/acceptance/openstack/client_test.go
@@ -0,0 +1,40 @@
+// +build acceptance
+
+package openstack
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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/common.go b/acceptance/openstack/common.go
new file mode 100644
index 0000000..ba78cb6
--- /dev/null
+++ b/acceptance/openstack/common.go
@@ -0,0 +1,19 @@
+// Package openstack contains common functions that can be used
+// across all OpenStack components for acceptance testing.
+package openstack
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/common/extensions"
+)
+
+// PrintExtension prints an extension and all of its attributes.
+func PrintExtension(t *testing.T, extension *extensions.Extension) {
+ t.Logf("Name: %s", extension.Name)
+ t.Logf("Namespace: %s", extension.Namespace)
+ t.Logf("Alias: %s", extension.Alias)
+ t.Logf("Description: %s", extension.Description)
+ t.Logf("Updated: %s", extension.Updated)
+ t.Logf("Links: %v", extension.Links)
+}
diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go
new file mode 100644
index 0000000..2ba8888
--- /dev/null
+++ b/acceptance/openstack/compute/v2/bootfromvolume_test.go
@@ -0,0 +1,261 @@
+// +build acceptance compute bootfromvolume
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ blockstorage "github.com/gophercloud/gophercloud/acceptance/openstack/blockstorage/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+)
+
+func TestBootFromImage(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: choices.ImageID,
+ },
+ }
+
+ server, err := CreateBootableVolumeServer(t, client, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ tools.PrintResource(t, server)
+}
+
+func TestBootFromNewVolume(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: choices.ImageID,
+ VolumeSize: 2,
+ },
+ }
+
+ server, err := CreateBootableVolumeServer(t, client, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ tools.PrintResource(t, server)
+}
+
+func TestBootFromExistingVolume(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ computeClient, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ blockStorageClient, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a block storage client: %v", err)
+ }
+
+ volume, err := blockstorage.CreateVolumeFromImage(t, blockStorageClient)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceVolume,
+ UUID: volume.ID,
+ },
+ }
+
+ server, err := CreateBootableVolumeServer(t, computeClient, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, computeClient, server)
+
+ tools.PrintResource(t, server)
+}
+
+func TestBootFromMultiEphemeralServer(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ BootIndex: 0,
+ DestinationType: bootfromvolume.DestinationLocal,
+ DeleteOnTermination: true,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: choices.ImageID,
+ VolumeSize: 5,
+ },
+ bootfromvolume.BlockDevice{
+ BootIndex: -1,
+ DestinationType: bootfromvolume.DestinationLocal,
+ DeleteOnTermination: true,
+ GuestFormat: "ext4",
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 1,
+ },
+ bootfromvolume.BlockDevice{
+ BootIndex: -1,
+ DestinationType: bootfromvolume.DestinationLocal,
+ DeleteOnTermination: true,
+ GuestFormat: "ext4",
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 1,
+ },
+ }
+
+ server, err := CreateMultiEphemeralServer(t, client, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ tools.PrintResource(t, server)
+}
+
+func TestAttachNewVolume(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: choices.ImageID,
+ },
+ bootfromvolume.BlockDevice{
+ BootIndex: 1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 2,
+ },
+ }
+
+ server, err := CreateBootableVolumeServer(t, client, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ tools.PrintResource(t, server)
+}
+
+func TestAttachExistingVolume(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ computeClient, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ blockStorageClient, err := clients.NewBlockStorageV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a block storage client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ volume, err := blockstorage.CreateVolume(t, blockStorageClient)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blockDevices := []bootfromvolume.BlockDevice{
+ bootfromvolume.BlockDevice{
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: choices.ImageID,
+ },
+ bootfromvolume.BlockDevice{
+ BootIndex: 1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceVolume,
+ UUID: volume.ID,
+ },
+ }
+
+ server, err := CreateBootableVolumeServer(t, computeClient, blockDevices)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, computeClient, server)
+
+ tools.PrintResource(t, server)
+}
diff --git a/acceptance/openstack/compute/v2/compute.go b/acceptance/openstack/compute/v2/compute.go
new file mode 100644
index 0000000..1f3dc16
--- /dev/null
+++ b/acceptance/openstack/compute/v2/compute.go
@@ -0,0 +1,767 @@
+// Package v2 contains common functions for creating compute-based resources
+// for use in acceptance tests. See the `*_test.go` files for example usages.
+package v2
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ dsr "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/defsecrules"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/networks"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/quotasets"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/schedulerhints"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/servergroups"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// AssociateFloatingIP will associate a floating IP with an instance. An error
+// will be returned if the floating IP was unable to be associated.
+func AssociateFloatingIP(t *testing.T, client *gophercloud.ServiceClient, floatingIP *floatingips.FloatingIP, server *servers.Server) error {
+ associateOpts := floatingips.AssociateOpts{
+ FloatingIP: floatingIP.IP,
+ }
+
+ t.Logf("Attempting to associate floating IP %s to instance %s", floatingIP.IP, server.ID)
+ err := floatingips.AssociateInstance(client, server.ID, associateOpts).ExtractErr()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// AssociateFloatingIPWithFixedIP will associate a floating IP with an
+// instance's specific fixed IP. An error will be returend if the floating IP
+// was unable to be associated.
+func AssociateFloatingIPWithFixedIP(t *testing.T, client *gophercloud.ServiceClient, floatingIP *floatingips.FloatingIP, server *servers.Server, fixedIP string) error {
+ associateOpts := floatingips.AssociateOpts{
+ FloatingIP: floatingIP.IP,
+ FixedIP: fixedIP,
+ }
+
+ t.Logf("Attempting to associate floating IP %s to fixed IP %s on instance %s", floatingIP.IP, fixedIP, server.ID)
+ err := floatingips.AssociateInstance(client, server.ID, associateOpts).ExtractErr()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CreateBootableVolumeServer works like CreateServer but is configured with
+// one or more block devices defined by passing in []bootfromvolume.BlockDevice.
+// An error will be returned if a server was unable to be created.
+func CreateBootableVolumeServer(t *testing.T, client *gophercloud.ServiceClient, blockDevices []bootfromvolume.BlockDevice) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create bootable volume server: %s", name)
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ }
+
+ if blockDevices[0].SourceType == bootfromvolume.SourceImage && blockDevices[0].DestinationType == bootfromvolume.DestinationLocal {
+ serverCreateOpts.ImageRef = blockDevices[0].UUID
+ }
+
+ server, err = bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{
+ serverCreateOpts,
+ blockDevices,
+ }).Extract()
+
+ if err != nil {
+ return server, err
+ }
+
+ if err := WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ return server, err
+ }
+
+ newServer, err := servers.Get(client, server.ID).Extract()
+
+ return newServer, nil
+}
+
+// CreateDefaultRule will create a default security group rule with a
+// random port range between 80 and 90. An error will be returned if
+// a default rule was unable to be created.
+func CreateDefaultRule(t *testing.T, client *gophercloud.ServiceClient) (dsr.DefaultRule, error) {
+ createOpts := dsr.CreateOpts{
+ FromPort: tools.RandomInt(80, 89),
+ ToPort: tools.RandomInt(90, 99),
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ defaultRule, err := dsr.Create(client, createOpts).Extract()
+ if err != nil {
+ return *defaultRule, err
+ }
+
+ t.Logf("Created default rule: %s", defaultRule.ID)
+
+ return *defaultRule, nil
+}
+
+// CreateFloatingIP will allocate a floating IP.
+// An error will be returend if one was unable to be allocated.
+func CreateFloatingIP(t *testing.T, client *gophercloud.ServiceClient) (*floatingips.FloatingIP, error) {
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ createOpts := floatingips.CreateOpts{
+ Pool: choices.FloatingIPPoolName,
+ }
+ floatingIP, err := floatingips.Create(client, createOpts).Extract()
+ if err != nil {
+ return floatingIP, err
+ }
+
+ t.Logf("Created floating IP: %s", floatingIP.ID)
+ return floatingIP, nil
+}
+
+func createKey() (string, error) {
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return "", err
+ }
+
+ publicKey := privateKey.PublicKey
+ pub, err := ssh.NewPublicKey(&publicKey)
+ if err != nil {
+ return "", err
+ }
+
+ pubBytes := ssh.MarshalAuthorizedKey(pub)
+ pk := string(pubBytes)
+ return pk, nil
+}
+
+// CreateKeyPair will create a KeyPair with a random name. An error will occur
+// if the keypair failed to be created. An error will be returned if the
+// keypair was unable to be created.
+func CreateKeyPair(t *testing.T, client *gophercloud.ServiceClient) (*keypairs.KeyPair, error) {
+ keyPairName := tools.RandomString("keypair_", 5)
+
+ t.Logf("Attempting to create keypair: %s", keyPairName)
+ createOpts := keypairs.CreateOpts{
+ Name: keyPairName,
+ }
+ keyPair, err := keypairs.Create(client, createOpts).Extract()
+ if err != nil {
+ return keyPair, err
+ }
+
+ t.Logf("Created keypair: %s", keyPairName)
+ return keyPair, nil
+}
+
+// CreateMultiEphemeralServer works like CreateServer but is configured with
+// one or more block devices defined by passing in []bootfromvolume.BlockDevice.
+// These block devices act like block devices when booting from a volume but
+// are actually local ephemeral disks.
+// An error will be returned if a server was unable to be created.
+func CreateMultiEphemeralServer(t *testing.T, client *gophercloud.ServiceClient, blockDevices []bootfromvolume.BlockDevice) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create bootable volume server: %s", name)
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ }
+
+ server, err = bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{
+ serverCreateOpts,
+ blockDevices,
+ }).Extract()
+
+ if err != nil {
+ return server, err
+ }
+
+ if err := WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ return server, err
+ }
+
+ newServer, err := servers.Get(client, server.ID).Extract()
+
+ return newServer, nil
+}
+
+// CreateSecurityGroup will create a security group with a random name.
+// An error will be returned if one was failed to be created.
+func CreateSecurityGroup(t *testing.T, client *gophercloud.ServiceClient) (secgroups.SecurityGroup, error) {
+ createOpts := secgroups.CreateOpts{
+ Name: tools.RandomString("secgroup_", 5),
+ Description: "something",
+ }
+
+ securityGroup, err := secgroups.Create(client, createOpts).Extract()
+ if err != nil {
+ return *securityGroup, err
+ }
+
+ t.Logf("Created security group: %s", securityGroup.ID)
+ return *securityGroup, nil
+}
+
+// CreateSecurityGroupRule will create a security group rule with a random name
+// and a random TCP port range between port 80 and 99. An error will be
+// returned if the rule failed to be created.
+func CreateSecurityGroupRule(t *testing.T, client *gophercloud.ServiceClient, securityGroupID string) (secgroups.Rule, error) {
+ createOpts := secgroups.CreateRuleOpts{
+ ParentGroupID: securityGroupID,
+ FromPort: tools.RandomInt(80, 89),
+ ToPort: tools.RandomInt(90, 99),
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := secgroups.CreateRule(client, createOpts).Extract()
+ if err != nil {
+ return *rule, err
+ }
+
+ t.Logf("Created security group rule: %s", rule.ID)
+ return *rule, nil
+}
+
+// CreateServer creates a basic instance with a randomly generated name.
+// The flavor of the instance will be the value of the OS_FLAVOR_ID environment variable.
+// The image will be the value of the OS_IMAGE_ID environment variable.
+// The instance will be launched on the network specified in OS_NETWORK_NAME.
+// An error will be returned if the instance was unable to be created.
+func CreateServer(t *testing.T, client *gophercloud.ServiceClient) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err = servers.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ Metadata: map[string]string{
+ "abc": "def",
+ },
+ Personality: servers.Personality{
+ &servers.File{
+ Path: "/etc/test",
+ Contents: []byte("hello world"),
+ },
+ },
+ }).Extract()
+ if err != nil {
+ return server, err
+ }
+
+ if err := WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ return server, err
+ }
+
+ return server, nil
+}
+
+// CreateServerWithoutImageRef creates a basic instance with a randomly generated name.
+// The flavor of the instance will be the value of the OS_FLAVOR_ID environment variable.
+// The image is intentionally missing to trigger an error.
+// The instance will be launched on the network specified in OS_NETWORK_NAME.
+// An error will be returned if the instance was unable to be created.
+func CreateServerWithoutImageRef(t *testing.T, client *gophercloud.ServiceClient) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ server, err = servers.Create(client, servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ AdminPass: pwd,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ Personality: servers.Personality{
+ &servers.File{
+ Path: "/etc/test",
+ Contents: []byte("hello world"),
+ },
+ },
+ }).Extract()
+ if err != nil {
+ return server, err
+ }
+
+ if err := WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ return server, err
+ }
+
+ return server, nil
+}
+
+// CreateServerGroup will create a server with a random name. An error will be
+// returned if the server group failed to be created.
+func CreateServerGroup(t *testing.T, client *gophercloud.ServiceClient, policy string) (*servergroups.ServerGroup, error) {
+ sg, err := servergroups.Create(client, &servergroups.CreateOpts{
+ Name: "test",
+ Policies: []string{policy},
+ }).Extract()
+
+ if err != nil {
+ return sg, err
+ }
+
+ return sg, nil
+}
+
+// CreateServerInServerGroup works like CreateServer but places the instance in
+// a specified Server Group.
+func CreateServerInServerGroup(t *testing.T, client *gophercloud.ServiceClient, serverGroup *servergroups.ServerGroup) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s", name)
+
+ pwd := tools.MakeNewPassword("")
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ AdminPass: pwd,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ }
+
+ schedulerHintsOpts := schedulerhints.CreateOptsExt{
+ serverCreateOpts,
+ schedulerhints.SchedulerHints{
+ Group: serverGroup.ID,
+ },
+ }
+ server, err = servers.Create(client, schedulerHintsOpts).Extract()
+ if err != nil {
+ return server, err
+ }
+
+ return server, nil
+}
+
+// CreateServerWithPublicKey works the same as CreateServer, but additionally
+// configures the server with a specified Key Pair name.
+func CreateServerWithPublicKey(t *testing.T, client *gophercloud.ServiceClient, keyPairName string) (*servers.Server, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ var server *servers.Server
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ return server, err
+ }
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create server: %s", name)
+
+ serverCreateOpts := servers.CreateOpts{
+ Name: name,
+ FlavorRef: choices.FlavorID,
+ ImageRef: choices.ImageID,
+ Networks: []servers.Network{
+ servers.Network{UUID: networkID},
+ },
+ }
+
+ server, err = servers.Create(client, keypairs.CreateOptsExt{
+ serverCreateOpts,
+ keyPairName,
+ }).Extract()
+ if err != nil {
+ return server, err
+ }
+
+ if err := WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ return server, err
+ }
+
+ return server, nil
+}
+
+// CreateVolumeAttachment will attach a volume to a server. An error will be
+// returned if the volume failed to attach.
+func CreateVolumeAttachment(t *testing.T, client *gophercloud.ServiceClient, blockClient *gophercloud.ServiceClient, server *servers.Server, volume *volumes.Volume) (*volumeattach.VolumeAttachment, error) {
+ volumeAttachOptions := volumeattach.CreateOpts{
+ VolumeID: volume.ID,
+ }
+
+ t.Logf("Attempting to attach volume %s to server %s", volume.ID, server.ID)
+ volumeAttachment, err := volumeattach.Create(client, server.ID, volumeAttachOptions).Extract()
+ if err != nil {
+ return volumeAttachment, err
+ }
+
+ if err := volumes.WaitForStatus(blockClient, volume.ID, "in-use", 60); err != nil {
+ return volumeAttachment, err
+ }
+
+ return volumeAttachment, nil
+}
+
+// DeleteDefaultRule deletes a default security group rule.
+// A fatal error will occur if the rule failed to delete. This works best when
+// using it as a deferred function.
+func DeleteDefaultRule(t *testing.T, client *gophercloud.ServiceClient, defaultRule dsr.DefaultRule) {
+ err := dsr.Delete(client, defaultRule.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete default rule %s: %v", defaultRule.ID, err)
+ }
+
+ t.Logf("Deleted default rule: %s", defaultRule.ID)
+}
+
+// DeleteFloatingIP will de-allocate a floating IP. A fatal error will occur if
+// the floating IP failed to de-allocate. This works best when using it as a
+// deferred function.
+func DeleteFloatingIP(t *testing.T, client *gophercloud.ServiceClient, floatingIP *floatingips.FloatingIP) {
+ err := floatingips.Delete(client, floatingIP.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete floating IP %s: %v", floatingIP.ID, err)
+ }
+
+ t.Logf("Deleted floating IP: %s", floatingIP.ID)
+}
+
+// DeleteKeyPair will delete a specified keypair. A fatal error will occur if
+// the keypair failed to be deleted. This works best when used as a deferred
+// function.
+func DeleteKeyPair(t *testing.T, client *gophercloud.ServiceClient, keyPair *keypairs.KeyPair) {
+ err := keypairs.Delete(client, keyPair.Name).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete keypair %s: %v", keyPair.Name, err)
+ }
+
+ t.Logf("Deleted keypair: %s", keyPair.Name)
+}
+
+// DeleteSecurityGroup will delete a security group. A fatal error will occur
+// if the group failed to be deleted. This works best as a deferred function.
+func DeleteSecurityGroup(t *testing.T, client *gophercloud.ServiceClient, securityGroup secgroups.SecurityGroup) {
+ err := secgroups.Delete(client, securityGroup.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete security group %s: %s", securityGroup.ID, err)
+ }
+
+ t.Logf("Deleted security group: %s", securityGroup.ID)
+}
+
+// DeleteSecurityGroupRule will delete a security group rule. A fatal error
+// will occur if the rule failed to be deleted. This works best when used
+// as a deferred function.
+func DeleteSecurityGroupRule(t *testing.T, client *gophercloud.ServiceClient, rule secgroups.Rule) {
+ err := secgroups.DeleteRule(client, rule.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete rule: %v", err)
+ }
+
+ t.Logf("Deleted security group rule: %s", rule.ID)
+}
+
+// DeleteServer deletes an instance via its UUID.
+// A fatal error will occur if the instance failed to be destroyed. This works
+// best when using it as a deferred function.
+func DeleteServer(t *testing.T, client *gophercloud.ServiceClient, server *servers.Server) {
+ err := servers.Delete(client, server.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete server %s: %s", server.ID, err)
+ }
+
+ t.Logf("Deleted server: %s", server.ID)
+}
+
+// DeleteServerGroup will delete a server group. A fatal error will occur if
+// the server group failed to be deleted. This works best when used as a
+// deferred function.
+func DeleteServerGroup(t *testing.T, client *gophercloud.ServiceClient, serverGroup *servergroups.ServerGroup) {
+ err := servergroups.Delete(client, serverGroup.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete server group %s: %v", serverGroup.ID, err)
+ }
+
+ t.Logf("Deleted server group %s", serverGroup.ID)
+}
+
+// DeleteVolumeAttachment will disconnect a volume from an instance. A fatal
+// error will occur if the volume failed to detach. This works best when used
+// as a deferred function.
+func DeleteVolumeAttachment(t *testing.T, client *gophercloud.ServiceClient, blockClient *gophercloud.ServiceClient, server *servers.Server, volumeAttachment *volumeattach.VolumeAttachment) {
+
+ err := volumeattach.Delete(client, server.ID, volumeAttachment.VolumeID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to detach volume: %v", err)
+ }
+
+ if err := volumes.WaitForStatus(blockClient, volumeAttachment.ID, "available", 60); err != nil {
+ t.Fatalf("Unable to wait for volume: %v", err)
+ }
+ t.Logf("Deleted volume: %s", volumeAttachment.VolumeID)
+}
+
+// DisassociateFloatingIP will disassociate a floating IP from an instance. A
+// fatal error will occur if the floating IP failed to disassociate. This works
+// best when using it as a deferred function.
+func DisassociateFloatingIP(t *testing.T, client *gophercloud.ServiceClient, floatingIP *floatingips.FloatingIP, server *servers.Server) {
+ disassociateOpts := floatingips.DisassociateOpts{
+ FloatingIP: floatingIP.IP,
+ }
+
+ err := floatingips.DisassociateInstance(client, server.ID, disassociateOpts).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to disassociate floating IP %s from server %s: %v", floatingIP.IP, server.ID, err)
+ }
+
+ t.Logf("Disassociated floating IP %s from server %s", floatingIP.IP, server.ID)
+}
+
+// GetNetworkIDFromNetworks will return the network ID from a specified network
+// UUID using the os-networks API extension. An error will be returned if the
+// network could not be retrieved.
+func GetNetworkIDFromNetworks(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", networkName, networkID)
+
+ return networkID, nil
+}
+
+// GetNetworkIDFromTenantNetworks will return the network UUID for a given
+// network name using the os-tenant-networks API extension. An error will be
+// returned if the network could not be retrieved.
+func GetNetworkIDFromTenantNetworks(t *testing.T, client *gophercloud.ServiceClient, networkName string) (string, error) {
+ allPages, err := tenantnetworks.List(client).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ allTenantNetworks, err := tenantnetworks.ExtractNetworks(allPages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, network := range allTenantNetworks {
+ if network.Name == networkName {
+ return network.ID, nil
+ }
+ }
+
+ return "", fmt.Errorf("Failed to obtain network ID for network %s", networkName)
+}
+
+// ImportPublicKey will create a KeyPair with a random name and a specified
+// public key. An error will be returned if the keypair failed to be created.
+func ImportPublicKey(t *testing.T, client *gophercloud.ServiceClient, publicKey string) (*keypairs.KeyPair, error) {
+ keyPairName := tools.RandomString("keypair_", 5)
+
+ t.Logf("Attempting to create keypair: %s", keyPairName)
+ createOpts := keypairs.CreateOpts{
+ Name: keyPairName,
+ PublicKey: publicKey,
+ }
+ keyPair, err := keypairs.Create(client, createOpts).Extract()
+ if err != nil {
+ return keyPair, err
+ }
+
+ t.Logf("Created keypair: %s", keyPairName)
+ return keyPair, nil
+}
+
+// ResizeServer performs a resize action on an instance. An error will be
+// returned if the instance failed to resize.
+// The new flavor that the instance will be resized to is specified in OS_FLAVOR_ID_RESIZE.
+func ResizeServer(t *testing.T, client *gophercloud.ServiceClient, server *servers.Server) error {
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := &servers.ResizeOpts{
+ FlavorRef: choices.FlavorIDResize,
+ }
+ if res := servers.Resize(client, server.ID, opts); res.Err != nil {
+ return res.Err
+ }
+
+ if err := WaitForComputeStatus(client, server, "VERIFY_RESIZE"); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// WaitForComputeStatus will poll an instance's status until it either matches
+// the specified status or the status becomes ERROR.
+func WaitForComputeStatus(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
+ }
+
+ if latest.Status == "ERROR" {
+ return false, fmt.Errorf("Instance in ERROR state")
+ }
+
+ return false, nil
+ })
+}
+
+//Convenience method to fill an QuotaSet-UpdateOpts-struct from a QuotaSet-struct
+func FillUpdateOptsFromQuotaSet(src quotasets.QuotaSet, dest *quotasets.UpdateOpts) {
+ dest.FixedIps = &src.FixedIps
+ dest.FloatingIps = &src.FloatingIps
+ dest.InjectedFileContentBytes = &src.InjectedFileContentBytes
+ dest.InjectedFilePathBytes = &src.InjectedFilePathBytes
+ dest.InjectedFiles = &src.InjectedFiles
+ dest.KeyPairs = &src.KeyPairs
+ dest.Ram = &src.Ram
+ dest.SecurityGroupRules = &src.SecurityGroupRules
+ dest.SecurityGroups = &src.SecurityGroups
+ dest.Cores = &src.Cores
+ dest.Instances = &src.Instances
+ dest.ServerGroups = &src.ServerGroups
+ dest.ServerGroupMembers = &src.ServerGroupMembers
+ dest.MetadataItems = &src.MetadataItems
+}
diff --git a/acceptance/openstack/compute/v2/defsecrules_test.go b/acceptance/openstack/compute/v2/defsecrules_test.go
new file mode 100644
index 0000000..16c43f4
--- /dev/null
+++ b/acceptance/openstack/compute/v2/defsecrules_test.go
@@ -0,0 +1,67 @@
+// +build acceptance compute defsecrules
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ dsr "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/defsecrules"
+)
+
+func TestDefSecRulesList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := dsr.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list default rules: %v", err)
+ }
+
+ allDefaultRules, err := dsr.ExtractDefaultRules(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract default rules: %v", err)
+ }
+
+ for _, defaultRule := range allDefaultRules {
+ tools.PrintResource(t, defaultRule)
+ }
+}
+
+func TestDefSecRulesCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ defaultRule, err := CreateDefaultRule(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create default rule: %v", err)
+ }
+ defer DeleteDefaultRule(t, client, defaultRule)
+
+ tools.PrintResource(t, defaultRule)
+}
+
+func TestDefSecRulesGet(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ defaultRule, err := CreateDefaultRule(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create default rule: %v", err)
+ }
+ defer DeleteDefaultRule(t, client, defaultRule)
+
+ newDefaultRule, err := dsr.Get(client, defaultRule.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get default rule %s: %v", defaultRule.ID, err)
+ }
+
+ tools.PrintResource(t, newDefaultRule)
+}
diff --git a/acceptance/openstack/compute/v2/extension_test.go b/acceptance/openstack/compute/v2/extension_test.go
new file mode 100644
index 0000000..5b2cf4a
--- /dev/null
+++ b/acceptance/openstack/compute/v2/extension_test.go
@@ -0,0 +1,46 @@
+// +build acceptance compute extensions
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/common/extensions"
+)
+
+func TestExtensionsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := extensions.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list extensions: %v", err)
+ }
+
+ allExtensions, err := extensions.ExtractExtensions(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract extensions: %v", err)
+ }
+
+ for _, extension := range allExtensions {
+ tools.PrintResource(t, extension)
+ }
+}
+
+func TestExtensionGet(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ extension, err := extensions.Get(client, "os-admin-actions").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get extension os-admin-actions: %v", err)
+ }
+
+ tools.PrintResource(t, extension)
+}
diff --git a/acceptance/openstack/compute/v2/flavors_test.go b/acceptance/openstack/compute/v2/flavors_test.go
new file mode 100644
index 0000000..ee698cc
--- /dev/null
+++ b/acceptance/openstack/compute/v2/flavors_test.go
@@ -0,0 +1,51 @@
+// +build acceptance compute flavors
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/flavors"
+)
+
+func TestFlavorsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := flavors.ListDetail(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve flavors: %v", err)
+ }
+
+ allFlavors, err := flavors.ExtractFlavors(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract flavor results: %v", err)
+ }
+
+ for _, flavor := range allFlavors {
+ tools.PrintResource(t, flavor)
+ }
+}
+
+func TestFlavorsGet(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ 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)
+ }
+
+ tools.PrintResource(t, 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..26b7bfe
--- /dev/null
+++ b/acceptance/openstack/compute/v2/floatingip_test.go
@@ -0,0 +1,148 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+func TestFloatingIPsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := floatingips.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve floating IPs: %v", err)
+ }
+
+ allFloatingIPs, err := floatingips.ExtractFloatingIPs(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract floating IPs: %v", err)
+ }
+
+ for _, floatingIP := range allFloatingIPs {
+ tools.PrintResource(t, floatingIP)
+ }
+}
+
+func TestFloatingIPsCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ floatingIP, err := CreateFloatingIP(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create floating IP: %v", err)
+ }
+ defer DeleteFloatingIP(t, client, floatingIP)
+
+ tools.PrintResource(t, floatingIP)
+}
+
+func TestFloatingIPsAssociate(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ floatingIP, err := CreateFloatingIP(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create floating IP: %v", err)
+ }
+ defer DeleteFloatingIP(t, client, floatingIP)
+
+ tools.PrintResource(t, floatingIP)
+
+ err = AssociateFloatingIP(t, client, floatingIP, server)
+ if err != nil {
+ t.Fatalf("Unable to associate floating IP %s with server %s: %v", floatingIP.IP, server.ID, err)
+ }
+ defer DisassociateFloatingIP(t, client, floatingIP, server)
+
+ newFloatingIP, err := floatingips.Get(client, floatingIP.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get floating IP %s: %v", floatingIP.ID, err)
+ }
+
+ t.Logf("Floating IP %s is associated with Fixed IP %s", floatingIP.IP, newFloatingIP.FixedIP)
+
+ tools.PrintResource(t, newFloatingIP)
+}
+
+func TestFloatingIPsFixedIPAssociate(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ newServer, err := servers.Get(client, server.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get server %s: %v", server.ID, err)
+ }
+
+ floatingIP, err := CreateFloatingIP(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create floating IP: %v", err)
+ }
+ defer DeleteFloatingIP(t, client, floatingIP)
+
+ tools.PrintResource(t, floatingIP)
+
+ var fixedIP string
+ for _, networkAddresses := range newServer.Addresses[choices.NetworkName].([]interface{}) {
+ address := networkAddresses.(map[string]interface{})
+ if address["OS-EXT-IPS:type"] == "fixed" {
+ if address["version"].(float64) == 4 {
+ fixedIP = address["addr"].(string)
+ }
+ }
+ }
+
+ err = AssociateFloatingIPWithFixedIP(t, client, floatingIP, newServer, fixedIP)
+ if err != nil {
+ t.Fatalf("Unable to associate floating IP %s with server %s: %v", floatingIP.IP, newServer.ID, err)
+ }
+ defer DisassociateFloatingIP(t, client, floatingIP, newServer)
+
+ newFloatingIP, err := floatingips.Get(client, floatingIP.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get floating IP %s: %v", floatingIP.ID, err)
+ }
+
+ t.Logf("Floating IP %s is associated with Fixed IP %s", floatingIP.IP, newFloatingIP.FixedIP)
+
+ tools.PrintResource(t, newFloatingIP)
+}
diff --git a/acceptance/openstack/compute/v2/images_test.go b/acceptance/openstack/compute/v2/images_test.go
new file mode 100644
index 0000000..a34ce3e
--- /dev/null
+++ b/acceptance/openstack/compute/v2/images_test.go
@@ -0,0 +1,51 @@
+// +build acceptance compute images
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+)
+
+func TestImagesList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute: client: %v", err)
+ }
+
+ allPages, err := images.ListDetail(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve images: %v", err)
+ }
+
+ allImages, err := images.ExtractImages(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract image results: %v", err)
+ }
+
+ for _, image := range allImages {
+ tools.PrintResource(t, image)
+ }
+}
+
+func TestImagesGet(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute: client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ image, err := images.Get(client, choices.ImageID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get image information: %v", err)
+ }
+
+ tools.PrintResource(t, image)
+}
diff --git a/acceptance/openstack/compute/v2/keypairs_test.go b/acceptance/openstack/compute/v2/keypairs_test.go
new file mode 100644
index 0000000..c4b91ec
--- /dev/null
+++ b/acceptance/openstack/compute/v2/keypairs_test.go
@@ -0,0 +1,107 @@
+// +build acceptance compute keypairs
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+const keyName = "gophercloud_test_key_pair"
+
+func TestKeypairsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := keypairs.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve keypairs: %s", err)
+ }
+
+ allKeys, err := keypairs.ExtractKeyPairs(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract keypairs results: %s", err)
+ }
+
+ for _, keypair := range allKeys {
+ tools.PrintResource(t, keypair)
+ }
+}
+
+func TestKeypairsCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ keyPair, err := CreateKeyPair(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create key pair: %v", err)
+ }
+ defer DeleteKeyPair(t, client, keyPair)
+
+ tools.PrintResource(t, keyPair)
+}
+
+func TestKeypairsImportPublicKey(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ publicKey, err := createKey()
+ if err != nil {
+ t.Fatalf("Unable to create public key: %s", err)
+ }
+
+ keyPair, err := ImportPublicKey(t, client, publicKey)
+ if err != nil {
+ t.Fatalf("Unable to create keypair: %s", err)
+ }
+ defer DeleteKeyPair(t, client, keyPair)
+
+ tools.PrintResource(t, keyPair)
+}
+
+func TestKeypairsServerCreateWithKey(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ publicKey, err := createKey()
+ if err != nil {
+ t.Fatalf("Unable to create public key: %s", err)
+ }
+
+ keyPair, err := ImportPublicKey(t, client, publicKey)
+ if err != nil {
+ t.Fatalf("Unable to create keypair: %s", err)
+ }
+ defer DeleteKeyPair(t, client, keyPair)
+
+ server, err := CreateServerWithPublicKey(t, client, keyPair.Name)
+ if err != nil {
+ t.Fatalf("Unable to create server: %s", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ server, err = servers.Get(client, server.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve server: %s", err)
+ }
+
+ if server.KeyName != keyPair.Name {
+ t.Fatalf("key name of server %s is %s, not %s", server.ID, server.KeyName, keyPair.Name)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/limits_test.go b/acceptance/openstack/compute/v2/limits_test.go
new file mode 100644
index 0000000..2bf5ce6
--- /dev/null
+++ b/acceptance/openstack/compute/v2/limits_test.go
@@ -0,0 +1,52 @@
+// +build acceptance compute limits
+
+package v2
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/limits"
+)
+
+func TestLimits(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ limits, err := limits.Get(client, nil).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get limits: %v", err)
+ }
+
+ t.Logf("Limits for scoped user:")
+ t.Logf("%#v", limits)
+}
+
+func TestLimitsForTenant(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ // I think this is the easiest way to get the tenant ID while being
+ // agnostic to Identity v2 and v3.
+ // Technically we're just returning the limits for ourselves, but it's
+ // the fact that we're specifying a tenant ID that is important here.
+ endpointParts := strings.Split(client.Endpoint, "/")
+ tenantID := endpointParts[4]
+
+ getOpts := limits.GetOpts{
+ TenantID: tenantID,
+ }
+
+ limits, err := limits.Get(client, getOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get absolute limits: %v", err)
+ }
+
+ t.Logf("Limits for tenant %s:", tenantID)
+ t.Logf("%#v", limits)
+}
diff --git a/acceptance/openstack/compute/v2/network_test.go b/acceptance/openstack/compute/v2/network_test.go
new file mode 100644
index 0000000..7451518
--- /dev/null
+++ b/acceptance/openstack/compute/v2/network_test.go
@@ -0,0 +1,56 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/networks"
+)
+
+func TestNetworksList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := networks.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ allNetworks, err := networks.ExtractNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ for _, network := range allNetworks {
+ tools.PrintResource(t, network)
+ }
+}
+
+func TestNetworksGet(t *testing.T) {
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ networkID, err := GetNetworkIDFromNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ network, err := networks.Get(client, networkID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get network %s: %v", networkID, err)
+ }
+
+ tools.PrintResource(t, network)
+}
diff --git a/acceptance/openstack/compute/v2/pkg.go b/acceptance/openstack/compute/v2/pkg.go
new file mode 100644
index 0000000..a57c1e7
--- /dev/null
+++ b/acceptance/openstack/compute/v2/pkg.go
@@ -0,0 +1,2 @@
+// Package v2 package contains acceptance tests for the Openstack Compute V2 service.
+package v2
diff --git a/acceptance/openstack/compute/v2/quotaset_test.go b/acceptance/openstack/compute/v2/quotaset_test.go
new file mode 100644
index 0000000..1a691c0
--- /dev/null
+++ b/acceptance/openstack/compute/v2/quotaset_test.go
@@ -0,0 +1,184 @@
+// +build acceptance compute quotasets
+
+package v2
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/quotasets"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "os"
+)
+
+func TestQuotasetGet(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ identityClient, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Unable to get a new identity client: %v", err)
+ }
+
+ tenantID, err := getTenantID(t, identityClient)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ quotaSet, err := quotasets.Get(client, tenantID).Extract()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tools.PrintResource(t, quotaSet)
+}
+
+func getTenantID(t *testing.T, client *gophercloud.ServiceClient) (string, error) {
+ allPages, err := tenants.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to get list of tenants: %v", err)
+ }
+
+ allTenants, err := tenants.ExtractTenants(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract tenants: %v", err)
+ }
+
+ for _, tenant := range allTenants {
+ return tenant.ID, nil
+ }
+
+ return "", fmt.Errorf("Unable to get tenant ID")
+}
+
+func getTenantIDByName(t *testing.T, client *gophercloud.ServiceClient, name string) (string, error) {
+ allPages, err := tenants.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to get list of tenants: %v", err)
+ }
+
+ allTenants, err := tenants.ExtractTenants(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract tenants: %v", err)
+ }
+
+ for _, tenant := range allTenants {
+ if tenant.Name == name {
+ return tenant.ID, nil
+ }
+ }
+
+ return "", fmt.Errorf("Unable to get tenant ID")
+}
+
+//What will be sent as desired Quotas to the Server
+var UpdatQuotaOpts = quotasets.UpdateOpts{
+ FixedIps: gophercloud.IntToPointer(10),
+ FloatingIps: gophercloud.IntToPointer(10),
+ InjectedFileContentBytes: gophercloud.IntToPointer(10240),
+ InjectedFilePathBytes: gophercloud.IntToPointer(255),
+ InjectedFiles: gophercloud.IntToPointer(5),
+ KeyPairs: gophercloud.IntToPointer(10),
+ MetadataItems: gophercloud.IntToPointer(128),
+ Ram: gophercloud.IntToPointer(20000),
+ SecurityGroupRules: gophercloud.IntToPointer(20),
+ SecurityGroups: gophercloud.IntToPointer(10),
+ Cores: gophercloud.IntToPointer(10),
+ Instances: gophercloud.IntToPointer(4),
+ ServerGroups: gophercloud.IntToPointer(2),
+ ServerGroupMembers: gophercloud.IntToPointer(3),
+}
+
+//What the Server hopefully returns as the new Quotas
+var UpdatedQuotas = quotasets.QuotaSet{
+ FixedIps: 10,
+ FloatingIps: 10,
+ InjectedFileContentBytes: 10240,
+ InjectedFilePathBytes: 255,
+ InjectedFiles: 5,
+ KeyPairs: 10,
+ MetadataItems: 128,
+ Ram: 20000,
+ SecurityGroupRules: 20,
+ SecurityGroups: 10,
+ Cores: 10,
+ Instances: 4,
+ ServerGroups: 2,
+ ServerGroupMembers: 3,
+}
+
+func TestQuotasetUpdateDelete(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ idclient, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Could not create IdentityClient to look up tenant id!")
+ }
+
+ tenantid, err := getTenantIDByName(t, idclient, os.Getenv("OS_TENANT_NAME"))
+ if err != nil {
+ t.Fatalf("Id for Tenant named '%' not found. Please set OS_TENANT_NAME appropriately", os.Getenv("OS_TENANT_NAME"))
+ }
+
+ //save original quotas
+ orig, err := quotasets.Get(client, tenantid).Extract()
+ th.AssertNoErr(t, err)
+
+ //Test Update
+ res, err := quotasets.Update(client, tenantid, UpdatQuotaOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, UpdatedQuotas, *res)
+
+ //Test Delete
+ _, err = quotasets.Delete(client, tenantid).Extract()
+ th.AssertNoErr(t, err)
+ //We dont know the default quotas, so just check if the quotas are not the same as before
+ newres, err := quotasets.Get(client, tenantid).Extract()
+ if newres == res {
+ t.Fatalf("Quotas after delete equal quotas before delete!")
+ }
+
+ restore := quotasets.UpdateOpts{}
+ FillUpdateOptsFromQuotaSet(*orig, &restore)
+
+ //restore original quotas
+ res, err = quotasets.Update(client, tenantid, restore).Extract()
+ th.AssertNoErr(t, err)
+
+ orig.ID = ""
+ th.AssertEquals(t, *orig, *res)
+
+}
+
+// Makes sure that the FillUpdateOptsFromQuotaSet() helper function works properly
+func TestFillFromQuotaSetHelperFunction(t *testing.T) {
+ op := "asets.UpdateOpts{}
+ expected := `
+ {
+ "fixed_ips": 10,
+ "floating_ips": 10,
+ "injected_file_content_bytes": 10240,
+ "injected_file_path_bytes": 255,
+ "injected_files": 5,
+ "key_pairs": 10,
+ "metadata_items": 128,
+ "ram": 20000,
+ "security_group_rules": 20,
+ "security_groups": 10,
+ "cores": 10,
+ "instances": 4,
+ "server_groups": 2,
+ "server_group_members": 3
+ }`
+ FillUpdateOptsFromQuotaSet(UpdatedQuotas, op)
+ th.AssertJSONEquals(t, expected, op)
+}
diff --git a/acceptance/openstack/compute/v2/secgroup_test.go b/acceptance/openstack/compute/v2/secgroup_test.go
new file mode 100644
index 0000000..c0d0230
--- /dev/null
+++ b/acceptance/openstack/compute/v2/secgroup_test.go
@@ -0,0 +1,137 @@
+// +build acceptance compute secgroups
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+)
+
+func TestSecGroupsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := secgroups.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve security groups: %v", err)
+ }
+
+ allSecGroups, err := secgroups.ExtractSecurityGroups(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract security groups: %v", err)
+ }
+
+ for _, secgroup := range allSecGroups {
+ tools.PrintResource(t, secgroup)
+ }
+}
+
+func TestSecGroupsCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ securityGroup, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, securityGroup)
+}
+
+func TestSecGroupsUpdate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ securityGroup, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, securityGroup)
+
+ updateOpts := secgroups.UpdateOpts{
+ Name: tools.RandomString("secgroup_", 4),
+ Description: tools.RandomString("dec_", 10),
+ }
+ updatedSecurityGroup, err := secgroups.Update(client, securityGroup.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update security group: %v", err)
+ }
+
+ t.Logf("Updated %s's name to %s", updatedSecurityGroup.ID, updatedSecurityGroup.Name)
+}
+
+func TestSecGroupsRuleCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ securityGroup, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, securityGroup)
+
+ rule, err := CreateSecurityGroupRule(t, client, securityGroup.ID)
+ if err != nil {
+ t.Fatalf("Unable to create rule: %v", err)
+ }
+ defer DeleteSecurityGroupRule(t, client, rule)
+
+ newSecurityGroup, err := secgroups.Get(client, securityGroup.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to obtain security group: %v", err)
+ }
+
+ tools.PrintResource(t, newSecurityGroup)
+
+}
+
+func TestSecGroupsAddGroupToServer(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ securityGroup, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, securityGroup)
+
+ rule, err := CreateSecurityGroupRule(t, client, securityGroup.ID)
+ if err != nil {
+ t.Fatalf("Unable to create rule: %v", err)
+ }
+ defer DeleteSecurityGroupRule(t, client, rule)
+
+ t.Logf("Adding group %s to server %s", securityGroup.ID, server.ID)
+ err = secgroups.AddServer(client, server.ID, securityGroup.Name).ExtractErr()
+ if err != nil && err.Error() != "EOF" {
+ t.Fatalf("Unable to add group %s to server %s: %s", securityGroup.ID, server.ID, err)
+ }
+
+ t.Logf("Removing group %s from server %s", securityGroup.ID, server.ID)
+ err = secgroups.RemoveServer(client, server.ID, securityGroup.Name).ExtractErr()
+ if err != nil && err.Error() != "EOF" {
+ t.Fatalf("Unable to remove group %s from server %s: %s", securityGroup.ID, server.ID, err)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/servergroup_test.go b/acceptance/openstack/compute/v2/servergroup_test.go
new file mode 100644
index 0000000..ac1915f
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servergroup_test.go
@@ -0,0 +1,90 @@
+// +build acceptance compute servergroups
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/servergroups"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+func TestServergroupsList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := servergroups.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list server groups: %v", err)
+ }
+
+ allServerGroups, err := servergroups.ExtractServerGroups(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract server groups: %v", err)
+ }
+
+ for _, serverGroup := range allServerGroups {
+ tools.PrintResource(t, serverGroup)
+ }
+}
+
+func TestServergroupsCreate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ serverGroup, err := CreateServerGroup(t, client, "anti-affinity")
+ if err != nil {
+ t.Fatalf("Unable to create server group: %v", err)
+ }
+ defer DeleteServerGroup(t, client, serverGroup)
+
+ serverGroup, err = servergroups.Get(client, serverGroup.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get server group: %v", err)
+ }
+
+ tools.PrintResource(t, serverGroup)
+}
+
+func TestServergroupsAffinityPolicy(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ serverGroup, err := CreateServerGroup(t, client, "affinity")
+ if err != nil {
+ t.Fatalf("Unable to create server group: %v", err)
+ }
+ defer DeleteServerGroup(t, client, serverGroup)
+
+ firstServer, err := CreateServerInServerGroup(t, client, serverGroup)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, firstServer)
+
+ firstServer, err = servers.Get(client, firstServer.ID).Extract()
+
+ secondServer, err := CreateServerInServerGroup(t, client, serverGroup)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ if err = WaitForComputeStatus(client, secondServer, "ACTIVE"); err != nil {
+ t.Fatalf("Unable to wait for server: %v", err)
+ }
+ defer DeleteServer(t, client, secondServer)
+
+ secondServer, err = servers.Get(client, secondServer.ID).Extract()
+
+ if firstServer.HostID != secondServer.HostID {
+ t.Fatalf("%s and %s were not scheduled on the same host.", firstServer.ID, secondServer.ID)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/servers_test.go b/acceptance/openstack/compute/v2/servers_test.go
new file mode 100644
index 0000000..f43c94d
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servers_test.go
@@ -0,0 +1,390 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/availabilityzones"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestServersList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := servers.List(client, servers.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve servers: %v", err)
+ }
+
+ allServers, err := servers.ExtractServers(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract servers: %v", err)
+ }
+
+ for _, server := range allServers {
+ tools.PrintResource(t, server)
+ }
+}
+
+func TestServersCreateDestroy(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+
+ defer DeleteServer(t, client, server)
+
+ newServer, err := servers.Get(client, server.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve server: %v", err)
+ }
+ tools.PrintResource(t, newServer)
+
+ allAddressPages, err := servers.ListAddresses(client, server.ID).AllPages()
+ if err != nil {
+ t.Errorf("Unable to list server addresses: %v", err)
+ }
+
+ allAddresses, err := servers.ExtractAddresses(allAddressPages)
+ if err != nil {
+ t.Errorf("Unable to extract server addresses: %v", err)
+ }
+
+ for network, address := range allAddresses {
+ t.Logf("Addresses on %s: %+v", network, address)
+ }
+
+ allNetworkAddressPages, err := servers.ListAddressesByNetwork(client, server.ID, choices.NetworkName).AllPages()
+ if err != nil {
+ t.Errorf("Unable to list server addresses: %v", err)
+ }
+
+ allNetworkAddresses, err := servers.ExtractNetworkAddresses(allNetworkAddressPages)
+ if err != nil {
+ t.Errorf("Unable to extract server addresses: %v", err)
+ }
+
+ t.Logf("Addresses on %s:", choices.NetworkName)
+ for _, address := range allNetworkAddresses {
+ t.Logf("%+v", address)
+ }
+}
+
+func TestServersCreateDestroyWithExtensions(t *testing.T) {
+ var extendedServer struct {
+ servers.Server
+ availabilityzones.ServerExt
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ err = servers.Get(client, server.ID).ExtractInto(&extendedServer)
+ if err != nil {
+ t.Errorf("Unable to retrieve server: %v", err)
+ }
+ tools.PrintResource(t, extendedServer)
+}
+
+func TestServersWithoutImageRef(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServerWithoutImageRef(t, client)
+ if err != nil {
+ if err400, ok := err.(*gophercloud.ErrUnexpectedResponseCode); ok {
+ if !strings.Contains("Missing imageRef attribute", string(err400.Body)) {
+ defer DeleteServer(t, client, server)
+ }
+ }
+ }
+}
+
+func TestServersUpdate(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ alternateName := tools.RandomString("ACPTTEST", 16)
+ for alternateName == server.Name {
+ alternateName = tools.RandomString("ACPTTEST", 16)
+ }
+
+ t.Logf("Attempting to rename the server to %s.", alternateName)
+
+ updateOpts := servers.UpdateOpts{
+ Name: alternateName,
+ }
+
+ updated, err := servers.Update(client, server.ID, updateOpts).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 TestServersMetadata(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ metadata, err := servers.UpdateMetadata(client, server.ID, servers.MetadataOpts{
+ "foo": "bar",
+ "this": "that",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update metadata: %v", err)
+ }
+ t.Logf("UpdateMetadata result: %+v\n", metadata)
+
+ err = servers.DeleteMetadatum(client, server.ID, "foo").ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete metadatum: %v", err)
+ }
+
+ metadata, err = servers.CreateMetadatum(client, server.ID, servers.MetadatumOpts{
+ "foo": "baz",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create metadatum: %v", err)
+ }
+ t.Logf("CreateMetadatum result: %+v\n", metadata)
+
+ metadata, err = servers.Metadatum(client, server.ID, "foo").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get metadatum: %v", err)
+ }
+ t.Logf("Metadatum result: %+v\n", metadata)
+ th.AssertEquals(t, "baz", metadata["foo"])
+
+ metadata, err = servers.Metadata(client, server.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get metadata: %v", err)
+ }
+ t.Logf("Metadata result: %+v\n", metadata)
+
+ metadata, err = servers.ResetMetadata(client, server.ID, servers.MetadataOpts{}).Extract()
+ if err != nil {
+ t.Fatalf("Unable to reset metadata: %v", err)
+ }
+ t.Logf("ResetMetadata result: %+v\n", metadata)
+ th.AssertDeepEquals(t, map[string]string{}, metadata)
+}
+
+func TestServersActionChangeAdminPassword(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ randomPassword := tools.MakeNewPassword(server.AdminPass)
+ res := servers.ChangeAdminPassword(client, server.ID, randomPassword)
+ if res.Err != nil {
+ t.Fatal(res.Err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "PASSWORD"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersActionReboot(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ rebootOpts := &servers.RebootOpts{
+ Type: servers.SoftReboot,
+ }
+
+ t.Logf("Attempting reboot of server %s", server.ID)
+ res := servers.Reboot(client, server.ID, rebootOpts)
+ if res.Err != nil {
+ t.Fatalf("Unable to reboot server: %v", res.Err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "REBOOT"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersActionRebuild(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ 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 = WaitForComputeStatus(client, rebuilt, "REBUILD"); err != nil {
+ t.Fatal(err)
+ }
+
+ if err = WaitForComputeStatus(client, rebuilt, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersActionResizeConfirm(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ t.Logf("Attempting to resize server %s", server.ID)
+ ResizeServer(t, client, server)
+
+ t.Logf("Attempting to confirm resize for server %s", server.ID)
+ if res := servers.ConfirmResize(client, server.ID); res.Err != nil {
+ t.Fatal(res.Err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersActionResizeRevert(t *testing.T) {
+ t.Parallel()
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer DeleteServer(t, client, server)
+
+ t.Logf("Attempting to resize server %s", server.ID)
+ ResizeServer(t, client, server)
+
+ t.Logf("Attempting to revert resize for server %s", server.ID)
+ if res := servers.RevertResize(client, server.ID); res.Err != nil {
+ t.Fatal(res.Err)
+ }
+
+ if err = WaitForComputeStatus(client, server, "ACTIVE"); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/acceptance/openstack/compute/v2/tenantnetworks_test.go b/acceptance/openstack/compute/v2/tenantnetworks_test.go
new file mode 100644
index 0000000..9b6b527
--- /dev/null
+++ b/acceptance/openstack/compute/v2/tenantnetworks_test.go
@@ -0,0 +1,56 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+)
+
+func TestTenantNetworksList(t *testing.T) {
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ allPages, err := tenantnetworks.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ allTenantNetworks, err := tenantnetworks.ExtractNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ for _, network := range allTenantNetworks {
+ tools.PrintResource(t, network)
+ }
+}
+
+func TestTenantNetworksGet(t *testing.T) {
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ networkID, err := GetNetworkIDFromTenantNetworks(t, client, choices.NetworkName)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ network, err := tenantnetworks.Get(client, networkID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get network %s: %v", networkID, err)
+ }
+
+ tools.PrintResource(t, network)
+}
diff --git a/acceptance/openstack/compute/v2/volumeattach_test.go b/acceptance/openstack/compute/v2/volumeattach_test.go
new file mode 100644
index 0000000..78d85a9
--- /dev/null
+++ b/acceptance/openstack/compute/v2/volumeattach_test.go
@@ -0,0 +1,78 @@
+// +build acceptance compute volumeattach
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+)
+
+func TestVolumeAttachAttachment(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires server creation in short mode.")
+ }
+
+ client, err := clients.NewComputeV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ blockClient, err := clients.NewBlockStorageV1Client()
+ if err != nil {
+ t.Fatalf("Unable to create a blockstorage client: %v", err)
+ }
+
+ server, err := CreateServer(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create server: %v", err)
+ }
+ defer DeleteServer(t, client, server)
+
+ volume, err := createVolume(t, blockClient)
+ if err != nil {
+ t.Fatalf("Unable to create volume: %v", err)
+ }
+
+ if err = volumes.WaitForStatus(blockClient, volume.ID, "available", 60); err != nil {
+ t.Fatalf("Unable to wait for volume: %v", err)
+ }
+ defer deleteVolume(t, blockClient, volume)
+
+ volumeAttachment, err := CreateVolumeAttachment(t, client, blockClient, server, volume)
+ if err != nil {
+ t.Fatalf("Unable to attach volume: %v", err)
+ }
+ defer DeleteVolumeAttachment(t, client, blockClient, server, volumeAttachment)
+
+ tools.PrintResource(t, volumeAttachment)
+
+}
+
+func createVolume(t *testing.T, blockClient *gophercloud.ServiceClient) (*volumes.Volume, error) {
+ volumeName := tools.RandomString("ACPTTEST", 16)
+ createOpts := volumes.CreateOpts{
+ Size: 1,
+ Name: volumeName,
+ }
+
+ volume, err := volumes.Create(blockClient, createOpts).Extract()
+ if err != nil {
+ return volume, err
+ }
+
+ t.Logf("Created volume: %s", volume.ID)
+ return volume, nil
+}
+
+func deleteVolume(t *testing.T, blockClient *gophercloud.ServiceClient, volume *volumes.Volume) {
+ err := volumes.Delete(blockClient, volume.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete volume: %v", err)
+ }
+
+ t.Logf("Deleted volume: %s", volume.ID)
+}
diff --git a/acceptance/openstack/db/v1/common.go b/acceptance/openstack/db/v1/common.go
new file mode 100644
index 0000000..bbe7ebd
--- /dev/null
+++ b/acceptance/openstack/db/v1/common.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ th "github.com/gophercloud/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..c52357a
--- /dev/null
+++ b/acceptance/openstack/db/v1/database_test.go
@@ -0,0 +1,45 @@
+// +build acceptance db
+
+package v1
+
+import (
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/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..6440cc9
--- /dev/null
+++ b/acceptance/openstack/db/v1/flavor_test.go
@@ -0,0 +1,31 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "github.com/gophercloud/gophercloud/openstack/db/v1/flavors"
+ "github.com/gophercloud/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..75668a2
--- /dev/null
+++ b/acceptance/openstack/db/v1/instance_test.go
@@ -0,0 +1,138 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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..0f5fcc2
--- /dev/null
+++ b/acceptance/openstack/db/v1/user_test.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ u "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+ "github.com/gophercloud/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/dns/v2/zones.go b/acceptance/openstack/dns/v2/zones.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/dns/v2/zones.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/dns/v2/zones_test.go b/acceptance/openstack/dns/v2/zones_test.go
new file mode 100644
index 0000000..add964d
--- /dev/null
+++ b/acceptance/openstack/dns/v2/zones_test.go
@@ -0,0 +1,33 @@
+// +build acceptance dns zones
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/dns/v2/zones"
+)
+
+func TestZonesList(t *testing.T) {
+ client, err := clients.NewDNSV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a DNS client: %v", err)
+ }
+
+ var allZones []zones.Zone
+ allPages, err := zones.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve zones: %v", err)
+ }
+
+ allZones, err = zones.ExtractZones(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract zones: %v", err)
+ }
+
+ for _, zone := range allZones {
+ tools.PrintResource(t, &zone)
+ }
+}
diff --git a/acceptance/openstack/identity/v2/extension_test.go b/acceptance/openstack/identity/v2/extension_test.go
new file mode 100644
index 0000000..c6a2bde
--- /dev/null
+++ b/acceptance/openstack/identity/v2/extension_test.go
@@ -0,0 +1,46 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions"
+)
+
+func TestExtensionsList(t *testing.T) {
+ client, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create an identity client: %v", err)
+ }
+
+ allPages, err := extensions.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list extensions: %v", err)
+ }
+
+ allExtensions, err := extensions.ExtractExtensions(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract extensions: %v", err)
+ }
+
+ for _, extension := range allExtensions {
+ tools.PrintResource(t, extension)
+ }
+}
+
+func TestExtensionsGet(t *testing.T) {
+ client, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create an identity client: %v", err)
+ }
+
+ extension, err := extensions.Get(client, "OS-KSCRUD").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get extension OS-KSCRUD: %v", err)
+ }
+
+ tools.PrintResource(t, extension)
+}
diff --git a/acceptance/openstack/identity/v2/identity.go b/acceptance/openstack/identity/v2/identity.go
new file mode 100644
index 0000000..2d3bd09
--- /dev/null
+++ b/acceptance/openstack/identity/v2/identity.go
@@ -0,0 +1,146 @@
+// Package v2 contains common functions for creating identity-based resources
+// for use in acceptance tests. See the `*_test.go` files for example usages.
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/users"
+)
+
+// AddUserRole will grant a role to a user in a tenant. An error will be
+// returned if the grant was unsuccessful.
+func AddUserRole(t *testing.T, client *gophercloud.ServiceClient, tenant *tenants.Tenant, user *users.User, role *roles.Role) error {
+ t.Logf("Attempting to grant user %s role %s in tenant %s", user.ID, role.ID, tenant.ID)
+
+ err := roles.AddUser(client, tenant.ID, user.ID, role.ID).ExtractErr()
+ if err != nil {
+ return err
+ }
+
+ t.Logf("Granted user %s role %s in tenant %s", user.ID, role.ID, tenant.ID)
+
+ return nil
+}
+
+// CreateUser will create a user with a random name and adds them to the given
+// tenant. An error will be returned if the user was unable to be created.
+func CreateUser(t *testing.T, client *gophercloud.ServiceClient, tenant *tenants.Tenant) (*users.User, error) {
+ userName := tools.RandomString("user_", 5)
+ userEmail := userName + "@foo.com"
+ t.Logf("Creating user: %s", userName)
+
+ createOpts := users.CreateOpts{
+ Name: userName,
+ Enabled: gophercloud.Disabled,
+ TenantID: tenant.ID,
+ Email: userEmail,
+ }
+
+ user, err := users.Create(client, createOpts).Extract()
+ if err != nil {
+ return user, err
+ }
+
+ return user, nil
+}
+
+// DeleteUser will delete a user. A fatal error will occur if the delete was
+// unsuccessful. This works best when used as a deferred function.
+func DeleteUser(t *testing.T, client *gophercloud.ServiceClient, user *users.User) {
+ t.Logf("Attempting to delete user: %s", user.Name)
+
+ result := users.Delete(client, user.ID)
+ if result.Err != nil {
+ t.Fatalf("Unable to delete user")
+ }
+
+ t.Logf("Deleted user: %s", user.Name)
+}
+
+// DeleteUserRole will revoke a role of a user in a tenant. A fatal error will
+// occur if the revoke was unsuccessful. This works best when used as a
+// deferred function.
+func DeleteUserRole(t *testing.T, client *gophercloud.ServiceClient, tenant *tenants.Tenant, user *users.User, role *roles.Role) {
+ t.Logf("Attempting to remove role %s from user %s in tenant %s", role.ID, user.ID, tenant.ID)
+
+ err := roles.DeleteUser(client, tenant.ID, user.ID, role.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to remove role")
+ }
+
+ t.Logf("Removed role %s from user %s in tenant %s", role.ID, user.ID, tenant.ID)
+}
+
+// FindRole finds all roles that the current authenticated client has access
+// to and returns the first one found. An error will be returned if the lookup
+// was unsuccessful.
+func FindRole(t *testing.T, client *gophercloud.ServiceClient) (*roles.Role, error) {
+ var role *roles.Role
+
+ allPages, err := roles.List(client).AllPages()
+ if err != nil {
+ return role, err
+ }
+
+ allRoles, err := roles.ExtractRoles(allPages)
+ if err != nil {
+ return role, err
+ }
+
+ for _, r := range allRoles {
+ role = &r
+ break
+ }
+
+ return role, nil
+}
+
+// FindTenant finds all tenants that the current authenticated client has access
+// to and returns the first one found. An error will be returned if the lookup
+// was unsuccessful.
+func FindTenant(t *testing.T, client *gophercloud.ServiceClient) (*tenants.Tenant, error) {
+ var tenant *tenants.Tenant
+
+ allPages, err := tenants.List(client, nil).AllPages()
+ if err != nil {
+ return tenant, err
+ }
+
+ allTenants, err := tenants.ExtractTenants(allPages)
+ if err != nil {
+ return tenant, err
+ }
+
+ for _, t := range allTenants {
+ tenant = &t
+ break
+ }
+
+ return tenant, nil
+}
+
+// UpdateUser will update an existing user with a new randomly generated name.
+// An error will be returned if the update was unsuccessful.
+func UpdateUser(t *testing.T, client *gophercloud.ServiceClient, user *users.User) (*users.User, error) {
+ userName := tools.RandomString("user_", 5)
+ userEmail := userName + "@foo.com"
+
+ t.Logf("Attempting to update user name from %s to %s", user.Name, userName)
+
+ updateOpts := users.UpdateOpts{
+ Name: userName,
+ Email: userEmail,
+ }
+
+ newUser, err := users.Update(client, user.ID, updateOpts).Extract()
+ if err != nil {
+ return newUser, err
+ }
+
+ return newUser, nil
+}
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..83fbd31
--- /dev/null
+++ b/acceptance/openstack/identity/v2/role_test.go
@@ -0,0 +1,77 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/users"
+)
+
+func TestRolesAddToUser(t *testing.T) {
+ client, err := clients.NewIdentityV2AdminClient()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ tenant, err := FindTenant(t, client)
+ if err != nil {
+ t.Fatalf("Unable to get a tenant: %v", err)
+ }
+
+ role, err := FindRole(t, client)
+ if err != nil {
+ t.Fatalf("Unable to get a role: %v", err)
+ }
+
+ user, err := CreateUser(t, client, tenant)
+ if err != nil {
+ t.Fatalf("Unable to create a user: %v", err)
+ }
+ defer DeleteUser(t, client, user)
+
+ err = AddUserRole(t, client, tenant, user, role)
+ if err != nil {
+ t.Fatalf("Unable to add role to user: %v", err)
+ }
+ defer DeleteUserRole(t, client, tenant, user, role)
+
+ allPages, err := users.ListRoles(client, tenant.ID, user.ID).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to obtain roles for user: %v", err)
+ }
+
+ allRoles, err := users.ExtractRoles(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract roles: %v", err)
+ }
+
+ t.Logf("Roles of user %s:", user.Name)
+ for _, role := range allRoles {
+ tools.PrintResource(t, role)
+ }
+}
+
+func TestRolesList(t *testing.T) {
+ client, err := clients.NewIdentityV2AdminClient()
+ if err != nil {
+ t.Fatalf("Unable to create an identity client: %v", err)
+ }
+
+ allPages, err := roles.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list all roles: %v", err)
+ }
+
+ allRoles, err := roles.ExtractRoles(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract roles: %v", err)
+ }
+
+ for _, r := range allRoles {
+ tools.PrintResource(t, r)
+ }
+}
diff --git a/acceptance/openstack/identity/v2/tenant_test.go b/acceptance/openstack/identity/v2/tenant_test.go
new file mode 100644
index 0000000..2053614
--- /dev/null
+++ b/acceptance/openstack/identity/v2/tenant_test.go
@@ -0,0 +1,32 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+)
+
+func TestTenantsList(t *testing.T) {
+ client, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ allPages, err := tenants.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list tenants: %v", err)
+ }
+
+ allTenants, err := tenants.ExtractTenants(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract tenants: %v", err)
+ }
+
+ for _, tenant := range allTenants {
+ tools.PrintResource(t, tenant)
+ }
+}
diff --git a/acceptance/openstack/identity/v2/token_test.go b/acceptance/openstack/identity/v2/token_test.go
new file mode 100644
index 0000000..82a317a
--- /dev/null
+++ b/acceptance/openstack/identity/v2/token_test.go
@@ -0,0 +1,69 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+)
+
+func TestTokenAuthenticate(t *testing.T) {
+ client, err := clients.NewIdentityV2UnauthenticatedClient()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ authOptions, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to obtain authentication options: %v", err)
+ }
+
+ result := tokens.Create(client, authOptions)
+ token, err := result.ExtractToken()
+ if err != nil {
+ t.Fatalf("Unable to extract token: %v", err)
+ }
+
+ tools.PrintResource(t, token)
+
+ catalog, err := result.ExtractServiceCatalog()
+ if err != nil {
+ t.Fatalf("Unable to extract service catalog: %v", err)
+ }
+
+ for _, entry := range catalog.Entries {
+ tools.PrintResource(t, entry)
+ }
+}
+
+func TestTokenValidate(t *testing.T) {
+ client, err := clients.NewIdentityV2Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ authOptions, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to obtain authentication options: %v", err)
+ }
+
+ result := tokens.Create(client, authOptions)
+ token, err := result.ExtractToken()
+ if err != nil {
+ t.Fatalf("Unable to extract token: %v", err)
+ }
+
+ tools.PrintResource(t, token)
+
+ getResult := tokens.Get(client, token.ID)
+ user, err := getResult.ExtractUser()
+ if err != nil {
+ t.Fatalf("Unable to extract user: %v", err)
+ }
+
+ tools.PrintResource(t, user)
+}
diff --git a/acceptance/openstack/identity/v2/user_test.go b/acceptance/openstack/identity/v2/user_test.go
new file mode 100644
index 0000000..faa5bba
--- /dev/null
+++ b/acceptance/openstack/identity/v2/user_test.go
@@ -0,0 +1,59 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/users"
+)
+
+func TestUsersList(t *testing.T) {
+ client, err := clients.NewIdentityV2AdminClient()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ allPages, err := users.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list users: %v", err)
+ }
+
+ allUsers, err := users.ExtractUsers(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract users: %v", err)
+ }
+
+ for _, user := range allUsers {
+ tools.PrintResource(t, user)
+ }
+}
+
+func TestUsersCreateUpdateDelete(t *testing.T) {
+ client, err := clients.NewIdentityV2AdminClient()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ tenant, err := FindTenant(t, client)
+ if err != nil {
+ t.Fatalf("Unable to get a tenant: %v", err)
+ }
+
+ user, err := CreateUser(t, client, tenant)
+ if err != nil {
+ t.Fatalf("Unable to create a user: %v", err)
+ }
+ defer DeleteUser(t, client, user)
+
+ tools.PrintResource(t, user)
+
+ newUser, err := UpdateUser(t, client, user)
+ if err != nil {
+ t.Fatalf("Unable to update user: %v", err)
+ }
+
+ tools.PrintResource(t, newUser)
+}
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
new file mode 100644
index 0000000..a589970
--- /dev/null
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -0,0 +1,86 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/endpoints"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/services"
+)
+
+func TestEndpointsList(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ allPages, err := endpoints.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list endpoints: %v", err)
+ }
+
+ allEndpoints, err := endpoints.ExtractEndpoints(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract endpoints: %v", err)
+ }
+
+ for _, endpoint := range allEndpoints {
+ tools.PrintResource(t, endpoint)
+ }
+}
+
+func TestEndpointsNavigateCatalog(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ // Discover the service we're interested in.
+ serviceListOpts := services.ListOpts{
+ ServiceType: "compute",
+ }
+
+ allPages, err := services.List(client, serviceListOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to lookup compute service: %v", err)
+ }
+
+ allServices, err := services.ExtractServices(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract service: %v")
+ }
+
+ if len(allServices) != 1 {
+ t.Fatalf("Expected one service, got %d", len(allServices))
+ }
+
+ computeService := allServices[0]
+ tools.PrintResource(t, computeService)
+
+ // Enumerate the endpoints available for this service.
+ endpointListOpts := endpoints.ListOpts{
+ Availability: gophercloud.AvailabilityPublic,
+ ServiceID: computeService.ID,
+ }
+
+ allPages, err = endpoints.List(client, endpointListOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to lookup compute endpoint: %v", err)
+ }
+
+ allEndpoints, err := endpoints.ExtractEndpoints(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract endpoint: %v")
+ }
+
+ if len(allEndpoints) != 1 {
+ t.Fatalf("Expected one endpoint, got %d", len(allEndpoints))
+ }
+
+ tools.PrintResource(t, allEndpoints[0])
+
+}
diff --git a/acceptance/openstack/identity/v3/identity.go b/acceptance/openstack/identity/v3/identity.go
new file mode 100644
index 0000000..3276efc
--- /dev/null
+++ b/acceptance/openstack/identity/v3/identity.go
@@ -0,0 +1,48 @@
+package v3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/projects"
+)
+
+// CreateProject will create a project with a random name.
+// It takes an optional createOpts parameter since creating a project
+// has so many options. An error will be returned if the project was
+// unable to be created.
+func CreateProject(t *testing.T, client *gophercloud.ServiceClient, c *projects.CreateOpts) (*projects.Project, error) {
+ name := tools.RandomString("ACPTTEST", 8)
+ t.Logf("Attempting to create project: %s", name)
+
+ var createOpts projects.CreateOpts
+ if c != nil {
+ createOpts = *c
+ } else {
+ createOpts = projects.CreateOpts{}
+ }
+
+ createOpts.Name = name
+
+ project, err := projects.Create(client, createOpts).Extract()
+ if err != nil {
+ return project, err
+ }
+
+ t.Logf("Successfully created project %s with ID %s", name, project.ID)
+
+ return project, nil
+}
+
+// DeleteProject will delete a project by ID. A fatal error will occur if
+// the project ID failed to be deleted. This works best when using it as
+// a deferred function.
+func DeleteProject(t *testing.T, client *gophercloud.ServiceClient, projectID string) {
+ err := projects.Delete(client, projectID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete project %s: %v", projectID, err)
+ }
+
+ t.Logf("Deleted project: %s", projectID)
+}
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/projects_test.go b/acceptance/openstack/identity/v3/projects_test.go
new file mode 100644
index 0000000..8325bee
--- /dev/null
+++ b/acceptance/openstack/identity/v3/projects_test.go
@@ -0,0 +1,158 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/projects"
+)
+
+func TestProjectsList(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ var iTrue bool = true
+ listOpts := projects.ListOpts{
+ Enabled: &iTrue,
+ }
+
+ allPages, err := projects.List(client, listOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list projects: %v", err)
+ }
+
+ allProjects, err := projects.ExtractProjects(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract projects: %v", err)
+ }
+
+ for _, project := range allProjects {
+ tools.PrintResource(t, project)
+ }
+}
+
+func TestProjectsGet(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v", err)
+ }
+
+ allPages, err := projects.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list projects: %v", err)
+ }
+
+ allProjects, err := projects.ExtractProjects(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract projects: %v", err)
+ }
+
+ project := allProjects[0]
+ p, err := projects.Get(client, project.ID, nil).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get project: %v", err)
+ }
+
+ tools.PrintResource(t, p)
+}
+
+func TestProjectsCRUD(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ project, err := CreateProject(t, client, nil)
+ if err != nil {
+ t.Fatalf("Unable to create project: %v", err)
+ }
+ defer DeleteProject(t, client, project.ID)
+
+ tools.PrintResource(t, project)
+
+ var iFalse bool = false
+ updateOpts := projects.UpdateOpts{
+ Enabled: &iFalse,
+ }
+
+ updatedProject, err := projects.Update(client, project.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update project: %v", err)
+ }
+
+ tools.PrintResource(t, updatedProject)
+}
+
+func TestProjectsDomain(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ var iTrue = true
+ createOpts := projects.CreateOpts{
+ IsDomain: &iTrue,
+ }
+
+ projectDomain, err := CreateProject(t, client, &createOpts)
+ if err != nil {
+ t.Fatalf("Unable to create project: %v", err)
+ }
+ defer DeleteProject(t, client, projectDomain.ID)
+
+ tools.PrintResource(t, projectDomain)
+
+ createOpts = projects.CreateOpts{
+ DomainID: projectDomain.ID,
+ }
+
+ project, err := CreateProject(t, client, &createOpts)
+ if err != nil {
+ t.Fatalf("Unable to create project: %v", err)
+ }
+ defer DeleteProject(t, client, project.ID)
+
+ tools.PrintResource(t, project)
+
+ var iFalse = false
+ updateOpts := projects.UpdateOpts{
+ Enabled: &iFalse,
+ }
+
+ _, err = projects.Update(client, projectDomain.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to disable domain: %v")
+ }
+}
+
+func TestProjectsNested(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ projectMain, err := CreateProject(t, client, nil)
+ if err != nil {
+ t.Fatalf("Unable to create project: %v", err)
+ }
+ defer DeleteProject(t, client, projectMain.ID)
+
+ tools.PrintResource(t, projectMain)
+
+ createOpts := projects.CreateOpts{
+ ParentID: projectMain.ID,
+ }
+
+ project, err := CreateProject(t, client, &createOpts)
+ if err != nil {
+ t.Fatalf("Unable to create project: %v", err)
+ }
+ defer DeleteProject(t, client, project.ID)
+
+ tools.PrintResource(t, project)
+}
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
new file mode 100644
index 0000000..7a0c71f
--- /dev/null
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -0,0 +1,33 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/services"
+)
+
+func TestServicesList(t *testing.T) {
+ client, err := clients.NewIdentityV3Client()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ allPages, err := services.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list services: %v", err)
+ }
+
+ allServices, err := services.ExtractServices(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract services: %v", err)
+ }
+
+ for _, service := range allServices {
+ tools.PrintResource(t, service)
+ }
+
+}
diff --git a/acceptance/openstack/identity/v3/token_test.go b/acceptance/openstack/identity/v3/token_test.go
new file mode 100644
index 0000000..e0f90d9
--- /dev/null
+++ b/acceptance/openstack/identity/v3/token_test.go
@@ -0,0 +1,37 @@
+// +build acceptance
+
+package v3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+)
+
+func TestGetToken(t *testing.T) {
+ client, err := clients.NewIdentityV3UnauthenticatedClient()
+ if err != nil {
+ t.Fatalf("Unable to obtain an identity client: %v")
+ }
+
+ ao, err := openstack.AuthOptionsFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to obtain environment auth options: %v", err)
+ }
+
+ authOptions := tokens.AuthOptions{
+ Username: ao.Username,
+ Password: ao.Password,
+ DomainName: "default",
+ }
+
+ token, err := tokens.Create(client, &authOptions).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get token: %v", err)
+ }
+
+ tools.PrintResource(t, token)
+}
diff --git a/acceptance/openstack/imageservice/v2/images_test.go b/acceptance/openstack/imageservice/v2/images_test.go
new file mode 100644
index 0000000..aa0390b
--- /dev/null
+++ b/acceptance/openstack/imageservice/v2/images_test.go
@@ -0,0 +1,26 @@
+// +build acceptance imageservice images
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+)
+
+func TestImagesCreateDestroyEmptyImage(t *testing.T) {
+ client, err := clients.NewImageServiceV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create an image service client: %v", err)
+ }
+
+ image, err := CreateEmptyImage(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create empty image: %v", err)
+ }
+
+ defer DeleteImage(t, client, image)
+
+ tools.PrintResource(t, image)
+}
diff --git a/acceptance/openstack/imageservice/v2/imageservice.go b/acceptance/openstack/imageservice/v2/imageservice.go
new file mode 100644
index 0000000..8aaeeb7
--- /dev/null
+++ b/acceptance/openstack/imageservice/v2/imageservice.go
@@ -0,0 +1,55 @@
+// Package v2 contains common functions for creating imageservice resources
+// for use in acceptance tests. See the `*_test.go` files for example usages.
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images"
+)
+
+// CreateEmptyImage will create an image, but with no actual image data.
+// An error will be returned if an image was unable to be created.
+func CreateEmptyImage(t *testing.T, client *gophercloud.ServiceClient) (*images.Image, error) {
+ var image *images.Image
+
+ name := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create image: %s", name)
+
+ protected := false
+ visibility := images.ImageVisibilityPrivate
+ createOpts := &images.CreateOpts{
+ Name: name,
+ ContainerFormat: "bare",
+ DiskFormat: "qcow2",
+ MinDisk: 0,
+ MinRAM: 0,
+ Protected: &protected,
+ Visibility: &visibility,
+ Properties: map[string]string{
+ "architecture": "x86_64",
+ },
+ }
+
+ image, err := images.Create(client, createOpts).Extract()
+ if err != nil {
+ return image, err
+ }
+
+ t.Logf("Created image %s: %#v", name, image)
+ return image, nil
+}
+
+// DeleteImage deletes an image.
+// A fatal error will occur if the image failed to delete. This works best when
+// used as a deferred function.
+func DeleteImage(t *testing.T, client *gophercloud.ServiceClient, image *images.Image) {
+ err := images.Delete(client, image.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete image %s: %v", image.ID, err)
+ }
+
+ t.Logf("Deleted image: %s", image.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..c6f8f26
--- /dev/null
+++ b/acceptance/openstack/networking/v2/apiversion_test.go
@@ -0,0 +1,53 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/apiversions"
+)
+
+func TestAPIVersionsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := apiversions.ListVersions(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list api versions: %v", err)
+ }
+
+ allAPIVersions, err := apiversions.ExtractAPIVersions(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract api versions: %v", err)
+ }
+
+ for _, apiVersion := range allAPIVersions {
+ tools.PrintResource(t, apiVersion)
+ }
+}
+
+func TestAPIResourcesList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := apiversions.ListVersionResources(client, "v2.0").AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list api version reosources: %v", err)
+ }
+
+ allVersionResources, err := apiversions.ExtractVersionResources(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract version resources: %v", err)
+ }
+
+ for _, versionResource := range allVersionResources {
+ tools.PrintResource(t, versionResource)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extension_test.go b/acceptance/openstack/networking/v2/extension_test.go
new file mode 100644
index 0000000..5609e85
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extension_test.go
@@ -0,0 +1,46 @@
+// +build acceptance networking extensions
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/common/extensions"
+)
+
+func TestExtensionsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := extensions.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list extensions: %v", err)
+ }
+
+ allExtensions, err := extensions.ExtractExtensions(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract extensions: %v", err)
+ }
+
+ for _, extension := range allExtensions {
+ tools.PrintResource(t, extension)
+ }
+}
+
+func TestExtensionGet(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ extension, err := extensions.Get(client, "router").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get extension port-security: %v", err)
+ }
+
+ tools.PrintResource(t, extension)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/extensions.go b/acceptance/openstack/networking/v2/extensions/extensions.go
new file mode 100644
index 0000000..154e34e
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/extensions.go
@@ -0,0 +1,138 @@
+package extensions
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/external"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/groups"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+// CreateExternalNetwork will create an external network. An error will be
+// returned if the creation failed.
+func CreateExternalNetwork(t *testing.T, client *gophercloud.ServiceClient) (*networks.Network, error) {
+ networkName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create external network: %s", networkName)
+
+ adminStateUp := true
+ isExternal := true
+ createOpts := external.CreateOpts{
+ External: &isExternal,
+ }
+
+ createOpts.Name = networkName
+ createOpts.AdminStateUp = &adminStateUp
+
+ network, err := networks.Create(client, createOpts).Extract()
+ if err != nil {
+ return network, err
+ }
+
+ t.Logf("Created external network: %s", networkName)
+
+ return network, nil
+}
+
+// CreatePortWithSecurityGroup will create a port with a security group
+// attached. An error will be returned if the port could not be created.
+func CreatePortWithSecurityGroup(t *testing.T, client *gophercloud.ServiceClient, networkID, subnetID, secGroupID string) (*ports.Port, error) {
+ portName := tools.RandomString("TESTACC-", 8)
+ iFalse := false
+
+ t.Logf("Attempting to create port: %s", portName)
+
+ createOpts := ports.CreateOpts{
+ NetworkID: networkID,
+ Name: portName,
+ AdminStateUp: &iFalse,
+ FixedIPs: []ports.IP{ports.IP{SubnetID: subnetID}},
+ SecurityGroups: []string{secGroupID},
+ }
+
+ port, err := ports.Create(client, createOpts).Extract()
+ if err != nil {
+ return port, err
+ }
+
+ t.Logf("Successfully created port: %s", portName)
+
+ return port, nil
+}
+
+// CreateSecurityGroup will create a security group with a random name.
+// An error will be returned if one was failed to be created.
+func CreateSecurityGroup(t *testing.T, client *gophercloud.ServiceClient) (*groups.SecGroup, error) {
+ secGroupName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create security group: %s", secGroupName)
+
+ createOpts := groups.CreateOpts{
+ Name: secGroupName,
+ }
+
+ secGroup, err := groups.Create(client, createOpts).Extract()
+ if err != nil {
+ return secGroup, err
+ }
+
+ t.Logf("Created security group: %s", secGroup.ID)
+
+ return secGroup, nil
+}
+
+// CreateSecurityGroupRule will create a security group rule with a random name
+// and random port between 80 and 99.
+// An error will be returned if one was failed to be created.
+func CreateSecurityGroupRule(t *testing.T, client *gophercloud.ServiceClient, secGroupID string) (*rules.SecGroupRule, error) {
+ t.Logf("Attempting to create security group rule in group: %s", secGroupID)
+
+ fromPort := tools.RandomInt(80, 89)
+ toPort := tools.RandomInt(90, 99)
+
+ createOpts := rules.CreateOpts{
+ Direction: "ingress",
+ EtherType: "IPv4",
+ SecGroupID: secGroupID,
+ PortRangeMin: fromPort,
+ PortRangeMax: toPort,
+ Protocol: rules.ProtocolTCP,
+ }
+
+ rule, err := rules.Create(client, createOpts).Extract()
+ if err != nil {
+ return rule, err
+ }
+
+ t.Logf("Created security group rule: %s", rule.ID)
+
+ return rule, nil
+}
+
+// DeleteSecurityGroup will delete a security group of a specified ID.
+// A fatal error will occur if the deletion failed. This works best as a
+// deferred function
+func DeleteSecurityGroup(t *testing.T, client *gophercloud.ServiceClient, secGroupID string) {
+ t.Logf("Attempting to delete security group: %s", secGroupID)
+
+ err := groups.Delete(client, secGroupID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete security group: %v", err)
+ }
+}
+
+// DeleteSecurityGroupRule will delete a security group rule of a specified ID.
+// A fatal error will occur if the deletion failed. This works best as a
+// deferred function
+func DeleteSecurityGroupRule(t *testing.T, client *gophercloud.ServiceClient, ruleID string) {
+ t.Logf("Attempting to delete security group rule: %s", ruleID)
+
+ err := rules.Delete(client, ruleID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete security group rule: %v", err)
+ }
+}
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..473013b
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go
@@ -0,0 +1,99 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ layer3 "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2/extensions/layer3"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/routerinsertion"
+)
+
+func TestFirewallList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := firewalls.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list firewalls: %v", err)
+ }
+
+ allFirewalls, err := firewalls.ExtractFirewalls(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract firewalls: %v", err)
+ }
+
+ for _, firewall := range allFirewalls {
+ tools.PrintResource(t, firewall)
+ }
+}
+
+func TestFirewallCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ router, err := layer3.CreateExternalRouter(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create router: %v", err)
+ }
+ defer layer3.DeleteRouter(t, client, router.ID)
+
+ rule, err := CreateRule(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create rule: %v", err)
+ }
+ defer DeleteRule(t, client, rule.ID)
+
+ tools.PrintResource(t, rule)
+
+ policy, err := CreatePolicy(t, client, rule.ID)
+ if err != nil {
+ t.Fatalf("Unable to create policy: %v", err)
+ }
+ defer DeletePolicy(t, client, policy.ID)
+
+ tools.PrintResource(t, policy)
+
+ firewall, err := CreateFirewallOnRouter(t, client, policy.ID, router.ID)
+ if err != nil {
+ t.Fatalf("Unable to create firewall: %v", err)
+ }
+ defer DeleteFirewall(t, client, firewall.ID)
+
+ tools.PrintResource(t, firewall)
+
+ router2, err := layer3.CreateExternalRouter(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create router: %v", err)
+ }
+ defer layer3.DeleteRouter(t, client, router2.ID)
+
+ firewallUpdateOpts := firewalls.UpdateOpts{
+ PolicyID: policy.ID,
+ Description: "Some firewall description",
+ }
+
+ updateOpts := routerinsertion.UpdateOptsExt{
+ firewallUpdateOpts,
+ []string{router2.ID},
+ }
+
+ _, err = firewalls.Update(client, firewall.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update firewall: %v", err)
+ }
+
+ newFirewall, err := firewalls.Get(client, firewall.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get firewall: %v", err)
+ }
+
+ tools.PrintResource(t, newFirewall)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/fwaas.go b/acceptance/openstack/networking/v2/extensions/fwaas/fwaas.go
new file mode 100644
index 0000000..204565b
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/fwaas.go
@@ -0,0 +1,203 @@
+package fwaas
+
+import (
+ "fmt"
+ "strconv"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/routerinsertion"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+)
+
+// CreateFirewall will create a Firewaill with a random name and a specified
+// policy ID. An error will be returned if the firewall could not be created.
+func CreateFirewall(t *testing.T, client *gophercloud.ServiceClient, policyID string) (*firewalls.Firewall, error) {
+ firewallName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create firewall %s", firewallName)
+
+ iTrue := true
+ createOpts := firewalls.CreateOpts{
+ Name: firewallName,
+ PolicyID: policyID,
+ AdminStateUp: &iTrue,
+ }
+
+ firewall, err := firewalls.Create(client, createOpts).Extract()
+ if err != nil {
+ return firewall, err
+ }
+
+ t.Logf("Waiting for firewall to become active.")
+ if err := WaitForFirewallState(client, firewall.ID, "ACTIVE", 60); err != nil {
+ return firewall, err
+ }
+
+ t.Logf("Successfully created firewall %s", firewallName)
+
+ return firewall, nil
+}
+
+// CreateFirewallOnRouter will create a Firewall with a random name and a
+// specified policy ID attached to a specified Router. An error will be
+// returned if the firewall could not be created.
+func CreateFirewallOnRouter(t *testing.T, client *gophercloud.ServiceClient, policyID string, routerID string) (*firewalls.Firewall, error) {
+ firewallName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create firewall %s", firewallName)
+
+ firewallCreateOpts := firewalls.CreateOpts{
+ Name: firewallName,
+ PolicyID: policyID,
+ }
+
+ createOpts := routerinsertion.CreateOptsExt{
+ firewallCreateOpts,
+ []string{routerID},
+ }
+
+ firewall, err := firewalls.Create(client, createOpts).Extract()
+ if err != nil {
+ return firewall, err
+ }
+
+ t.Logf("Waiting for firewall to become active.")
+ if err := WaitForFirewallState(client, firewall.ID, "ACTIVE", 60); err != nil {
+ return firewall, err
+ }
+
+ t.Logf("Successfully created firewall %s", firewallName)
+
+ return firewall, nil
+}
+
+// CreatePolicy will create a Firewall Policy with a random name and given
+// rule. An error will be returned if the rule could not be created.
+func CreatePolicy(t *testing.T, client *gophercloud.ServiceClient, ruleID string) (*policies.Policy, error) {
+ policyName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create policy %s", policyName)
+
+ createOpts := policies.CreateOpts{
+ Name: policyName,
+ Rules: []string{
+ ruleID,
+ },
+ }
+
+ policy, err := policies.Create(client, createOpts).Extract()
+ if err != nil {
+ return policy, err
+ }
+
+ t.Logf("Successfully created policy %s", policyName)
+
+ return policy, nil
+}
+
+// CreateRule will create a Firewall Rule with a random source address and
+//source port, destination address and port. An error will be returned if
+// the rule could not be created.
+func CreateRule(t *testing.T, client *gophercloud.ServiceClient) (*rules.Rule, error) {
+ ruleName := tools.RandomString("TESTACC-", 8)
+ sourceAddress := fmt.Sprintf("192.168.1.%d", tools.RandomInt(1, 100))
+ sourcePort := strconv.Itoa(tools.RandomInt(1, 100))
+ destinationAddress := fmt.Sprintf("192.168.2.%d", tools.RandomInt(1, 100))
+ destinationPort := strconv.Itoa(tools.RandomInt(1, 100))
+
+ t.Logf("Attempting to create rule %s with source %s:%s and destination %s:%s",
+ ruleName, sourceAddress, sourcePort, destinationAddress, destinationPort)
+
+ createOpts := rules.CreateOpts{
+ Name: ruleName,
+ Protocol: rules.ProtocolTCP,
+ Action: "allow",
+ SourceIPAddress: sourceAddress,
+ SourcePort: sourcePort,
+ DestinationIPAddress: destinationAddress,
+ DestinationPort: destinationPort,
+ }
+
+ rule, err := rules.Create(client, createOpts).Extract()
+ if err != nil {
+ return rule, err
+ }
+
+ t.Logf("Rule %s successfully created", ruleName)
+
+ return rule, nil
+}
+
+// DeleteFirewall will delete a firewall with a specified ID. A fatal error
+// will occur if the delete was not successful. This works best when used as
+// a deferred function.
+func DeleteFirewall(t *testing.T, client *gophercloud.ServiceClient, firewallID string) {
+ t.Logf("Attempting to delete firewall: %s", firewallID)
+
+ err := firewalls.Delete(client, firewallID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete firewall %s: %v", firewallID, err)
+ }
+
+ t.Logf("Waiting for firewall to delete.")
+ if err := WaitForFirewallState(client, firewallID, "DELETED", 60); err != nil {
+ t.Logf("Unable to delete firewall: %s", firewallID)
+ }
+
+ t.Logf("Firewall deleted: %s", firewallID)
+}
+
+// DeletePolicy will delete a policy with a specified ID. A fatal error will
+// occur if the delete was not successful. This works best when used as a
+// deferred function.
+func DeletePolicy(t *testing.T, client *gophercloud.ServiceClient, policyID string) {
+ t.Logf("Attempting to delete policy: %s", policyID)
+
+ err := policies.Delete(client, policyID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete policy %s: %v", policyID, err)
+ }
+
+ t.Logf("Deleted policy: %s", policyID)
+}
+
+// DeleteRule will delete a rule with a specified ID. A fatal error will occur
+// if the delete was not successful. This works best when used as a deferred
+// function.
+func DeleteRule(t *testing.T, client *gophercloud.ServiceClient, ruleID string) {
+ t.Logf("Attempting to delete rule: %s", ruleID)
+
+ err := rules.Delete(client, ruleID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete rule %s: %v", ruleID, err)
+ }
+
+ t.Logf("Deleted rule: %s", ruleID)
+}
+
+// WaitForFirewallState will wait until a firewall reaches a given state.
+func WaitForFirewallState(client *gophercloud.ServiceClient, firewallID, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := firewalls.Get(client, firewallID).Extract()
+ if err != nil {
+ if httpStatus, ok := err.(gophercloud.ErrDefault404); ok {
+ if httpStatus.Actual == 404 {
+ if status == "DELETED" {
+ return true, nil
+ }
+ }
+ }
+ return false, err
+ }
+
+ if current.Status == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
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..3220d82
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go
@@ -0,0 +1,71 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+)
+
+func TestPolicyList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := policies.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list policies: %v", err)
+ }
+
+ allPolicies, err := policies.ExtractPolicies(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract policies: %v", err)
+ }
+
+ for _, policy := range allPolicies {
+ tools.PrintResource(t, policy)
+ }
+}
+
+func TestPolicyCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ rule, err := CreateRule(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create rule: %v", err)
+ }
+ defer DeleteRule(t, client, rule.ID)
+
+ tools.PrintResource(t, rule)
+
+ policy, err := CreatePolicy(t, client, rule.ID)
+ if err != nil {
+ t.Fatalf("Unable to create policy: %v", err)
+ }
+ defer DeletePolicy(t, client, policy.ID)
+
+ tools.PrintResource(t, policy)
+
+ updateOpts := policies.UpdateOpts{
+ Description: "Some policy description",
+ }
+
+ _, err = policies.Update(client, policy.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update policy: %v", err)
+ }
+
+ newPolicy, err := policies.Get(client, policy.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get policy: %v", err)
+ }
+
+ tools.PrintResource(t, newPolicy)
+}
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..4521a60
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go
@@ -0,0 +1,64 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+)
+
+func TestRuleList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := rules.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list rules: %v", err)
+ }
+
+ allRules, err := rules.ExtractRules(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract rules: %v", err)
+ }
+
+ for _, rule := range allRules {
+ tools.PrintResource(t, rule)
+ }
+}
+
+func TestRuleCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ rule, err := CreateRule(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create rule: %v", err)
+ }
+ defer DeleteRule(t, client, rule.ID)
+
+ tools.PrintResource(t, rule)
+
+ ruleDescription := "Some rule description"
+ updateOpts := rules.UpdateOpts{
+ Description: &ruleDescription,
+ }
+
+ _, err = rules.Update(client, rule.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update rule: %v", err)
+ }
+
+ newRule, err := rules.Get(client, rule.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get rule: %v", err)
+ }
+
+ tools.PrintResource(t, newRule)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go b/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go
new file mode 100644
index 0000000..8c1562d
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go
@@ -0,0 +1,98 @@
+// +build acceptance networking layer3 floatingips
+
+package layer3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+func TestLayer3FloatingIPsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ listOpts := floatingips.ListOpts{}
+ allPages, err := floatingips.List(client, listOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list floating IPs: %v", err)
+ }
+
+ allFIPs, err := floatingips.ExtractFloatingIPs(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract floating IPs: %v", err)
+ }
+
+ for _, fip := range allFIPs {
+ tools.PrintResource(t, fip)
+ }
+}
+
+func TestLayer3FloatingIPsCreateDelete(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to get choices: %v", err)
+ }
+
+ netid, err := networks.IDFromName(client,chocices.NetworkName)
+ if err != nil {
+ t.Fatalf("Unable to find network id: %v", err)
+ }
+
+ subnet, err := networking.CreateSubnet(t, client, netid)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ router, err := CreateExternalRouter(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create router: %v", err)
+ }
+ defer DeleteRouter(t, client, router.ID)
+
+ port, err := networking.CreatePort(t, client, netid, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+
+ _, err = CreateRouterInterface(t, client, port.ID, router.ID)
+ if err != nil {
+ t.Fatalf("Unable to create router interface: %v", err)
+ }
+ defer DeleteRouterInterface(t, client, port.ID, router.ID)
+
+ fip, err := CreateFloatingIP(t, client, choices.ExternalNetworkID, port.ID)
+ if err != nil {
+ t.Fatalf("Unable to create floating IP: %v", err)
+ }
+ defer DeleteFloatingIP(t, client, fip.ID)
+
+ newFip, err := floatingips.Get(client, fip.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get floating ip: %v", err)
+ }
+
+ tools.PrintResource(t, newFip)
+
+ // Disassociate the floating IP
+ updateOpts := floatingips.UpdateOpts{
+ PortID: nil,
+ }
+
+ newFip, err = floatingips.Update(client, fip.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to disassociate floating IP: %v", err)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extensions/layer3/layer3.go b/acceptance/openstack/networking/v2/extensions/layer3/layer3.go
new file mode 100644
index 0000000..7d3a626
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/layer3/layer3.go
@@ -0,0 +1,248 @@
+package layer3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+// CreateFloatingIP creates a floating IP on a given network and port. An error
+// will be returned if the creation failed.
+func CreateFloatingIP(t *testing.T, client *gophercloud.ServiceClient, networkID, portID string) (*floatingips.FloatingIP, error) {
+ t.Logf("Attempting to create floating IP on port: %s", portID)
+
+ createOpts := &floatingips.CreateOpts{
+ FloatingNetworkID: networkID,
+ PortID: portID,
+ }
+
+ floatingIP, err := floatingips.Create(client, createOpts).Extract()
+ if err != nil {
+ return floatingIP, err
+ }
+
+ t.Logf("Created floating IP.")
+
+ return floatingIP, err
+}
+
+// CreateExternalRouter creates a router on the external network. This requires
+// the OS_EXTGW_ID environment variable to be set. An error is returned if the
+// creation failed.
+func CreateExternalRouter(t *testing.T, client *gophercloud.ServiceClient) (*routers.Router, error) {
+ var router *routers.Router
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ return router, err
+ }
+
+ routerName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create external router: %s", routerName)
+
+ adminStateUp := true
+ gatewayInfo := routers.GatewayInfo{
+ NetworkID: choices.ExternalNetworkID,
+ }
+
+ createOpts := routers.CreateOpts{
+ Name: routerName,
+ AdminStateUp: &adminStateUp,
+ GatewayInfo: &gatewayInfo,
+ }
+
+ router, err = routers.Create(client, createOpts).Extract()
+ if err != nil {
+ return router, err
+ }
+
+ if err := WaitForRouterToCreate(client, router.ID, 60); err != nil {
+ return router, err
+ }
+
+ t.Logf("Created router: %s", routerName)
+
+ return router, nil
+}
+
+// CreateRouter creates a router on a specified Network ID. An error will be
+// returned if the creation failed.
+func CreateRouter(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*routers.Router, error) {
+ routerName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create router: %s", routerName)
+
+ adminStateUp := true
+ gatewayInfo := routers.GatewayInfo{
+ NetworkID: networkID,
+ }
+
+ createOpts := routers.CreateOpts{
+ Name: routerName,
+ AdminStateUp: &adminStateUp,
+ GatewayInfo: &gatewayInfo,
+ }
+
+ router, err := routers.Create(client, createOpts).Extract()
+ if err != nil {
+ return router, err
+ }
+
+ if err := WaitForRouterToCreate(client, router.ID, 60); err != nil {
+ return router, err
+ }
+
+ t.Logf("Created router: %s", routerName)
+
+ return router, nil
+}
+
+// CreateRouterInterface will attach a subnet to a router. An error will be
+// returned if the operation fails.
+func CreateRouterInterface(t *testing.T, client *gophercloud.ServiceClient, portID, routerID string) (*routers.InterfaceInfo, error) {
+ t.Logf("Attempting to add port %s to router %s", portID, routerID)
+
+ aiOpts := routers.AddInterfaceOpts{
+ PortID: portID,
+ }
+
+ iface, err := routers.AddInterface(client, routerID, aiOpts).Extract()
+ if err != nil {
+ return iface, err
+ }
+
+ if err := WaitForRouterInterfaceToAttach(client, portID, 60); err != nil {
+ return iface, err
+ }
+
+ t.Logf("Successfully added port %s to router %s", portID, routerID)
+ return iface, nil
+}
+
+// DeleteRouter deletes a router of a specified ID. A fatal error will occur
+// if the deletion failed. This works best when used as a deferred function.
+func DeleteRouter(t *testing.T, client *gophercloud.ServiceClient, routerID string) {
+ t.Logf("Attempting to delete router: %s", routerID)
+
+ err := routers.Delete(client, routerID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Error deleting router: %v", err)
+ }
+
+ if err := WaitForRouterToDelete(client, routerID, 60); err != nil {
+ t.Fatalf("Error waiting for router to delete: %v", err)
+ }
+
+ t.Logf("Deleted router: %s", routerID)
+}
+
+// DeleteRouterInterface will detach a subnet to a router. A fatal error will
+// occur if the deletion failed. This works best when used as a deferred
+// function.
+func DeleteRouterInterface(t *testing.T, client *gophercloud.ServiceClient, portID, routerID string) {
+ t.Logf("Attempting to detach port %s from router %s", portID, routerID)
+
+ riOpts := routers.RemoveInterfaceOpts{
+ PortID: portID,
+ }
+
+ _, err := routers.RemoveInterface(client, routerID, riOpts).Extract()
+ if err != nil {
+ t.Fatalf("Failed to detach port %s from router %s", portID, routerID)
+ }
+
+ if err := WaitForRouterInterfaceToDetach(client, portID, 60); err != nil {
+ t.Fatalf("Failed to wait for port %s to detach from router %s", portID, routerID)
+ }
+
+ t.Logf("Successfully detached port %s from router %s", portID, routerID)
+}
+
+// DeleteFloatingIP deletes a floatingIP of a specified ID. A fatal error will
+// occur if the deletion failed. This works best when used as a deferred
+// function.
+func DeleteFloatingIP(t *testing.T, client *gophercloud.ServiceClient, floatingIPID string) {
+ t.Logf("Attempting to delete floating IP: %s", floatingIPID)
+
+ err := floatingips.Delete(client, floatingIPID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Failed to delete floating IP: %v", err)
+ }
+
+ t.Logf("Deleted floating IP: %s", floatingIPID)
+}
+
+func WaitForRouterToCreate(client *gophercloud.ServiceClient, routerID string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ r, err := routers.Get(client, routerID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if r.Status == "ACTIVE" {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
+
+func WaitForRouterToDelete(client *gophercloud.ServiceClient, routerID string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ _, err := routers.Get(client, routerID).Extract()
+ if err != nil {
+ if _, ok := err.(gophercloud.ErrDefault404); ok {
+ return true, nil
+ }
+
+ return false, err
+ }
+
+ return false, nil
+ })
+}
+
+func WaitForRouterInterfaceToAttach(client *gophercloud.ServiceClient, routerInterfaceID string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ r, err := ports.Get(client, routerInterfaceID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if r.Status == "ACTIVE" {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
+
+func WaitForRouterInterfaceToDetach(client *gophercloud.ServiceClient, routerInterfaceID string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ r, err := ports.Get(client, routerInterfaceID).Extract()
+ if err != nil {
+ if _, ok := err.(gophercloud.ErrDefault404); ok {
+ return true, nil
+ }
+
+ if errCode, ok := err.(gophercloud.ErrUnexpectedResponseCode); ok {
+ if errCode.Actual == 409 {
+ return false, nil
+ }
+ }
+
+ return false, err
+ }
+
+ if r.Status == "ACTIVE" {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
\ No newline at end of file
diff --git a/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go b/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go
new file mode 100644
index 0000000..3450ee0
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go
@@ -0,0 +1,118 @@
+// +build acceptance networking layer3 router
+
+package layer3
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+)
+
+func TestLayer3RouterList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ listOpts := routers.ListOpts{}
+ allPages, err := routers.List(client, listOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list routers: %v", err)
+ }
+
+ allRouters, err := routers.ExtractRouters(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract routers: %v", err)
+ }
+
+ for _, router := range allRouters {
+ tools.PrintResource(t, router)
+ }
+}
+
+func TestLayer3RouterCreateDelete(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ router, err := CreateExternalRouter(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create router: %v", err)
+ }
+ defer DeleteRouter(t, client, router.ID)
+
+ tools.PrintResource(t, router)
+
+ newName := tools.RandomString("TESTACC-", 8)
+ updateOpts := routers.UpdateOpts{
+ Name: newName,
+ }
+
+ _, err = routers.Update(client, router.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update router: %v", err)
+ }
+
+ newRouter, err := routers.Get(client, router.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get router: %v", err)
+ }
+
+ tools.PrintResource(t, newRouter)
+}
+
+func TestLayer3RouterInterface(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a compute client: %v", err)
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to get choices: %v", err)
+ }
+
+ netid, err := networks.IDFromName(client,chocices.NetworkName)
+ if err != nil {
+ t.Fatalf("Unable to find network id: %v", err)
+ }
+
+ subnet, err := networking.CreateSubnet(t, client, netid)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ tools.PrintResource(t, subnet)
+
+ router, err := CreateExternalRouter(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create router: %v", err)
+ }
+ defer DeleteRouter(t, client, router.ID)
+
+ aiOpts := routers.AddInterfaceOpts{
+ SubnetID: subnet.ID,
+ }
+
+ iface, err := routers.AddInterface(client, router.ID, aiOpts).Extract()
+ if err != nil {
+ t.Fatalf("Failed to add interface to router: %v", err)
+ }
+
+ tools.PrintResource(t, router)
+ tools.PrintResource(t, iface)
+
+ riOpts := routers.RemoveInterfaceOpts{
+ SubnetID: subnet.ID,
+ }
+
+ _, err = routers.RemoveInterface(client, router.ID, riOpts).Extract()
+ if err != nil {
+ t.Fatalf("Failed to remove interface from router: %v", err)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/lbaas.go b/acceptance/openstack/networking/v2/extensions/lbaas/lbaas.go
new file mode 100644
index 0000000..1f7b2b0
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/lbaas.go
@@ -0,0 +1,160 @@
+package lbaas
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/members"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/vips"
+)
+
+// CreateMember will create a load balancer member in a specified pool on a
+// random port. An error will be returned if the member could not be created.
+func CreateMember(t *testing.T, client *gophercloud.ServiceClient, poolID string) (*members.Member, error) {
+ protocolPort := tools.RandomInt(100, 1000)
+ address := tools.RandomInt(2, 200)
+ t.Logf("Attempting to create member in port %d", protocolPort)
+
+ createOpts := members.CreateOpts{
+ PoolID: poolID,
+ ProtocolPort: protocolPort,
+ Address: fmt.Sprintf("192.168.1.%d", address),
+ }
+
+ member, err := members.Create(client, createOpts).Extract()
+ if err != nil {
+ return member, err
+ }
+
+ t.Logf("Successfully created member %s")
+
+ return member, nil
+}
+
+// CreateMonitor will create a monitor with a random name for a specific pool.
+// An error will be returned if the monitor could not be created.
+func CreateMonitor(t *testing.T, client *gophercloud.ServiceClient) (*monitors.Monitor, error) {
+ t.Logf("Attempting to create monitor.")
+
+ createOpts := monitors.CreateOpts{
+ Type: monitors.TypePING,
+ Delay: 90,
+ Timeout: 60,
+ MaxRetries: 10,
+ AdminStateUp: gophercloud.Enabled,
+ }
+
+ monitor, err := monitors.Create(client, createOpts).Extract()
+ if err != nil {
+ return monitor, err
+ }
+
+ t.Logf("Successfully created monitor")
+
+ return monitor, nil
+}
+
+// CreatePool will create a pool with a random name. An error will be returned
+// if the pool could not be deleted.
+func CreatePool(t *testing.T, client *gophercloud.ServiceClient, subnetID string) (*pools.Pool, error) {
+ poolName := tools.RandomString("TESTACCT-", 8)
+
+ t.Logf("Attempting to create pool %s", poolName)
+
+ createOpts := pools.CreateOpts{
+ Name: poolName,
+ SubnetID: subnetID,
+ Protocol: pools.ProtocolTCP,
+ LBMethod: pools.LBMethodRoundRobin,
+ }
+
+ pool, err := pools.Create(client, createOpts).Extract()
+ if err != nil {
+ return pool, err
+ }
+
+ t.Logf("Successfully created pool %s", poolName)
+
+ return pool, nil
+}
+
+// CreateVIP will create a vip with a random name and a random port in a
+// specified subnet and pool. An error will be returned if the vip could
+// not be created.
+func CreateVIP(t *testing.T, client *gophercloud.ServiceClient, subnetID, poolID string) (*vips.VirtualIP, error) {
+ vipName := tools.RandomString("TESTACCT-", 8)
+ vipPort := tools.RandomInt(100, 10000)
+
+ t.Logf("Attempting to create VIP %s", vipName)
+
+ createOpts := vips.CreateOpts{
+ Name: vipName,
+ SubnetID: subnetID,
+ PoolID: poolID,
+ Protocol: "TCP",
+ ProtocolPort: vipPort,
+ }
+
+ vip, err := vips.Create(client, createOpts).Extract()
+ if err != nil {
+ return vip, err
+ }
+
+ t.Logf("Successfully created vip %s", vipName)
+
+ return vip, nil
+}
+
+// DeleteMember will delete a specified member. A fatal error will occur if
+// the member could not be deleted. This works best when used as a deferred
+// function.
+func DeleteMember(t *testing.T, client *gophercloud.ServiceClient, memberID string) {
+ t.Logf("Attempting to delete member %s", memberID)
+
+ if err := members.Delete(client, memberID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete member: %v", err)
+ }
+
+ t.Logf("Successfully deleted member %s", memberID)
+}
+
+// DeleteMonitor will delete a specified monitor. A fatal error will occur if
+// the monitor could not be deleted. This works best when used as a deferred
+// function.
+func DeleteMonitor(t *testing.T, client *gophercloud.ServiceClient, monitorID string) {
+ t.Logf("Attempting to delete monitor %s", monitorID)
+
+ if err := monitors.Delete(client, monitorID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete monitor: %v", err)
+ }
+
+ t.Logf("Successfully deleted monitor %s", monitorID)
+}
+
+// DeletePool will delete a specified pool. A fatal error will occur if the
+// pool could not be deleted. This works best when used as a deferred function.
+func DeletePool(t *testing.T, client *gophercloud.ServiceClient, poolID string) {
+ t.Logf("Attempting to delete pool %s", poolID)
+
+ if err := pools.Delete(client, poolID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete pool: %v", err)
+ }
+
+ t.Logf("Successfully deleted pool %s", poolID)
+}
+
+// DeleteVIP will delete a specified vip. A fatal error will occur if the vip
+// could not be deleted. This works best when used as a deferred function.
+func DeleteVIP(t *testing.T, client *gophercloud.ServiceClient, vipID string) {
+ t.Logf("Attempting to delete vip %s", vipID)
+
+ if err := vips.Delete(client, vipID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete vip: %v", err)
+ }
+
+ t.Logf("Successfully deleted vip %s", vipID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/members_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/members_test.go
new file mode 100644
index 0000000..75dec83
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/members_test.go
@@ -0,0 +1,83 @@
+// +build acceptance networking lbaas member
+
+package lbaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/members"
+)
+
+func TestMembersList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := members.List(client, members.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list members: %v", err)
+ }
+
+ allMembers, err := members.ExtractMembers(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract members: %v", err)
+ }
+
+ for _, member := range allMembers {
+ tools.PrintResource(t, member)
+ }
+}
+
+func TestMembersCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ pool, err := CreatePool(t, client, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create pool: %v", err)
+ }
+ defer DeletePool(t, client, pool.ID)
+
+ member, err := CreateMember(t, client, pool.ID)
+ if err != nil {
+ t.Fatalf("Unable to create member: %v", err)
+ }
+ defer DeleteMember(t, client, member.ID)
+
+ tools.PrintResource(t, member)
+
+ updateOpts := members.UpdateOpts{
+ AdminStateUp: gophercloud.Enabled,
+ }
+
+ _, err = members.Update(client, member.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update member: %v")
+ }
+
+ newMember, err := members.Get(client, member.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get member: %v")
+ }
+
+ tools.PrintResource(t, newMember)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/monitors_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/monitors_test.go
new file mode 100644
index 0000000..56b413a
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/monitors_test.go
@@ -0,0 +1,63 @@
+// +build acceptance networking lbaas monitors
+
+package lbaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+)
+
+func TestMonitorsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := monitors.List(client, monitors.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list monitors: %v", err)
+ }
+
+ allMonitors, err := monitors.ExtractMonitors(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract monitors: %v", err)
+ }
+
+ for _, monitor := range allMonitors {
+ tools.PrintResource(t, monitor)
+ }
+}
+
+func TestMonitorsCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ monitor, err := CreateMonitor(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create monitor: %v", err)
+ }
+ defer DeleteMonitor(t, client, monitor.ID)
+
+ tools.PrintResource(t, monitor)
+
+ updateOpts := monitors.UpdateOpts{
+ Delay: 999,
+ }
+
+ _, err = monitors.Update(client, monitor.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update monitor: %v")
+ }
+
+ newMonitor, err := monitors.Get(client, monitor.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get monitor: %v")
+ }
+
+ tools.PrintResource(t, newMonitor)
+}
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/pools_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/pools_test.go
new file mode 100644
index 0000000..b53237c
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/pools_test.go
@@ -0,0 +1,118 @@
+// +build acceptance networking lbaas pool
+
+package lbaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+)
+
+func TestPoolsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := pools.List(client, pools.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list pools: %v", err)
+ }
+
+ allPools, err := pools.ExtractPools(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract pools: %v", err)
+ }
+
+ for _, pool := range allPools {
+ tools.PrintResource(t, pool)
+ }
+}
+
+func TestPoolsCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ pool, err := CreatePool(t, client, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create pool: %v", err)
+ }
+ defer DeletePool(t, client, pool.ID)
+
+ tools.PrintResource(t, pool)
+
+ updateOpts := pools.UpdateOpts{
+ LBMethod: pools.LBMethodLeastConnections,
+ }
+
+ _, err = pools.Update(client, pool.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update pool: %v")
+ }
+
+ newPool, err := pools.Get(client, pool.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get pool: %v")
+ }
+
+ tools.PrintResource(t, newPool)
+}
+
+func TestPoolsMonitors(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ pool, err := CreatePool(t, client, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create pool: %v", err)
+ }
+ defer DeletePool(t, client, pool.ID)
+
+ monitor, err := CreateMonitor(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create monitor: %v", err)
+ }
+ defer DeleteMonitor(t, client, monitor.ID)
+
+ t.Logf("Associating monitor %s with pool %s", monitor.ID, pool.ID)
+ if res := pools.AssociateMonitor(client, pool.ID, monitor.ID); res.Err != nil {
+ t.Fatalf("Unable to associate monitor to pool")
+ }
+
+ t.Logf("Disassociating monitor %s with pool %s", monitor.ID, pool.ID)
+ if res := pools.DisassociateMonitor(client, pool.ID, monitor.ID); res.Err != nil {
+ t.Fatalf("Unable to disassociate monitor from pool")
+ }
+
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/vips_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/vips_test.go
new file mode 100644
index 0000000..ba3f9b4
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/vips_test.go
@@ -0,0 +1,82 @@
+// +build acceptance networking lbaas vip
+
+package lbaas
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/vips"
+)
+
+func TestVIPsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := vips.List(client, vips.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list vips: %v", err)
+ }
+
+ allVIPs, err := vips.ExtractVIPs(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract vips: %v", err)
+ }
+
+ for _, vip := range allVIPs {
+ tools.PrintResource(t, vip)
+ }
+}
+
+func TestVIPsCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ pool, err := CreatePool(t, client, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create pool: %v", err)
+ }
+ defer DeletePool(t, client, pool.ID)
+
+ vip, err := CreateVIP(t, client, subnet.ID, pool.ID)
+ if err != nil {
+ t.Fatalf("Unable to create vip: %v", err)
+ }
+ defer DeleteVIP(t, client, vip.ID)
+
+ tools.PrintResource(t, vip)
+
+ connLimit := 100
+ updateOpts := vips.UpdateOpts{
+ ConnLimit: &connLimit,
+ }
+
+ _, err = vips.Update(client, vip.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update vip: %v")
+ }
+
+ newVIP, err := vips.Get(client, vip.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get vip: %v")
+ }
+
+ tools.PrintResource(t, newVIP)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/lbaas_v2.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/lbaas_v2.go
new file mode 100644
index 0000000..093f835
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/lbaas_v2.go
@@ -0,0 +1,282 @@
+package lbaas_v2
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/loadbalancers"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+)
+
+const loadbalancerActiveTimeoutSeconds = 300
+const loadbalancerDeleteTimeoutSeconds = 300
+
+// CreateListener will create a listener for a given load balancer on a random
+// port with a random name. An error will be returned if the listener could not
+// be created.
+func CreateListener(t *testing.T, client *gophercloud.ServiceClient, lb *loadbalancers.LoadBalancer) (*listeners.Listener, error) {
+ listenerName := tools.RandomString("TESTACCT-", 8)
+ listenerPort := tools.RandomInt(1, 100)
+
+ t.Logf("Attempting to create listener %s on port %d", listenerName, listenerPort)
+
+ createOpts := listeners.CreateOpts{
+ Name: listenerName,
+ LoadbalancerID: lb.ID,
+ Protocol: "TCP",
+ ProtocolPort: listenerPort,
+ }
+
+ listener, err := listeners.Create(client, createOpts).Extract()
+ if err != nil {
+ return listener, err
+ }
+
+ t.Logf("Successfully created listener %s", listenerName)
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ return listener, fmt.Errorf("Timed out waiting for loadbalancer to become active")
+ }
+
+ return listener, nil
+}
+
+// CreateLoadBalancer will create a load balancer with a random name on a given
+// subnet. An error will be returned if the loadbalancer could not be created.
+func CreateLoadBalancer(t *testing.T, client *gophercloud.ServiceClient, subnetID string) (*loadbalancers.LoadBalancer, error) {
+ lbName := tools.RandomString("TESTACCT-", 8)
+
+ t.Logf("Attempting to create loadbalancer %s on subnet %s", lbName, subnetID)
+
+ createOpts := loadbalancers.CreateOpts{
+ Name: lbName,
+ VipSubnetID: subnetID,
+ AdminStateUp: gophercloud.Enabled,
+ }
+
+ lb, err := loadbalancers.Create(client, createOpts).Extract()
+ if err != nil {
+ return lb, err
+ }
+
+ t.Logf("Successfully created loadbalancer %s on subnet %s", lbName, subnetID)
+ t.Logf("Waiting for loadbalancer %s to become active", lbName)
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ return lb, err
+ }
+
+ t.Logf("LoadBalancer %s is active", lbName)
+
+ return lb, nil
+}
+
+// CreateMember will create a member with a random name, port, address, and
+// weight. An error will be returned if the member could not be created.
+func CreateMember(t *testing.T, client *gophercloud.ServiceClient, lb *loadbalancers.LoadBalancer, pool *pools.Pool, subnetID, subnetCIDR string) (*pools.Member, error) {
+ memberName := tools.RandomString("TESTACCT-", 8)
+ memberPort := tools.RandomInt(100, 1000)
+ memberWeight := tools.RandomInt(1, 10)
+
+ cidrParts := strings.Split(subnetCIDR, "/")
+ subnetParts := strings.Split(cidrParts[0], ".")
+ memberAddress := fmt.Sprintf("%s.%s.%s.%d", subnetParts[0], subnetParts[1], subnetParts[2], tools.RandomInt(10, 100))
+
+ t.Logf("Attempting to create member %s", memberName)
+
+ createOpts := pools.CreateMemberOpts{
+ Name: memberName,
+ ProtocolPort: memberPort,
+ Weight: memberWeight,
+ Address: memberAddress,
+ SubnetID: subnetID,
+ }
+
+ t.Logf("Member create opts: %#v", createOpts)
+
+ member, err := pools.CreateMember(client, pool.ID, createOpts).Extract()
+ if err != nil {
+ return member, err
+ }
+
+ t.Logf("Successfully created member %s", memberName)
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ return member, fmt.Errorf("Timed out waiting for loadbalancer to become active")
+ }
+
+ return member, nil
+}
+
+// CreateMonitor will create a monitor with a random name for a specific pool.
+// An error will be returned if the monitor could not be created.
+func CreateMonitor(t *testing.T, client *gophercloud.ServiceClient, lb *loadbalancers.LoadBalancer, pool *pools.Pool) (*monitors.Monitor, error) {
+ monitorName := tools.RandomString("TESTACCT-", 8)
+
+ t.Logf("Attempting to create monitor %s", monitorName)
+
+ createOpts := monitors.CreateOpts{
+ PoolID: pool.ID,
+ Name: monitorName,
+ Delay: 10,
+ Timeout: 5,
+ MaxRetries: 5,
+ Type: "PING",
+ }
+
+ monitor, err := monitors.Create(client, createOpts).Extract()
+ if err != nil {
+ return monitor, err
+ }
+
+ t.Logf("Successfully created monitor: %s", monitorName)
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ return monitor, fmt.Errorf("Timed out waiting for loadbalancer to become active")
+ }
+
+ return monitor, nil
+}
+
+// CreatePool will create a pool with a random name with a specified listener
+// and loadbalancer. An error will be returned if the pool could not be
+// created.
+func CreatePool(t *testing.T, client *gophercloud.ServiceClient, lb *loadbalancers.LoadBalancer) (*pools.Pool, error) {
+ poolName := tools.RandomString("TESTACCT-", 8)
+
+ t.Logf("Attempting to create pool %s", poolName)
+
+ createOpts := pools.CreateOpts{
+ Name: poolName,
+ Protocol: pools.ProtocolTCP,
+ LoadbalancerID: lb.ID,
+ LBMethod: pools.LBMethodLeastConnections,
+ }
+
+ pool, err := pools.Create(client, createOpts).Extract()
+ if err != nil {
+ return pool, err
+ }
+
+ t.Logf("Successfully created pool %s", poolName)
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ return pool, fmt.Errorf("Timed out waiting for loadbalancer to become active")
+ }
+
+ return pool, nil
+}
+
+// DeleteListener will delete a specified listener. A fatal error will occur if
+// the listener could not be deleted. This works best when used as a deferred
+// function.
+func DeleteListener(t *testing.T, client *gophercloud.ServiceClient, lbID, listenerID string) {
+ t.Logf("Attempting to delete listener %s", listenerID)
+
+ if err := listeners.Delete(client, listenerID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete listener: %v", err)
+ }
+
+ if err := WaitForLoadBalancerState(client, lbID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ t.Logf("Successfully deleted listener %s", listenerID)
+}
+
+// DeleteMember will delete a specified member. A fatal error will occur if the
+// member could not be deleted. This works best when used as a deferred
+// function.
+func DeleteMember(t *testing.T, client *gophercloud.ServiceClient, lbID, poolID, memberID string) {
+ t.Logf("Attempting to delete member %s", memberID)
+
+ if err := pools.DeleteMember(client, poolID, memberID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete member: %s", memberID)
+ }
+
+ if err := WaitForLoadBalancerState(client, lbID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ t.Logf("Successfully deleted member %s", memberID)
+}
+
+// DeleteLoadBalancer will delete a specified loadbalancer. A fatal error will
+// occur if the loadbalancer could not be deleted. This works best when used
+// as a deferred function.
+func DeleteLoadBalancer(t *testing.T, client *gophercloud.ServiceClient, lbID string) {
+ t.Logf("Attempting to delete loadbalancer %s", lbID)
+
+ if err := loadbalancers.Delete(client, lbID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete loadbalancer: %v", err)
+ }
+
+ t.Logf("Waiting for loadbalancer %s to delete", lbID)
+
+ if err := WaitForLoadBalancerState(client, lbID, "DELETED", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Loadbalancer did not delete in time.")
+ }
+
+ t.Logf("Successfully deleted loadbalancer %s", lbID)
+}
+
+// DeleteMonitor will delete a specified monitor. A fatal error will occur if
+// the monitor could not be deleted. This works best when used as a deferred
+// function.
+func DeleteMonitor(t *testing.T, client *gophercloud.ServiceClient, lbID, monitorID string) {
+ t.Logf("Attempting to delete monitor %s", monitorID)
+
+ if err := monitors.Delete(client, monitorID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete monitor: %v", err)
+ }
+
+ if err := WaitForLoadBalancerState(client, lbID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ t.Logf("Successfully deleted monitor %s", monitorID)
+}
+
+// DeletePool will delete a specified pool. A fatal error will occur if the
+// pool could not be deleted. This works best when used as a deferred function.
+func DeletePool(t *testing.T, client *gophercloud.ServiceClient, lbID, poolID string) {
+ t.Logf("Attempting to delete pool %s", poolID)
+
+ if err := pools.Delete(client, poolID).ExtractErr(); err != nil {
+ t.Fatalf("Unable to delete pool: %v", err)
+ }
+
+ if err := WaitForLoadBalancerState(client, lbID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ t.Logf("Successfully deleted pool %s", poolID)
+}
+
+// WaitForLoadBalancerState will wait until a loadbalancer reaches a given state.
+func WaitForLoadBalancerState(client *gophercloud.ServiceClient, lbID, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := loadbalancers.Get(client, lbID).Extract()
+ if err != nil {
+ if httpStatus, ok := err.(gophercloud.ErrDefault404); ok {
+ if httpStatus.Actual == 404 {
+ if status == "DELETED" {
+ return true, nil
+ }
+ }
+ }
+ return false, err
+ }
+
+ if current.ProvisioningStatus == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/listeners_test.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/listeners_test.go
new file mode 100644
index 0000000..2d2dd03
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/listeners_test.go
@@ -0,0 +1,32 @@
+// +build acceptance networking lbaas_v2 listeners
+
+package lbaas_v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+)
+
+func TestListenersList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := listeners.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list listeners: %v", err)
+ }
+
+ allListeners, err := listeners.ExtractListeners(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract listeners: %v", err)
+ }
+
+ for _, listener := range allListeners {
+ tools.PrintResource(t, listener)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/loadbalancers_test.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/loadbalancers_test.go
new file mode 100644
index 0000000..650eb2c
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/loadbalancers_test.go
@@ -0,0 +1,178 @@
+// +build acceptance networking lbaas_v2 loadbalancers
+
+package lbaas_v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/loadbalancers"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+)
+
+func TestLoadbalancersList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := loadbalancers.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list loadbalancers: %v", err)
+ }
+
+ allLoadbalancers, err := loadbalancers.ExtractLoadBalancers(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract loadbalancers: %v", err)
+ }
+
+ for _, lb := range allLoadbalancers {
+ tools.PrintResource(t, lb)
+ }
+}
+
+func TestLoadbalancersCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ lb, err := CreateLoadBalancer(t, client, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create loadbalancer: %v", err)
+ }
+ defer DeleteLoadBalancer(t, client, lb.ID)
+
+ newLB, err := loadbalancers.Get(client, lb.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get loadbalancer: %v", err)
+ }
+
+ tools.PrintResource(t, newLB)
+
+ // Because of the time it takes to create a loadbalancer,
+ // this test will include some other resources.
+
+ // Listener
+ listener, err := CreateListener(t, client, lb)
+ if err != nil {
+ t.Fatalf("Unable to create listener: %v", err)
+ }
+ defer DeleteListener(t, client, lb.ID, listener.ID)
+
+ updateListenerOpts := listeners.UpdateOpts{
+ Description: "Some listener description",
+ }
+ _, err = listeners.Update(client, listener.ID, updateListenerOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update listener")
+ }
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ newListener, err := listeners.Get(client, listener.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get listener")
+ }
+
+ tools.PrintResource(t, newListener)
+
+ // Pool
+ pool, err := CreatePool(t, client, lb)
+ if err != nil {
+ t.Fatalf("Unable to create pool: %v", err)
+ }
+ defer DeletePool(t, client, lb.ID, pool.ID)
+
+ updatePoolOpts := pools.UpdateOpts{
+ Description: "Some pool description",
+ }
+ _, err = pools.Update(client, pool.ID, updatePoolOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update pool")
+ }
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ newPool, err := pools.Get(client, pool.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get pool")
+ }
+
+ tools.PrintResource(t, newPool)
+
+ // Member
+ member, err := CreateMember(t, client, lb, newPool, subnet.ID, subnet.CIDR)
+ if err != nil {
+ t.Fatalf("Unable to create member: %v", err)
+ }
+ defer DeleteMember(t, client, lb.ID, pool.ID, member.ID)
+
+ newWeight := tools.RandomInt(11, 100)
+ updateMemberOpts := pools.UpdateMemberOpts{
+ Weight: newWeight,
+ }
+ _, err = pools.UpdateMember(client, pool.ID, member.ID, updateMemberOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update pool")
+ }
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ newMember, err := pools.GetMember(client, pool.ID, member.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get member")
+ }
+
+ tools.PrintResource(t, newMember)
+
+ // Monitor
+ monitor, err := CreateMonitor(t, client, lb, newPool)
+ if err != nil {
+ t.Fatalf("Unable to create monitor: %v", err)
+ }
+ defer DeleteMonitor(t, client, lb.ID, monitor.ID)
+
+ newDelay := tools.RandomInt(20, 30)
+ updateMonitorOpts := monitors.UpdateOpts{
+ Delay: newDelay,
+ }
+ _, err = monitors.Update(client, monitor.ID, updateMonitorOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update monitor")
+ }
+
+ if err := WaitForLoadBalancerState(client, lb.ID, "ACTIVE", loadbalancerActiveTimeoutSeconds); err != nil {
+ t.Fatalf("Timed out waiting for loadbalancer to become active")
+ }
+
+ newMonitor, err := monitors.Get(client, monitor.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get monitor")
+ }
+
+ tools.PrintResource(t, newMonitor)
+
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/monitors_test.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/monitors_test.go
new file mode 100644
index 0000000..b312370
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/monitors_test.go
@@ -0,0 +1,32 @@
+// +build acceptance networking lbaas_v2 monitors
+
+package lbaas_v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+)
+
+func TestMonitorsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := monitors.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list monitors: %v", err)
+ }
+
+ allMonitors, err := monitors.ExtractMonitors(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract monitors: %v", err)
+ }
+
+ for _, monitor := range allMonitors {
+ tools.PrintResource(t, monitor)
+ }
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/pkg.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/pkg.go
new file mode 100644
index 0000000..24b7482
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/pkg.go
@@ -0,0 +1 @@
+package lbaas_v2
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas_v2/pools_test.go b/acceptance/openstack/networking/v2/extensions/lbaas_v2/pools_test.go
new file mode 100644
index 0000000..b4f55a0
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas_v2/pools_test.go
@@ -0,0 +1,32 @@
+// +build acceptance networking lbaas_v2 pools
+
+package lbaas_v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+)
+
+func TestPoolsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := pools.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list pools: %v", err)
+ }
+
+ allPools, err := pools.ExtractPools(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract pools: %v", err)
+ }
+
+ for _, pool := range allPools {
+ tools.PrintResource(t, pool)
+ }
+}
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/portsbinding/pkg.go b/acceptance/openstack/networking/v2/extensions/portsbinding/pkg.go
new file mode 100644
index 0000000..5dae1b1
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/portsbinding/pkg.go
@@ -0,0 +1 @@
+package portsbinding
diff --git a/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding.go b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding.go
new file mode 100644
index 0000000..a6d75f3
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding.go
@@ -0,0 +1,38 @@
+package portsbinding
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/portsbinding"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+// CreatePortsbinding will create a port on the specified subnet. An error will be
+// returned if the port could not be created.
+func CreatePortsbinding(t *testing.T, client *gophercloud.ServiceClient, networkID, subnetID, hostID string) (*portsbinding.Port, error) {
+ portName := tools.RandomString("TESTACC-", 8)
+ iFalse := false
+
+ t.Logf("Attempting to create port: %s", portName)
+
+ createOpts := portsbinding.CreateOpts{
+ CreateOptsBuilder: ports.CreateOpts{
+ NetworkID: networkID,
+ Name: portName,
+ AdminStateUp: &iFalse,
+ FixedIPs: []ports.IP{ports.IP{SubnetID: subnetID}},
+ },
+ HostID: hostID,
+ }
+
+ port, err := portsbinding.Create(client, createOpts).Extract()
+ if err != nil {
+ return port, err
+ }
+
+ t.Logf("Successfully created port: %s", portName)
+
+ return port, nil
+}
diff --git a/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go
new file mode 100644
index 0000000..803f62a
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go
@@ -0,0 +1,58 @@
+// +build acceptance networking
+
+package portsbinding
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/portsbinding"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+func TestPortsbindingCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ // Define a host
+ hostID := "localhost"
+
+ // Create port
+ port, err := CreatePortsbinding(t, client, network.ID, subnet.ID, hostID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+ defer networking.DeletePort(t, client, port.ID)
+
+ tools.PrintResource(t, port)
+
+ // Update port
+ newPortName := tools.RandomString("TESTACC-", 8)
+ updateOpts := ports.UpdateOpts{
+ Name: newPortName,
+ }
+ newPort, err := portsbinding.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ tools.PrintResource(t, newPort)
+}
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..b0d5846
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/provider_test.go
@@ -0,0 +1,35 @@
+// +build acceptance networking provider
+
+package extensions
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/provider"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+func TestNetworksProviderCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create a network
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ getResult := networks.Get(client, network.ID)
+ newNetwork, err := provider.ExtractGet(getResult)
+ if err != nil {
+ t.Fatalf("Unable to extract network: %v", err)
+ }
+
+ tools.PrintResource(t, newNetwork)
+}
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..c696377
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/security_test.go
@@ -0,0 +1,94 @@
+// +build acceptance networking security
+
+package extensions
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/groups"
+)
+
+func TestSecurityGroupsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ listOpts := groups.ListOpts{}
+ allPages, err := groups.List(client, listOpts).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list groups: %v", err)
+ }
+
+ allGroups, err := groups.ExtractGroups(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract groups: %v", err)
+ }
+
+ for _, group := range allGroups {
+ tools.PrintResource(t, group)
+ }
+}
+
+func TestSecurityGroupsCreateDelete(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ group, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, group.ID)
+
+ rule, err := CreateSecurityGroupRule(t, client, group.ID)
+ if err != nil {
+ t.Fatalf("Unable to create security group rule: %v", err)
+ }
+ defer DeleteSecurityGroupRule(t, client, rule.ID)
+
+ tools.PrintResource(t, group)
+}
+
+func TestSecurityGroupsPort(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ network, err := networking.CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer networking.DeleteNetwork(t, client, network.ID)
+
+ subnet, err := networking.CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer networking.DeleteSubnet(t, client, subnet.ID)
+
+ group, err := CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer DeleteSecurityGroup(t, client, group.ID)
+
+ rule, err := CreateSecurityGroupRule(t, client, group.ID)
+ if err != nil {
+ t.Fatalf("Unable to create security group rule: %v", err)
+ }
+ defer DeleteSecurityGroupRule(t, client, rule.ID)
+
+ port, err := CreatePortWithSecurityGroup(t, client, network.ID, subnet.ID, group.ID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+ defer networking.DeletePort(t, client, port.ID)
+
+ tools.PrintResource(t, port)
+}
diff --git a/acceptance/openstack/networking/v2/networking.go b/acceptance/openstack/networking/v2/networking.go
new file mode 100644
index 0000000..c5e7ca2
--- /dev/null
+++ b/acceptance/openstack/networking/v2/networking.go
@@ -0,0 +1,211 @@
+package v2
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+)
+
+// CreateNetwork will create basic network. An error will be returned if the
+// network could not be created.
+func CreateNetwork(t *testing.T, client *gophercloud.ServiceClient) (*networks.Network, error) {
+ networkName := tools.RandomString("TESTACC-", 8)
+ createOpts := networks.CreateOpts{
+ Name: networkName,
+ AdminStateUp: gophercloud.Enabled,
+ }
+
+ t.Logf("Attempting to create network: %s", networkName)
+
+ network, err := networks.Create(client, createOpts).Extract()
+ if err != nil {
+ return network, err
+ }
+
+ t.Logf("Successfully created network.")
+ return network, nil
+}
+
+// CreatePort will create a port on the specified subnet. An error will be
+// returned if the port could not be created.
+func CreatePort(t *testing.T, client *gophercloud.ServiceClient, networkID, subnetID string) (*ports.Port, error) {
+ portName := tools.RandomString("TESTACC-", 8)
+
+ t.Logf("Attempting to create port: %s", portName)
+
+ createOpts := ports.CreateOpts{
+ NetworkID: networkID,
+ Name: portName,
+ AdminStateUp: gophercloud.Enabled,
+ FixedIPs: []ports.IP{ports.IP{SubnetID: subnetID}},
+ }
+
+ port, err := ports.Create(client, createOpts).Extract()
+ if err != nil {
+ return port, err
+ }
+
+ if err := WaitForPortToCreate(client, port.ID, 60); err != nil {
+ return port, err
+ }
+
+ newPort, err := ports.Get(client, port.ID).Extract()
+ if err != nil {
+ return newPort, err
+ }
+
+ t.Logf("Successfully created port: %s", portName)
+
+ return newPort, nil
+}
+
+// CreateSubnet will create a subnet on the specified Network ID. An error
+// will be returned if the subnet could not be created.
+func CreateSubnet(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*subnets.Subnet, error) {
+ subnetName := tools.RandomString("TESTACC-", 8)
+ subnetOctet := tools.RandomInt(1, 250)
+ subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
+ subnetGateway := fmt.Sprintf("192.168.%d.1", subnetOctet)
+ createOpts := subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: subnetCIDR,
+ IPVersion: 4,
+ Name: subnetName,
+ EnableDHCP: gophercloud.Disabled,
+ GatewayIP: &subnetGateway,
+ }
+
+ t.Logf("Attempting to create subnet: %s", subnetName)
+
+ subnet, err := subnets.Create(client, createOpts).Extract()
+ if err != nil {
+ return subnet, err
+ }
+
+ t.Logf("Successfully created subnet.")
+ return subnet, nil
+}
+
+// CreateSubnetWithDefaultGateway will create a subnet on the specified Network
+// ID and have Neutron set the gateway by default An error will be returned if
+// the subnet could not be created.
+func CreateSubnetWithDefaultGateway(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*subnets.Subnet, error) {
+ subnetName := tools.RandomString("TESTACC-", 8)
+ subnetOctet := tools.RandomInt(1, 250)
+ subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
+ createOpts := subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: subnetCIDR,
+ IPVersion: 4,
+ Name: subnetName,
+ EnableDHCP: gophercloud.Disabled,
+ }
+
+ t.Logf("Attempting to create subnet: %s", subnetName)
+
+ subnet, err := subnets.Create(client, createOpts).Extract()
+ if err != nil {
+ return subnet, err
+ }
+
+ t.Logf("Successfully created subnet.")
+ return subnet, nil
+}
+
+// CreateSubnetWithNoGateway will create a subnet with no gateway on the
+// specified Network ID. An error will be returned if the subnet could not be
+// created.
+func CreateSubnetWithNoGateway(t *testing.T, client *gophercloud.ServiceClient, networkID string) (*subnets.Subnet, error) {
+ var noGateway = ""
+ subnetName := tools.RandomString("TESTACC-", 8)
+ subnetOctet := tools.RandomInt(1, 250)
+ subnetCIDR := fmt.Sprintf("192.168.%d.0/24", subnetOctet)
+ dhcpStart := fmt.Sprintf("192.168.%d.10", subnetOctet)
+ dhcpEnd := fmt.Sprintf("192.168.%d.200", subnetOctet)
+ createOpts := subnets.CreateOpts{
+ NetworkID: networkID,
+ CIDR: subnetCIDR,
+ IPVersion: 4,
+ Name: subnetName,
+ EnableDHCP: gophercloud.Disabled,
+ GatewayIP: &noGateway,
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: dhcpStart,
+ End: dhcpEnd,
+ },
+ },
+ }
+
+ t.Logf("Attempting to create subnet: %s", subnetName)
+
+ subnet, err := subnets.Create(client, createOpts).Extract()
+ if err != nil {
+ return subnet, err
+ }
+
+ t.Logf("Successfully created subnet.")
+ return subnet, nil
+}
+
+// DeleteNetwork will delete a network with a specified ID. A fatal error will
+// occur if the delete was not successful. This works best when used as a
+// deferred function.
+func DeleteNetwork(t *testing.T, client *gophercloud.ServiceClient, networkID string) {
+ t.Logf("Attempting to delete network: %s", networkID)
+
+ err := networks.Delete(client, networkID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete network %s: %v", networkID, err)
+ }
+
+ t.Logf("Deleted network: %s", networkID)
+}
+
+// DeletePort will delete a port with a specified ID. A fatal error will
+// occur if the delete was not successful. This works best when used as a
+// deferred function.
+func DeletePort(t *testing.T, client *gophercloud.ServiceClient, portID string) {
+ t.Logf("Attempting to delete port: %s", portID)
+
+ err := ports.Delete(client, portID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete port %s: %v", portID, err)
+ }
+
+ t.Logf("Deleted port: %s", portID)
+}
+
+// DeleteSubnet will delete a subnet with a specified ID. A fatal error will
+// occur if the delete was not successful. This works best when used as a
+// deferred function.
+func DeleteSubnet(t *testing.T, client *gophercloud.ServiceClient, subnetID string) {
+ t.Logf("Attempting to delete subnet: %s", subnetID)
+
+ err := subnets.Delete(client, subnetID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete subnet %s: %v", subnetID, err)
+ }
+
+ t.Logf("Deleted subnet: %s", subnetID)
+}
+
+func WaitForPortToCreate(client *gophercloud.ServiceClient, portID string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ p, err := ports.Get(client, portID).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if p.Status == "ACTIVE" || p.Status == "DOWN" {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/acceptance/openstack/networking/v2/networks_test.go b/acceptance/openstack/networking/v2/networks_test.go
new file mode 100644
index 0000000..66f42f8
--- /dev/null
+++ b/acceptance/openstack/networking/v2/networks_test.go
@@ -0,0 +1,65 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+func TestNetworksList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := networks.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list networks: %v", err)
+ }
+
+ allNetworks, err := networks.ExtractNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract networks: %v", err)
+ }
+
+ for _, network := range allNetworks {
+ tools.PrintResource(t, network)
+ }
+}
+
+func TestNetworksCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create a network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ tools.PrintResource(t, network)
+
+ newName := tools.RandomString("TESTACC-", 8)
+ updateOpts := &networks.UpdateOpts{
+ Name: newName,
+ }
+
+ _, err = networks.Update(client, network.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update network: %v", err)
+ }
+
+ newNetwork, err := networks.Get(client, network.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve network: %v", err)
+ }
+
+ tools.PrintResource(t, newNetwork)
+}
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/ports_test.go b/acceptance/openstack/networking/v2/ports_test.go
new file mode 100644
index 0000000..394e90f
--- /dev/null
+++ b/acceptance/openstack/networking/v2/ports_test.go
@@ -0,0 +1,192 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ extensions "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2/extensions"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+func TestPortsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := ports.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list ports: %v", err)
+ }
+
+ allPorts, err := ports.ExtractPorts(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract ports: %v", err)
+ }
+
+ for _, port := range allPorts {
+ tools.PrintResource(t, port)
+ }
+}
+
+func TestPortsCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ // Create port
+ port, err := CreatePort(t, client, network.ID, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+ defer DeletePort(t, client, port.ID)
+
+ tools.PrintResource(t, port)
+
+ // Update port
+ newPortName := tools.RandomString("TESTACC-", 8)
+ updateOpts := ports.UpdateOpts{
+ Name: newPortName,
+ }
+ newPort, err := ports.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ tools.PrintResource(t, newPort)
+}
+
+func TestPortsRemoveSecurityGroups(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ // Create port
+ port, err := CreatePort(t, client, network.ID, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+ defer DeletePort(t, client, port.ID)
+
+ tools.PrintResource(t, port)
+
+ // Create a Security Group
+ group, err := extensions.CreateSecurityGroup(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security group: %v", err)
+ }
+ defer extensions.DeleteSecurityGroup(t, client, group.ID)
+
+ // Add the group to the port
+ updateOpts := ports.UpdateOpts{
+ SecurityGroups: []string{group.ID},
+ }
+ newPort, err := ports.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ // Remove the group
+ updateOpts = ports.UpdateOpts{
+ SecurityGroups: []string{},
+ }
+ newPort, err = ports.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ tools.PrintResource(t, newPort)
+
+ if len(newPort.SecurityGroups) > 0 {
+ t.Fatalf("Unable to remove security group from port")
+ }
+}
+
+func TestPortsRemoveAddressPair(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ // Create port
+ port, err := CreatePort(t, client, network.ID, subnet.ID)
+ if err != nil {
+ t.Fatalf("Unable to create port: %v", err)
+ }
+ defer DeletePort(t, client, port.ID)
+
+ tools.PrintResource(t, port)
+
+ // Add an address pair to the port
+ updateOpts := ports.UpdateOpts{
+ AllowedAddressPairs: []ports.AddressPair{
+ ports.AddressPair{IPAddress: "192.168.255.10", MACAddress: "aa:bb:cc:dd:ee:ff"},
+ },
+ }
+ newPort, err := ports.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ // Remove the address pair
+ updateOpts = ports.UpdateOpts{
+ AllowedAddressPairs: []ports.AddressPair{},
+ }
+ newPort, err = ports.Update(client, port.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Could not update port: %v", err)
+ }
+
+ tools.PrintResource(t, newPort)
+
+ if len(newPort.AllowedAddressPairs) > 0 {
+ t.Fatalf("Unable to remove the address pair")
+ }
+}
diff --git a/acceptance/openstack/networking/v2/subnets_test.go b/acceptance/openstack/networking/v2/subnets_test.go
new file mode 100644
index 0000000..fd50a1f
--- /dev/null
+++ b/acceptance/openstack/networking/v2/subnets_test.go
@@ -0,0 +1,158 @@
+// +build acceptance networking
+
+package v2
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+)
+
+func TestSubnetsList(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ allPages, err := subnets.List(client, nil).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list subnets: %v", err)
+ }
+
+ allSubnets, err := subnets.ExtractSubnets(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract subnets: %v", err)
+ }
+
+ for _, subnet := range allSubnets {
+ tools.PrintResource(t, subnet)
+ }
+}
+
+func TestSubnetCRUD(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnet(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ tools.PrintResource(t, subnet)
+
+ // Update Subnet
+ newSubnetName := tools.RandomString("TESTACC-", 8)
+ updateOpts := subnets.UpdateOpts{
+ Name: newSubnetName,
+ }
+ _, err = subnets.Update(client, subnet.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update subnet: %v", err)
+ }
+
+ // Get subnet
+ newSubnet, err := subnets.Get(client, subnet.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to get subnet: %v", err)
+ }
+
+ tools.PrintResource(t, newSubnet)
+}
+
+func TestSubnetsDefaultGateway(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnetWithDefaultGateway(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ tools.PrintResource(t, subnet)
+
+ if subnet.GatewayIP == "" {
+ t.Fatalf("A default gateway was not created.")
+ }
+
+ var noGateway = ""
+ updateOpts := subnets.UpdateOpts{
+ GatewayIP: &noGateway,
+ }
+
+ newSubnet, err := subnets.Update(client, subnet.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update subnet")
+ }
+
+ if newSubnet.GatewayIP != "" {
+ t.Fatalf("Gateway was not updated correctly")
+ }
+}
+
+func TestSubnetsNoGateway(t *testing.T) {
+ client, err := clients.NewNetworkV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a network client: %v", err)
+ }
+
+ // Create Network
+ network, err := CreateNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create network: %v", err)
+ }
+ defer DeleteNetwork(t, client, network.ID)
+
+ // Create Subnet
+ subnet, err := CreateSubnetWithNoGateway(t, client, network.ID)
+ if err != nil {
+ t.Fatalf("Unable to create subnet: %v", err)
+ }
+ defer DeleteSubnet(t, client, subnet.ID)
+
+ tools.PrintResource(t, subnet)
+
+ if subnet.GatewayIP != "" {
+ t.Fatalf("A gateway exists when it shouldn't.")
+ }
+
+ subnetParts := strings.Split(subnet.CIDR, ".")
+ newGateway := fmt.Sprintf("%s.%s.%s.1", subnetParts[0], subnetParts[1], subnetParts[2])
+ updateOpts := subnets.UpdateOpts{
+ GatewayIP: &newGateway,
+ }
+
+ newSubnet, err := subnets.Update(client, subnet.ID, updateOpts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to update subnet")
+ }
+
+ if newSubnet.GatewayIP == "" {
+ t.Fatalf("Gateway was not updated correctly")
+ }
+}
diff --git a/acceptance/openstack/objectstorage/v1/accounts_test.go b/acceptance/openstack/objectstorage/v1/accounts_test.go
new file mode 100644
index 0000000..5a29235
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/accounts_test.go
@@ -0,0 +1,50 @@
+// +build acceptance
+
+package v1
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/accounts"
+ th "github.com/gophercloud/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..1114ed5
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/common.go
@@ -0,0 +1,28 @@
+// +build acceptance
+
+package v1
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ th "github.com/gophercloud/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..056b2a9
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/containers_test.go
@@ -0,0 +1,137 @@
+// +build acceptance
+
+package v1
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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..3a27738
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/objects_test.go
@@ -0,0 +1,119 @@
+// +build acceptance
+
+package v1
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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/objectstorage/v1/pkg.go b/acceptance/openstack/objectstorage/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/orchestration/v1/buildinfo_test.go b/acceptance/openstack/orchestration/v1/buildinfo_test.go
new file mode 100644
index 0000000..1b48662
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/buildinfo_test.go
@@ -0,0 +1,20 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/buildinfo"
+ th "github.com/gophercloud/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..4eec2e3
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/common.go
@@ -0,0 +1,44 @@
+// +build acceptance
+
+package v1
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ th "github.com/gophercloud/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/pkg.go b/acceptance/openstack/orchestration/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/orchestration/v1/stackevents_test.go b/acceptance/openstack/orchestration/v1/stackevents_test.go
new file mode 100644
index 0000000..4be4bf6
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackevents_test.go
@@ -0,0 +1,68 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackevents"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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..50a0f06
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackresources_test.go
@@ -0,0 +1,62 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackresources"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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..c87cc5d
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacks_test.go
@@ -0,0 +1,153 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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..9992e0c
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
@@ -0,0 +1,75 @@
+// +build acceptance
+
+package v1
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacktemplates"
+ th "github.com/gophercloud/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..ef11064
--- /dev/null
+++ b/acceptance/openstack/pkg.go
@@ -0,0 +1,3 @@
+// +build acceptance
+
+package openstack
diff --git a/acceptance/openstack/sharedfilesystems/v2/availabilityzones_test.go b/acceptance/openstack/sharedfilesystems/v2/availabilityzones_test.go
new file mode 100644
index 0000000..8841160
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/availabilityzones_test.go
@@ -0,0 +1,29 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/availabilityzones"
+)
+
+func TestAvailabilityZonesList(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ allPages, err := availabilityzones.List(client).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to list availability zones: %v", err)
+ }
+
+ zones, err := availabilityzones.ExtractAvailabilityZones(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract availability zones: %v", err)
+ }
+
+ if len(zones) == 0 {
+ t.Fatal("At least one availability zone was expected to be found")
+ }
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/pkg.go b/acceptance/openstack/sharedfilesystems/v2/pkg.go
new file mode 100644
index 0000000..5a5cd2b
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/pkg.go
@@ -0,0 +1,3 @@
+// The v2 package contains acceptance tests for the Openstack Manila V2 service.
+
+package v2
diff --git a/acceptance/openstack/sharedfilesystems/v2/securityservices.go b/acceptance/openstack/sharedfilesystems/v2/securityservices.go
new file mode 100644
index 0000000..265323d
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/securityservices.go
@@ -0,0 +1,60 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/securityservices"
+)
+
+// CreateSecurityService will create a security service with a random name. An
+// error will be returned if the security service was unable to be created.
+func CreateSecurityService(t *testing.T, client *gophercloud.ServiceClient) (*securityservices.SecurityService, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires share network creation in short mode.")
+ }
+
+ securityServiceName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create security service: %s", securityServiceName)
+
+ createOpts := securityservices.CreateOpts{
+ Name: securityServiceName,
+ Type: "kerberos",
+ }
+
+ securityService, err := securityservices.Create(client, createOpts).Extract()
+ if err != nil {
+ return securityService, err
+ }
+
+ return securityService, nil
+}
+
+// DeleteSecurityService will delete a security service. An error will occur if
+// the security service was unable to be deleted.
+func DeleteSecurityService(t *testing.T, client *gophercloud.ServiceClient, securityService *securityservices.SecurityService) {
+ err := securityservices.Delete(client, securityService.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Failed to delete security service %s: %v", securityService.ID, err)
+ }
+
+ t.Logf("Deleted security service: %s", securityService.ID)
+}
+
+// PrintSecurityService will print a security service and all of its attributes.
+func PrintSecurityService(t *testing.T, securityService *securityservices.SecurityService) {
+ t.Logf("ID: %s", securityService.ID)
+ t.Logf("Project ID: %s", securityService.ProjectID)
+ t.Logf("Domain: %s", securityService.Domain)
+ t.Logf("Status: %s", securityService.Status)
+ t.Logf("Type: %s", securityService.Type)
+ t.Logf("Name: %s", securityService.Name)
+ t.Logf("Description: %s", securityService.Description)
+ t.Logf("DNS IP: %s", securityService.DNSIP)
+ t.Logf("User: %s", securityService.User)
+ t.Logf("Password: %s", securityService.Password)
+ t.Logf("Server: %s", securityService.Server)
+ t.Logf("Created at: %v", securityService.CreatedAt)
+ t.Logf("Updated at: %v", securityService.UpdatedAt)
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/securityservices_test.go b/acceptance/openstack/sharedfilesystems/v2/securityservices_test.go
new file mode 100644
index 0000000..5e2b45e
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/securityservices_test.go
@@ -0,0 +1,87 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/securityservices"
+)
+
+func TestSecurityServiceCreateDelete(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ securityService, err := CreateSecurityService(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security service: %v", err)
+ }
+
+ PrintSecurityService(t, securityService)
+
+ defer DeleteSecurityService(t, client, securityService)
+}
+
+func TestSecurityServiceList(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ allPages, err := securityservices.List(client, securityservices.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve security services: %v", err)
+ }
+
+ allSecurityServices, err := securityservices.ExtractSecurityServices(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract security services: %v", err)
+ }
+
+ for _, securityService := range allSecurityServices {
+ PrintSecurityService(t, &securityService)
+ }
+}
+
+// The test creates 2 security services and verifies that only the one(s) with
+// a particular name are being listed
+func TestSecurityServiceListFiltering(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ securityService, err := CreateSecurityService(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security service: %v", err)
+ }
+ defer DeleteSecurityService(t, client, securityService)
+
+ securityService, err = CreateSecurityService(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create security service: %v", err)
+ }
+ defer DeleteSecurityService(t, client, securityService)
+
+ options := securityservices.ListOpts{
+ Name: securityService.Name,
+ }
+
+ allPages, err := securityservices.List(client, options).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve security services: %v", err)
+ }
+
+ allSecurityServices, err := securityservices.ExtractSecurityServices(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract security services: %v", err)
+ }
+
+ for _, listedSecurityService := range allSecurityServices {
+ if listedSecurityService.Name != securityService.Name {
+ t.Fatalf("The name of the security service was expected to be %s", securityService.Name)
+ }
+ PrintSecurityService(t, &listedSecurityService)
+ }
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/sharenetworks.go b/acceptance/openstack/sharedfilesystems/v2/sharenetworks.go
new file mode 100644
index 0000000..b0aefd8
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/sharenetworks.go
@@ -0,0 +1,60 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharenetworks"
+)
+
+// CreateShareNetwork will create a share network with a random name. An
+// error will be returned if the share network was unable to be created.
+func CreateShareNetwork(t *testing.T, client *gophercloud.ServiceClient) (*sharenetworks.ShareNetwork, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires share network creation in short mode.")
+ }
+
+ shareNetworkName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create share network: %s", shareNetworkName)
+
+ createOpts := sharenetworks.CreateOpts{
+ Name: shareNetworkName,
+ Description: "This is a shared network",
+ }
+
+ shareNetwork, err := sharenetworks.Create(client, createOpts).Extract()
+ if err != nil {
+ return shareNetwork, err
+ }
+
+ return shareNetwork, nil
+}
+
+// DeleteShareNetwork will delete a share network. An error will occur if
+// the share network was unable to be deleted.
+func DeleteShareNetwork(t *testing.T, client *gophercloud.ServiceClient, shareNetwork *sharenetworks.ShareNetwork) {
+ err := sharenetworks.Delete(client, shareNetwork.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Failed to delete share network %s: %v", shareNetwork.ID, err)
+ }
+
+ t.Logf("Deleted share network: %s", shareNetwork.ID)
+}
+
+// PrintShareNetwork will print a share network and all of its attributes.
+func PrintShareNetwork(t *testing.T, sharenetwork *sharenetworks.ShareNetwork) {
+ t.Logf("ID: %s", sharenetwork.ID)
+ t.Logf("Project ID: %s", sharenetwork.ProjectID)
+ t.Logf("Neutron network ID: %s", sharenetwork.NeutronNetID)
+ t.Logf("Neutron sub-network ID: %s", sharenetwork.NeutronSubnetID)
+ t.Logf("Nova network ID: %s", sharenetwork.NovaNetID)
+ t.Logf("Network type: %s", sharenetwork.NetworkType)
+ t.Logf("Segmentation ID: %d", sharenetwork.SegmentationID)
+ t.Logf("CIDR: %s", sharenetwork.CIDR)
+ t.Logf("IP version: %d", sharenetwork.IPVersion)
+ t.Logf("Name: %s", sharenetwork.Name)
+ t.Logf("Description: %s", sharenetwork.Description)
+ t.Logf("Created at: %v", sharenetwork.CreatedAt)
+ t.Logf("Updated at: %v", sharenetwork.UpdatedAt)
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/sharenetworks_test.go b/acceptance/openstack/sharedfilesystems/v2/sharenetworks_test.go
new file mode 100644
index 0000000..1a4ae9b
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/sharenetworks_test.go
@@ -0,0 +1,186 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharenetworks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestShareNetworkCreateDestroy(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ shareNetwork, err := CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+
+ newShareNetwork, err := sharenetworks.Get(client, shareNetwork.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve shareNetwork: %v", err)
+ }
+
+ if newShareNetwork.Name != shareNetwork.Name {
+ t.Fatalf("Share network name was expeted to be: %s", shareNetwork.Name)
+ }
+
+ PrintShareNetwork(t, shareNetwork)
+
+ defer DeleteShareNetwork(t, client, shareNetwork)
+}
+
+// Create a share network and update the name and description. Get the share
+// network and verify that the name and description have been updated
+func TestShareNetworkUpdate(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ shareNetwork, err := CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+
+ expectedShareNetwork, err := sharenetworks.Get(client, shareNetwork.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve shareNetwork: %v", err)
+ }
+
+ options := sharenetworks.UpdateOpts{
+ Name: "NewName",
+ Description: "New share network description",
+ NovaNetID: "New_nova_network_id",
+ }
+
+ expectedShareNetwork.Name = options.Name
+ expectedShareNetwork.Description = options.Description
+ expectedShareNetwork.NovaNetID = options.NovaNetID
+
+ _, err = sharenetworks.Update(client, shareNetwork.ID, options).Extract()
+ if err != nil {
+ t.Errorf("Unable to update shareNetwork: %v", err)
+ }
+
+ updatedShareNetwork, err := sharenetworks.Get(client, shareNetwork.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve shareNetwork: %v", err)
+ }
+
+ // Update time has to be set in order to get the assert equal to pass
+ expectedShareNetwork.UpdatedAt = updatedShareNetwork.UpdatedAt
+
+ th.CheckDeepEquals(t, expectedShareNetwork, updatedShareNetwork)
+
+ PrintShareNetwork(t, shareNetwork)
+
+ defer DeleteShareNetwork(t, client, shareNetwork)
+}
+
+func TestShareNetworkListDetail(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ allPages, err := sharenetworks.ListDetail(client, sharenetworks.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve share networks: %v", err)
+ }
+
+ allShareNetworks, err := sharenetworks.ExtractShareNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract share networks: %v", err)
+ }
+
+ for _, shareNetwork := range allShareNetworks {
+ PrintShareNetwork(t, &shareNetwork)
+ }
+}
+
+// The test creates 2 shared networks and verifies that only the one(s) with
+// a particular name are being listed
+func TestShareNetworkListFiltering(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ shareNetwork, err := CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+ defer DeleteShareNetwork(t, client, shareNetwork)
+
+ shareNetwork, err = CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+ defer DeleteShareNetwork(t, client, shareNetwork)
+
+ options := sharenetworks.ListOpts{
+ Name: shareNetwork.Name,
+ }
+
+ allPages, err := sharenetworks.ListDetail(client, options).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve share networks: %v", err)
+ }
+
+ allShareNetworks, err := sharenetworks.ExtractShareNetworks(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract share networks: %v", err)
+ }
+
+ for _, listedShareNetwork := range allShareNetworks {
+ if listedShareNetwork.Name != shareNetwork.Name {
+ t.Fatalf("The name of the share network was expected to be %s", shareNetwork.Name)
+ }
+ PrintShareNetwork(t, &listedShareNetwork)
+ }
+}
+
+func TestShareNetworkListPagination(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ shareNetwork, err := CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+ defer DeleteShareNetwork(t, client, shareNetwork)
+
+ shareNetwork, err = CreateShareNetwork(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share network: %v", err)
+ }
+ defer DeleteShareNetwork(t, client, shareNetwork)
+
+ count := 0
+
+ err = sharenetworks.ListDetail(client, sharenetworks.ListOpts{Offset: 0, Limit: 1}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ _, err := sharenetworks.ExtractShareNetworks(page)
+ if err != nil {
+ t.Fatalf("Failed to extract share networks: %v", err)
+ return false, err
+ }
+
+ return true, nil
+ })
+ if err != nil {
+ t.Fatalf("Unable to retrieve share networks: %v", err)
+ }
+
+ if count < 2 {
+ t.Fatal("Expected to get at least 2 pages")
+ }
+
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/shares.go b/acceptance/openstack/sharedfilesystems/v2/shares.go
new file mode 100644
index 0000000..e9060b4
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/shares.go
@@ -0,0 +1,78 @@
+package v2
+
+import (
+ "encoding/json"
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/shares"
+ "testing"
+)
+
+// CreateShare will create a share with a name, and a size of 1Gb. An
+// error will be returned if the share could not be created
+func CreateShare(t *testing.T, client *gophercloud.ServiceClient) (*shares.Share, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requres share creation in short mode.")
+ }
+
+ choices, err := clients.AcceptanceTestChoicesFromEnv()
+ if err != nil {
+ t.Fatalf("Unable to fetch environment information")
+ }
+
+ t.Logf("Share network id %s", choices.ShareNetworkID)
+ createOpts := shares.CreateOpts{
+ Size: 1,
+ Name: "My Test Share",
+ ShareProto: "NFS",
+ ShareNetworkID: choices.ShareNetworkID,
+ }
+
+ share, err := shares.Create(client, createOpts).Extract()
+ if err != nil {
+ return share, err
+ }
+
+ err = waitForStatus(client, share.ID, "available", 60)
+ if err != nil {
+ return share, err
+ }
+
+ return share, nil
+}
+
+// DeleteShare will delete a share. A fatal error will occur if the share
+// failed to be deleted. This works best when used as a deferred function.
+func DeleteShare(t *testing.T, client *gophercloud.ServiceClient, share *shares.Share) {
+ err := shares.Delete(client, share.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to delete share %s: %v", share.ID, err)
+ }
+
+ t.Logf("Deleted share: %s", share.ID)
+}
+
+// PrintShare prints some information of the share
+func PrintShare(t *testing.T, share *shares.Share) {
+ asJSON, err := json.MarshalIndent(share, "", " ")
+ if err != nil {
+ t.Logf("Cannot print the contents of %s", share.ID)
+ }
+
+ t.Logf("Share %s", string(asJSON))
+}
+
+func waitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+ return gophercloud.WaitFor(secs, func() (bool, error) {
+ current, err := shares.Get(c, id).Extract()
+ if err != nil {
+ return false, err
+ }
+
+ if current.Status == status {
+ return true, nil
+ }
+
+ return false, nil
+ })
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/shares_test.go b/acceptance/openstack/sharedfilesystems/v2/shares_test.go
new file mode 100644
index 0000000..ed5d7cc
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/shares_test.go
@@ -0,0 +1,27 @@
+package v2
+
+import (
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/shares"
+ "testing"
+)
+
+func TestShareCreate(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a sharedfs client: %v", err)
+ }
+
+ share, err := CreateShare(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create a share: %v", err)
+ }
+
+ defer DeleteShare(t, client, share)
+
+ created, err := shares.Get(client, share.ID).Extract()
+ if err != nil {
+ t.Errorf("Unable to retrieve share: %v", err)
+ }
+ PrintShare(t, created)
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/sharetypes.go b/acceptance/openstack/sharedfilesystems/v2/sharetypes.go
new file mode 100644
index 0000000..97b44bd
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/sharetypes.go
@@ -0,0 +1,56 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/acceptance/tools"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharetypes"
+)
+
+// CreateShareType will create a share type with a random name. An
+// error will be returned if the share type was unable to be created.
+func CreateShareType(t *testing.T, client *gophercloud.ServiceClient) (*sharetypes.ShareType, error) {
+ if testing.Short() {
+ t.Skip("Skipping test that requires share type creation in short mode.")
+ }
+
+ shareTypeName := tools.RandomString("ACPTTEST", 16)
+ t.Logf("Attempting to create share type: %s", shareTypeName)
+
+ extraSpecsOps := sharetypes.ExtraSpecsOpts{
+ DriverHandlesShareServers: true,
+ }
+
+ createOpts := sharetypes.CreateOpts{
+ Name: shareTypeName,
+ IsPublic: false,
+ ExtraSpecs: extraSpecsOps,
+ }
+
+ shareType, err := sharetypes.Create(client, createOpts).Extract()
+ if err != nil {
+ return shareType, err
+ }
+
+ return shareType, nil
+}
+
+// DeleteShareType will delete a share type. An error will occur if
+// the share type was unable to be deleted.
+func DeleteShareType(t *testing.T, client *gophercloud.ServiceClient, shareType *sharetypes.ShareType) {
+ err := sharetypes.Delete(client, shareType.ID).ExtractErr()
+ if err != nil {
+ t.Fatalf("Failed to delete share type %s: %v", shareType.ID, err)
+ }
+
+ t.Logf("Deleted share type: %s", shareType.ID)
+}
+
+// PrintShareType will print a share type and all of its attributes.
+func PrintShareType(t *testing.T, shareType *sharetypes.ShareType) {
+ t.Logf("Name: %s", shareType.Name)
+ t.Logf("ID: %s", shareType.ID)
+ t.Logf("OS share type access is public: %t", shareType.IsPublic)
+ t.Logf("Extra specs: %#v", shareType.ExtraSpecs)
+}
diff --git a/acceptance/openstack/sharedfilesystems/v2/sharetypes_test.go b/acceptance/openstack/sharedfilesystems/v2/sharetypes_test.go
new file mode 100644
index 0000000..7a2c21b
--- /dev/null
+++ b/acceptance/openstack/sharedfilesystems/v2/sharetypes_test.go
@@ -0,0 +1,137 @@
+package v2
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/acceptance/clients"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharetypes"
+)
+
+func TestShareTypeCreateDestroy(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ shareType, err := CreateShareType(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share type: %v", err)
+ }
+
+ PrintShareType(t, shareType)
+
+ defer DeleteShareType(t, client, shareType)
+}
+
+func TestShareTypeList(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ allPages, err := sharetypes.List(client, sharetypes.ListOpts{}).AllPages()
+ if err != nil {
+ t.Fatalf("Unable to retrieve share types: %v", err)
+ }
+
+ allShareTypes, err := sharetypes.ExtractShareTypes(allPages)
+ if err != nil {
+ t.Fatalf("Unable to extract share types: %v", err)
+ }
+
+ for _, shareType := range allShareTypes {
+ PrintShareType(t, &shareType)
+ }
+}
+
+func TestShareTypeGetDefault(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create a shared file system client: %v", err)
+ }
+
+ shareType, err := sharetypes.GetDefault(client).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve the default share type: %v", err)
+ }
+
+ if shareType.Name != "default" {
+ t.Fatal("Share type name was expected to be: default")
+ }
+
+ PrintShareType(t, shareType)
+}
+
+func TestShareTypeExtraSpecs(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ shareType, err := CreateShareType(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share type: %v", err)
+ }
+
+ options := sharetypes.SetExtraSpecsOpts{
+ Specs: map[string]interface{}{"my_new_key": "my_value"},
+ }
+
+ _, err = sharetypes.SetExtraSpecs(client, shareType.ID, options).Extract()
+ if err != nil {
+ t.Fatalf("Unable to set extra specs for Share type: %s", shareType.Name)
+ }
+
+ extraSpecs, err := sharetypes.GetExtraSpecs(client, shareType.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve share type: %s", shareType.Name)
+ }
+
+ if extraSpecs["driver_handles_share_servers"] != "True" {
+ t.Fatal("driver_handles_share_servers was expected to be true")
+ }
+
+ if extraSpecs["my_new_key"] != "my_value" {
+ t.Fatal("my_new_key was expected to be equal to my_value")
+ }
+
+ err = sharetypes.UnsetExtraSpecs(client, shareType.ID, "my_new_key").ExtractErr()
+ if err != nil {
+ t.Fatalf("Unable to unset extra specs for Share type: %s", shareType.Name)
+ }
+
+ extraSpecs, err = sharetypes.GetExtraSpecs(client, shareType.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve share type: %s", shareType.Name)
+ }
+
+ if _, ok := extraSpecs["my_new_key"]; ok {
+ t.Fatalf("my_new_key was expected to be unset for Share type: %s", shareType.Name)
+ }
+
+ PrintShareType(t, shareType)
+
+ defer DeleteShareType(t, client, shareType)
+}
+
+func TestShareTypeShowAccess(t *testing.T) {
+ client, err := clients.NewSharedFileSystemV2Client()
+ if err != nil {
+ t.Fatalf("Unable to create shared file system client: %v", err)
+ }
+
+ shareType, err := CreateShareType(t, client)
+ if err != nil {
+ t.Fatalf("Unable to create share type: %v", err)
+ }
+
+ _, err = sharetypes.ShowAccess(client, shareType.ID).Extract()
+ if err != nil {
+ t.Fatalf("Unable to retrieve the access details for a share type: %v", err)
+ }
+
+ PrintShareType(t, shareType)
+
+ defer DeleteShareType(t, client, shareType)
+
+}
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..d2fd298
--- /dev/null
+++ b/acceptance/tools/tools.go
@@ -0,0 +1,73 @@
+package tools
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ mrand "math/rand"
+ "testing"
+ "time"
+)
+
+// ErrTimeout is returned if WaitFor takes longer than 300 second to happen.
+var ErrTimeout = errors.New("Timed out")
+
+// 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
+}
+
+// PrintResource returns a resource as a readable structure
+func PrintResource(t *testing.T, resource interface{}) {
+ b, _ := json.MarshalIndent(resource, "", " ")
+ t.Logf(string(b))
+}
diff --git a/auth_options.go b/auth_options.go
new file mode 100644
index 0000000..7a16131
--- /dev/null
+++ b/auth_options.go
@@ -0,0 +1,338 @@
+package gophercloud
+
+/*
+AuthOptions stores information needed to authenticate to an OpenStack Cloud.
+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 `json:"-"`
+
+ // 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 string `json:"username,omitempty"`
+ UserID string `json:"id,omitempty"`
+
+ Password string `json:"password,omitempty"`
+
+ // At most one of DomainID and DomainName must be provided if using Username
+ // with Identity V3. Otherwise, either are optional.
+ DomainID string `json:"id,omitempty"`
+ DomainName string `json:"name,omitempty"`
+
+ // The TenantID and TenantName fields are optional for the Identity V2 API.
+ // The same fields are known as project_id and project_name in the Identity
+ // V3 API, but are collected as TenantID and TenantName here in both cases.
+ // 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.
+ // If DomainID or DomainName are provided, they will also apply to TenantName.
+ // It is not currently possible to authenticate with Username and a Domain
+ // and scope to a Project in a different Domain by using TenantName. To
+ // accomplish that, the ProjectID will need to be provided to the TenantID
+ // option.
+ TenantID string `json:"tenantId,omitempty"`
+ TenantName string `json:"tenantName,omitempty"`
+
+ // 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.
+ //
+ // NOTE: The reauth function will try to re-authenticate endlessly if left unchecked.
+ // The way to limit the number of attempts is to provide a custom HTTP client to the provider client
+ // and provide a transport that implements the RoundTripper interface and stores the number of failed retries.
+ // For an example of this, see here: https://github.com/rackspace/rack/blob/1.0.0/auth/clients.go#L311
+ AllowReauth bool `json:"-"`
+
+ // TokenID allows users to authenticate (possibly as another user) with an
+ // authentication token ID.
+ TokenID string `json:"-"`
+}
+
+// ToTokenV2CreateMap allows AuthOptions to satisfy the AuthOptionsBuilder
+// interface in the v2 tokens package
+func (opts AuthOptions) ToTokenV2CreateMap() (map[string]interface{}, error) {
+ // Populate the request map.
+ authMap := make(map[string]interface{})
+
+ if opts.Username != "" {
+ if opts.Password != "" {
+ authMap["passwordCredentials"] = map[string]interface{}{
+ "username": opts.Username,
+ "password": opts.Password,
+ }
+ } else {
+ return nil, ErrMissingInput{Argument: "Password"}
+ }
+ } else if opts.TokenID != "" {
+ authMap["token"] = map[string]interface{}{
+ "id": opts.TokenID,
+ }
+ } else {
+ return nil, ErrMissingInput{Argument: "Username"}
+ }
+
+ if opts.TenantID != "" {
+ authMap["tenantId"] = opts.TenantID
+ }
+ if opts.TenantName != "" {
+ authMap["tenantName"] = opts.TenantName
+ }
+
+ return map[string]interface{}{"auth": authMap}, nil
+}
+
+func (opts *AuthOptions) ToTokenV3CreateMap(scope map[string]interface{}) (map[string]interface{}, error) {
+ 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 authReq struct {
+ Identity identityReq `json:"identity"`
+ }
+
+ 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 opts.TenantID != "" {
+ return nil, ErrTenantIDProvided{}
+ }
+ if opts.TenantName != "" {
+ return nil, ErrTenantNameProvided{}
+ }
+
+ if opts.Password == "" {
+ if opts.TokenID != "" {
+ // Because we aren't using password authentication, it's an error to also provide any of the user-based authentication
+ // parameters.
+ if opts.Username != "" {
+ return nil, ErrUsernameWithToken{}
+ }
+ if opts.UserID != "" {
+ return nil, ErrUserIDWithToken{}
+ }
+ if opts.DomainID != "" {
+ return nil, ErrDomainIDWithToken{}
+ }
+ if opts.DomainName != "" {
+ return nil, ErrDomainNameWithToken{}
+ }
+
+ // Configure the request for Token authentication.
+ req.Auth.Identity.Methods = []string{"token"}
+ req.Auth.Identity.Token = &tokenReq{
+ ID: opts.TokenID,
+ }
+ } else {
+ // If no password or token ID are available, authentication can't continue.
+ return nil, ErrMissingPassword{}
+ }
+ } else {
+ // Password authentication.
+ req.Auth.Identity.Methods = []string{"password"}
+
+ // At least one of Username and UserID must be specified.
+ if opts.Username == "" && opts.UserID == "" {
+ return nil, ErrUsernameOrUserID{}
+ }
+
+ if opts.Username != "" {
+ // If Username is provided, UserID may not be provided.
+ if opts.UserID != "" {
+ return nil, ErrUsernameOrUserID{}
+ }
+
+ // Either DomainID or DomainName must also be specified.
+ if opts.DomainID == "" && opts.DomainName == "" {
+ return nil, ErrDomainIDOrDomainName{}
+ }
+
+ if opts.DomainID != "" {
+ if opts.DomainName != "" {
+ return nil, ErrDomainIDOrDomainName{}
+ }
+
+ // Configure the request for Username and Password authentication with a DomainID.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &opts.Username,
+ Password: opts.Password,
+ Domain: &domainReq{ID: &opts.DomainID},
+ },
+ }
+ }
+
+ if opts.DomainName != "" {
+ // Configure the request for Username and Password authentication with a DomainName.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{
+ Name: &opts.Username,
+ Password: opts.Password,
+ Domain: &domainReq{Name: &opts.DomainName},
+ },
+ }
+ }
+ }
+
+ if opts.UserID != "" {
+ // If UserID is specified, neither DomainID nor DomainName may be.
+ if opts.DomainID != "" {
+ return nil, ErrDomainIDWithUserID{}
+ }
+ if opts.DomainName != "" {
+ return nil, ErrDomainNameWithUserID{}
+ }
+
+ // Configure the request for UserID and Password authentication.
+ req.Auth.Identity.Password = &passwordReq{
+ User: userReq{ID: &opts.UserID, Password: opts.Password},
+ }
+ }
+ }
+
+ b, err := BuildRequestBody(req, "")
+ if err != nil {
+ return nil, err
+ }
+
+ if len(scope) != 0 {
+ b["auth"].(map[string]interface{})["scope"] = scope
+ }
+
+ return b, nil
+}
+
+func (opts *AuthOptions) ToTokenV3ScopeMap() (map[string]interface{}, error) {
+
+ var scope struct {
+ ProjectID string
+ ProjectName string
+ DomainID string
+ DomainName string
+ }
+
+ if opts.TenantID != "" {
+ scope.ProjectID = opts.TenantID
+ opts.TenantID = ""
+ opts.TenantName = ""
+ } else {
+ if opts.TenantName != "" {
+ scope.ProjectName = opts.TenantName
+ scope.DomainID = opts.DomainID
+ scope.DomainName = opts.DomainName
+ }
+ opts.TenantName = ""
+ }
+
+ if scope.ProjectName != "" {
+ // ProjectName provided: either DomainID or DomainName must also be supplied.
+ // ProjectID may not be supplied.
+ if scope.DomainID == "" && scope.DomainName == "" {
+ return nil, ErrScopeDomainIDOrDomainName{}
+ }
+ if scope.ProjectID != "" {
+ return nil, ErrScopeProjectIDOrProjectName{}
+ }
+
+ if scope.DomainID != "" {
+ // ProjectName + DomainID
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "name": &scope.ProjectName,
+ "domain": map[string]interface{}{"id": &scope.DomainID},
+ },
+ }, nil
+ }
+
+ if scope.DomainName != "" {
+ // ProjectName + DomainName
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "name": &scope.ProjectName,
+ "domain": map[string]interface{}{"name": &scope.DomainName},
+ },
+ }, nil
+ }
+ } else if scope.ProjectID != "" {
+ // ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+ if scope.DomainID != "" {
+ return nil, ErrScopeProjectIDAlone{}
+ }
+ if scope.DomainName != "" {
+ return nil, ErrScopeProjectIDAlone{}
+ }
+
+ // ProjectID
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "id": &scope.ProjectID,
+ },
+ }, nil
+ } else if scope.DomainID != "" {
+ // DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+ if scope.DomainName != "" {
+ return nil, ErrScopeDomainIDOrDomainName{}
+ }
+
+ // DomainID
+ return map[string]interface{}{
+ "domain": map[string]interface{}{
+ "id": &scope.DomainID,
+ },
+ }, nil
+ } else if scope.DomainName != "" {
+ return nil, ErrScopeDomainName{}
+ }
+
+ return nil, nil
+}
+
+func (opts AuthOptions) CanReauth() bool {
+ return opts.AllowReauth
+}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..b559516
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,69 @@
+/*
+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. The IdentityEndpoint is typically refered to as
+"auth_url" in information provided by the cloud operator. Additionally,
+the cloud may refer to TenantID or TenantName as project_id and project_name.
+These are defined like so:
+
+ opts := gophercloud.AuthOptions{
+ IdentityEndpoint: "https://openstack.example.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..9887947
--- /dev/null
+++ b/endpoint_search.go
@@ -0,0 +1,76 @@
+package gophercloud
+
+// 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/errors.go b/errors.go
new file mode 100644
index 0000000..e0fe7c1
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,408 @@
+package gophercloud
+
+import "fmt"
+
+// BaseError is an error type that all other error types embed.
+type BaseError struct {
+ DefaultErrString string
+ Info string
+}
+
+func (e BaseError) Error() string {
+ e.DefaultErrString = "An error occurred while executing a Gophercloud request."
+ return e.choseErrString()
+}
+
+func (e BaseError) choseErrString() string {
+ if e.Info != "" {
+ return e.Info
+ }
+ return e.DefaultErrString
+}
+
+// ErrMissingInput is the error when input is required in a particular
+// situation but not provided by the user
+type ErrMissingInput struct {
+ BaseError
+ Argument string
+}
+
+func (e ErrMissingInput) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Missing input for argument [%s]", e.Argument)
+ return e.choseErrString()
+}
+
+// ErrInvalidInput is an error type used for most non-HTTP Gophercloud errors.
+type ErrInvalidInput struct {
+ ErrMissingInput
+ Value interface{}
+}
+
+func (e ErrInvalidInput) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Invalid input provided for argument [%s]: [%+v]", e.Argument, e.Value)
+ return e.choseErrString()
+}
+
+// ErrUnexpectedResponseCode is returned by the Request method when a response code other than
+// those listed in OkCodes is encountered.
+type ErrUnexpectedResponseCode struct {
+ BaseError
+ URL string
+ Method string
+ Expected []int
+ Actual int
+ Body []byte
+}
+
+func (e ErrUnexpectedResponseCode) Error() string {
+ e.DefaultErrString = fmt.Sprintf(
+ "Expected HTTP response code %v when accessing [%s %s], but got %d instead\n%s",
+ e.Expected, e.Method, e.URL, e.Actual, e.Body,
+ )
+ return e.choseErrString()
+}
+
+// ErrDefault400 is the default error type returned on a 400 HTTP response code.
+type ErrDefault400 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault401 is the default error type returned on a 401 HTTP response code.
+type ErrDefault401 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault404 is the default error type returned on a 404 HTTP response code.
+type ErrDefault404 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault405 is the default error type returned on a 405 HTTP response code.
+type ErrDefault405 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault408 is the default error type returned on a 408 HTTP response code.
+type ErrDefault408 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault429 is the default error type returned on a 429 HTTP response code.
+type ErrDefault429 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault500 is the default error type returned on a 500 HTTP response code.
+type ErrDefault500 struct {
+ ErrUnexpectedResponseCode
+}
+
+// ErrDefault503 is the default error type returned on a 503 HTTP response code.
+type ErrDefault503 struct {
+ ErrUnexpectedResponseCode
+}
+
+func (e ErrDefault400) Error() string {
+ return "Invalid request due to incorrect syntax or missing required parameters."
+}
+func (e ErrDefault401) Error() string {
+ return "Authentication failed"
+}
+func (e ErrDefault404) Error() string {
+ return "Resource not found"
+}
+func (e ErrDefault405) Error() string {
+ return "Method not allowed"
+}
+func (e ErrDefault408) Error() string {
+ return "The server timed out waiting for the request"
+}
+func (e ErrDefault429) Error() string {
+ return "Too many requests have been sent in a given amount of time. Pause" +
+ " requests, wait up to one minute, and try again."
+}
+func (e ErrDefault500) Error() string {
+ return "Internal Server Error"
+}
+func (e ErrDefault503) Error() string {
+ return "The service is currently unable to handle the request due to a temporary" +
+ " overloading or maintenance. This is a temporary condition. Try again later."
+}
+
+// Err400er is the interface resource error types implement to override the error message
+// from a 400 error.
+type Err400er interface {
+ Error400(ErrUnexpectedResponseCode) error
+}
+
+// Err401er is the interface resource error types implement to override the error message
+// from a 401 error.
+type Err401er interface {
+ Error401(ErrUnexpectedResponseCode) error
+}
+
+// Err404er is the interface resource error types implement to override the error message
+// from a 404 error.
+type Err404er interface {
+ Error404(ErrUnexpectedResponseCode) error
+}
+
+// Err405er is the interface resource error types implement to override the error message
+// from a 405 error.
+type Err405er interface {
+ Error405(ErrUnexpectedResponseCode) error
+}
+
+// Err408er is the interface resource error types implement to override the error message
+// from a 408 error.
+type Err408er interface {
+ Error408(ErrUnexpectedResponseCode) error
+}
+
+// Err429er is the interface resource error types implement to override the error message
+// from a 429 error.
+type Err429er interface {
+ Error429(ErrUnexpectedResponseCode) error
+}
+
+// Err500er is the interface resource error types implement to override the error message
+// from a 500 error.
+type Err500er interface {
+ Error500(ErrUnexpectedResponseCode) error
+}
+
+// Err503er is the interface resource error types implement to override the error message
+// from a 503 error.
+type Err503er interface {
+ Error503(ErrUnexpectedResponseCode) error
+}
+
+// ErrTimeOut is the error type returned when an operations times out.
+type ErrTimeOut struct {
+ BaseError
+}
+
+func (e ErrTimeOut) Error() string {
+ e.DefaultErrString = "A time out occurred"
+ return e.choseErrString()
+}
+
+// ErrUnableToReauthenticate is the error type returned when reauthentication fails.
+type ErrUnableToReauthenticate struct {
+ BaseError
+ ErrOriginal error
+}
+
+func (e ErrUnableToReauthenticate) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Unable to re-authenticate: %s", e.ErrOriginal)
+ return e.choseErrString()
+}
+
+// ErrErrorAfterReauthentication is the error type returned when reauthentication
+// succeeds, but an error occurs afterword (usually an HTTP error).
+type ErrErrorAfterReauthentication struct {
+ BaseError
+ ErrOriginal error
+}
+
+func (e ErrErrorAfterReauthentication) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Successfully re-authenticated, but got error executing request: %s", e.ErrOriginal)
+ return e.choseErrString()
+}
+
+// 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.
+type ErrServiceNotFound struct {
+ BaseError
+}
+
+func (e ErrServiceNotFound) Error() string {
+ e.DefaultErrString = "No suitable service could be found in the service catalog."
+ return e.choseErrString()
+}
+
+// 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.
+type ErrEndpointNotFound struct {
+ BaseError
+}
+
+func (e ErrEndpointNotFound) Error() string {
+ e.DefaultErrString = "No suitable endpoint could be found in the service catalog."
+ return e.choseErrString()
+}
+
+// ErrResourceNotFound is the error when trying to retrieve a resource's
+// ID by name and the resource doesn't exist.
+type ErrResourceNotFound struct {
+ BaseError
+ Name string
+ ResourceType string
+}
+
+func (e ErrResourceNotFound) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Unable to find %s with name %s", e.ResourceType, e.Name)
+ return e.choseErrString()
+}
+
+// ErrMultipleResourcesFound is the error when trying to retrieve a resource's
+// ID by name and multiple resources have the user-provided name.
+type ErrMultipleResourcesFound struct {
+ BaseError
+ Name string
+ Count int
+ ResourceType string
+}
+
+func (e ErrMultipleResourcesFound) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Found %d %ss matching %s", e.Count, e.ResourceType, e.Name)
+ return e.choseErrString()
+}
+
+// ErrUnexpectedType is the error when an unexpected type is encountered
+type ErrUnexpectedType struct {
+ BaseError
+ Expected string
+ Actual string
+}
+
+func (e ErrUnexpectedType) Error() string {
+ e.DefaultErrString = fmt.Sprintf("Expected %s but got %s", e.Expected, e.Actual)
+ return e.choseErrString()
+}
+
+func unacceptedAttributeErr(attribute string) string {
+ return fmt.Sprintf("The base Identity V3 API does not accept authentication by %s", attribute)
+}
+
+func redundantWithTokenErr(attribute string) string {
+ return fmt.Sprintf("%s may not be provided when authenticating with a TokenID", attribute)
+}
+
+func redundantWithUserID(attribute string) string {
+ return fmt.Sprintf("%s may not be provided when authenticating with a UserID", attribute)
+}
+
+// ErrAPIKeyProvided indicates that an APIKey was provided but can't be used.
+type ErrAPIKeyProvided struct{ BaseError }
+
+func (e ErrAPIKeyProvided) Error() string {
+ return unacceptedAttributeErr("APIKey")
+}
+
+// ErrTenantIDProvided indicates that a TenantID was provided but can't be used.
+type ErrTenantIDProvided struct{ BaseError }
+
+func (e ErrTenantIDProvided) Error() string {
+ return unacceptedAttributeErr("TenantID")
+}
+
+// ErrTenantNameProvided indicates that a TenantName was provided but can't be used.
+type ErrTenantNameProvided struct{ BaseError }
+
+func (e ErrTenantNameProvided) Error() string {
+ return unacceptedAttributeErr("TenantName")
+}
+
+// ErrUsernameWithToken indicates that a Username was provided, but token authentication is being used instead.
+type ErrUsernameWithToken struct{ BaseError }
+
+func (e ErrUsernameWithToken) Error() string {
+ return redundantWithTokenErr("Username")
+}
+
+// ErrUserIDWithToken indicates that a UserID was provided, but token authentication is being used instead.
+type ErrUserIDWithToken struct{ BaseError }
+
+func (e ErrUserIDWithToken) Error() string {
+ return redundantWithTokenErr("UserID")
+}
+
+// ErrDomainIDWithToken indicates that a DomainID was provided, but token authentication is being used instead.
+type ErrDomainIDWithToken struct{ BaseError }
+
+func (e ErrDomainIDWithToken) Error() string {
+ return redundantWithTokenErr("DomainID")
+}
+
+// ErrDomainNameWithToken indicates that a DomainName was provided, but token authentication is being used instead.s
+type ErrDomainNameWithToken struct{ BaseError }
+
+func (e ErrDomainNameWithToken) Error() string {
+ return redundantWithTokenErr("DomainName")
+}
+
+// ErrUsernameOrUserID indicates that neither username nor userID are specified, or both are at once.
+type ErrUsernameOrUserID struct{ BaseError }
+
+func (e ErrUsernameOrUserID) Error() string {
+ return "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.
+type ErrDomainIDWithUserID struct{ BaseError }
+
+func (e ErrDomainIDWithUserID) Error() string {
+ return redundantWithUserID("DomainID")
+}
+
+// ErrDomainNameWithUserID indicates that a DomainName was provided, but unnecessary because a UserID is being used.
+type ErrDomainNameWithUserID struct{ BaseError }
+
+func (e ErrDomainNameWithUserID) Error() string {
+ return 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.
+type ErrDomainIDOrDomainName struct{ BaseError }
+
+func (e ErrDomainIDOrDomainName) Error() string {
+ return "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.
+type ErrMissingPassword struct{ BaseError }
+
+func (e ErrMissingPassword) Error() string {
+ return "You must provide a password to authenticate"
+}
+
+// ErrScopeDomainIDOrDomainName indicates that a domain ID or Name was required in a Scope, but not present.
+type ErrScopeDomainIDOrDomainName struct{ BaseError }
+
+func (e ErrScopeDomainIDOrDomainName) Error() string {
+ return "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.
+type ErrScopeProjectIDOrProjectName struct{ BaseError }
+
+func (e ErrScopeProjectIDOrProjectName) Error() string {
+ return "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.
+type ErrScopeProjectIDAlone struct{ BaseError }
+
+func (e ErrScopeProjectIDAlone) Error() string {
+ return "ProjectID must be supplied alone in a Scope"
+}
+
+// ErrScopeDomainName indicates that a DomainName was provided alone in a Scope.
+type ErrScopeDomainName struct{ BaseError }
+
+func (e ErrScopeDomainName) Error() string {
+ return "DomainName must be supplied with a ProjectName or ProjectID in a Scope"
+}
+
+// ErrScopeEmpty indicates that no credentials were provided in a Scope.
+type ErrScopeEmpty struct{ BaseError }
+
+func (e ErrScopeEmpty) Error() string {
+ return "You must provide either a Project or Domain in a Scope"
+}
diff --git a/internal/pkg.go b/internal/pkg.go
new file mode 100644
index 0000000..5bf0569
--- /dev/null
+++ b/internal/pkg.go
@@ -0,0 +1 @@
+package internal
diff --git a/openstack/auth_env.go b/openstack/auth_env.go
new file mode 100644
index 0000000..f6d2eb1
--- /dev/null
+++ b/openstack/auth_env.go
@@ -0,0 +1,52 @@
+package openstack
+
+import (
+ "os"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+var nilOptions = gophercloud.AuthOptions{}
+
+// AuthOptionsFromEnv 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 == "" {
+ err := gophercloud.ErrMissingInput{Argument: "authURL"}
+ return nilOptions, err
+ }
+
+ if username == "" && userID == "" {
+ err := gophercloud.ErrMissingInput{Argument: "username"}
+ return nilOptions, err
+ }
+
+ if password == "" {
+ err := gophercloud.ErrMissingInput{Argument: "password"}
+ return nilOptions, err
+ }
+
+ 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/extensions/volumeactions/doc.go b/openstack/blockstorage/extensions/volumeactions/doc.go
new file mode 100644
index 0000000..0935fdb
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/doc.go
@@ -0,0 +1,5 @@
+// Package volumeactions 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 volumeactions
diff --git a/openstack/blockstorage/extensions/volumeactions/requests.go b/openstack/blockstorage/extensions/volumeactions/requests.go
new file mode 100644
index 0000000..e3c7df3
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/requests.go
@@ -0,0 +1,253 @@
+package volumeactions
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// AttachOptsBuilder allows extensions to add additional parameters to the
+// Attach request.
+type AttachOptsBuilder interface {
+ ToVolumeAttachMap() (map[string]interface{}, error)
+}
+
+// AttachMode describes the attachment mode for volumes.
+type AttachMode string
+
+// These constants determine how a volume is attached
+const (
+ ReadOnly AttachMode = "ro"
+ ReadWrite AttachMode = "rw"
+)
+
+// AttachOpts contains options for attaching a Volume.
+type AttachOpts struct {
+ // The mountpoint of this volume
+ MountPoint string `json:"mountpoint,omitempty"`
+ // The nova instance ID, can't set simultaneously with HostName
+ InstanceUUID string `json:"instance_uuid,omitempty"`
+ // The hostname of baremetal host, can't set simultaneously with InstanceUUID
+ HostName string `json:"host_name,omitempty"`
+ // Mount mode of this volume
+ Mode AttachMode `json:"mode,omitempty"`
+}
+
+// ToVolumeAttachMap assembles a request body based on the contents of a
+// AttachOpts.
+func (opts AttachOpts) ToVolumeAttachMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "os-attach")
+}
+
+// Attach will attach a volume based on the values in AttachOpts.
+func Attach(client *gophercloud.ServiceClient, id string, opts AttachOptsBuilder) (r AttachResult) {
+ b, err := opts.ToVolumeAttachMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(attachURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// BeginDetach will mark the volume as detaching
+func BeginDetaching(client *gophercloud.ServiceClient, id string) (r BeginDetachingResult) {
+ b := map[string]interface{}{"os-begin_detaching": make(map[string]interface{})}
+ _, r.Err = client.Post(beginDetachingURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// DetachOptsBuilder allows extensions to add additional parameters to the
+// Detach request.
+type DetachOptsBuilder interface {
+ ToVolumeDetachMap() (map[string]interface{}, error)
+}
+
+type DetachOpts struct {
+ AttachmentID string `json:"attachment_id,omitempty"`
+}
+
+// ToVolumeDetachMap assembles a request body based on the contents of a
+// DetachOpts.
+func (opts DetachOpts) ToVolumeDetachMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "os-detach")
+}
+
+// Detach will detach a volume based on volume id.
+func Detach(client *gophercloud.ServiceClient, id string, opts DetachOptsBuilder) (r DetachResult) {
+ b, err := opts.ToVolumeDetachMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(detachURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// Reserve will reserve a volume based on volume id.
+func Reserve(client *gophercloud.ServiceClient, id string) (r ReserveResult) {
+ b := map[string]interface{}{"os-reserve": make(map[string]interface{})}
+ _, r.Err = client.Post(reserveURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return
+}
+
+// Unreserve will unreserve a volume based on volume id.
+func Unreserve(client *gophercloud.ServiceClient, id string) (r UnreserveResult) {
+ b := map[string]interface{}{"os-unreserve": make(map[string]interface{})}
+ _, r.Err = client.Post(unreserveURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return
+}
+
+// InitializeConnectionOptsBuilder allows extensions to add additional parameters to the
+// InitializeConnection request.
+type InitializeConnectionOptsBuilder interface {
+ ToVolumeInitializeConnectionMap() (map[string]interface{}, error)
+}
+
+// InitializeConnectionOpts hosts options for InitializeConnection.
+type InitializeConnectionOpts struct {
+ IP string `json:"ip,omitempty"`
+ Host string `json:"host,omitempty"`
+ Initiator string `json:"initiator,omitempty"`
+ Wwpns []string `json:"wwpns,omitempty"`
+ Wwnns string `json:"wwnns,omitempty"`
+ Multipath *bool `json:"multipath,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ OSType string `json:"os_type,omitempty"`
+}
+
+// ToVolumeInitializeConnectionMap assembles a request body based on the contents of a
+// InitializeConnectionOpts.
+func (opts InitializeConnectionOpts) ToVolumeInitializeConnectionMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "connector")
+ return map[string]interface{}{"os-initialize_connection": b}, err
+}
+
+// InitializeConnection initializes iscsi connection.
+func InitializeConnection(client *gophercloud.ServiceClient, id string, opts InitializeConnectionOptsBuilder) (r InitializeConnectionResult) {
+ b, err := opts.ToVolumeInitializeConnectionMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(initializeConnectionURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return
+}
+
+// TerminateConnectionOptsBuilder allows extensions to add additional parameters to the
+// TerminateConnection request.
+type TerminateConnectionOptsBuilder interface {
+ ToVolumeTerminateConnectionMap() (map[string]interface{}, error)
+}
+
+// TerminateConnectionOpts hosts options for TerminateConnection.
+type TerminateConnectionOpts struct {
+ IP string `json:"ip,omitempty"`
+ Host string `json:"host,omitempty"`
+ Initiator string `json:"initiator,omitempty"`
+ Wwpns []string `json:"wwpns,omitempty"`
+ Wwnns string `json:"wwnns,omitempty"`
+ Multipath *bool `json:"multipath,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ OSType string `json:"os_type,omitempty"`
+}
+
+// ToVolumeTerminateConnectionMap assembles a request body based on the contents of a
+// TerminateConnectionOpts.
+func (opts TerminateConnectionOpts) ToVolumeTerminateConnectionMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "connector")
+ return map[string]interface{}{"os-terminate_connection": b}, err
+}
+
+// TerminateConnection terminates iscsi connection.
+func TerminateConnection(client *gophercloud.ServiceClient, id string, opts TerminateConnectionOptsBuilder) (r TerminateConnectionResult) {
+ b, err := opts.ToVolumeTerminateConnectionMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(teminateConnectionURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// ExtendSizeOptsBuilder allows extensions to add additional parameters to the
+// ExtendSize request.
+type ExtendSizeOptsBuilder interface {
+ ToVolumeExtendSizeMap() (map[string]interface{}, error)
+}
+
+// ExtendSizeOpts contain options for extending the size of an existing Volume. This object is passed
+// to the volumes.ExtendSize function.
+type ExtendSizeOpts struct {
+ // NewSize is the new size of the volume, in GB
+ NewSize int `json:"new_size" required:"true"`
+}
+
+// ToVolumeExtendSizeMap assembles a request body based on the contents of an
+// ExtendSizeOpts.
+func (opts ExtendSizeOpts) ToVolumeExtendSizeMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "os-extend")
+}
+
+// ExtendSize will extend the size of the volume based on the provided information.
+// This operation does not return a response body.
+func ExtendSize(client *gophercloud.ServiceClient, id string, opts ExtendSizeOptsBuilder) (r ExtendSizeResult) {
+ b, err := opts.ToVolumeExtendSizeMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(extendSizeURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// UploadImageOptsBuilder allows extensions to add additional parameters to the
+// UploadImage request.
+type UploadImageOptsBuilder interface {
+ ToVolumeUploadImageMap() (map[string]interface{}, error)
+}
+
+// UploadImageOpts contains options for uploading a Volume to image storage.
+type UploadImageOpts struct {
+ // Container format, may be bare, ofv, ova, etc.
+ ContainerFormat string `json:"container_format,omitempty"`
+ // Disk format, may be raw, qcow2, vhd, vdi, vmdk, etc.
+ DiskFormat string `json:"disk_format,omitempty"`
+ // The name of image that will be stored in glance
+ ImageName string `json:"image_name,omitempty"`
+ // Force image creation, usable if volume attached to instance
+ Force bool `json:"force,omitempty"`
+}
+
+// ToVolumeUploadImageMap assembles a request body based on the contents of a
+// UploadImageOpts.
+func (opts UploadImageOpts) ToVolumeUploadImageMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "os-volume_upload_image")
+}
+
+// UploadImage will upload image base on the values in UploadImageOptsBuilder
+func UploadImage(client *gophercloud.ServiceClient, id string, opts UploadImageOptsBuilder) (r UploadImageResult) {
+ b, err := opts.ToVolumeUploadImageMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(uploadURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
diff --git a/openstack/blockstorage/extensions/volumeactions/results.go b/openstack/blockstorage/extensions/volumeactions/results.go
new file mode 100644
index 0000000..634b04d
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/results.go
@@ -0,0 +1,61 @@
+package volumeactions
+
+import "github.com/gophercloud/gophercloud"
+
+// AttachResult contains the response body and error from a Get request.
+type AttachResult struct {
+ gophercloud.ErrResult
+}
+
+// BeginDetachingResult contains the response body and error from a Get request.
+type BeginDetachingResult struct {
+ gophercloud.ErrResult
+}
+
+// DetachResult contains the response body and error from a Get request.
+type DetachResult struct {
+ gophercloud.ErrResult
+}
+
+// UploadImageResult contains the response body and error from a UploadImage request.
+type UploadImageResult struct {
+ gophercloud.ErrResult
+}
+
+// ReserveResult contains the response body and error from a Get request.
+type ReserveResult struct {
+ gophercloud.ErrResult
+}
+
+// UnreserveResult contains the response body and error from a Get request.
+type UnreserveResult struct {
+ gophercloud.ErrResult
+}
+
+// TerminateConnectionResult contains the response body and error from a Get request.
+type TerminateConnectionResult struct {
+ gophercloud.ErrResult
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Volume object out of the commonResult object.
+func (r commonResult) Extract() (map[string]interface{}, error) {
+ var s struct {
+ ConnectionInfo map[string]interface{} `json:"connection_info"`
+ }
+ err := r.ExtractInto(&s)
+ return s.ConnectionInfo, err
+}
+
+// InitializeConnectionResult contains the response body and error from a Get request.
+type InitializeConnectionResult struct {
+ commonResult
+}
+
+// ExtendSizeResult contains the response body and error from an ExtendSize request.
+type ExtendSizeResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/blockstorage/extensions/volumeactions/testing/doc.go b/openstack/blockstorage/extensions/volumeactions/testing/doc.go
new file mode 100644
index 0000000..e720733
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/testing/doc.go
@@ -0,0 +1,2 @@
+// volumeactions
+package testing
diff --git a/openstack/blockstorage/extensions/volumeactions/testing/fixtures.go b/openstack/blockstorage/extensions/volumeactions/testing/fixtures.go
new file mode 100644
index 0000000..d914097
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/testing/fixtures.go
@@ -0,0 +1,251 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockAttachResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-attach":
+ {
+ "mountpoint": "/mnt",
+ "mode": "rw",
+ "instance_uuid": "50902f4f-a974-46a0-85e9-7efc5e22dfdd"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockBeginDetachingResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-begin_detaching": {}
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockDetachResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-detach": {}
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockUploadImageResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-volume_upload_image": {
+ "container_format": "bare",
+ "force": true,
+ "image_name": "test",
+ "disk_format": "raw"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockReserveResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-reserve": {}
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockUnreserveResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-unreserve": {}
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockInitializeConnectionResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-initialize_connection":
+ {
+ "connector":
+ {
+ "ip":"127.0.0.1",
+ "host":"stack",
+ "initiator":"iqn.1994-05.com.redhat:17cf566367d2",
+ "multipath": false,
+ "platform": "x86_64",
+ "os_type": "linux2"
+ }
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{
+"connection_info": {
+ "data": {
+ "target_portals": [
+ "172.31.17.48:3260"
+ ],
+ "auth_method": "CHAP",
+ "auth_username": "5MLtcsTEmNN5jFVcT6ui",
+ "access_mode": "rw",
+ "target_lun": 0,
+ "volume_id": "cd281d77-8217-4830-be95-9528227c105c",
+ "target_luns": [
+ 0
+ ],
+ "target_iqns": [
+ "iqn.2010-10.org.openstack:volume-cd281d77-8217-4830-be95-9528227c105c"
+ ],
+ "auth_password": "x854ZY5Re3aCkdNL",
+ "target_discovered": false,
+ "encrypted": false,
+ "qos_specs": null,
+ "target_iqn": "iqn.2010-10.org.openstack:volume-cd281d77-8217-4830-be95-9528227c105c",
+ "target_portal": "172.31.17.48:3260"
+ },
+ "driver_volume_type": "iscsi"
+ }
+ }`)
+ })
+}
+
+func MockTerminateConnectionResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-terminate_connection":
+ {
+ "connector":
+ {
+ "ip":"127.0.0.1",
+ "host":"stack",
+ "initiator":"iqn.1994-05.com.redhat:17cf566367d2",
+ "multipath": true,
+ "platform": "x86_64",
+ "os_type": "linux2"
+ }
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
+
+func MockExtendSizeResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action",
+ 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, `
+{
+ "os-extend":
+ {
+ "new_size": 3
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `{}`)
+ })
+}
diff --git a/openstack/blockstorage/extensions/volumeactions/testing/requests_test.go b/openstack/blockstorage/extensions/volumeactions/testing/requests_test.go
new file mode 100644
index 0000000..6132161
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/testing/requests_test.go
@@ -0,0 +1,130 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/extensions/volumeactions"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestAttach(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAttachResponse(t)
+
+ options := &volumeactions.AttachOpts{
+ MountPoint: "/mnt",
+ Mode: "rw",
+ InstanceUUID: "50902f4f-a974-46a0-85e9-7efc5e22dfdd",
+ }
+ err := volumeactions.Attach(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestBeginDetaching(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockBeginDetachingResponse(t)
+
+ err := volumeactions.BeginDetaching(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDetach(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDetachResponse(t)
+
+ err := volumeactions.Detach(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", &volumeactions.DetachOpts{}).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestUploadImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ MockUploadImageResponse(t)
+ options := &volumeactions.UploadImageOpts{
+ ContainerFormat: "bare",
+ DiskFormat: "raw",
+ ImageName: "test",
+ Force: true,
+ }
+
+ err := volumeactions.UploadImage(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestReserve(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockReserveResponse(t)
+
+ err := volumeactions.Reserve(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestUnreserve(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUnreserveResponse(t)
+
+ err := volumeactions.Unreserve(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestInitializeConnection(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockInitializeConnectionResponse(t)
+
+ options := &volumeactions.InitializeConnectionOpts{
+ IP: "127.0.0.1",
+ Host: "stack",
+ Initiator: "iqn.1994-05.com.redhat:17cf566367d2",
+ Multipath: gophercloud.Disabled,
+ Platform: "x86_64",
+ OSType: "linux2",
+ }
+ _, err := volumeactions.InitializeConnection(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestTerminateConnection(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockTerminateConnectionResponse(t)
+
+ options := &volumeactions.TerminateConnectionOpts{
+ IP: "127.0.0.1",
+ Host: "stack",
+ Initiator: "iqn.1994-05.com.redhat:17cf566367d2",
+ Multipath: gophercloud.Enabled,
+ Platform: "x86_64",
+ OSType: "linux2",
+ }
+ err := volumeactions.TerminateConnection(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestExtendSize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockExtendSizeResponse(t)
+
+ options := &volumeactions.ExtendSizeOpts{
+ NewSize: 3,
+ }
+
+ err := volumeactions.ExtendSize(client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/blockstorage/extensions/volumeactions/urls.go b/openstack/blockstorage/extensions/volumeactions/urls.go
new file mode 100644
index 0000000..5efd2b2
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumeactions/urls.go
@@ -0,0 +1,39 @@
+package volumeactions
+
+import "github.com/gophercloud/gophercloud"
+
+func attachURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("volumes", id, "action")
+}
+
+func beginDetachingURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func detachURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func uploadURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func reserveURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func unreserveURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func initializeConnectionURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func teminateConnectionURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
+
+func extendSizeURL(c *gophercloud.ServiceClient, id string) string {
+ return attachURL(c, id)
+}
diff --git a/openstack/blockstorage/extensions/volumetenants/results.go b/openstack/blockstorage/extensions/volumetenants/results.go
new file mode 100644
index 0000000..b7d51c7
--- /dev/null
+++ b/openstack/blockstorage/extensions/volumetenants/results.go
@@ -0,0 +1,12 @@
+package volumetenants
+
+// VolumeExt is an extension to the base Volume object
+type VolumeExt struct {
+ // TenantID is the id of the project that owns the volume.
+ TenantID string `json:"os-vol-tenant-attr:tenant_id"`
+}
+
+// UnmarshalJSON to override default
+func (r *VolumeExt) UnmarshalJSON(b []byte) error {
+ return 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..725c13a
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/requests.go
@@ -0,0 +1,20 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, v), &r.Body, nil)
+ return
+}
diff --git a/openstack/blockstorage/v1/apiversions/results.go b/openstack/blockstorage/v1/apiversions/results.go
new file mode 100644
index 0000000..f510c6d
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/results.go
@@ -0,0 +1,49 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Cinder.
+type APIVersion struct {
+ ID string `json:"id"` // unique identifier
+ Status string `json:"status"` // current status
+ Updated string `json:"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)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]APIVersion, error) {
+ var s struct {
+ Versions []APIVersion `json:"versions"`
+ }
+ err := (r.(APIVersionPage)).ExtractInto(&s)
+ return s.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 s struct {
+ Version *APIVersion `json:"version"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Version, err
+}
diff --git a/openstack/blockstorage/v1/apiversions/testing/doc.go b/openstack/blockstorage/v1/apiversions/testing/doc.go
new file mode 100644
index 0000000..12e4bda
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/testing/doc.go
@@ -0,0 +1,2 @@
+// apiversions_v1
+package testing
diff --git a/openstack/blockstorage/v1/apiversions/testing/fixtures.go b/openstack/blockstorage/v1/apiversions/testing/fixtures.go
new file mode 100644
index 0000000..885fdf6
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/testing/fixtures.go
@@ -0,0 +1,91 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(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", 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"
+ }
+ ]
+ }
+ ]
+ }`)
+ })
+}
+
+func MockGetResponse(t *testing.T) {
+ 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"
+ }
+ ]
+ }
+ }`)
+ })
+}
diff --git a/openstack/blockstorage/v1/apiversions/testing/requests_test.go b/openstack/blockstorage/v1/apiversions/testing/requests_test.go
new file mode 100644
index 0000000..3103497
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/testing/requests_test.go
@@ -0,0 +1,64 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/apiversions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ apiversions.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := apiversions.ExtractAPIVersions(page)
+ th.AssertNoErr(t, err)
+
+ expected := []apiversions.APIVersion{
+ {
+ ID: "v1.0",
+ Status: "CURRENT",
+ Updated: "2012-01-04T11:33:21Z",
+ },
+ {
+ ID: "v2.0",
+ Status: "CURRENT",
+ Updated: "2012-11-21T11:33:21Z",
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertEquals(t, 1, count)
+}
+
+func TestAPIInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ actual, err := apiversions.Get(client.ServiceClient(), "v1").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := apiversions.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/urls.go b/openstack/blockstorage/v1/apiversions/urls.go
new file mode 100644
index 0000000..d1861ac
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/urls.go
@@ -0,0 +1,18 @@
+package apiversions
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+func getURL(c *gophercloud.ServiceClient, version string) string {
+ return c.ServiceURL(strings.TrimRight(version, "/") + "/")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ u, _ := url.Parse(c.ServiceURL(""))
+ u.Path = "/"
+ return u.String()
+}
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/requests.go b/openstack/blockstorage/v1/snapshots/requests.go
new file mode 100644
index 0000000..cb9d0d0
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/requests.go
@@ -0,0 +1,158 @@
+package snapshots
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ VolumeID string `json:"volume_id" required:"true"`
+ Description string `json:"display_description,omitempty"`
+ Force bool `json:"force,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ Name string `json:"display_name,omitempty"`
+}
+
+// ToSnapshotCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToSnapshotCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "snapshot")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToSnapshotCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete will delete the existing Snapshot with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return SnapshotPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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{} `json:"metadata,omitempty"`
+}
+
+// ToSnapshotUpdateMetadataMap assembles a request body based on the contents of
+// an UpdateMetadataOpts.
+func (opts UpdateMetadataOpts) ToSnapshotUpdateMetadataMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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) (r UpdateMetadataResult) {
+ b, err := opts.ToSnapshotUpdateMetadataMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateMetadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// IDFromName is a convienience function that returns a snapshot's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractSnapshots(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "snapshot"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "snapshot"}
+ }
+}
diff --git a/openstack/blockstorage/v1/snapshots/results.go b/openstack/blockstorage/v1/snapshots/results.go
new file mode 100644
index 0000000..2bcb74f
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/results.go
@@ -0,0 +1,110 @@
+package snapshots
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Snapshot contains all the information associated with an OpenStack Snapshot.
+type Snapshot struct {
+ // Currect status of the Snapshot.
+ Status string `json:"status"`
+
+ // Display name.
+ Name string `json:"display_name"`
+
+ // Instances onto which the Snapshot is attached.
+ Attachments []string `json:"attachments"`
+
+ // Logical group.
+ AvailabilityZone string `json:"availability_zone"`
+
+ // Is the Snapshot bootable?
+ Bootable string `json:"bootable"`
+
+ // Date created.
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+
+ // Display description.
+ Description string `json:"display_description"`
+
+ // See VolumeType object for more information.
+ VolumeType string `json:"volume_type"`
+
+ // ID of the Snapshot from which this Snapshot was created.
+ SnapshotID string `json:"snapshot_id"`
+
+ // ID of the Volume from which this Snapshot was created.
+ VolumeID string `json:"volume_id"`
+
+ // User-defined key-value pairs.
+ Metadata map[string]string `json:"metadata"`
+
+ // Unique identifier.
+ ID string `json:"id"`
+
+ // Size of the Snapshot, in GB.
+ Size int `json:"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
+}
+
+// SnapshotPage is a pagination.Pager that is returned from a call to the List function.
+type SnapshotPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a SnapshotPage contains no Snapshots.
+func (r SnapshotPage) IsEmpty() (bool, error) {
+ volumes, err := ExtractSnapshots(r)
+ return len(volumes) == 0, err
+}
+
+// ExtractSnapshots extracts and returns Snapshots. It is used while iterating over a snapshots.List call.
+func ExtractSnapshots(r pagination.Page) ([]Snapshot, error) {
+ var s struct {
+ Snapshots []Snapshot `json:"snapshots"`
+ }
+ err := (r.(SnapshotPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Snapshot *Snapshot `json:"snapshot"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Snapshot, err
+}
diff --git a/openstack/blockstorage/v1/snapshots/testing/doc.go b/openstack/blockstorage/v1/snapshots/testing/doc.go
new file mode 100644
index 0000000..85c45f4
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/testing/doc.go
@@ -0,0 +1,2 @@
+// snapshots_v1
+package testing
diff --git a/openstack/blockstorage/v1/snapshots/testing/fixtures.go b/openstack/blockstorage/v1/snapshots/testing/fixtures.go
new file mode 100644
index 0000000..21be6f9
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/testing/fixtures.go
@@ -0,0 +1,134 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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",
+ "volume_id": "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+ "display_description": "Daily Backup",
+ "status": "available",
+ "size": 30,
+ "created_at": "2012-02-14T20:53:07"
+ },
+ {
+ "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+ "display_name": "snapshot-002",
+ "volume_id": "76b8950a-8594-4e5b-8dce-0dfa9c696358",
+ "display_description": "Weekly Backup",
+ "status": "available",
+ "size": 25,
+ "created_at": "2012-02-14T20:53:08"
+ }
+ ]
+ }
+ `)
+ })
+}
+
+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": {
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "display_name": "snapshot-001",
+ "display_description": "Daily backup",
+ "volume_id": "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+ "status": "available",
+ "size": 30,
+ "created_at": "2012-02-29T03:50:07"
+ }
+}
+ `)
+ })
+}
+
+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",
+ "display_description": "Daily backup",
+ "volume_id": "1234",
+ "status": "available",
+ "size": 30,
+ "created_at": "2012-02-29T03:50:07"
+ }
+}
+ `)
+ })
+}
+
+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/testing/requests_test.go b/openstack/blockstorage/v1/snapshots/testing/requests_test.go
new file mode 100644
index 0000000..ad7e0bb
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/testing/requests_test.go
@@ -0,0 +1,117 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/snapshots"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ snapshots.List(client.ServiceClient(), &snapshots.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := snapshots.ExtractSnapshots(page)
+ if err != nil {
+ t.Errorf("Failed to extract snapshots: %v", err)
+ return false, err
+ }
+
+ expected := []snapshots.Snapshot{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "snapshot-001",
+ VolumeID: "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+ Status: "available",
+ Size: 30,
+ CreatedAt: gophercloud.JSONRFC3339MilliNoZ(time.Date(2012, 2, 14, 20, 53, 7, 0, time.UTC)),
+ Description: "Daily Backup",
+ },
+ {
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "snapshot-002",
+ VolumeID: "76b8950a-8594-4e5b-8dce-0dfa9c696358",
+ Status: "available",
+ Size: 25,
+ CreatedAt: gophercloud.JSONRFC3339MilliNoZ(time.Date(2012, 2, 14, 20, 53, 8, 0, time.UTC)),
+ Description: "Weekly Backup",
+ },
+ }
+
+ 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 := snapshots.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 := snapshots.CreateOpts{VolumeID: "1234", Name: "snapshot-001"}
+ n, err := snapshots.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 := &snapshots.UpdateMetadataOpts{
+ Metadata: map[string]interface{}{
+ "key": "v1",
+ },
+ }
+
+ actual, err := snapshots.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 := snapshots.Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/blockstorage/v1/snapshots/urls.go b/openstack/blockstorage/v1/snapshots/urls.go
new file mode 100644
index 0000000..7780437
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/urls.go
@@ -0,0 +1,27 @@
+package snapshots
+
+import "github.com/gophercloud/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/util.go b/openstack/blockstorage/v1/snapshots/util.go
new file mode 100644
index 0000000..40fbb82
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/util.go
@@ -0,0 +1,22 @@
+package snapshots
+
+import (
+ "github.com/gophercloud/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..566def5
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/requests.go
@@ -0,0 +1,167 @@
+package volumes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ Size int `json:"size" required:"true"`
+ AvailabilityZone string `json:"availability_zone,omitempty"`
+ Description string `json:"display_description,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ Name string `json:"display_name,omitempty"`
+ SnapshotID string `json:"snapshot_id,omitempty"`
+ SourceVolID string `json:"source_volid,omitempty"`
+ ImageID string `json:"imageRef,omitempty"`
+ VolumeType string `json:"volume_type,omitempty"`
+}
+
+// ToVolumeCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToVolumeCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete will delete the existing Volume with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// 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:"display_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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return VolumePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 {
+ Name string `json:"display_name,omitempty"`
+ Description string `json:"display_description,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// ToVolumeUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// 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) (r UpdateResult) {
+ b, err := opts.ToVolumeUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractVolumes(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "volume"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "volume"}
+ }
+}
diff --git a/openstack/blockstorage/v1/volumes/results.go b/openstack/blockstorage/v1/volumes/results.go
new file mode 100644
index 0000000..5c954bf
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/results.go
@@ -0,0 +1,89 @@
+package volumes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Volume contains all the information associated with an OpenStack Volume.
+type Volume struct {
+ // Current status of the volume.
+ Status string `json:"status"`
+ // Human-readable display name for the volume.
+ Name string `json:"display_name"`
+ // Instances onto which the volume is attached.
+ Attachments []map[string]interface{} `json:"attachments"`
+ // This parameter is no longer used.
+ AvailabilityZone string `json:"availability_zone"`
+ // Indicates whether this is a bootable volume.
+ Bootable string `json:"bootable"`
+ // The date when this volume was created.
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ // Human-readable description for the volume.
+ Description string `json:"display_description"`
+ // The type of volume to create, either SATA or SSD.
+ VolumeType string `json:"volume_type"`
+ // The ID of the snapshot from which the volume was created
+ SnapshotID string `json:"snapshot_id"`
+ // The ID of another block storage volume from which the current volume was created
+ SourceVolID string `json:"source_volid"`
+ // Arbitrary key-value pairs defined by the user.
+ Metadata map[string]string `json:"metadata"`
+ // Unique identifier for the volume.
+ ID string `json:"id"`
+ // Size of the volume in GB.
+ Size int `json:"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
+}
+
+// VolumePage is a pagination.pager that is returned from a call to the List function.
+type VolumePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a VolumePage contains no Volumes.
+func (r VolumePage) IsEmpty() (bool, error) {
+ volumes, err := ExtractVolumes(r)
+ return len(volumes) == 0, err
+}
+
+// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call.
+func ExtractVolumes(r pagination.Page) ([]Volume, error) {
+ var s struct {
+ Volumes []Volume `json:"volumes"`
+ }
+ err := (r.(VolumePage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Volume *Volume `json:"volume"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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..088e43c
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/doc.go
@@ -0,0 +1,9 @@
+// volumes_v1
+package testing
+
+/*
+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
+*/
diff --git a/openstack/blockstorage/v1/volumes/testing/fixtures.go b/openstack/blockstorage/v1/volumes/testing/fixtures.go
new file mode 100644
index 0000000..306901b
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/fixtures.go
@@ -0,0 +1,127 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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": {
+ "id": "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+ "display_name": "vol-001",
+ "display_description": "Another volume.",
+ "status": "active",
+ "size": 30,
+ "volume_type": "289da7f8-6440-407c-9fb4-7db01ec49164",
+ "metadata": {
+ "contents": "junk"
+ },
+ "availability_zone": "us-east1",
+ "bootable": "false",
+ "snapshot_id": null,
+ "attachments": [
+ {
+ "attachment_id": "03987cd1-0ad5-40d1-9b2a-7cc48295d4fa",
+ "id": "47e9ecc5-4045-4ee3-9a4b-d859d546a0cf",
+ "volume_id": "6c80f8ac-e3e2-480c-8e6e-f1db92fe4bfe",
+ "server_id": "d1c4788b-9435-42e2-9b81-29f3be1cd01f",
+ "host_name": "mitaka",
+ "device": "/"
+ }
+ ],
+ "created_at": "2012-02-14T20:53:07"
+ }
+ }
+ `)
+ })
+}
+
+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,
+ "availability_zone": "us-east1"
+ }
+}
+ `)
+
+ 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/testing/requests_test.go b/openstack/blockstorage/v1/volumes/testing/requests_test.go
new file mode 100644
index 0000000..6e2516c
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/requests_test.go
@@ -0,0 +1,153 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ volumes.List(client.ServiceClient(), &volumes.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := volumes.ExtractVolumes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volumes: %v", err)
+ return false, err
+ }
+
+ expected := []volumes.Volume{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ },
+ {
+ 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()
+
+ MockListResponse(t)
+
+ allPages, err := volumes.List(client.ServiceClient(), &volumes.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := volumes.ExtractVolumes(allPages)
+ th.AssertNoErr(t, err)
+
+ expected := []volumes.Volume{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ },
+ {
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ actual, err := volumes.Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &volumes.Volume{
+ Status: "active",
+ Name: "vol-001",
+ Attachments: []map[string]interface{}{
+ {
+ "attachment_id": "03987cd1-0ad5-40d1-9b2a-7cc48295d4fa",
+ "id": "47e9ecc5-4045-4ee3-9a4b-d859d546a0cf",
+ "volume_id": "6c80f8ac-e3e2-480c-8e6e-f1db92fe4bfe",
+ "server_id": "d1c4788b-9435-42e2-9b81-29f3be1cd01f",
+ "host_name": "mitaka",
+ "device": "/",
+ },
+ },
+ AvailabilityZone: "us-east1",
+ Bootable: "false",
+ CreatedAt: gophercloud.JSONRFC3339MilliNoZ(time.Date(2012, 2, 14, 20, 53, 07, 0, time.UTC)),
+ Description: "Another volume.",
+ VolumeType: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ SnapshotID: "",
+ SourceVolID: "",
+ Metadata: map[string]string{
+ "contents": "junk",
+ },
+ ID: "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+ Size: 30,
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := &volumes.CreateOpts{
+ Size: 75,
+ AvailabilityZone: "us-east1",
+ }
+ n, err := volumes.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()
+
+ MockDeleteResponse(t)
+
+ res := volumes.Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUpdateResponse(t)
+
+ options := volumes.UpdateOpts{Name: "vol-002"}
+ v, err := volumes.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/urls.go b/openstack/blockstorage/v1/volumes/urls.go
new file mode 100644
index 0000000..8a00f97
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/urls.go
@@ -0,0 +1,23 @@
+package volumes
+
+import "github.com/gophercloud/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/util.go b/openstack/blockstorage/v1/volumes/util.go
new file mode 100644
index 0000000..e86c1b4
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/util.go
@@ -0,0 +1,22 @@
+package volumes
+
+import (
+ "github.com/gophercloud/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/requests.go b/openstack/blockstorage/v1/volumetypes/requests.go
new file mode 100644
index 0000000..b95c09a
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/requests.go
@@ -0,0 +1,59 @@
+package volumetypes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ // See VolumeType.
+ ExtraSpecs map[string]interface{} `json:"extra_specs,omitempty"`
+ // See VolumeType.
+ Name string `json:"name,omitempty"`
+}
+
+// ToVolumeTypeCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToVolumeTypeCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volume_type")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToVolumeTypeCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete will delete the volume type with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// List returns all volume types.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return VolumeTypePage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/blockstorage/v1/volumetypes/results.go b/openstack/blockstorage/v1/volumetypes/results.go
new file mode 100644
index 0000000..2c31238
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/results.go
@@ -0,0 +1,61 @@
+package volumetypes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// VolumeType contains all information associated with an OpenStack Volume Type.
+type VolumeType struct {
+ ExtraSpecs map[string]interface{} `json:"extra_specs"` // user-defined metadata
+ ID string `json:"id"` // unique identifier
+ Name string `json:"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
+}
+
+// VolumeTypePage is a pagination.Pager that is returned from a call to the List function.
+type VolumeTypePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a VolumeTypePage contains no Volume Types.
+func (r VolumeTypePage) IsEmpty() (bool, error) {
+ volumeTypes, err := ExtractVolumeTypes(r)
+ return len(volumeTypes) == 0, err
+}
+
+// ExtractVolumeTypes extracts and returns Volume Types.
+func ExtractVolumeTypes(r pagination.Page) ([]VolumeType, error) {
+ var s struct {
+ VolumeTypes []VolumeType `json:"volume_types"`
+ }
+ err := (r.(VolumeTypePage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ VolumeType *VolumeType `json:"volume_type"`
+ }
+ err := r.ExtractInto(&s)
+ return s.VolumeType, err
+}
diff --git a/openstack/blockstorage/v1/volumetypes/testing/doc.go b/openstack/blockstorage/v1/volumetypes/testing/doc.go
new file mode 100644
index 0000000..73834ed
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/testing/doc.go
@@ -0,0 +1,2 @@
+// volumetypes_v1
+package testing
diff --git a/openstack/blockstorage/v1/volumetypes/testing/fixtures.go b/openstack/blockstorage/v1/volumetypes/testing/fixtures.go
new file mode 100644
index 0000000..0e2715a
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/testing/fixtures.go
@@ -0,0 +1,60 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/blockstorage/v1/volumetypes/testing/requests_test.go
new file mode 100644
index 0000000..4244615
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/testing/requests_test.go
@@ -0,0 +1,119 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumetypes"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ volumetypes.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := volumetypes.ExtractVolumeTypes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volume types: %v", err)
+ return false, err
+ }
+
+ expected := []volumetypes.VolumeType{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-type-001",
+ ExtraSpecs: map[string]interface{}{
+ "capabilities": "gpu",
+ },
+ },
+ {
+ 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 := volumetypes.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 := &volumetypes.CreateOpts{Name: "vol-type-001"}
+ n, err := volumetypes.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 := volumetypes.Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/blockstorage/v1/volumetypes/urls.go b/openstack/blockstorage/v1/volumetypes/urls.go
new file mode 100644
index 0000000..822c7dd
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/urls.go
@@ -0,0 +1,19 @@
+package volumetypes
+
+import "github.com/gophercloud/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/v2/volumes/doc.go b/openstack/blockstorage/v2/volumes/doc.go
new file mode 100644
index 0000000..307b8b1
--- /dev/null
+++ b/openstack/blockstorage/v2/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/v2/volumes/requests.go b/openstack/blockstorage/v2/volumes/requests.go
new file mode 100644
index 0000000..18c9cb2
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/requests.go
@@ -0,0 +1,182 @@
+package volumes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ // The size of the volume, in GB
+ Size int `json:"size" required:"true"`
+ // The availability zone
+ AvailabilityZone string `json:"availability_zone,omitempty"`
+ // ConsistencyGroupID is the ID of a consistency group
+ ConsistencyGroupID string `json:"consistencygroup_id,omitempty"`
+ // The volume description
+ Description string `json:"description,omitempty"`
+ // One or more metadata key and value pairs to associate with the volume
+ Metadata map[string]string `json:"metadata,omitempty"`
+ // The volume name
+ Name string `json:"name,omitempty"`
+ // the ID of the existing volume snapshot
+ SnapshotID string `json:"snapshot_id,omitempty"`
+ // SourceReplica is a UUID of an existing volume to replicate with
+ SourceReplica string `json:"source_replica,omitempty"`
+ // the ID of the existing volume
+ SourceVolID string `json:"source_volid,omitempty"`
+ // The ID of the image from which you want to create the volume.
+ // Required to create a bootable volume.
+ ImageID string `json:"imageRef,omitempty"`
+ // The associated volume type
+ VolumeType string `json:"volume_type,omitempty"`
+}
+
+// ToVolumeCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToVolumeCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ return
+}
+
+// Delete will delete the existing Volume with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return VolumePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 {
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// ToVolumeUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// 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) (r UpdateResult) {
+ b, err := opts.ToVolumeUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractVolumes(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "volume"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "volume"}
+ }
+}
diff --git a/openstack/blockstorage/v2/volumes/results.go b/openstack/blockstorage/v2/volumes/results.go
new file mode 100644
index 0000000..674ec34
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/results.go
@@ -0,0 +1,154 @@
+package volumes
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type Attachment struct {
+ AttachedAt time.Time `json:"-"`
+ AttachmentID string `json:"attachment_id"`
+ Device string `json:"device"`
+ HostName string `json:"host_name"`
+ ID string `json:"id"`
+ ServerID string `json:"server_id"`
+ VolumeID string `json:"volume_id"`
+}
+
+func (r *Attachment) UnmarshalJSON(b []byte) error {
+ type tmp Attachment
+ var s struct {
+ tmp
+ AttachedAt gophercloud.JSONRFC3339MilliNoZ `json:"attached_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Attachment(s.tmp)
+
+ r.AttachedAt = time.Time(s.AttachedAt)
+
+ return err
+}
+
+// Volume contains all the information associated with an OpenStack Volume.
+type Volume struct {
+ // Unique identifier for the volume.
+ ID string `json:"id"`
+ // Current status of the volume.
+ Status string `json:"status"`
+ // Size of the volume in GB.
+ Size int `json:"size"`
+ // AvailabilityZone is which availability zone the volume is in.
+ AvailabilityZone string `json:"availability_zone"`
+ // The date when this volume was created.
+ CreatedAt time.Time `json:"-"`
+ // The date when this volume was last updated
+ UpdatedAt time.Time `json:"-"`
+ // Instances onto which the volume is attached.
+ Attachments []Attachment `json:"attachments"`
+ // Human-readable display name for the volume.
+ Name string `json:"name"`
+ // Human-readable description for the volume.
+ Description string `json:"description"`
+ // The type of volume to create, either SATA or SSD.
+ VolumeType string `json:"volume_type"`
+ // The ID of the snapshot from which the volume was created
+ SnapshotID string `json:"snapshot_id"`
+ // The ID of another block storage volume from which the current volume was created
+ SourceVolID string `json:"source_volid"`
+ // Arbitrary key-value pairs defined by the user.
+ Metadata map[string]string `json:"metadata"`
+ // UserID is the id of the user who created the volume.
+ UserID string `json:"user_id"`
+ // Indicates whether this is a bootable volume.
+ Bootable string `json:"bootable"`
+ // Encrypted denotes if the volume is encrypted.
+ Encrypted bool `json:"encrypted"`
+ // ReplicationStatus is the status of replication.
+ ReplicationStatus string `json:"replication_status"`
+ // ConsistencyGroupID is the consistency group ID.
+ ConsistencyGroupID string `json:"consistencygroup_id"`
+ // Multiattach denotes if the volume is multi-attach capable.
+ Multiattach bool `json:"multiattach"`
+}
+
+func (r *Volume) UnmarshalJSON(b []byte) error {
+ type tmp Volume
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Volume(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+ r.UpdatedAt = time.Time(s.UpdatedAt)
+
+ return err
+}
+
+// VolumePage is a pagination.pager that is returned from a call to the List function.
+type VolumePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Volumes.
+func (r VolumePage) IsEmpty() (bool, error) {
+ volumes, err := ExtractVolumes(r)
+ return len(volumes) == 0, err
+}
+
+// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call.
+func ExtractVolumes(r pagination.Page) ([]Volume, error) {
+ var s []Volume
+ err := ExtractVolumesInto(r, &s)
+ return s, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Volume object out of the commonResult object.
+func (r commonResult) Extract() (*Volume, error) {
+ var s Volume
+ err := r.ExtractInto(&s)
+ return &s, err
+}
+
+func (r commonResult) ExtractInto(v interface{}) error {
+ return r.Result.ExtractIntoStructPtr(v, "volume")
+}
+
+func ExtractVolumesInto(r pagination.Page, v interface{}) error {
+ return r.(VolumePage).Result.ExtractIntoSlicePtr(v, "volumes")
+}
+
+// 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
+}
+
+// UpdateResult contains the response body and error from an Update request.
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/blockstorage/v2/volumes/testing/doc.go b/openstack/blockstorage/v2/volumes/testing/doc.go
new file mode 100644
index 0000000..aa8351a
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/testing/doc.go
@@ -0,0 +1,2 @@
+// volumes_v2
+package testing
diff --git a/openstack/blockstorage/v2/volumes/testing/fixtures.go b/openstack/blockstorage/v2/volumes/testing/fixtures.go
new file mode 100644
index 0000000..44d2ca3
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/testing/fixtures.go
@@ -0,0 +1,203 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/volumes/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")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+ {
+ "volumes": [
+ {
+ "volume_type": "lvmdriver-1",
+ "created_at": "2015-09-17T03:35:03.000000",
+ "bootable": "false",
+ "name": "vol-001",
+ "os-vol-mig-status-attr:name_id": null,
+ "consistencygroup_id": null,
+ "source_volid": null,
+ "os-volume-replication:driver_data": null,
+ "multiattach": false,
+ "snapshot_id": null,
+ "replication_status": "disabled",
+ "os-volume-replication:extended_status": null,
+ "encrypted": false,
+ "os-vol-host-attr:host": null,
+ "availability_zone": "nova",
+ "attachments": [{
+ "server_id": "83ec2e3b-4321-422b-8706-a84185f52a0a",
+ "attachment_id": "05551600-a936-4d4a-ba42-79a037c1-c91a",
+ "attached_at": "2016-08-06T14:48:20.000000",
+ "host_name": "foobar",
+ "volume_id": "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ "device": "/dev/vdc",
+ "id": "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75"
+ }],
+ "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+ "size": 75,
+ "user_id": "ff1ce52c03ab433aaba9108c2e3ef541",
+ "os-vol-tenant-attr:tenant_id": "304dc00909ac4d0da6c62d816bcb3459",
+ "os-vol-mig-status-attr:migstat": null,
+ "metadata": {"foo": "bar"},
+ "status": "available",
+ "description": null
+ },
+ {
+ "volume_type": "lvmdriver-1",
+ "created_at": "2015-09-17T03:32:29.000000",
+ "bootable": "false",
+ "name": "vol-002",
+ "os-vol-mig-status-attr:name_id": null,
+ "consistencygroup_id": null,
+ "source_volid": null,
+ "os-volume-replication:driver_data": null,
+ "multiattach": false,
+ "snapshot_id": null,
+ "replication_status": "disabled",
+ "os-volume-replication:extended_status": null,
+ "encrypted": false,
+ "os-vol-host-attr:host": null,
+ "availability_zone": "nova",
+ "attachments": [],
+ "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+ "size": 75,
+ "user_id": "ff1ce52c03ab433aaba9108c2e3ef541",
+ "os-vol-tenant-attr:tenant_id": "304dc00909ac4d0da6c62d816bcb3459",
+ "os-vol-mig-status-attr:migstat": null,
+ "metadata": {},
+ "status": "available",
+ "description": null
+ }
+ ]
+}
+ `)
+ })
+}
+
+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": {
+ "volume_type": "lvmdriver-1",
+ "created_at": "2015-09-17T03:32:29.000000",
+ "bootable": "false",
+ "name": "vol-001",
+ "os-vol-mig-status-attr:name_id": null,
+ "consistencygroup_id": null,
+ "source_volid": null,
+ "os-volume-replication:driver_data": null,
+ "multiattach": false,
+ "snapshot_id": null,
+ "replication_status": "disabled",
+ "os-volume-replication:extended_status": null,
+ "encrypted": false,
+ "os-vol-host-attr:host": null,
+ "availability_zone": "nova",
+ "attachments": [{
+ "server_id": "83ec2e3b-4321-422b-8706-a84185f52a0a",
+ "attachment_id": "05551600-a936-4d4a-ba42-79a037c1-c91a",
+ "attached_at": "2016-08-06T14:48:20.000000",
+ "host_name": "foobar",
+ "volume_id": "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ "device": "/dev/vdc",
+ "id": "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75"
+ }],
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "size": 75,
+ "user_id": "ff1ce52c03ab433aaba9108c2e3ef541",
+ "os-vol-tenant-attr:tenant_id": "304dc00909ac4d0da6c62d816bcb3459",
+ "os-vol-mig-status-attr:migstat": null,
+ "metadata": {},
+ "status": "available",
+ "description": null
+ }
+}
+ `)
+ })
+}
+
+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": {
+ "name": "vol-001",
+ "size": 75
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "volume": {
+ "size": 75,
+ "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ "metadata": {},
+ "created_at": "2015-09-17T03:32:29.044216",
+ "encrypted": false,
+ "bootable": "false",
+ "availability_zone": "nova",
+ "attachments": [],
+ "user_id": "ff1ce52c03ab433aaba9108c2e3ef541",
+ "status": "creating",
+ "description": null,
+ "volume_type": "lvmdriver-1",
+ "name": "vol-001",
+ "replication_status": "disabled",
+ "consistencygroup_id": null,
+ "source_volid": null,
+ "snapshot_id": null,
+ "multiattach": false
+ }
+}
+ `)
+ })
+}
+
+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.StatusAccepted)
+ })
+}
+
+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": {
+ "name": "vol-002"
+ }
+}
+ `)
+ })
+}
diff --git a/openstack/blockstorage/v2/volumes/testing/requests_test.go b/openstack/blockstorage/v2/volumes/testing/requests_test.go
new file mode 100644
index 0000000..0a18544
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/testing/requests_test.go
@@ -0,0 +1,257 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/extensions/volumetenants"
+ "github.com/gophercloud/gophercloud/openstack/blockstorage/v2/volumes"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListWithExtensions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ count := 0
+
+ volumes.List(client.ServiceClient(), &volumes.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := volumes.ExtractVolumes(page)
+ if err != nil {
+ t.Errorf("Failed to extract volumes: %v", err)
+ return false, err
+ }
+
+ expected := []volumes.Volume{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ Attachments: []volumes.Attachment{{
+ ServerID: "83ec2e3b-4321-422b-8706-a84185f52a0a",
+ AttachmentID: "05551600-a936-4d4a-ba42-79a037c1-c91a",
+ AttachedAt: time.Date(2016, 8, 6, 14, 48, 20, 0, time.UTC),
+ HostName: "foobar",
+ VolumeID: "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ Device: "/dev/vdc",
+ ID: "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ }},
+ AvailabilityZone: "nova",
+ Bootable: "false",
+ ConsistencyGroupID: "",
+ CreatedAt: time.Date(2015, 9, 17, 3, 35, 3, 0, time.UTC),
+ Description: "",
+ Encrypted: false,
+ Metadata: map[string]string{"foo": "bar"},
+ Multiattach: false,
+ //TenantID: "304dc00909ac4d0da6c62d816bcb3459",
+ //ReplicationDriverData: "",
+ //ReplicationExtendedStatus: "",
+ ReplicationStatus: "disabled",
+ Size: 75,
+ SnapshotID: "",
+ SourceVolID: "",
+ Status: "available",
+ UserID: "ff1ce52c03ab433aaba9108c2e3ef541",
+ VolumeType: "lvmdriver-1",
+ },
+ {
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ Attachments: []volumes.Attachment{},
+ AvailabilityZone: "nova",
+ Bootable: "false",
+ ConsistencyGroupID: "",
+ CreatedAt: time.Date(2015, 9, 17, 3, 32, 29, 0, time.UTC),
+ Description: "",
+ Encrypted: false,
+ Metadata: map[string]string{},
+ Multiattach: false,
+ //TenantID: "304dc00909ac4d0da6c62d816bcb3459",
+ //ReplicationDriverData: "",
+ //ReplicationExtendedStatus: "",
+ ReplicationStatus: "disabled",
+ Size: 75,
+ SnapshotID: "",
+ SourceVolID: "",
+ Status: "available",
+ UserID: "ff1ce52c03ab433aaba9108c2e3ef541",
+ VolumeType: "lvmdriver-1",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ if count != 1 {
+ t.Errorf("Expected 1 page, got %d", count)
+ }
+}
+
+func TestListAllWithExtensions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ type VolumeWithExt struct {
+ volumes.Volume
+ volumetenants.VolumeExt
+ }
+
+ allPages, err := volumes.List(client.ServiceClient(), &volumes.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+
+ var actual []VolumeWithExt
+ err = volumes.ExtractVolumesInto(allPages, &actual)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 2, len(actual))
+ th.AssertEquals(t, "304dc00909ac4d0da6c62d816bcb3459", actual[0].TenantID)
+}
+
+func TestListAll(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ allPages, err := volumes.List(client.ServiceClient(), &volumes.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := volumes.ExtractVolumes(allPages)
+ th.AssertNoErr(t, err)
+
+ expected := []volumes.Volume{
+ {
+ ID: "289da7f8-6440-407c-9fb4-7db01ec49164",
+ Name: "vol-001",
+ Attachments: []volumes.Attachment{{
+ ServerID: "83ec2e3b-4321-422b-8706-a84185f52a0a",
+ AttachmentID: "05551600-a936-4d4a-ba42-79a037c1-c91a",
+ AttachedAt: time.Date(2016, 8, 6, 14, 48, 20, 0, time.UTC),
+ HostName: "foobar",
+ VolumeID: "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ Device: "/dev/vdc",
+ ID: "d6cacb1a-8b59-4c88-ad90-d70ebb82bb75",
+ }},
+ AvailabilityZone: "nova",
+ Bootable: "false",
+ ConsistencyGroupID: "",
+ CreatedAt: time.Date(2015, 9, 17, 3, 35, 3, 0, time.UTC),
+ Description: "",
+ Encrypted: false,
+ Metadata: map[string]string{"foo": "bar"},
+ Multiattach: false,
+ //TenantID: "304dc00909ac4d0da6c62d816bcb3459",
+ //ReplicationDriverData: "",
+ //ReplicationExtendedStatus: "",
+ ReplicationStatus: "disabled",
+ Size: 75,
+ SnapshotID: "",
+ SourceVolID: "",
+ Status: "available",
+ UserID: "ff1ce52c03ab433aaba9108c2e3ef541",
+ VolumeType: "lvmdriver-1",
+ },
+ {
+ ID: "96c3bda7-c82a-4f50-be73-ca7621794835",
+ Name: "vol-002",
+ Attachments: []volumes.Attachment{},
+ AvailabilityZone: "nova",
+ Bootable: "false",
+ ConsistencyGroupID: "",
+ CreatedAt: time.Date(2015, 9, 17, 3, 32, 29, 0, time.UTC),
+ Description: "",
+ Encrypted: false,
+ Metadata: map[string]string{},
+ Multiattach: false,
+ //TenantID: "304dc00909ac4d0da6c62d816bcb3459",
+ //ReplicationDriverData: "",
+ //ReplicationExtendedStatus: "",
+ ReplicationStatus: "disabled",
+ Size: 75,
+ SnapshotID: "",
+ SourceVolID: "",
+ Status: "available",
+ UserID: "ff1ce52c03ab433aaba9108c2e3ef541",
+ VolumeType: "lvmdriver-1",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ v, err := volumes.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")
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := &volumes.CreateOpts{Size: 75, Name: "vol-001"}
+ n, err := volumes.Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Size, 75)
+ th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+
+ res := volumes.Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUpdateResponse(t)
+
+ options := volumes.UpdateOpts{Name: "vol-002"}
+ v, err := volumes.Update(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22", options).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "vol-002", v.Name)
+}
+
+func TestGetWithExtensions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ var s struct {
+ volumes.Volume
+ volumetenants.VolumeExt
+ }
+ err := volumes.Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractInto(&s)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "304dc00909ac4d0da6c62d816bcb3459", s.TenantID)
+
+ err = volumes.Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractInto(s)
+ if err == nil {
+ t.Errorf("Expected error when providing non-pointer struct")
+ }
+}
diff --git a/openstack/blockstorage/v2/volumes/urls.go b/openstack/blockstorage/v2/volumes/urls.go
new file mode 100644
index 0000000..1707249
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/urls.go
@@ -0,0 +1,23 @@
+package volumes
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("volumes")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("volumes", "detail")
+}
+
+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/v2/volumes/util.go b/openstack/blockstorage/v2/volumes/util.go
new file mode 100644
index 0000000..e86c1b4
--- /dev/null
+++ b/openstack/blockstorage/v2/volumes/util.go
@@ -0,0 +1,22 @@
+package volumes
+
+import (
+ "github.com/gophercloud/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/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/requests.go b/openstack/cdn/v1/base/requests.go
new file mode 100644
index 0000000..34d3b72
--- /dev/null
+++ b/openstack/cdn/v1/base/requests.go
@@ -0,0 +1,19 @@
+package base
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retrieves the home document, allowing the user to discover the
+// entire API.
+func Get(c *gophercloud.ServiceClient) (r GetResult) {
+ _, r.Err = c.Get(getURL(c), &r.Body, nil)
+ return
+}
+
+// Ping retrieves a ping to the server.
+func Ping(c *gophercloud.ServiceClient) (r PingResult) {
+ _, r.Err = c.Get(pingURL(c), nil, &gophercloud.RequestOpts{
+ OkCodes: []int{204},
+ MoreHeaders: map[string]string{"Accept": ""},
+ })
+ return
+}
diff --git a/openstack/cdn/v1/base/results.go b/openstack/cdn/v1/base/results.go
new file mode 100644
index 0000000..2dfde7d
--- /dev/null
+++ b/openstack/cdn/v1/base/results.go
@@ -0,0 +1,23 @@
+package base
+
+import "github.com/gophercloud/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) {
+ var s HomeDocument
+ err := r.ExtractInto(&s)
+ return &s, err
+}
+
+// PingResult represents the result of a Ping operation.
+type PingResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/base/testing/doc.go b/openstack/cdn/v1/base/testing/doc.go
new file mode 100644
index 0000000..891c69a
--- /dev/null
+++ b/openstack/cdn/v1/base/testing/doc.go
@@ -0,0 +1,2 @@
+// cdn_base_v1
+package testing
diff --git a/openstack/cdn/v1/base/testing/fixtures.go b/openstack/cdn/v1/base/testing/fixtures.go
new file mode 100644
index 0000000..f1f4ac0
--- /dev/null
+++ b/openstack/cdn/v1/base/testing/fixtures.go
@@ -0,0 +1,53 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/cdn/v1/base/testing/requests_test.go
new file mode 100644
index 0000000..cd1209b
--- /dev/null
+++ b/openstack/cdn/v1/base/testing/requests_test.go
@@ -0,0 +1,44 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/cdn/v1/base"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetHomeDocument(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := base.Get(fake.ServiceClient()).Extract()
+ th.CheckNoErr(t, err)
+
+ expected := base.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 := base.Ping(fake.ServiceClient()).ExtractErr()
+ th.CheckNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/base/urls.go b/openstack/cdn/v1/base/urls.go
new file mode 100644
index 0000000..07d892b
--- /dev/null
+++ b/openstack/cdn/v1/base/urls.go
@@ -0,0 +1,11 @@
+package base
+
+import "github.com/gophercloud/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/requests.go b/openstack/cdn/v1/flavors/requests.go
new file mode 100644
index 0000000..1977fe3
--- /dev/null
+++ b/openstack/cdn/v1/flavors/requests.go
@@ -0,0 +1,19 @@
+package flavors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a single page of CDN flavors.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(c, listURL(c), func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get retrieves a specific flavor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/cdn/v1/flavors/results.go b/openstack/cdn/v1/flavors/results.go
new file mode 100644
index 0000000..02c2851
--- /dev/null
+++ b/openstack/cdn/v1/flavors/results.go
@@ -0,0 +1,60 @@
+package flavors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"provider"`
+ // Specifies a list with an href where rel is provider_url.
+ Links []gophercloud.Link `json:"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 `json:"id"`
+ // Specifies the list of providers mapped to this flavor.
+ Providers []Provider `json:"providers"`
+ // Specifies the self-navigating JSON document paths.
+ Links []gophercloud.Link `json:"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)
+ return len(flavors) == 0, err
+}
+
+// ExtractFlavors extracts and returns Flavors. It is used while iterating over
+// a flavors.List call.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+ var s struct {
+ Flavors []Flavor `json:"flavors"`
+ }
+ err := (r.(FlavorPage)).ExtractInto(&s)
+ return s.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) {
+ var s *Flavor
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/cdn/v1/flavors/testing/doc.go b/openstack/cdn/v1/flavors/testing/doc.go
new file mode 100644
index 0000000..567b67e
--- /dev/null
+++ b/openstack/cdn/v1/flavors/testing/doc.go
@@ -0,0 +1,2 @@
+// cdn_flavors_v1
+package testing
diff --git a/openstack/cdn/v1/flavors/testing/fixtures.go b/openstack/cdn/v1/flavors/testing/fixtures.go
new file mode 100644
index 0000000..ed97247
--- /dev/null
+++ b/openstack/cdn/v1/flavors/testing/fixtures.go
@@ -0,0 +1,82 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/cdn/v1/flavors/testing/requests_test.go
new file mode 100644
index 0000000..bc4b1a5
--- /dev/null
+++ b/openstack/cdn/v1/flavors/testing/requests_test.go
@@ -0,0 +1,90 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/cdn/v1/flavors"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListCDNFlavorsSuccessfully(t)
+
+ count := 0
+
+ err := flavors.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := flavors.ExtractFlavors(page)
+ if err != nil {
+ t.Errorf("Failed to extract flavors: %v", err)
+ return false, err
+ }
+
+ expected := []flavors.Flavor{
+ {
+ ID: "europe",
+ Providers: []flavors.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 := &flavors.Flavor{
+ ID: "asia",
+ Providers: []flavors.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 := flavors.Get(fake.ServiceClient(), "asia").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/cdn/v1/flavors/urls.go b/openstack/cdn/v1/flavors/urls.go
new file mode 100644
index 0000000..a8540a2
--- /dev/null
+++ b/openstack/cdn/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/gophercloud/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/requests.go b/openstack/cdn/v1/serviceassets/requests.go
new file mode 100644
index 0000000..80c908f
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/requests.go
@@ -0,0 +1,51 @@
+package serviceassets
+
+import (
+ "strings"
+
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// 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) (r DeleteResult) {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = deleteURL(c, idOrURL)
+ }
+ if opts != nil {
+ q, err := opts.ToCDNAssetDeleteParams()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ url += q
+ }
+ _, r.Err = c.Delete(url, nil)
+ return
+}
diff --git a/openstack/cdn/v1/serviceassets/results.go b/openstack/cdn/v1/serviceassets/results.go
new file mode 100644
index 0000000..b6114c6
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/results.go
@@ -0,0 +1,8 @@
+package serviceassets
+
+import "github.com/gophercloud/gophercloud"
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/serviceassets/testing/doc.go b/openstack/cdn/v1/serviceassets/testing/doc.go
new file mode 100644
index 0000000..1adb681
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/testing/doc.go
@@ -0,0 +1,2 @@
+// cdn_serviceassets_v1
+package testing
diff --git a/openstack/cdn/v1/serviceassets/testing/fixtures.go b/openstack/cdn/v1/serviceassets/testing/fixtures.go
new file mode 100644
index 0000000..3172d30
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/testing/fixtures.go
@@ -0,0 +1,19 @@
+package testing
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/cdn/v1/serviceassets/testing/requests_test.go
new file mode 100644
index 0000000..ff2073b
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/testing/requests_test.go
@@ -0,0 +1,19 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/cdn/v1/serviceassets"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleDeleteCDNAssetSuccessfully(t)
+
+ err := serviceassets.Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", nil).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/serviceassets/urls.go b/openstack/cdn/v1/serviceassets/urls.go
new file mode 100644
index 0000000..ce17418
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/urls.go
@@ -0,0 +1,7 @@
+package serviceassets
+
+import "github.com/gophercloud/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/requests.go b/openstack/cdn/v1/services/requests.go
new file mode 100644
index 0000000..4c0c626
--- /dev/null
+++ b/openstack/cdn/v1/services/requests.go
@@ -0,0 +1,285 @@
+package services
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ p := ServicePage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+}
+
+// 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 {
+ // Specifies the name of the service. The minimum length for name is
+ // 3. The maximum length is 256.
+ Name string `json:"name" required:"true"`
+ // Specifies a list of domains used by users to access their website.
+ Domains []Domain `json:"domains" required:"true"`
+ // Specifies a list of origin domains or IP addresses where the
+ // original assets are stored.
+ Origins []Origin `json:"origins" required:"true"`
+ // 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 `json:"flavor_id" required:"true"`
+ // Specifies the TTL rules for the assets under this service. Supports wildcards for fine-grained control.
+ Caching []CacheRule `json:"caching,omitempty"`
+ // Specifies the restrictions that define who can access assets (content from the CDN cache).
+ Restrictions []Restriction `json:"restrictions,omitempty"`
+}
+
+// ToCDNServiceCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToCDNServiceCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Create accepts a CreateOpts struct and creates a new CDN service using the
+// values provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToCDNServiceCreateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ resp, err := c.Post(createURL(c), &b, nil, nil)
+ r.Header = resp.Header
+ r.Err = err
+ return
+}
+
+// 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) (r GetResult) {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = getURL(c, idOrURL)
+ }
+ _, r.Err = c.Get(url, &r.Body, nil)
+ return
+}
+
+// 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 (opts Insertion) ToCDNServiceUpdateMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "add",
+ "path": opts.Value.renderRootOr(func(p Path) string { return p.renderIndex(opts.Index) }),
+ "value": opts.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 (opts Removal) ToCDNServiceUpdateMap() map[string]interface{} {
+ b := map[string]interface{}{"op": "remove"}
+ if opts.All {
+ b["path"] = opts.Path.renderRoot()
+ } else {
+ b["path"] = opts.Path.renderIndex(opts.Index)
+ }
+ return b
+}
+
+// UpdateOpts is a slice of Patches used to update a CDN service
+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) (r UpdateResult) {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = updateURL(c, idOrURL)
+ }
+
+ b := make([]map[string]interface{}, len(opts))
+ for i, patch := range opts {
+ b[i] = patch.ToCDNServiceUpdateMap()
+ }
+
+ resp, err := c.Request("PATCH", url, &gophercloud.RequestOpts{
+ JSONBody: &b,
+ OkCodes: []int{202},
+ })
+ r.Header = resp.Header
+ r.Err = err
+ return
+}
+
+// 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) (r DeleteResult) {
+ var url string
+ if strings.Contains(idOrURL, "/") {
+ url = idOrURL
+ } else {
+ url = deleteURL(c, idOrURL)
+ }
+ _, r.Err = c.Delete(url, nil)
+ return
+}
diff --git a/openstack/cdn/v1/services/results.go b/openstack/cdn/v1/services/results.go
new file mode 100644
index 0000000..f9a1caa
--- /dev/null
+++ b/openstack/cdn/v1/services/results.go
@@ -0,0 +1,304 @@
+package services
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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 `json:"domain" required:"true"`
+ // Specifies the protocol used to access the assets on this domain. Only "http"
+ // or "https" are currently allowed. The default is "http".
+ Protocol string `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 `json:"name" required:"true"`
+ // Specifies the request URL this rule should match for this origin to be used. Regex is supported.
+ RequestURL string `json:"request_url" required:"true"`
+}
+
+// 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 `json:"origin" required:"true"`
+ // Specifies the port used to access the origin. The default is port 80.
+ Port int `json:"port,omitempty"`
+ // Specifies whether or not to use HTTPS to access the origin. The default
+ // is false.
+ SSL bool `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 `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 `json:"name" required:"true"`
+ // Specifies the request URL this rule should match for this TTL to be used. Regex is supported.
+ RequestURL string `json:"request_url" required:"true"`
+}
+
+// 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 `json:"name" required:"true"`
+ // Specifies the TTL to apply.
+ TTL int `json:"ttl,omitempty"`
+ // Specifies a collection of rules that determine if this TTL should be applied to an asset.
+ Rules []TTLRule `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 `json:"name" required:"true"`
+ // Specifies the http host that requests must come from.
+ Referrer string `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 `json:"name" required:"true"`
+ // Specifies a collection of rules that determine if this TTL should be applied to an asset.
+ Rules []RestrictionRule `json:"rules,omitempty"`
+}
+
+// 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 `json:"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 `json:"id"`
+ // Specifies the name of the service.
+ Name string `json:"name"`
+ // Specifies a list of domains used by users to access their website.
+ Domains []Domain `json:"domains"`
+ // Specifies a list of origin domains or IP addresses where the original assets are stored.
+ Origins []Origin `json:"origins"`
+ // Specifies the TTL rules for the assets under this service. Supports wildcards for fine grained control.
+ Caching []CacheRule `json:"caching"`
+ // Specifies the restrictions that define who can access assets (content from the CDN cache).
+ Restrictions []Restriction `json:"restrictions"`
+ // Specifies the CDN provider flavor ID to use. For a list of flavors, see the operation to list the available flavors.
+ FlavorID string `json:"flavor_id"`
+ // Specifies the current status of the service.
+ Status string `json:"status"`
+ // Specifies the list of errors that occurred during the previous service action.
+ Errors []Error `json:"errors"`
+ // Specifies the self-navigating JSON document paths.
+ Links []gophercloud.Link `json:"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)
+ return len(services) == 0, err
+}
+
+// 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(r pagination.Page) ([]Service, error) {
+ var s struct {
+ Services []Service `json:"services"`
+ }
+ err := (r.(ServicePage)).ExtractInto(&s)
+ return s.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) {
+ var s Service
+ err := r.ExtractInto(&s)
+ return &s, 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/testing/doc.go b/openstack/cdn/v1/services/testing/doc.go
new file mode 100644
index 0000000..c72e391
--- /dev/null
+++ b/openstack/cdn/v1/services/testing/doc.go
@@ -0,0 +1,2 @@
+// cdn_services_v1
+package testing
diff --git a/openstack/cdn/v1/services/testing/fixtures.go b/openstack/cdn/v1/services/testing/fixtures.go
new file mode 100644
index 0000000..d4093e0
--- /dev/null
+++ b/openstack/cdn/v1/services/testing/fixtures.go
@@ -0,0 +1,372 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/cdn/v1/services/testing/requests_test.go
new file mode 100644
index 0000000..0abc98e
--- /dev/null
+++ b/openstack/cdn/v1/services/testing/requests_test.go
@@ -0,0 +1,359 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/cdn/v1/services"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListCDNServiceSuccessfully(t)
+
+ count := 0
+
+ err := services.List(fake.ServiceClient(), &services.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := services.ExtractServices(page)
+ if err != nil {
+ t.Errorf("Failed to extract services: %v", err)
+ return false, err
+ }
+
+ expected := []services.Service{
+ {
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []services.Domain{
+ {
+ Domain: "www.mywebsite.com",
+ },
+ },
+ Origins: []services.Origin{
+ {
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []services.CacheRule{
+ {
+ Name: "default",
+ TTL: 3600,
+ },
+ {
+ Name: "home",
+ TTL: 17200,
+ Rules: []services.TTLRule{
+ {
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ {
+ Name: "images",
+ TTL: 12800,
+ Rules: []services.TTLRule{
+ {
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []services.Restriction{
+ {
+ Name: "website only",
+ Rules: []services.RestrictionRule{
+ {
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "asia",
+ Status: "deployed",
+ Errors: []services.Error{},
+ Links: []gophercloud.Link{
+ {
+ 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: []services.Domain{
+ {
+ Domain: "www.myothersite.com",
+ },
+ },
+ Origins: []services.Origin{
+ {
+ Origin: "44.33.22.11",
+ Port: 80,
+ SSL: false,
+ },
+ {
+ Origin: "77.66.55.44",
+ Port: 80,
+ SSL: false,
+ Rules: []services.OriginRule{
+ {
+ Name: "videos",
+ RequestURL: "^/videos/*.m3u",
+ },
+ },
+ },
+ },
+ Caching: []services.CacheRule{
+ {
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ Restrictions: []services.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 := services.CreateOpts{
+ Name: "mywebsite.com",
+ Domains: []services.Domain{
+ {
+ Domain: "www.mywebsite.com",
+ },
+ {
+ Domain: "blog.mywebsite.com",
+ },
+ },
+ Origins: []services.Origin{
+ {
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Restrictions: []services.Restriction{
+ {
+ Name: "website only",
+ Rules: []services.RestrictionRule{
+ {
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ Caching: []services.CacheRule{
+ {
+ Name: "default",
+ TTL: 3600,
+ },
+ },
+ FlavorID: "cdn",
+ }
+
+ expected := "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+ actual, err := services.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 := &services.Service{
+ ID: "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+ Name: "mywebsite.com",
+ Domains: []services.Domain{
+ {
+ Domain: "www.mywebsite.com",
+ Protocol: "http",
+ },
+ },
+ Origins: []services.Origin{
+ {
+ Origin: "mywebsite.com",
+ Port: 80,
+ SSL: false,
+ },
+ },
+ Caching: []services.CacheRule{
+ {
+ Name: "default",
+ TTL: 3600,
+ },
+ {
+ Name: "home",
+ TTL: 17200,
+ Rules: []services.TTLRule{
+ {
+ Name: "index",
+ RequestURL: "/index.htm",
+ },
+ },
+ },
+ {
+ Name: "images",
+ TTL: 12800,
+ Rules: []services.TTLRule{
+ {
+ Name: "images",
+ RequestURL: "*.png",
+ },
+ },
+ },
+ },
+ Restrictions: []services.Restriction{
+ {
+ Name: "website only",
+ Rules: []services.RestrictionRule{
+ {
+ Name: "mywebsite.com",
+ Referrer: "www.mywebsite.com",
+ },
+ },
+ },
+ },
+ FlavorID: "cdn",
+ Status: "deployed",
+ Errors: []services.Error{},
+ Links: []gophercloud.Link{
+ {
+ 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",
+ },
+ },
+ }
+
+ actual, err := services.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 := services.UpdateOpts{
+ // Append a single Domain
+ services.Append{Value: services.Domain{Domain: "appended.mocksite4.com"}},
+ // Insert a single Domain
+ services.Insertion{
+ Index: 4,
+ Value: services.Domain{Domain: "inserted.mocksite4.com"},
+ },
+ // Bulk addition
+ services.Append{
+ Value: services.DomainList{
+ {Domain: "bulkadded1.mocksite4.com"},
+ {Domain: "bulkadded2.mocksite4.com"},
+ },
+ },
+ // Replace a single Origin
+ services.Replacement{
+ Index: 2,
+ Value: services.Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+ },
+ // Bulk replace Origins
+ services.Replacement{
+ Index: 0, // Ignored
+ Value: services.OriginList{
+ {Origin: "44.33.22.11", Port: 80, SSL: false},
+ {Origin: "55.44.33.22", Port: 443, SSL: true},
+ },
+ },
+ // Remove a single CacheRule
+ services.Removal{
+ Index: 8,
+ Path: services.PathCaching,
+ },
+ // Bulk removal
+ services.Removal{
+ All: true,
+ Path: services.PathCaching,
+ },
+ // Service name replacement
+ services.NameReplacement{
+ NewName: "differentServiceName",
+ },
+ }
+
+ actual, err := services.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 := services.Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/services/urls.go b/openstack/cdn/v1/services/urls.go
new file mode 100644
index 0000000..5bb3ca9
--- /dev/null
+++ b/openstack/cdn/v1/services/urls.go
@@ -0,0 +1,23 @@
+package services
+
+import "github.com/gophercloud/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..2d30cc6
--- /dev/null
+++ b/openstack/client.go
@@ -0,0 +1,336 @@
+package openstack
+
+import (
+ "fmt"
+ "net/url"
+ "reflect"
+
+ "github.com/gophercloud/gophercloud"
+ tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+ "github.com/gophercloud/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{
+ {ID: v20, Priority: 20, Suffix: "/v2.0/"},
+ {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, gophercloud.EndpointOpts{})
+ case v30:
+ return v3auth(client, endpoint, &options, gophercloud.EndpointOpts{})
+ 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, eo gophercloud.EndpointOpts) error {
+ return v2auth(client, "", options, eo)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions, eo gophercloud.EndpointOpts) error {
+ v2Client, err := NewIdentityV2(client, eo)
+ if err != nil {
+ return err
+ }
+
+ if endpoint != "" {
+ v2Client.Endpoint = endpoint
+ }
+
+ v2Opts := tokens2.AuthOptions{
+ IdentityEndpoint: options.IdentityEndpoint,
+ Username: options.Username,
+ Password: options.Password,
+ TenantID: options.TenantID,
+ TenantName: options.TenantName,
+ AllowReauth: options.AllowReauth,
+ TokenID: options.TokenID,
+ }
+
+ result := tokens2.Create(v2Client, v2Opts)
+
+ 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 v2auth(client, endpoint, options, eo)
+ }
+ }
+ 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 tokens3.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
+ return v3auth(client, "", options, eo)
+}
+
+func v3auth(client *gophercloud.ProviderClient, endpoint string, opts tokens3.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
+ // Override the generated service endpoint with the one returned by the version endpoint.
+ v3Client, err := NewIdentityV3(client, eo)
+ if err != nil {
+ return err
+ }
+
+ if endpoint != "" {
+ v3Client.Endpoint = endpoint
+ }
+
+ result := tokens3.Create(v3Client, opts)
+
+ token, err := result.ExtractToken()
+ if err != nil {
+ return err
+ }
+
+ catalog, err := result.ExtractServiceCatalog()
+ if err != nil {
+ return err
+ }
+
+ client.TokenID = token.ID
+
+ if opts.CanReauth() {
+ client.ReauthFunc = func() error {
+ client.TokenID = ""
+ return v3auth(client, endpoint, opts, eo)
+ }
+ }
+ 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, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ endpoint := client.IdentityBase + "v2.0/"
+ var err error
+ if !reflect.DeepEqual(eo, gophercloud.EndpointOpts{}) {
+ eo.ApplyDefaults("identity")
+ endpoint, err = client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: endpoint,
+ }, nil
+}
+
+// NewIdentityV3 creates a ServiceClient that may be used to access the v3 identity service.
+func NewIdentityV3(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ endpoint := client.IdentityBase + "v3/"
+ var err error
+ if !reflect.DeepEqual(eo, gophercloud.EndpointOpts{}) {
+ eo.ApplyDefaults("identity")
+ endpoint, err = client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: endpoint,
+ }, nil
+}
+
+// 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
+}
+
+// NewBlockStorageV2 creates a ServiceClient that may be used to access the v2 block storage service.
+func NewBlockStorageV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("volumev2")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewSharedFileSystemV2 creates a ServiceClient that may be used to access the v2 shared file system service.
+func NewSharedFileSystemV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("sharev2")
+ 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
+}
+
+// NewDNSV2 creates a ServiceClient that may be used to access the v2 DNS service.
+func NewDNSV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("dns")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{
+ ProviderClient: client,
+ Endpoint: url,
+ ResourceBase: url + "v2/"}, nil
+}
+
+// NewImageServiceV2 creates a ServiceClient that may be used to access the v2 image service.
+func NewImageServiceV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+ eo.ApplyDefaults("image")
+ url, err := client.EndpointLocator(eo)
+ if err != nil {
+ return nil, err
+ }
+ return &gophercloud.ServiceClient{ProviderClient: client,
+ Endpoint: url,
+ ResourceBase: url + "v2/"}, nil
+}
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/requests.go b/openstack/common/extensions/requests.go
new file mode 100755
index 0000000..46b7d60
--- /dev/null
+++ b/openstack/common/extensions/requests.go
@@ -0,0 +1,20 @@
+package extensions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) (r GetResult) {
+ _, r.Err = c.Get(ExtensionURL(c, alias), &r.Body, nil)
+ return
+}
+
+// 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/results.go b/openstack/common/extensions/results.go
new file mode 100755
index 0000000..d5f8650
--- /dev/null
+++ b/openstack/common/extensions/results.go
@@ -0,0 +1,53 @@
+package extensions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Extension *Extension `json:"extension"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Extension, err
+}
+
+// Extension is a struct that represents an OpenStack extension.
+type Extension struct {
+ Updated string `json:"updated"`
+ Name string `json:"name"`
+ Links []interface{} `json:"links"`
+ Namespace string `json:"namespace"`
+ Alias string `json:"alias"`
+ Description string `json:"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)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Extension, error) {
+ var s struct {
+ Extensions []Extension `json:"extensions"`
+ }
+ err := (r.(ExtensionPage)).ExtractInto(&s)
+ return s.Extensions, err
+}
diff --git a/openstack/common/extensions/testing/doc.go b/openstack/common/extensions/testing/doc.go
new file mode 100644
index 0000000..24b0795
--- /dev/null
+++ b/openstack/common/extensions/testing/doc.go
@@ -0,0 +1,2 @@
+// common_extensions
+package testing
diff --git a/openstack/common/extensions/testing/fixtures.go b/openstack/common/extensions/testing/fixtures.go
new file mode 100644
index 0000000..a986c95
--- /dev/null
+++ b/openstack/common/extensions/testing/fixtures.go
@@ -0,0 +1,90 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 = extensions.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 = []extensions.Extension{ListedExtension}
+
+// SingleExtension is the Extension that should be parsed from GetOutput.
+var SingleExtension = &extensions.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/testing/requests_test.go b/openstack/common/extensions/testing/requests_test.go
new file mode 100644
index 0000000..fbaedfa
--- /dev/null
+++ b/openstack/common/extensions/testing/requests_test.go
@@ -0,0 +1,39 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListExtensionsSuccessfully(t)
+
+ count := 0
+
+ extensions.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := extensions.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 := extensions.Get(client.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, SingleExtension, actual)
+}
diff --git a/openstack/common/extensions/urls.go b/openstack/common/extensions/urls.go
new file mode 100644
index 0000000..eaf38b2
--- /dev/null
+++ b/openstack/common/extensions/urls.go
@@ -0,0 +1,13 @@
+package extensions
+
+import "github.com/gophercloud/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/compute/v2/extensions/availabilityzones/results.go b/openstack/compute/v2/extensions/availabilityzones/results.go
new file mode 100644
index 0000000..96a6a50
--- /dev/null
+++ b/openstack/compute/v2/extensions/availabilityzones/results.go
@@ -0,0 +1,12 @@
+package availabilityzones
+
+// ServerExt is an extension to the base Server object
+type ServerExt struct {
+ // AvailabilityZone is the availabilty zone the server is in.
+ AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"`
+}
+
+// UnmarshalJSON to override default
+func (r *ServerExt) UnmarshalJSON(b []byte) error {
+ return nil
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests.go b/openstack/compute/v2/extensions/bootfromvolume/requests.go
new file mode 100644
index 0000000..9dae14c
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/requests.go
@@ -0,0 +1,120 @@
+package bootfromvolume
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+type (
+ // DestinationType represents the type of medium being used as the
+ // destination of the bootable device.
+ DestinationType string
+
+ // SourceType represents the type of medium being used as the source of the
+ // bootable device.
+ SourceType string
+)
+
+const (
+ // DestinationLocal DestinationType is for using an ephemeral disk as the
+ // destination.
+ DestinationLocal DestinationType = "local"
+
+ // DestinationVolume DestinationType is for using a volume as the destination.
+ DestinationVolume DestinationType = "volume"
+
+ // SourceBlank SourceType is for a "blank" or empty source.
+ SourceBlank SourceType = "blank"
+
+ // SourceImage SourceType is for using images as the source of a block device.
+ SourceImage SourceType = "image"
+
+ // SourceSnapshot SourceType is for using a volume snapshot as the source of
+ // a block device.
+ SourceSnapshot SourceType = "snapshot"
+
+ // SourceVolume SourceType is for using a volume as the source of block
+ // device.
+ SourceVolume SourceType = "volume"
+)
+
+// BlockDevice is a structure with options for creating block devices in a
+// server. The block device may be created from an image, snapshot, new volume,
+// or existing volume. The destination may be a new volume, existing volume
+// which will be attached to the instance, ephemeral disk, or boot device.
+type BlockDevice struct {
+ // SourceType must be one of: "volume", "snapshot", "image", or "blank".
+ SourceType SourceType `json:"source_type" required:"true"`
+
+ // UUID is the unique identifier for the existing volume, snapshot, or
+ // image (see above).
+ UUID string `json:"uuid,omitempty"`
+
+ // BootIndex is the boot index. It defaults to 0.
+ BootIndex int `json:"boot_index"`
+
+ // DeleteOnTermination specifies whether or not to delete the attached volume
+ // when the server is deleted. Defaults to `false`.
+ DeleteOnTermination bool `json:"delete_on_termination"`
+
+ // DestinationType is the type that gets created. Possible values are "volume"
+ // and "local".
+ DestinationType DestinationType `json:"destination_type,omitempty"`
+
+ // GuestFormat specifies the format of the block device.
+ GuestFormat string `json:"guest_format,omitempty"`
+
+ // VolumeSize is the size of the volume to create (in gigabytes). This can be
+ // omitted for existing volumes.
+ VolumeSize int `json:"volume_size,omitempty"`
+}
+
+// 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 {
+ err := gophercloud.ErrMissingInput{}
+ err.Argument = "bootfromvolume.CreateOptsExt.BlockDevice"
+ return nil, err
+ }
+
+ serverMap := base["server"].(map[string]interface{})
+
+ blockDevice := make([]map[string]interface{}, len(opts.BlockDevice))
+
+ for i, bd := range opts.BlockDevice {
+ b, err := gophercloud.BuildRequestBody(bd, "")
+ if err != nil {
+ return nil, err
+ }
+ blockDevice[i] = b
+ }
+ 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) (r servers.CreateResult) {
+ b, err := opts.ToServerCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/results.go b/openstack/compute/v2/extensions/bootfromvolume/results.go
new file mode 100644
index 0000000..3211fb1
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/results.go
@@ -0,0 +1,10 @@
+package bootfromvolume
+
+import (
+ os "github.com/gophercloud/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/testing/doc.go b/openstack/compute/v2/extensions/bootfromvolume/testing/doc.go
new file mode 100644
index 0000000..cb879d9
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_bootfromvolume_v2
+package testing
diff --git a/openstack/compute/v2/extensions/bootfromvolume/testing/requests_test.go b/openstack/compute/v2/extensions/bootfromvolume/testing/requests_test.go
new file mode 100644
index 0000000..7fd3e7d
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/testing/requests_test.go
@@ -0,0 +1,327 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestBootFromNewVolume(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ UUID: "123456",
+ SourceType: bootfromvolume.SourceImage,
+ DestinationType: bootfromvolume.DestinationVolume,
+ VolumeSize: 10,
+ DeleteOnTermination: true,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name":"createdserver",
+ "flavorRef":"performance1-1",
+ "imageRef":"",
+ "block_device_mapping_v2":[
+ {
+ "uuid":"123456",
+ "source_type":"image",
+ "destination_type":"volume",
+ "boot_index": 0,
+ "delete_on_termination": true,
+ "volume_size": 10
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestBootFromExistingVolume(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ UUID: "123456",
+ SourceType: bootfromvolume.SourceVolume,
+ DestinationType: bootfromvolume.DestinationVolume,
+ DeleteOnTermination: true,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name":"createdserver",
+ "flavorRef":"performance1-1",
+ "imageRef":"",
+ "block_device_mapping_v2":[
+ {
+ "uuid":"123456",
+ "source_type":"volume",
+ "destination_type":"volume",
+ "boot_index": 0,
+ "delete_on_termination": true
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestBootFromImage(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: "asdfasdfasdf",
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "block_device_mapping_v2":[
+ {
+ "boot_index": 0,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "source_type":"image",
+ "uuid":"asdfasdfasdf"
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestCreateMultiEphemeralOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: "asdfasdfasdf",
+ },
+ {
+ BootIndex: -1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ GuestFormat: "ext4",
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 1,
+ },
+ {
+ BootIndex: -1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ GuestFormat: "ext4",
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 1,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "block_device_mapping_v2":[
+ {
+ "boot_index": 0,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "source_type":"image",
+ "uuid":"asdfasdfasdf"
+ },
+ {
+ "boot_index": -1,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "guest_format":"ext4",
+ "source_type":"blank",
+ "volume_size": 1
+ },
+ {
+ "boot_index": -1,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "guest_format":"ext4",
+ "source_type":"blank",
+ "volume_size": 1
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestAttachNewVolume(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: "asdfasdfasdf",
+ },
+ {
+ BootIndex: 1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceBlank,
+ VolumeSize: 1,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "block_device_mapping_v2":[
+ {
+ "boot_index": 0,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "source_type":"image",
+ "uuid":"asdfasdfasdf"
+ },
+ {
+ "boot_index": 1,
+ "delete_on_termination": true,
+ "destination_type":"volume",
+ "source_type":"blank",
+ "volume_size": 1
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestAttachExistingVolume(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := bootfromvolume.CreateOptsExt{
+ CreateOptsBuilder: base,
+ BlockDevice: []bootfromvolume.BlockDevice{
+ {
+ BootIndex: 0,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationLocal,
+ SourceType: bootfromvolume.SourceImage,
+ UUID: "asdfasdfasdf",
+ },
+ {
+ BootIndex: 1,
+ DeleteOnTermination: true,
+ DestinationType: bootfromvolume.DestinationVolume,
+ SourceType: bootfromvolume.SourceVolume,
+ UUID: "123456",
+ VolumeSize: 1,
+ },
+ },
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "block_device_mapping_v2":[
+ {
+ "boot_index": 0,
+ "delete_on_termination": true,
+ "destination_type":"local",
+ "source_type":"image",
+ "uuid":"asdfasdfasdf"
+ },
+ {
+ "boot_index": 1,
+ "delete_on_termination": true,
+ "destination_type":"volume",
+ "source_type":"volume",
+ "uuid":"123456",
+ "volume_size": 1
+ }
+ ]
+ }
+ }
+ `
+ actual, err := ext.ToServerCreateMap()
+ th.AssertNoErr(t, err)
+ th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls.go b/openstack/compute/v2/extensions/bootfromvolume/urls.go
new file mode 100644
index 0000000..dc007ea
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/urls.go
@@ -0,0 +1,7 @@
+package bootfromvolume
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("os-volumes_boot")
+}
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/requests.go b/openstack/compute/v2/extensions/defsecrules/requests.go
new file mode 100644
index 0000000..184fdc9
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests.go
@@ -0,0 +1,70 @@
+package defsecrules
+
+import (
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will return a collection of default rules.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+ return DefaultRulePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateOpts represents the configuration for adding a new default rule.
+type CreateOpts struct {
+ // The lower bound of the port range that will be opened.s
+ FromPort int `json:"from_port"`
+ // The upper bound of the port range that will be opened.
+ ToPort int `json:"to_port"`
+ // The protocol type that will be allowed, e.g. TCP.
+ IPProtocol string `json:"ip_protocol" required:"true"`
+ // 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) {
+ if opts.FromPort == 0 && strings.ToUpper(opts.IPProtocol) != "ICMP" {
+ return nil, gophercloud.ErrMissingInput{Argument: "FromPort"}
+ }
+ if opts.ToPort == 0 && strings.ToUpper(opts.IPProtocol) != "ICMP" {
+ return nil, gophercloud.ErrMissingInput{Argument: "ToPort"}
+ }
+ return gophercloud.BuildRequestBody(opts, "security_group_default_rule")
+}
+
+// Create is the operation responsible for creating a new default rule.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToRuleCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get will return details for a particular default rule.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete will permanently delete a default rule from the project.
+func Delete(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Delete(resourceURL(client, id), nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/results.go b/openstack/compute/v2/extensions/defsecrules/results.go
new file mode 100644
index 0000000..f990c99
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/results.go
@@ -0,0 +1,67 @@
+package defsecrules
+
+import (
+ "encoding/json"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// DefaultRule represents a default rule - which is identical to a
+// normal security rule.
+type DefaultRule secgroups.Rule
+
+func (r *DefaultRule) UnmarshalJSON(b []byte) error {
+ var s secgroups.Rule
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = DefaultRule(s)
+ return nil
+}
+
+// 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)
+ return len(users) == 0, err
+}
+
+// ExtractDefaultRules returns a slice of DefaultRules contained in a single
+// page of results.
+func ExtractDefaultRules(r pagination.Page) ([]DefaultRule, error) {
+ var s struct {
+ DefaultRules []DefaultRule `json:"security_group_default_rules"`
+ }
+ err := (r.(DefaultRulePage)).ExtractInto(&s)
+ return s.DefaultRules, 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) {
+ var s struct {
+ DefaultRule DefaultRule `json:"security_group_default_rule"`
+ }
+ err := r.ExtractInto(&s)
+ return &s.DefaultRule, err
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/testing/doc.go b/openstack/compute/v2/extensions/defsecrules/testing/doc.go
new file mode 100644
index 0000000..7e51c8f
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_defsecrules_v2
+package testing
diff --git a/openstack/compute/v2/extensions/defsecrules/testing/fixtures.go b/openstack/compute/v2/extensions/defsecrules/testing/fixtures.go
new file mode 100644
index 0000000..e4a62d4
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/testing/fixtures.go
@@ -0,0 +1,143 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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 mockCreateRuleResponseICMPZero(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": "ICMP",
+ "from_port": 0,
+ "to_port": 0,
+ "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": 0,
+ "id": "{ruleID}",
+ "ip_protocol": "ICMP",
+ "ip_range": {
+ "cidr": "10.10.12.0/24"
+ },
+ "to_port": 0
+ }
+}
+`)
+ })
+}
+
+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/testing/requests_test.go b/openstack/compute/v2/extensions/defsecrules/testing/requests_test.go
new file mode 100644
index 0000000..1f2fb86
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/testing/requests_test.go
@@ -0,0 +1,127 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/defsecrules"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const ruleID = "{ruleID}"
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListRulesResponse(t)
+
+ count := 0
+
+ err := defsecrules.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := defsecrules.ExtractDefaultRules(page)
+ th.AssertNoErr(t, err)
+
+ expected := []defsecrules.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 := defsecrules.CreateOpts{
+ IPProtocol: "TCP",
+ FromPort: 80,
+ ToPort: 80,
+ CIDR: "10.10.12.0/24",
+ }
+
+ group, err := defsecrules.Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &defsecrules.DefaultRule{
+ ID: ruleID,
+ FromPort: 80,
+ ToPort: 80,
+ IPProtocol: "TCP",
+ IPRange: secgroups.IPRange{CIDR: "10.10.12.0/24"},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestCreateICMPZero(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateRuleResponseICMPZero(t)
+
+ opts := defsecrules.CreateOpts{
+ IPProtocol: "ICMP",
+ FromPort: 0,
+ ToPort: 0,
+ CIDR: "10.10.12.0/24",
+ }
+
+ group, err := defsecrules.Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &defsecrules.DefaultRule{
+ ID: ruleID,
+ FromPort: 0,
+ ToPort: 0,
+ IPProtocol: "ICMP",
+ 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 := defsecrules.Get(client.ServiceClient(), ruleID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &defsecrules.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 := defsecrules.Delete(client.ServiceClient(), ruleID).ExtractErr()
+ th.AssertNoErr(t, 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..e5fbf82
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/urls.go
@@ -0,0 +1,13 @@
+package defsecrules
+
+import "github.com/gophercloud/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..00e7c3b
--- /dev/null
+++ b/openstack/compute/v2/extensions/delegate.go
@@ -0,0 +1,23 @@
+package extensions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ "github.com/gophercloud/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/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..41d04b9
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests.go
@@ -0,0 +1,103 @@
+package diskconfig
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"
+)
+
+// 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 controls how the rebuilt server's disk is partitioned.
+ DiskConfig DiskConfig `json:"OS-DCF:diskConfig,omitempty"`
+}
+
+// ToServerRebuildMap adds the diskconfig option to the base server rebuild options.
+func (opts RebuildOptsExt) ToServerRebuildMap() (map[string]interface{}, error) {
+ if opts.DiskConfig != Auto && opts.DiskConfig != Manual {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "diskconfig.RebuildOptsExt.DiskConfig"
+ err.Info = "Must be either diskconfig.Auto or diskconfig.Manual"
+ 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) {
+ if opts.DiskConfig != Auto && opts.DiskConfig != Manual {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "diskconfig.ResizeOptsExt.DiskConfig"
+ err.Info = "Must be either diskconfig.Auto or diskconfig.Manual"
+ 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/results.go b/openstack/compute/v2/extensions/diskconfig/results.go
new file mode 100644
index 0000000..3ba66f5
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results.go
@@ -0,0 +1,17 @@
+package diskconfig
+
+import "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+
+type ServerWithDiskConfig struct {
+ servers.Server
+ DiskConfig DiskConfig `json:"OS-DCF:diskConfig"`
+}
+
+func (s ServerWithDiskConfig) ToServerCreateResult() (m map[string]interface{}) {
+ m["OS-DCF:diskConfig"] = s.DiskConfig
+ return
+}
+
+type CreateServerResultBuilder interface {
+ ToServerCreateResult() map[string]interface{}
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/testing/doc.go b/openstack/compute/v2/extensions/diskconfig/testing/doc.go
new file mode 100644
index 0000000..54c863b
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_diskconfig_v2
+package testing
diff --git a/openstack/compute/v2/extensions/diskconfig/testing/requests_test.go b/openstack/compute/v2/extensions/diskconfig/testing/requests_test.go
new file mode 100644
index 0000000..6ce560a
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/testing/requests_test.go
@@ -0,0 +1,88 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/diskconfig"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ ext := diskconfig.CreateOptsExt{
+ CreateOptsBuilder: base,
+ DiskConfig: diskconfig.Manual,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1",
+ "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 := diskconfig.RebuildOptsExt{
+ RebuildOptsBuilder: base,
+ DiskConfig: 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 := diskconfig.ResizeOptsExt{
+ ResizeOptsBuilder: base,
+ DiskConfig: 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/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/floatingips/doc.go b/openstack/compute/v2/extensions/floatingips/doc.go
new file mode 100644
index 0000000..6682fa6
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/doc.go
@@ -0,0 +1,3 @@
+// Package floatingips provides the ability to manage floating ips through
+// nova-network
+package floatingips
diff --git a/openstack/compute/v2/extensions/floatingips/requests.go b/openstack/compute/v2/extensions/floatingips/requests.go
new file mode 100644
index 0000000..b36aeba
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/requests.go
@@ -0,0 +1,112 @@
+package floatingips
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 FloatingIPPage{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 `json:"pool" required:"true"`
+}
+
+// ToFloatingIPCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToFloatingIPCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Create requests the creation of a new floating IP
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToFloatingIPCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get returns data about a previously created FloatingIP.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete requests the deletion of a previous allocated FloatingIP.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// AssociateOptsBuilder is the interface types must satfisfy to be used as
+// Associate options
+type AssociateOptsBuilder interface {
+ ToFloatingIPAssociateMap() (map[string]interface{}, error)
+}
+
+// AssociateOpts specifies the required information to associate a floating IP with an instance
+type AssociateOpts struct {
+ // FloatingIP is the floating IP to associate with an instance
+ FloatingIP string `json:"address" required:"true"`
+ // FixedIP is an optional fixed IP address of the server
+ FixedIP string `json:"fixed_address,omitempty"`
+}
+
+// ToFloatingIPAssociateMap constructs a request body from AssociateOpts.
+func (opts AssociateOpts) ToFloatingIPAssociateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "addFloatingIp")
+}
+
+// AssociateInstance pairs an allocated floating IP with an instance.
+func AssociateInstance(client *gophercloud.ServiceClient, serverID string, opts AssociateOptsBuilder) (r AssociateResult) {
+ b, err := opts.ToFloatingIPAssociateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(associateURL(client, serverID), b, nil, nil)
+ return
+}
+
+// DisassociateOptsBuilder is the interface types must satfisfy to be used as
+// Disassociate options
+type DisassociateOptsBuilder interface {
+ ToFloatingIPDisassociateMap() (map[string]interface{}, error)
+}
+
+// DisassociateOpts specifies the required information to disassociate a floating IP with an instance
+type DisassociateOpts struct {
+ FloatingIP string `json:"address" required:"true"`
+}
+
+// ToFloatingIPDisassociateMap constructs a request body from AssociateOpts.
+func (opts DisassociateOpts) ToFloatingIPDisassociateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "removeFloatingIp")
+}
+
+// DisassociateInstance decouples an allocated floating IP from an instance
+func DisassociateInstance(client *gophercloud.ServiceClient, serverID string, opts DisassociateOptsBuilder) (r DisassociateResult) {
+ b, err := opts.ToFloatingIPDisassociateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(disassociateURL(client, serverID), b, nil, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/floatingips/results.go b/openstack/compute/v2/extensions/floatingips/results.go
new file mode 100644
index 0000000..2f5b338
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/results.go
@@ -0,0 +1,117 @@
+package floatingips
+
+import (
+ "encoding/json"
+ "strconv"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"-"`
+
+ // FixedIP is the IP of the instance related to the Floating IP
+ FixedIP string `json:"fixed_ip,omitempty"`
+
+ // InstanceID is the ID of the instance that is using the Floating IP
+ InstanceID string `json:"instance_id"`
+
+ // IP is the actual Floating IP
+ IP string `json:"ip"`
+
+ // Pool is the pool of floating IPs that this floating IP belongs to
+ Pool string `json:"pool"`
+}
+
+func (r *FloatingIP) UnmarshalJSON(b []byte) error {
+ type tmp FloatingIP
+ var s struct {
+ tmp
+ ID interface{} `json:"id"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = FloatingIP(s.tmp)
+
+ switch t := s.ID.(type) {
+ case float64:
+ r.ID = strconv.FormatFloat(t, 'f', -1, 64)
+ case string:
+ r.ID = t
+ }
+
+ return err
+}
+
+// FloatingIPPage stores a single, only page of FloatingIPs
+// results from a List call.
+type FloatingIPPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a FloatingIPsPage is empty.
+func (page FloatingIPPage) 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(r pagination.Page) ([]FloatingIP, error) {
+ var s struct {
+ FloatingIPs []FloatingIP `json:"floating_ips"`
+ }
+ err := (r.(FloatingIPPage)).ExtractInto(&s)
+ return s.FloatingIPs, err
+}
+
+// FloatingIPResult is the raw result from a FloatingIP request.
+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) {
+ var s struct {
+ FloatingIP *FloatingIP `json:"floating_ip"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/floatingips/testing/doc.go b/openstack/compute/v2/extensions/floatingips/testing/doc.go
new file mode 100644
index 0000000..961aeee
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_floatingips_v2
+package testing
diff --git a/openstack/compute/v2/extensions/floatingips/testing/fixtures.go b/openstack/compute/v2/extensions/floatingips/testing/fixtures.go
new file mode 100644
index 0000000..6866e26
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/testing/fixtures.go
@@ -0,0 +1,223 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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"
+ }
+}
+`
+
+// CreateOutputWithNumericID is a sample response to a Post call
+// with a legacy nova-network-based numeric ID.
+const CreateOutputWithNumericID = `
+{
+ "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 = floatingips.FloatingIP{
+ ID: "1",
+ IP: "10.10.10.1",
+ Pool: "nova",
+}
+
+// SecondFloatingIP is the first result in ListOutput.
+var SecondFloatingIP = floatingips.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 = []floatingips.FloatingIP{FirstFloatingIP, SecondFloatingIP}
+
+// CreatedFloatingIP is the parsed result from CreateOutput.
+var CreatedFloatingIP = floatingips.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)
+ })
+}
+
+// HandleCreateWithNumericIDSuccessfully configures the test server to respond to a Create request
+// for a new floating ip
+func HandleCreateWithNumericIDSuccessfully(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, CreateOutputWithNumericID)
+ })
+}
+
+// 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/floatingips/testing/requests_test.go b/openstack/compute/v2/extensions/floatingips/testing/requests_test.go
new file mode 100644
index 0000000..2356671
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/testing/requests_test.go
@@ -0,0 +1,111 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := floatingips.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := floatingips.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 := floatingips.Create(client.ServiceClient(), floatingips.CreateOpts{
+ Pool: "nova",
+ }).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &CreatedFloatingIP, actual)
+}
+
+func TestCreateWithNumericID(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateWithNumericIDSuccessfully(t)
+
+ actual, err := floatingips.Create(client.ServiceClient(), floatingips.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 := floatingips.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 := floatingips.Delete(client.ServiceClient(), "1").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAssociate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAssociateSuccessfully(t)
+
+ associateOpts := floatingips.AssociateOpts{
+ FloatingIP: "10.10.10.2",
+ }
+
+ err := floatingips.AssociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAssociateFixed(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAssociateFixedSuccessfully(t)
+
+ associateOpts := floatingips.AssociateOpts{
+ FloatingIP: "10.10.10.2",
+ FixedIP: "166.78.185.201",
+ }
+
+ err := floatingips.AssociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", associateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestDisassociateInstance(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDisassociateSuccessfully(t)
+
+ disassociateOpts := floatingips.DisassociateOpts{
+ FloatingIP: "10.10.10.2",
+ }
+
+ err := floatingips.DisassociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", disassociateOpts).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/floatingips/urls.go b/openstack/compute/v2/extensions/floatingips/urls.go
new file mode 100644
index 0000000..4768e5a
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/urls.go
@@ -0,0 +1,37 @@
+package floatingips
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/compute/v2/extensions/keypairs/requests.go
new file mode 100644
index 0000000..adf1e55
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/requests.go
@@ -0,0 +1,84 @@
+package keypairs
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ "github.com/gophercloud/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 is a friendly name to refer to this KeyPair in other services.
+ Name string `json:"name" required:"true"`
+ // 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 `json:"public_key,omitempty"`
+}
+
+// ToKeyPairCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToKeyPairCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "keypair")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToKeyPairCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get returns public data about a previously uploaded KeyPair.
+func Get(client *gophercloud.ServiceClient, name string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, name), &r.Body, nil)
+ return
+}
+
+// Delete requests the deletion of a previous stored KeyPair from the server.
+func Delete(client *gophercloud.ServiceClient, name string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, name), nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/keypairs/results.go b/openstack/compute/v2/extensions/keypairs/results.go
new file mode 100644
index 0000000..4c785a2
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/results.go
@@ -0,0 +1,86 @@
+package keypairs
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// KeyPair is an SSH key known to the OpenStack Cloud 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 `json:"name"`
+
+ // Fingerprint is a short sequence of bytes that can be used to authenticate or validate a longer
+ // public key.
+ Fingerprint string `json:"fingerprint"`
+
+ // PublicKey is the public key from this pair, in OpenSSH format. "ssh-rsa AAAAB3Nz..."
+ PublicKey string `json:"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 `json:"private_key"`
+
+ // UserID is the user who owns this keypair.
+ UserID string `json:"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(r pagination.Page) ([]KeyPair, error) {
+ type pair struct {
+ KeyPair KeyPair `json:"keypair"`
+ }
+ var s struct {
+ KeyPairs []pair `json:"keypairs"`
+ }
+ err := (r.(KeyPairPage)).ExtractInto(&s)
+ results := make([]KeyPair, len(s.KeyPairs))
+ for i, pair := range s.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) {
+ var s struct {
+ KeyPair *KeyPair `json:"keypair"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/compute/v2/extensions/keypairs/testing/doc.go
new file mode 100644
index 0000000..8f8aaca
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_keypairs_v2
+package testing
diff --git a/openstack/compute/v2/extensions/keypairs/testing/fixtures.go b/openstack/compute/v2/extensions/keypairs/testing/fixtures.go
new file mode 100644
index 0000000..dc716d8
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/testing/fixtures.go
@@ -0,0 +1,170 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 = keypairs.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 = keypairs.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 = []keypairs.KeyPair{FirstKeyPair, SecondKeyPair}
+
+// CreatedKeyPair is the parsed result from CreatedOutput.
+var CreatedKeyPair = keypairs.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 = keypairs.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/testing/requests_test.go b/openstack/compute/v2/extensions/keypairs/testing/requests_test.go
new file mode 100644
index 0000000..1e05e66
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/testing/requests_test.go
@@ -0,0 +1,72 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := keypairs.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := keypairs.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 := keypairs.Create(client.ServiceClient(), keypairs.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 := keypairs.Create(client.ServiceClient(), keypairs.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 := keypairs.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 := keypairs.Delete(client.ServiceClient(), "deletedkey").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/keypairs/urls.go b/openstack/compute/v2/extensions/keypairs/urls.go
new file mode 100644
index 0000000..fec38f3
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/urls.go
@@ -0,0 +1,25 @@
+package keypairs
+
+import "github.com/gophercloud/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/limits/requests.go b/openstack/compute/v2/extensions/limits/requests.go
new file mode 100644
index 0000000..70324b8
--- /dev/null
+++ b/openstack/compute/v2/extensions/limits/requests.go
@@ -0,0 +1,39 @@
+package limits
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// GetOptsBuilder allows extensions to add additional parameters to the
+// Get request.
+type GetOptsBuilder interface {
+ ToLimitsQuery() (string, error)
+}
+
+// GetOpts enables retrieving limits by a specific tenant.
+type GetOpts struct {
+ // The tenant ID to retrieve limits for
+ TenantID string `q:"tenant_id"`
+}
+
+// ToLimitsQuery formats a GetOpts into a query string.
+func (opts GetOpts) ToLimitsQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// Get returns the limits about the currently scoped tenant.
+func Get(client *gophercloud.ServiceClient, opts GetOptsBuilder) (r GetResult) {
+ url := getURL(client)
+ if opts != nil {
+ query, err := opts.ToLimitsQuery()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ url += query
+ }
+
+ _, r.Err = client.Get(url, &r.Body, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/limits/results.go b/openstack/compute/v2/extensions/limits/results.go
new file mode 100644
index 0000000..95b794d
--- /dev/null
+++ b/openstack/compute/v2/extensions/limits/results.go
@@ -0,0 +1,90 @@
+package limits
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// Limits is a struct that contains the response of a limit query.
+type Limits struct {
+ // Absolute contains the limits and usage information.
+ Absolute Absolute `json:"absolute"`
+}
+
+// Usage is a struct that contains the current resource usage and limits
+// of a tenant.
+type Absolute struct {
+ // MaxTotalCores is the number of cores available to a tenant.
+ MaxTotalCores int `json:"maxTotalCores"`
+
+ // MaxImageMeta is the amount of image metadata available to a tenant.
+ MaxImageMeta int `json:"maxImageMeta"`
+
+ // MaxServerMeta is the amount of server metadata available to a tenant.
+ MaxServerMeta int `json:"maxServerMeta"`
+
+ // MaxPersonality is the amount of personality/files available to a tenant.
+ MaxPersonality int `json:"maxPersonality"`
+
+ // MaxPersonalitySize is the personality file size available to a tenant.
+ MaxPersonalitySize int `json:"maxPersonalitySize"`
+
+ // MaxTotalKeypairs is the total keypairs available to a tenant.
+ MaxTotalKeypairs int `json:maxTotalKeypairs"`
+
+ // MaxSecurityGroups is the number of security groups available to a tenant.
+ MaxSecurityGroups int `json:"maxSecurityGroups"`
+
+ // MaxSecurityGroupRules is the number of security group rules available to
+ // a tenant.
+ MaxSecurityGroupRules int `json:"maxSecurityGroupRules"`
+
+ // MaxServerGroups is the number of server groups available to a tenant.
+ MaxServerGroups int `json:"maxServerGroups"`
+
+ // MaxServerGroupMembers is the number of server group members available
+ // to a tenant.
+ MaxServerGroupMembers int `json:"maxServerGroupMembers"`
+
+ // MaxTotalFloatingIps is the number of floating IPs available to a tenant.
+ MaxTotalFloatingIps int `json:"maxTotalFloatingIps"`
+
+ // MaxTotalInstances is the number of instances/servers available to a tenant.
+ MaxTotalInstances int `json:"maxTotalInstances"`
+
+ // MaxTotalRAMSize is the total amount of RAM available to a tenant measured
+ // in megabytes (MB).
+ MaxTotalRAMSize int `json:"maxTotalRAMSize"`
+
+ // TotalCoresUsed is the number of cores currently in use.
+ TotalCoresUsed int `json:"totalCoresUsed"`
+
+ // TotalInstancesUsed is the number of instances/servers in use.
+ TotalInstancesUsed int `json:"totalInstancesUsed"`
+
+ // TotalFloatingIpsUsed is the number of floating IPs in use.
+ TotalFloatingIpsUsed int `json:"totalFloatingIpsUsed"`
+
+ // TotalRAMUsed is the total RAM/memory in use measured in megabytes (MB).
+ TotalRAMUsed int `json:"totalRAMUsed"`
+
+ // TotalSecurityGroupsUsed is the total number of security groups in use.
+ TotalSecurityGroupsUsed int `json:"totalSecurityGroupsUsed"`
+
+ // TotalServerGroupsUsed is the total number of server groups in use.
+ TotalServerGroupsUsed int `json:"totalServerGroupsUsed"`
+}
+
+// Extract interprets a limits result as a Limits.
+func (r GetResult) Extract() (*Limits, error) {
+ var s struct {
+ Limits *Limits `json:"limits"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Limits, err
+}
+
+// GetResult is the response from a Get operation. Call its ExtractAbsolute
+// method to interpret it as an Absolute.
+type GetResult struct {
+ gophercloud.Result
+}
diff --git a/openstack/compute/v2/extensions/limits/testing/fixtures.go b/openstack/compute/v2/extensions/limits/testing/fixtures.go
new file mode 100644
index 0000000..9ec24a9
--- /dev/null
+++ b/openstack/compute/v2/extensions/limits/testing/fixtures.go
@@ -0,0 +1,80 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/limits"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "limits": {
+ "rate": [],
+ "absolute": {
+ "maxServerMeta": 128,
+ "maxPersonality": 5,
+ "totalServerGroupsUsed": 0,
+ "maxImageMeta": 128,
+ "maxPersonalitySize": 10240,
+ "maxTotalKeypairs": 100,
+ "maxSecurityGroupRules": 20,
+ "maxServerGroups": 10,
+ "totalCoresUsed": 1,
+ "totalRAMUsed": 2048,
+ "totalInstancesUsed": 1,
+ "maxSecurityGroups": 10,
+ "totalFloatingIpsUsed": 0,
+ "maxTotalCores": 20,
+ "maxServerGroupMembers": 10,
+ "maxTotalFloatingIps": 10,
+ "totalSecurityGroupsUsed": 1,
+ "maxTotalInstances": 10,
+ "maxTotalRAMSize": 51200
+ }
+ }
+}
+`
+
+// LimitsResult is the result of the limits in GetOutput.
+var LimitsResult = limits.Limits{
+ limits.Absolute{
+ MaxServerMeta: 128,
+ MaxPersonality: 5,
+ TotalServerGroupsUsed: 0,
+ MaxImageMeta: 128,
+ MaxPersonalitySize: 10240,
+ MaxTotalKeypairs: 100,
+ MaxSecurityGroupRules: 20,
+ MaxServerGroups: 10,
+ TotalCoresUsed: 1,
+ TotalRAMUsed: 2048,
+ TotalInstancesUsed: 1,
+ MaxSecurityGroups: 10,
+ TotalFloatingIpsUsed: 0,
+ MaxTotalCores: 20,
+ MaxServerGroupMembers: 10,
+ MaxTotalFloatingIps: 10,
+ TotalSecurityGroupsUsed: 1,
+ MaxTotalInstances: 10,
+ MaxTotalRAMSize: 51200,
+ },
+}
+
+const TenantID = "555544443333222211110000ffffeeee"
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for a limit.
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/limits", 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/limits/testing/requests_test.go b/openstack/compute/v2/extensions/limits/testing/requests_test.go
new file mode 100644
index 0000000..9c8456c
--- /dev/null
+++ b/openstack/compute/v2/extensions/limits/testing/requests_test.go
@@ -0,0 +1,23 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/limits"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ getOpts := limits.GetOpts{
+ TenantID: TenantID,
+ }
+
+ actual, err := limits.Get(client.ServiceClient(), getOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &LimitsResult, actual)
+}
diff --git a/openstack/compute/v2/extensions/limits/urls.go b/openstack/compute/v2/extensions/limits/urls.go
new file mode 100644
index 0000000..edd97e4
--- /dev/null
+++ b/openstack/compute/v2/extensions/limits/urls.go
@@ -0,0 +1,11 @@
+package limits
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+const resourcePath = "limits"
+
+func getURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
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/requests.go b/openstack/compute/v2/extensions/networks/requests.go
new file mode 100644
index 0000000..5432a10
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/requests.go
@@ -0,0 +1,19 @@
+package networks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/networks/results.go b/openstack/compute/v2/extensions/networks/results.go
new file mode 100644
index 0000000..cbcce31
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/results.go
@@ -0,0 +1,134 @@
+package networks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"bridge"`
+
+ // BridgeInterface is what interface is connected to the Bridge
+ BridgeInterface string `json:"bridge_interface"`
+
+ // The Broadcast address of the network.
+ Broadcast string `json:"broadcast"`
+
+ // CIDR is the IPv4 subnet.
+ CIDR string `json:"cidr"`
+
+ // CIDRv6 is the IPv6 subnet.
+ CIDRv6 string `json:"cidr_v6"`
+
+ // CreatedAt is when the network was created..
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at,omitempty"`
+
+ // Deleted shows if the network has been deleted.
+ Deleted bool `json:"deleted"`
+
+ // DeletedAt is the time when the network was deleted.
+ DeletedAt gophercloud.JSONRFC3339MilliNoZ `json:"deleted_at,omitempty"`
+
+ // DHCPStart is the start of the DHCP address range.
+ DHCPStart string `json:"dhcp_start"`
+
+ // DNS1 is the first DNS server to use through DHCP.
+ DNS1 string `json:"dns_1"`
+
+ // DNS2 is the first DNS server to use through DHCP.
+ DNS2 string `json:"dns_2"`
+
+ // Gateway is the network gateway.
+ Gateway string `json:"gateway"`
+
+ // Gatewayv6 is the IPv6 network gateway.
+ Gatewayv6 string `json:"gateway_v6"`
+
+ // Host is the host that the network service is running on.
+ Host string `json:"host"`
+
+ // ID is the UUID of the network.
+ ID string `json:"id"`
+
+ // Injected determines if network information is injected into the host.
+ Injected bool `json:"injected"`
+
+ // Label is the common name that the network has..
+ Label string `json:"label"`
+
+ // MultiHost is if multi-host networking is enablec..
+ MultiHost bool `json:"multi_host"`
+
+ // Netmask is the network netmask.
+ Netmask string `json:"netmask"`
+
+ // Netmaskv6 is the IPv6 netmask.
+ Netmaskv6 string `json:"netmask_v6"`
+
+ // Priority is the network interface priority.
+ Priority int `json:"priority"`
+
+ // ProjectID is the project associated with this network.
+ ProjectID string `json:"project_id"`
+
+ // RXTXBase configures bandwidth entitlement.
+ RXTXBase int `json:"rxtx_base"`
+
+ // UpdatedAt is the time when the network was last updated.
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at,omitempty"`
+
+ // VLAN is the vlan this network runs on.
+ VLAN int `json:"vlan"`
+
+ // VPNPrivateAddress is the private address of the CloudPipe VPN.
+ VPNPrivateAddress string `json:"vpn_private_address"`
+
+ // VPNPublicAddress is the public address of the CloudPipe VPN.
+ VPNPublicAddress string `json:"vpn_public_address"`
+
+ // VPNPublicPort is the port of the CloudPipe VPN.
+ VPNPublicPort int `json:"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(r pagination.Page) ([]Network, error) {
+ var s struct {
+ Networks []Network `json:"networks"`
+ }
+ err := (r.(NetworkPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Network *Network `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/compute/v2/extensions/networks/testing/doc.go
new file mode 100644
index 0000000..76a18cd
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_networks_v2
+package testing
diff --git a/openstack/compute/v2/extensions/networks/testing/fixtures.go b/openstack/compute/v2/extensions/networks/testing/fixtures.go
new file mode 100644
index 0000000..e2fa49b
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/testing/fixtures.go
@@ -0,0 +1,204 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/networks"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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-15T06:19:19.387525",
+ "deleted": false,
+ "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-16T09: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-15T06:19:19.387525",
+ "deleted": false,
+ "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,
+ "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-15T06:19:19.387525",
+ "deleted": false,
+ "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,
+ "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 = networks.Network{
+ Bridge: "br100",
+ BridgeInterface: "eth0",
+ Broadcast: "10.0.0.7",
+ CIDR: "10.0.0.0/29",
+ CIDRv6: "",
+ CreatedAt: gophercloud.JSONRFC3339MilliNoZ(time.Date(2011, 8, 15, 6, 19, 19, 387525000, time.UTC)),
+ Deleted: false,
+ DeletedAt: gophercloud.JSONRFC3339MilliNoZ(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: gophercloud.JSONRFC3339MilliNoZ(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 = networks.Network{
+ Bridge: "br101",
+ BridgeInterface: "eth0",
+ Broadcast: "10.0.0.15",
+ CIDR: "10.0.0.10/29",
+ CIDRv6: "",
+ CreatedAt: gophercloud.JSONRFC3339MilliNoZ(time.Date(2011, 8, 15, 6, 19, 19, 387525000, time.UTC)),
+ Deleted: false,
+ DeletedAt: gophercloud.JSONRFC3339MilliNoZ(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: gophercloud.JSONRFC3339MilliNoZ(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 = []networks.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/testing/requests_test.go b/openstack/compute/v2/extensions/networks/testing/requests_test.go
new file mode 100644
index 0000000..36b5463
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/testing/requests_test.go
@@ -0,0 +1,38 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/networks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := networks.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := networks.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 := networks.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/urls.go b/openstack/compute/v2/extensions/networks/urls.go
new file mode 100644
index 0000000..491bde6
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/urls.go
@@ -0,0 +1,17 @@
+package networks
+
+import "github.com/gophercloud/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/quotasets/doc.go b/openstack/compute/v2/extensions/quotasets/doc.go
new file mode 100644
index 0000000..721024e
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/doc.go
@@ -0,0 +1,3 @@
+// Package quotasets provides information and interaction with QuotaSet
+// extension for the OpenStack Compute service.
+package quotasets
diff --git a/openstack/compute/v2/extensions/quotasets/requests.go b/openstack/compute/v2/extensions/quotasets/requests.go
new file mode 100644
index 0000000..bb9cb22
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/requests.go
@@ -0,0 +1,76 @@
+package quotasets
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// Get returns public data about a previously created QuotaSet.
+func Get(client *gophercloud.ServiceClient, tenantID string) GetResult {
+ var res GetResult
+ _, res.Err = client.Get(getURL(client, tenantID), &res.Body, nil)
+ return res
+}
+
+//Updates the quotas for the given tenantID and returns the new quota-set
+func Update(client *gophercloud.ServiceClient, tenantID string, opts UpdateOptsBuilder) (res UpdateResult) {
+ reqBody, err := opts.ToComputeQuotaUpdateMap()
+ if err != nil {
+ res.Err = err
+ return
+ }
+
+ _, res.Err = client.Put(updateURL(client, tenantID), reqBody, &res.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return res
+}
+
+//Resets the uotas for the given tenant to their default values
+func Delete(client *gophercloud.ServiceClient, tenantID string) (res DeleteResult) {
+ _, res.Err = client.Delete(deleteURL(client, tenantID), nil)
+ return
+}
+
+//Options for Updating the quotas of a Tenant
+//All int-values are pointers so they can be nil if they are not needed
+//you can use gopercloud.IntToPointer() for convenience
+type UpdateOpts struct {
+ //FixedIps is number of fixed ips alloted this quota_set
+ FixedIps *int `json:"fixed_ips,omitempty"`
+ // FloatingIps is number of floating ips alloted this quota_set
+ FloatingIps *int `json:"floating_ips,omitempty"`
+ // InjectedFileContentBytes is content bytes allowed for each injected file
+ InjectedFileContentBytes *int `json:"injected_file_content_bytes,omitempty"`
+ // InjectedFilePathBytes is allowed bytes for each injected file path
+ InjectedFilePathBytes *int `json:"injected_file_path_bytes,omitempty"`
+ // InjectedFiles is injected files allowed for each project
+ InjectedFiles *int `json:"injected_files,omitempty"`
+ // KeyPairs is number of ssh keypairs
+ KeyPairs *int `json:"key_pairs,omitempty"`
+ // MetadataItems is number of metadata items allowed for each instance
+ MetadataItems *int `json:"metadata_items,omitempty"`
+ // Ram is megabytes allowed for each instance
+ Ram *int `json:"ram,omitempty"`
+ // SecurityGroupRules is rules allowed for each security group
+ SecurityGroupRules *int `json:"security_group_rules,omitempty"`
+ // SecurityGroups security groups allowed for each project
+ SecurityGroups *int `json:"security_groups,omitempty"`
+ // Cores is number of instance cores allowed for each project
+ Cores *int `json:"cores,omitempty"`
+ // Instances is number of instances allowed for each project
+ Instances *int `json:"instances,omitempty"`
+ // Number of ServerGroups allowed for the project
+ ServerGroups *int `json:"server_groups,omitempty"`
+ // Max number of Members for each ServerGroup
+ ServerGroupMembers *int `json:"server_group_members,omitempty"`
+ //Users can force the update even if the quota has already been used and the reserved quota exceeds the new quota.
+ Force bool `json:"force,omitempty"`
+}
+
+type UpdateOptsBuilder interface {
+ //Extra specific name to prevent collisions with interfaces for other quotas (e.g. neutron)
+ ToComputeQuotaUpdateMap() (map[string]interface{}, error)
+}
+
+func (opts UpdateOpts) ToComputeQuotaUpdateMap() (map[string]interface{}, error) {
+
+ return gophercloud.BuildRequestBody(opts, "quota_set")
+}
diff --git a/openstack/compute/v2/extensions/quotasets/results.go b/openstack/compute/v2/extensions/quotasets/results.go
new file mode 100644
index 0000000..44e6b06
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/results.go
@@ -0,0 +1,91 @@
+package quotasets
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// QuotaSet is a set of operational limits that allow for control of compute usage.
+type QuotaSet struct {
+ //ID is tenant associated with this quota_set
+ ID string `json:"id"`
+ //FixedIps is number of fixed ips alloted this quota_set
+ FixedIps int `json:"fixed_ips"`
+ // FloatingIps is number of floating ips alloted this quota_set
+ FloatingIps int `json:"floating_ips"`
+ // InjectedFileContentBytes is content bytes allowed for each injected file
+ InjectedFileContentBytes int `json:"injected_file_content_bytes"`
+ // InjectedFilePathBytes is allowed bytes for each injected file path
+ InjectedFilePathBytes int `json:"injected_file_path_bytes"`
+ // InjectedFiles is injected files allowed for each project
+ InjectedFiles int `json:"injected_files"`
+ // KeyPairs is number of ssh keypairs
+ KeyPairs int `json:"key_pairs"`
+ // MetadataItems is number of metadata items allowed for each instance
+ MetadataItems int `json:"metadata_items"`
+ // Ram is megabytes allowed for each instance
+ Ram int `json:"ram"`
+ // SecurityGroupRules is rules allowed for each security group
+ SecurityGroupRules int `json:"security_group_rules"`
+ // SecurityGroups security groups allowed for each project
+ SecurityGroups int `json:"security_groups"`
+ // Cores is number of instance cores allowed for each project
+ Cores int `json:"cores"`
+ // Instances is number of instances allowed for each project
+ Instances int `json:"instances"`
+ // Number of ServerGroups allowed for the project
+ ServerGroups int `json:"server_groups"`
+ // Max number of Members for each ServerGroup
+ ServerGroupMembers int `json:"server_group_members"`
+}
+
+// QuotaSetPage stores a single, only page of QuotaSet results from a List call.
+type QuotaSetPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a QuotaSetsetPage is empty.
+func (page QuotaSetPage) IsEmpty() (bool, error) {
+ ks, err := ExtractQuotaSets(page)
+ return len(ks) == 0, err
+}
+
+// ExtractQuotaSets interprets a page of results as a slice of QuotaSets.
+func ExtractQuotaSets(r pagination.Page) ([]QuotaSet, error) {
+ var s struct {
+ QuotaSets []QuotaSet `json:"quotas"`
+ }
+ err := (r.(QuotaSetPage)).ExtractInto(&s)
+ return s.QuotaSets, err
+}
+
+type quotaResult struct {
+ gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any QuotaSet resource response as a QuotaSet struct.
+func (r quotaResult) Extract() (*QuotaSet, error) {
+ var s struct {
+ QuotaSet *QuotaSet `json:"quota_set"`
+ }
+ err := r.ExtractInto(&s)
+ return s.QuotaSet, err
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a QuotaSet.
+type GetResult struct {
+ quotaResult
+}
+
+// UpdateResult is the response from a Update operation. Call its Extract method to interpret it
+// as a QuotaSet.
+type UpdateResult struct {
+ quotaResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to interpret it
+// as a QuotaSet.
+type DeleteResult struct {
+ quotaResult
+}
diff --git a/openstack/compute/v2/extensions/quotasets/testing/doc.go b/openstack/compute/v2/extensions/quotasets/testing/doc.go
new file mode 100644
index 0000000..19ad75d
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_quotasets_v2
+package testing
diff --git a/openstack/compute/v2/extensions/quotasets/testing/fixtures.go b/openstack/compute/v2/extensions/quotasets/testing/fixtures.go
new file mode 100644
index 0000000..79305a7
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/testing/fixtures.go
@@ -0,0 +1,118 @@
+package testing
+
+import (
+ "fmt"
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/quotasets"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+ "net/http"
+ "testing"
+)
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "quota_set" : {
+ "instances" : 25,
+ "security_groups" : 10,
+ "security_group_rules" : 20,
+ "cores" : 200,
+ "injected_file_content_bytes" : 10240,
+ "injected_files" : 5,
+ "metadata_items" : 128,
+ "ram" : 200000,
+ "key_pairs" : 10,
+ "injected_file_path_bytes" : 255,
+ "server_groups" : 2,
+ "server_group_members" : 3
+ }
+}
+`
+const FirstTenantID = "555544443333222211110000ffffeeee"
+
+// FirstQuotaSet is the first result in ListOutput.
+var FirstQuotaSet = quotasets.QuotaSet{
+ FixedIps: 0,
+ FloatingIps: 0,
+ InjectedFileContentBytes: 10240,
+ InjectedFilePathBytes: 255,
+ InjectedFiles: 5,
+ KeyPairs: 10,
+ MetadataItems: 128,
+ Ram: 200000,
+ SecurityGroupRules: 20,
+ SecurityGroups: 10,
+ Cores: 200,
+ Instances: 25,
+ ServerGroups: 2,
+ ServerGroupMembers: 3,
+}
+
+//The expected update Body. Is also returned by PUT request
+const UpdateOutput = `{"quota_set":{"cores":200,"fixed_ips":0,"floating_ips":0,"injected_file_content_bytes":10240,"injected_file_path_bytes":255,"injected_files":5,"instances":25,"key_pairs":10,"metadata_items":128,"ram":200000,"security_group_rules":20,"security_groups":10,"server_groups":2,"server_group_members":3}}`
+
+//The expected partialupdate Body. Is also returned by PUT request
+const PartialUpdateBody = `{"quota_set":{"cores":200, "force":true}}`
+
+//Result of Quota-update
+var UpdatedQuotaSet = quotasets.UpdateOpts{
+ FixedIps: gophercloud.IntToPointer(0),
+ FloatingIps: gophercloud.IntToPointer(0),
+ InjectedFileContentBytes: gophercloud.IntToPointer(10240),
+ InjectedFilePathBytes: gophercloud.IntToPointer(255),
+ InjectedFiles: gophercloud.IntToPointer(5),
+ KeyPairs: gophercloud.IntToPointer(10),
+ MetadataItems: gophercloud.IntToPointer(128),
+ Ram: gophercloud.IntToPointer(200000),
+ SecurityGroupRules: gophercloud.IntToPointer(20),
+ SecurityGroups: gophercloud.IntToPointer(10),
+ Cores: gophercloud.IntToPointer(200),
+ Instances: gophercloud.IntToPointer(25),
+ ServerGroups: gophercloud.IntToPointer(2),
+ ServerGroupMembers: gophercloud.IntToPointer(3),
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request for sample tenant
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-quota-sets/"+FirstTenantID, 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)
+ })
+}
+
+// HandlePutSuccessfully configures the test server to respond to a Put request for sample tenant
+func HandlePutSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-quota-sets/"+FirstTenantID, 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, UpdateOutput)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, UpdateOutput)
+ })
+}
+
+// HandlePartialPutSuccessfully configures the test server to respond to a Put request for sample tenant that only containes specific values
+func HandlePartialPutSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-quota-sets/"+FirstTenantID, 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, PartialUpdateBody)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, UpdateOutput)
+ })
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for sample tenant
+func HandleDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/os-quota-sets/"+FirstTenantID, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestBody(t, r, "")
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(202)
+ })
+}
diff --git a/openstack/compute/v2/extensions/quotasets/testing/requests_test.go b/openstack/compute/v2/extensions/quotasets/testing/requests_test.go
new file mode 100644
index 0000000..0193f97
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/testing/requests_test.go
@@ -0,0 +1,64 @@
+package testing
+
+import (
+ "testing"
+
+ "errors"
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/quotasets"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+ actual, err := quotasets.Get(client.ServiceClient(), FirstTenantID).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstQuotaSet, actual)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePutSuccessfully(t)
+ actual, err := quotasets.Update(client.ServiceClient(), FirstTenantID, UpdatedQuotaSet).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstQuotaSet, actual)
+}
+
+func TestPartialUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePartialPutSuccessfully(t)
+ opts := quotasets.UpdateOpts{Cores: gophercloud.IntToPointer(200), Force: true}
+ actual, err := quotasets.Update(client.ServiceClient(), FirstTenantID, opts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstQuotaSet, actual)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteSuccessfully(t)
+ _, err := quotasets.Delete(client.ServiceClient(), FirstTenantID).Extract()
+ th.AssertNoErr(t, err)
+}
+
+type ErrorUpdateOpts quotasets.UpdateOpts
+
+func (opts ErrorUpdateOpts) ToComputeQuotaUpdateMap() (map[string]interface{}, error) {
+ return nil, errors.New("This is an error")
+}
+
+func TestErrorInToComputeQuotaUpdateMap(t *testing.T) {
+ opts := &ErrorUpdateOpts{}
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePutSuccessfully(t)
+ _, err := quotasets.Update(client.ServiceClient(), FirstTenantID, opts).Extract()
+ if err == nil {
+ t.Fatal("Error handling failed")
+ }
+}
diff --git a/openstack/compute/v2/extensions/quotasets/urls.go b/openstack/compute/v2/extensions/quotasets/urls.go
new file mode 100644
index 0000000..64190e9
--- /dev/null
+++ b/openstack/compute/v2/extensions/quotasets/urls.go
@@ -0,0 +1,21 @@
+package quotasets
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-quota-sets"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(resourcePath)
+}
+
+func getURL(c *gophercloud.ServiceClient, tenantID string) string {
+ return c.ServiceURL(resourcePath, tenantID)
+}
+
+func updateURL(c *gophercloud.ServiceClient, tenantID string) string {
+ return getURL(c, tenantID)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, tenantID string) string {
+ return getURL(c, tenantID)
+}
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..1e06b46
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests.go
@@ -0,0 +1,148 @@
+package schedulerhints
+
+import (
+ "net"
+ "regexp"
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"target_cell,omitempty"`
+ // BuildNearHostIP specifies a subnet of compute nodes to host the instance.
+ BuildNearHostIP string
+}
+
+// CreateOptsBuilder builds the scheduler hints into a serializable format.
+type CreateOptsBuilder interface {
+ ToServerSchedulerHintsCreateMap() (map[string]interface{}, error)
+}
+
+// ToServerSchedulerHintsMap builds the scheduler hints into a serializable format.
+func (opts SchedulerHints) ToServerSchedulerHintsCreateMap() (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) {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "schedulerhints.SchedulerHints.Group"
+ err.Value = opts.Group
+ err.Info = "Group must be a UUID"
+ return nil, err
+ }
+ sh["group"] = opts.Group
+ }
+
+ if len(opts.DifferentHost) > 0 {
+ for _, diffHost := range opts.DifferentHost {
+ if !uuidRegex.MatchString(diffHost) {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "schedulerhints.SchedulerHints.DifferentHost"
+ err.Value = opts.DifferentHost
+ err.Info = "The hosts must be in UUID format."
+ return nil, err
+ }
+ }
+ sh["different_host"] = opts.DifferentHost
+ }
+
+ if len(opts.SameHost) > 0 {
+ for _, sameHost := range opts.SameHost {
+ if !uuidRegex.MatchString(sameHost) {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "schedulerhints.SchedulerHints.SameHost"
+ err.Value = opts.SameHost
+ err.Info = "The hosts must be in UUID format."
+ return nil, err
+ }
+ }
+ 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 {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "schedulerhints.SchedulerHints.Query"
+ err.Value = opts.Query
+ err.Info = "Must be a conditional statement in the format of [op,variable,value]"
+ return nil, err
+ }
+ sh["query"] = opts.Query
+ }
+
+ if opts.TargetCell != "" {
+ sh["target_cell"] = opts.TargetCell
+ }
+
+ if opts.BuildNearHostIP != "" {
+ if _, _, err := net.ParseCIDR(opts.BuildNearHostIP); err != nil {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "schedulerhints.SchedulerHints.BuildNearHostIP"
+ err.Value = opts.BuildNearHostIP
+ err.Info = "Must be a valid subnet in the form 192.168.1.1/24"
+ return nil, err
+ }
+ 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 CreateOptsBuilder
+}
+
+// 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.ToServerSchedulerHintsCreateMap()
+ 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/testing/doc.go b/openstack/compute/v2/extensions/schedulerhints/testing/doc.go
new file mode 100644
index 0000000..0640b5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_schedulerhints_v2
+package testing
diff --git a/openstack/compute/v2/extensions/schedulerhints/testing/requests_test.go b/openstack/compute/v2/extensions/schedulerhints/testing/requests_test.go
new file mode 100644
index 0000000..cf21c4e
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/testing/requests_test.go
@@ -0,0 +1,127 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/schedulerhints"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+ base := servers.CreateOpts{
+ Name: "createdserver",
+ ImageRef: "asdfasdfasdf",
+ FlavorRef: "performance1-1",
+ }
+
+ schedulerHints := 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 := schedulerhints.CreateOptsExt{
+ CreateOptsBuilder: base,
+ SchedulerHints: schedulerHints,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1"
+ },
+ "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.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 := schedulerhints.CreateOptsExt{
+ CreateOptsBuilder: base,
+ SchedulerHints: schedulerHints,
+ }
+
+ expected := `
+ {
+ "server": {
+ "name": "createdserver",
+ "imageRef": "asdfasdfasdf",
+ "flavorRef": "performance1-1"
+ },
+ "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/requests.go b/openstack/compute/v2/extensions/secgroups/requests.go
new file mode 100644
index 0000000..ec8019f
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests.go
@@ -0,0 +1,171 @@
+package secgroups
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+func commonList(client *gophercloud.ServiceClient, url string) pagination.Pager {
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return SecurityGroupPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 {
+ // the name of your security group.
+ Name string `json:"name" required:"true"`
+ // the description of your security group.
+ Description string `json:"description" required:"true"`
+}
+
+// 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)
+}
+
+// ToSecGroupCreateMap builds the create options into a serializable format.
+func (opts CreateOpts) ToSecGroupCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// Create will create a new security group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToSecGroupCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// 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) {
+ return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// Update will modify the mutable properties of a security group, notably its
+// name and description.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToSecGroupUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(resourceURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get will return details for a particular security group.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete will permanently delete a security group from the project.
+func Delete(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Delete(resourceURL(client, id), nil)
+ return
+}
+
+// CreateRuleOpts represents the configuration for adding a new rule to an
+// existing security group.
+type CreateRuleOpts struct {
+ // the ID of the group that this rule will be added to.
+ ParentGroupID string `json:"parent_group_id" required:"true"`
+ // the lower bound of the port range that will be opened.
+ FromPort int `json:"from_port"`
+ // the upper bound of the port range that will be opened.
+ ToPort int `json:"to_port"`
+ // the protocol type that will be allowed, e.g. TCP.
+ IPProtocol string `json:"ip_protocol" required:"true"`
+ // 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" or:"FromGroupID"`
+ // 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" or:"CIDR"`
+}
+
+// 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) {
+ return gophercloud.BuildRequestBody(opts, "security_group_rule")
+}
+
+// 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) (r CreateRuleResult) {
+ b, err := opts.ToRuleCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(rootRuleURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// DeleteRule will permanently delete a rule from a security group.
+func DeleteRule(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Delete(resourceRuleURL(client, id), nil)
+ return
+}
+
+func actionMap(prefix, groupName string) map[string]map[string]string {
+ return map[string]map[string]string{
+ prefix + "SecurityGroup": map[string]string{"name": groupName},
+ }
+}
+
+// AddServer will associate a server and a security group, enforcing the
+// rules of the group on the server.
+func AddServer(client *gophercloud.ServiceClient, serverID, groupName string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Post(serverActionURL(client, serverID), actionMap("add", groupName), &r.Body, nil)
+ return
+}
+
+// RemoveServer will disassociate a server from a security group.
+func RemoveServer(client *gophercloud.ServiceClient, serverID, groupName string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Post(serverActionURL(client, serverID), actionMap("remove", groupName), &r.Body, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/secgroups/results.go b/openstack/compute/v2/extensions/secgroups/results.go
new file mode 100644
index 0000000..f49338a
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/results.go
@@ -0,0 +1,184 @@
+package secgroups
+
+import (
+ "encoding/json"
+ "strconv"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"-"`
+
+ // The human-readable name of the group, which needs to be unique.
+ Name string `json:"name"`
+
+ // The human-readable description of the group.
+ Description string `json:"description"`
+
+ // The rules which determine how this security group operates.
+ Rules []Rule `json:"rules"`
+
+ // The ID of the tenant to which this security group belongs.
+ TenantID string `json:"tenant_id"`
+}
+
+func (r *SecurityGroup) UnmarshalJSON(b []byte) error {
+ type tmp SecurityGroup
+ var s struct {
+ tmp
+ ID interface{} `json:"id"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = SecurityGroup(s.tmp)
+
+ switch t := s.ID.(type) {
+ case float64:
+ r.ID = strconv.FormatFloat(t, 'f', -1, 64)
+ case string:
+ r.ID = t
+ }
+
+ return err
+}
+
+// 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 `json:"-"`
+
+ // The lower bound of the port range which this security group should open up
+ FromPort int `json:"from_port"`
+
+ // The upper bound of the port range which this security group should open up
+ ToPort int `json:"to_port"`
+
+ // The IP protocol (e.g. TCP) which the security group accepts
+ IPProtocol string `json:"ip_protocol"`
+
+ // The CIDR IP range whose traffic can be received
+ IPRange IPRange `json:"ip_range"`
+
+ // The security group ID to which this rule belongs
+ ParentGroupID string `json:"parent_group_id"`
+
+ // Not documented.
+ Group Group
+}
+
+func (r *Rule) UnmarshalJSON(b []byte) error {
+ type tmp Rule
+ var s struct {
+ tmp
+ ID interface{} `json:"id"`
+ ParentGroupID interface{} `json:"parent_group_id"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = Rule(s.tmp)
+
+ switch t := s.ID.(type) {
+ case float64:
+ r.ID = strconv.FormatFloat(t, 'f', -1, 64)
+ case string:
+ r.ID = t
+ }
+
+ switch t := s.ParentGroupID.(type) {
+ case float64:
+ r.ParentGroupID = strconv.FormatFloat(t, 'f', -1, 64)
+ case string:
+ r.ParentGroupID = t
+ }
+
+ return err
+}
+
+// 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 `json:"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)
+ return len(users) == 0, err
+}
+
+// ExtractSecurityGroups returns a slice of SecurityGroups contained in a single page of results.
+func ExtractSecurityGroups(r pagination.Page) ([]SecurityGroup, error) {
+ var s struct {
+ SecurityGroups []SecurityGroup `json:"security_groups"`
+ }
+ err := (r.(SecurityGroupPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ SecurityGroup *SecurityGroup `json:"security_group"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Rule *Rule `json:"security_group_rule"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Rule, err
+}
diff --git a/openstack/compute/v2/extensions/secgroups/testing/doc.go b/openstack/compute/v2/extensions/secgroups/testing/doc.go
new file mode 100644
index 0000000..fbf4613
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_secgroups_v2
+package testing
diff --git a/openstack/compute/v2/extensions/secgroups/testing/fixtures.go b/openstack/compute/v2/extensions/secgroups/testing/fixtures.go
new file mode 100644
index 0000000..536e7f8
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/testing/fixtures.go
@@ -0,0 +1,328 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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": %d
+ }
+}
+ `, groupID)
+ })
+}
+
+func mockGetNumericIDGroupRuleResponse(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": %d,
+ "rules": [
+ {
+ "parent_group_id": %d,
+ "id": %d
+ }
+ ]
+ }
+}
+ `, groupID, groupID, groupID)
+ })
+}
+
+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 mockAddRuleResponseICMPZero(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": 0,
+ "ip_protocol": "ICMP",
+ "to_port": 0,
+ "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": 0,
+ "group": {},
+ "ip_protocol": "ICMP",
+ "to_port": 0,
+ "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/testing/requests_test.go b/openstack/compute/v2/extensions/secgroups/testing/requests_test.go
new file mode 100644
index 0000000..b520764
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/testing/requests_test.go
@@ -0,0 +1,302 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 := secgroups.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := secgroups.ExtractSecurityGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []secgroups.SecurityGroup{
+ {
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ Rules: []secgroups.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 := secgroups.ListByServer(client.ServiceClient(), serverID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := secgroups.ExtractSecurityGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []secgroups.SecurityGroup{
+ {
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ Rules: []secgroups.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 := secgroups.CreateOpts{
+ Name: "test",
+ Description: "something",
+ }
+
+ group, err := secgroups.Create(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.SecurityGroup{
+ ID: groupID,
+ Name: "test",
+ Description: "something",
+ TenantID: "openstack",
+ Rules: []secgroups.Rule{},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateGroupResponse(t, groupID)
+
+ opts := secgroups.UpdateOpts{
+ Name: "new_name",
+ Description: "new_desc",
+ }
+
+ group, err := secgroups.Update(client.ServiceClient(), groupID, opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.SecurityGroup{
+ ID: groupID,
+ Name: "new_name",
+ Description: "something",
+ TenantID: "openstack",
+ Rules: []secgroups.Rule{},
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetGroupsResponse(t, groupID)
+
+ group, err := secgroups.Get(client.ServiceClient(), groupID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.SecurityGroup{
+ ID: groupID,
+ Description: "default",
+ Name: "default",
+ TenantID: "openstack",
+ Rules: []secgroups.Rule{
+ {
+ FromPort: 80,
+ ToPort: 85,
+ IPProtocol: "TCP",
+ IPRange: secgroups.IPRange{CIDR: "0.0.0.0"},
+ Group: secgroups.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 := secgroups.Get(client.ServiceClient(), "12345").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.SecurityGroup{ID: "12345"}
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGetNumericRuleID(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ numericGroupID := 12345
+
+ mockGetNumericIDGroupRuleResponse(t, numericGroupID)
+
+ group, err := secgroups.Get(client.ServiceClient(), "12345").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.SecurityGroup{
+ ID: "12345",
+ Rules: []secgroups.Rule{
+ {
+ ParentGroupID: "12345",
+ ID: "12345",
+ },
+ },
+ }
+ th.AssertDeepEquals(t, expected, group)
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteGroupResponse(t, groupID)
+
+ err := secgroups.Delete(client.ServiceClient(), groupID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAddRule(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddRuleResponse(t)
+
+ opts := secgroups.CreateRuleOpts{
+ ParentGroupID: groupID,
+ FromPort: 22,
+ ToPort: 22,
+ IPProtocol: "TCP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := secgroups.CreateRule(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.Rule{
+ FromPort: 22,
+ ToPort: 22,
+ Group: secgroups.Group{},
+ IPProtocol: "TCP",
+ ParentGroupID: groupID,
+ IPRange: secgroups.IPRange{CIDR: "0.0.0.0/0"},
+ ID: ruleID,
+ }
+
+ th.AssertDeepEquals(t, expected, rule)
+}
+
+func TestAddRuleICMPZero(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddRuleResponseICMPZero(t)
+
+ opts := secgroups.CreateRuleOpts{
+ ParentGroupID: groupID,
+ FromPort: 0,
+ ToPort: 0,
+ IPProtocol: "ICMP",
+ CIDR: "0.0.0.0/0",
+ }
+
+ rule, err := secgroups.CreateRule(client.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &secgroups.Rule{
+ FromPort: 0,
+ ToPort: 0,
+ Group: secgroups.Group{},
+ IPProtocol: "ICMP",
+ ParentGroupID: groupID,
+ IPRange: secgroups.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 := secgroups.DeleteRule(client.ServiceClient(), ruleID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestAddServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockAddServerToGroupResponse(t, serverID)
+
+ err := secgroups.AddServer(client.ServiceClient(), serverID, "test").ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestRemoveServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockRemoveServerFromGroupResponse(t, serverID)
+
+ err := secgroups.RemoveServer(client.ServiceClient(), serverID, "test").ExtractErr()
+ th.AssertNoErr(t, 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..d99746c
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/urls.go
@@ -0,0 +1,32 @@
+package secgroups
+
+import "github.com/gophercloud/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/requests.go b/openstack/compute/v2/extensions/servergroups/requests.go
new file mode 100644
index 0000000..ee98837
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/requests.go
@@ -0,0 +1,57 @@
+package servergroups
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 ServerGroupPage{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 `json:"name" required:"true"`
+ // Policies are the server group policies
+ Policies []string `json:"policies" required:"true"`
+}
+
+// ToServerGroupCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToServerGroupCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "server_group")
+}
+
+// Create requests the creation of a new Server Group
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToServerGroupCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get returns data about a previously created ServerGroup.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete requests the deletion of a previously allocated ServerGroup.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/servergroups/results.go b/openstack/compute/v2/extensions/servergroups/results.go
new file mode 100644
index 0000000..ab49b35
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/results.go
@@ -0,0 +1,78 @@
+package servergroups
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"id"`
+
+ // Name is the common name of the server group.
+ Name string `json:"name"`
+
+ // Polices are the group policies.
+ Policies []string `json:"policies"`
+
+ // Members are the members of the server group.
+ Members []string `json:"members"`
+
+ // Metadata includes a list of all user-specified key-value pairs attached to the Server Group.
+ Metadata map[string]interface{}
+}
+
+// ServerGroupPage stores a single, only page of ServerGroups
+// results from a List call.
+type ServerGroupPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a ServerGroupsPage is empty.
+func (page ServerGroupPage) 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(r pagination.Page) ([]ServerGroup, error) {
+ var s struct {
+ ServerGroups []ServerGroup `json:"server_groups"`
+ }
+ err := (r.(ServerGroupPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ ServerGroup *ServerGroup `json:"server_group"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/compute/v2/extensions/servergroups/testing/doc.go
new file mode 100644
index 0000000..65433f7
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_servergroups_v2
+package testing
diff --git a/openstack/compute/v2/extensions/servergroups/testing/fixtures.go b/openstack/compute/v2/extensions/servergroups/testing/fixtures.go
new file mode 100644
index 0000000..b53757a
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/testing/fixtures.go
@@ -0,0 +1,160 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/servergroups"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 = servergroups.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 = servergroups.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 = []servergroups.ServerGroup{FirstServerGroup, SecondServerGroup}
+
+// CreatedServerGroup is the parsed result from CreateOutput.
+var CreatedServerGroup = servergroups.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/testing/requests_test.go b/openstack/compute/v2/extensions/servergroups/testing/requests_test.go
new file mode 100644
index 0000000..d86fa56
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/testing/requests_test.go
@@ -0,0 +1,60 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/servergroups"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := servergroups.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := servergroups.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 := servergroups.Create(client.ServiceClient(), servergroups.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 := servergroups.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 := servergroups.Delete(client.ServiceClient(), "616fb98f-46ca-475e-917e-2563e5a8cd19").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/servergroups/urls.go b/openstack/compute/v2/extensions/servergroups/urls.go
new file mode 100644
index 0000000..9a1f99b
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/urls.go
@@ -0,0 +1,25 @@
+package servergroups
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/compute/v2/extensions/startstop/requests.go
new file mode 100644
index 0000000..1d8a593
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/requests.go
@@ -0,0 +1,19 @@
+package startstop
+
+import "github.com/gophercloud/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) (r gophercloud.ErrResult) {
+ _, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"os-start": nil}, nil, nil)
+ return
+}
+
+// Stop is the operation responsible for stopping a Compute server.
+func Stop(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+ _, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"os-stop": nil}, nil, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/startstop/testing/doc.go b/openstack/compute/v2/extensions/startstop/testing/doc.go
new file mode 100644
index 0000000..6135475
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_startstop_v2
+package testing
diff --git a/openstack/compute/v2/extensions/startstop/testing/fixtures.go b/openstack/compute/v2/extensions/startstop/testing/fixtures.go
new file mode 100644
index 0000000..1086b0e
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/testing/fixtures.go
@@ -0,0 +1,27 @@
+package testing
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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/testing/requests_test.go b/openstack/compute/v2/extensions/startstop/testing/requests_test.go
new file mode 100644
index 0000000..be45bf5
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/testing/requests_test.go
@@ -0,0 +1,31 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/startstop"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const serverID = "{serverId}"
+
+func TestStart(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockStartServerResponse(t, serverID)
+
+ err := startstop.Start(client.ServiceClient(), serverID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
+
+func TestStop(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockStopServerResponse(t, serverID)
+
+ err := startstop.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/requests.go b/openstack/compute/v2/extensions/tenantnetworks/requests.go
new file mode 100644
index 0000000..82836d4
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/requests.go
@@ -0,0 +1,19 @@
+package tenantnetworks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return NetworkPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/results.go b/openstack/compute/v2/extensions/tenantnetworks/results.go
new file mode 100644
index 0000000..88cbc80
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/results.go
@@ -0,0 +1,59 @@
+package tenantnetworks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// A Network represents a nova-network that an instance communicates on
+type Network struct {
+ // CIDR is the IPv4 subnet.
+ CIDR string `json:"cidr"`
+
+ // ID is the UUID of the network.
+ ID string `json:"id"`
+
+ // Name is the common name that the network has.
+ Name string `json:"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(r pagination.Page) ([]Network, error) {
+ var s struct {
+ Networks []Network `json:"networks"`
+ }
+ err := (r.(NetworkPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Network *Network `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/compute/v2/extensions/tenantnetworks/testing/doc.go
new file mode 100644
index 0000000..7ed7ce3
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_tenantnetworks_v2
+package testing
diff --git a/openstack/compute/v2/extensions/tenantnetworks/testing/fixtures.go b/openstack/compute/v2/extensions/tenantnetworks/testing/fixtures.go
new file mode 100644
index 0000000..ae679b4
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/testing/fixtures.go
@@ -0,0 +1,83 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 = tenantnetworks.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 = tenantnetworks.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 = []tenantnetworks.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/testing/requests_test.go b/openstack/compute/v2/extensions/tenantnetworks/testing/requests_test.go
new file mode 100644
index 0000000..703c846
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/testing/requests_test.go
@@ -0,0 +1,38 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := tenantnetworks.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := tenantnetworks.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 := tenantnetworks.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/urls.go b/openstack/compute/v2/extensions/tenantnetworks/urls.go
new file mode 100644
index 0000000..683041d
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/urls.go
@@ -0,0 +1,17 @@
+package tenantnetworks
+
+import "github.com/gophercloud/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/testing/delegate_test.go b/openstack/compute/v2/extensions/testing/delegate_test.go
new file mode 100644
index 0000000..822093f
--- /dev/null
+++ b/openstack/compute/v2/extensions/testing/delegate_test.go
@@ -0,0 +1,56 @@
+package testing
+
+import (
+ "testing"
+
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListExtensionsSuccessfully(t)
+
+ count := 0
+ extensions.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := extensions.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()
+
+ HandleGetExtensionsSuccessfully(t)
+
+ ext, err := extensions.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/testing/doc.go b/openstack/compute/v2/extensions/testing/doc.go
new file mode 100644
index 0000000..5818711
--- /dev/null
+++ b/openstack/compute/v2/extensions/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_extensions_v2
+package testing
diff --git a/openstack/compute/v2/extensions/testing/fixtures.go b/openstack/compute/v2/extensions/testing/fixtures.go
new file mode 100644
index 0000000..2a3fb69
--- /dev/null
+++ b/openstack/compute/v2/extensions/testing/fixtures.go
@@ -0,0 +1,57 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+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": [
+ {
+ "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"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func HandleGetExtensionsSuccessfully(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, `
+{
+ "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."
+ }
+}
+ `)
+ })
+}
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..ee4d62d
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/requests.go
@@ -0,0 +1,57 @@
+package volumeattach
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 VolumeAttachmentPage{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 `json:"device,omitempty"`
+ // VolumeID is the ID of the volume to attach to the instance
+ VolumeID string `json:"volumeId" required:"true"`
+}
+
+// ToVolumeAttachmentCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToVolumeAttachmentCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "volumeAttachment")
+}
+
+// Create requests the creation of a new volume attachment on the server
+func Create(client *gophercloud.ServiceClient, serverID string, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToVolumeAttachmentCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client, serverID), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Get returns public data about a previously created VolumeAttachment.
+func Get(client *gophercloud.ServiceClient, serverID, attachmentID string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, serverID, attachmentID), &r.Body, nil)
+ return
+}
+
+// Delete requests the deletion of a previous stored VolumeAttachment from the server.
+func Delete(client *gophercloud.ServiceClient, serverID, attachmentID string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, serverID, attachmentID), nil)
+ return
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/results.go b/openstack/compute/v2/extensions/volumeattach/results.go
new file mode 100644
index 0000000..53faf5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/results.go
@@ -0,0 +1,76 @@
+package volumeattach
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// VolumeAttachment controls the attachment of a volume to an instance.
+type VolumeAttachment struct {
+ // ID is a unique id of the attachment
+ ID string `json:"id"`
+
+ // Device is what device the volume is attached as
+ Device string `json:"device"`
+
+ // VolumeID is the ID of the attached volume
+ VolumeID string `json:"volumeId"`
+
+ // ServerID is the ID of the instance that has the volume attached
+ ServerID string `json:"serverId"`
+}
+
+// VolumeAttachmentPage stores a single, only page of VolumeAttachments
+// results from a List call.
+type VolumeAttachmentPage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a VolumeAttachmentsPage is empty.
+func (page VolumeAttachmentPage) 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(r pagination.Page) ([]VolumeAttachment, error) {
+ var s struct {
+ VolumeAttachments []VolumeAttachment `json:"volumeAttachments"`
+ }
+ err := (r.(VolumeAttachmentPage)).ExtractInto(&s)
+ return s.VolumeAttachments, err
+}
+
+// VolumeAttachmentResult is the result from a volume attachment operation.
+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) {
+ var s struct {
+ VolumeAttachment *VolumeAttachment `json:"volumeAttachment"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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..2cc0ab4
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/doc.go
@@ -0,0 +1,9 @@
+// compute_extensions_volumeattach_v2
+package testing
+
+/*
+Package testing holds fixtures (which imports testing),
+so that importing volumeattach package does not inadvertently import testing into production code
+More information here:
+https://github.com/gophercloud/gophercloud/issues/473
+*/
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..4f99610
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go
@@ -0,0 +1,108 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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/testing/requests_test.go b/openstack/compute/v2/extensions/volumeattach/testing/requests_test.go
new file mode 100644
index 0000000..9486f9b
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/requests_test.go
@@ -0,0 +1,102 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/volumeattach"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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()
+
+ HandleListSuccessfully(t)
+
+ serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ count := 0
+ err := volumeattach.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()
+
+ HandleCreateSuccessfully(t)
+
+ serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := volumeattach.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()
+
+ HandleGetSuccessfully(t)
+
+ aID := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ actual, err := volumeattach.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()
+
+ HandleDeleteSuccessfully(t)
+
+ aID := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+ serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+ err := volumeattach.Delete(client.ServiceClient(), serverID, aID).ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/urls.go b/openstack/compute/v2/extensions/volumeattach/urls.go
new file mode 100644
index 0000000..083f8dc
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/urls.go
@@ -0,0 +1,25 @@
+package volumeattach
+
+import "github.com/gophercloud/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/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..d5d571c
--- /dev/null
+++ b/openstack/compute/v2/flavors/requests.go
@@ -0,0 +1,141 @@
+package flavors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+type CreateOptsBuilder interface {
+ ToFlavorCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is passed to Create to create a flavor
+// Source:
+// https://github.com/openstack/nova/blob/stable/newton/nova/api/openstack/compute/schemas/flavor_manage.py#L20
+type CreateOpts struct {
+ Name string `json:"name" required:"true"`
+ // memory size, in MBs
+ RAM int `json:"ram" required:"true"`
+ VCPUs int `json:"vcpus" required:"true"`
+ // disk size, in GBs
+ Disk *int `json:"disk" required:"true"`
+ ID string `json:"id,omitempty"`
+ // non-zero, positive
+ Swap *int `json:"swap,omitempty"`
+ RxTxFactor float64 `json:"rxtx_factor,omitempty"`
+ IsPublic *bool `json:"os-flavor-access:is_public,omitempty"`
+ // ephemeral disk size, in GBs, non-zero, positive
+ Ephemeral *int `json:"OS-FLV-EXT-DATA:ephemeral,omitempty"`
+}
+
+// ToFlavorCreateMap satisfies the CreateOptsBuilder interface
+func (opts *CreateOpts) ToFlavorCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "flavor")
+}
+
+// Create a flavor
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToFlavorCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// IDFromName is a convienience function that returns a flavor's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ allPages, err := ListDetail(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractFlavors(allPages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, f := range all {
+ if f.Name == name {
+ count++
+ id = f.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ err := &gophercloud.ErrResourceNotFound{}
+ err.ResourceType = "flavor"
+ err.Name = name
+ return "", err
+ case 1:
+ return id, nil
+ default:
+ err := &gophercloud.ErrMultipleResourcesFound{}
+ err.ResourceType = "flavor"
+ err.Name = name
+ err.Count = count
+ return "", err
+ }
+}
diff --git a/openstack/compute/v2/flavors/results.go b/openstack/compute/v2/flavors/results.go
new file mode 100644
index 0000000..18b8434
--- /dev/null
+++ b/openstack/compute/v2/flavors/results.go
@@ -0,0 +1,113 @@
+package flavors
+
+import (
+ "encoding/json"
+ "strconv"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+ commonResult
+}
+
+// Extract provides access to the individual Flavor returned by the Get and Create functions.
+func (r commonResult) Extract() (*Flavor, error) {
+ var s struct {
+ Flavor *Flavor `json:"flavor"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 `json:"id"`
+ // The Disk and RA< fields provide a measure of storage space offered by the flavor, in GB and MB, respectively.
+ Disk int `json:"disk"`
+ RAM int `json:"ram"`
+ // The Name field provides a human-readable moniker for the flavor.
+ Name string `json:"name"`
+ RxTxFactor float64 `json:"rxtx_factor"`
+ // Swap indicates how much space is reserved for swap.
+ // If not provided, this field will be set to 0.
+ Swap int `json:"swap"`
+ // VCPUs indicates how many (virtual) CPUs are available for this flavor.
+ VCPUs int `json:"vcpus"`
+}
+
+func (r *Flavor) UnmarshalJSON(b []byte) error {
+ type tmp Flavor
+ var s struct {
+ tmp
+ Swap interface{} `json:"swap"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = Flavor(s.tmp)
+
+ switch t := s.Swap.(type) {
+ case float64:
+ r.Swap = int(t)
+ case string:
+ switch t {
+ case "":
+ r.Swap = 0
+ default:
+ swap, err := strconv.ParseFloat(t, 64)
+ if err != nil {
+ return err
+ }
+ r.Swap = int(swap)
+ }
+ }
+
+ return nil
+}
+
+// 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 (page FlavorPage) IsEmpty() (bool, error) {
+ flavors, err := ExtractFlavors(page)
+ return len(flavors) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page FlavorPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"flavors_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+ var s struct {
+ Flavors []Flavor `json:"flavors"`
+ }
+ err := (r.(FlavorPage)).ExtractInto(&s)
+ return s.Flavors, err
+}
diff --git a/openstack/compute/v2/flavors/testing/doc.go b/openstack/compute/v2/flavors/testing/doc.go
new file mode 100644
index 0000000..0d00761
--- /dev/null
+++ b/openstack/compute/v2/flavors/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_flavors_v2
+package testing
diff --git a/openstack/compute/v2/flavors/testing/requests_test.go b/openstack/compute/v2/flavors/testing/requests_test.go
new file mode 100644
index 0000000..f4f1c8d
--- /dev/null
+++ b/openstack/compute/v2/flavors/testing/requests_test.go
@@ -0,0 +1,186 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/flavors"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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,
+ "swap":""
+ },
+ {
+ "id": "2",
+ "name": "m2.small",
+ "disk": 10,
+ "ram": 1024,
+ "vcpus": 2,
+ "swap": 1000
+ }
+ ],
+ "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 := flavors.ListDetail(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := flavors.ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []flavors.Flavor{
+ {ID: "1", Name: "m1.tiny", Disk: 1, RAM: 512, VCPUs: 1, Swap: 0},
+ {ID: "2", Name: "m2.small", Disk: 10, RAM: 1024, VCPUs: 2, Swap: 1000},
+ }
+
+ 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,
+ "swap": ""
+ }
+ }
+ `)
+ })
+
+ actual, err := flavors.Get(fake.ServiceClient(), "12345").Extract()
+ if err != nil {
+ t.Fatalf("Unable to get flavor: %v", err)
+ }
+
+ expected := &flavors.Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ Disk: 1,
+ RAM: 512,
+ VCPUs: 1,
+ RxTxFactor: 1,
+ Swap: 0,
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but was %#v", expected, actual)
+ }
+}
+
+func TestCreateFlavor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/flavors", 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")
+ fmt.Fprintf(w, `
+ {
+ "flavor": {
+ "id": "1",
+ "name": "m1.tiny",
+ "disk": 1,
+ "ram": 512,
+ "vcpus": 1,
+ "rxtx_factor": 1,
+ "swap": ""
+ }
+ }
+ `)
+ })
+
+ disk := 1
+ opts := &flavors.CreateOpts{
+ ID: "1",
+ Name: "m1.tiny",
+ Disk: &disk,
+ RAM: 512,
+ VCPUs: 1,
+ RxTxFactor: 1.0,
+ }
+ actual, err := flavors.Create(fake.ServiceClient(), opts).Extract()
+ if err != nil {
+ t.Fatalf("Unable to create flavor: %v", err)
+ }
+
+ expected := &flavors.Flavor{
+ ID: "1",
+ Name: "m1.tiny",
+ Disk: 1,
+ RAM: 512,
+ VCPUs: 1,
+ RxTxFactor: 1,
+ Swap: 0,
+ }
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but was %#v", expected, actual)
+ }
+}
diff --git a/openstack/compute/v2/flavors/urls.go b/openstack/compute/v2/flavors/urls.go
new file mode 100644
index 0000000..2fc2179
--- /dev/null
+++ b/openstack/compute/v2/flavors/urls.go
@@ -0,0 +1,17 @@
+package flavors
+
+import (
+ "github.com/gophercloud/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")
+}
+
+func createURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("flavors")
+}
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..df9f1da
--- /dev/null
+++ b/openstack/compute/v2/images/requests.go
@@ -0,0 +1,102 @@
+package images
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// 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) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete deletes the specified image ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// IDFromName is a convienience function that returns an image's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ allPages, err := ListDetail(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractImages(allPages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, f := range all {
+ if f.Name == name {
+ count++
+ id = f.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ err := &gophercloud.ErrResourceNotFound{}
+ err.ResourceType = "image"
+ err.Name = name
+ return "", err
+ case 1:
+ return id, nil
+ default:
+ err := &gophercloud.ErrMultipleResourcesFound{}
+ err.ResourceType = "image"
+ err.Name = name
+ err.Count = count
+ return "", err
+ }
+}
diff --git a/openstack/compute/v2/images/results.go b/openstack/compute/v2/images/results.go
new file mode 100644
index 0000000..f9ebc69
--- /dev/null
+++ b/openstack/compute/v2/images/results.go
@@ -0,0 +1,83 @@
+package images
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 (r GetResult) Extract() (*Image, error) {
+ var s struct {
+ Image *Image `json:"image"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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
+
+ Metadata map[string]interface{}
+}
+
+// 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)
+ return len(images) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page ImagePage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"images_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractImages converts a page of List results into a slice of usable Image structs.
+func ExtractImages(r pagination.Page) ([]Image, error) {
+ var s struct {
+ Images []Image `json:"images"`
+ }
+ err := (r.(ImagePage)).ExtractInto(&s)
+ return s.Images, err
+}
diff --git a/openstack/compute/v2/images/testing/doc.go b/openstack/compute/v2/images/testing/doc.go
new file mode 100644
index 0000000..6f59ade
--- /dev/null
+++ b/openstack/compute/v2/images/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_images_v2
+package testing
diff --git a/openstack/compute/v2/images/testing/requests_test.go b/openstack/compute/v2/images/testing/requests_test.go
new file mode 100644
index 0000000..1de0303
--- /dev/null
+++ b/openstack/compute/v2/images/testing/requests_test.go
@@ -0,0 +1,225 @@
+package testing
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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": {
+ "architecture": "x86_64",
+ "block_device_mapping": {
+ "guest_format": null,
+ "boot_index": 0,
+ "device_name": "/dev/vda",
+ "delete_on_termination": false
+ }
+ }
+ },
+ {
+ "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
+ }
+ ]
+ }
+ `)
+ case "2":
+ fmt.Fprintf(w, `{ "images": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ pages := 0
+ options := &images.ListOpts{Limit: 2}
+ err := images.ListDetail(fake.ServiceClient(), options).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := images.ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []images.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",
+ Metadata: map[string]interface{}{
+ "architecture": "x86_64",
+ "block_device_mapping": map[string]interface{}{
+ "guest_format": interface{}(nil),
+ "boot_index": float64(0),
+ "device_name": "/dev/vda",
+ "delete_on_termination": false,
+ },
+ },
+ },
+ {
+ 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": {
+ "architecture": "x86_64",
+ "block_device_mapping": {
+ "guest_format": null,
+ "boot_index": 0,
+ "device_name": "/dev/vda",
+ "delete_on_termination": false
+ }
+ }
+ }
+ }
+ `)
+ })
+
+ actual, err := images.Get(fake.ServiceClient(), "12345678").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Get: %v", err)
+ }
+
+ expected := &images.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,
+ Metadata: map[string]interface{}{
+ "architecture": "x86_64",
+ "block_device_mapping": map[string]interface{}{
+ "guest_format": interface{}(nil),
+ "boot_index": float64(0),
+ "device_name": "/dev/vda",
+ "delete_on_termination": false,
+ },
+ },
+ }
+
+ if !reflect.DeepEqual(expected, actual) {
+ t.Errorf("Expected %#v, but got %#v", expected, actual)
+ }
+}
+
+func TestNextPageURL(t *testing.T) {
+ var page images.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 := images.Delete(fake.ServiceClient(), "12345678")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/compute/v2/images/urls.go b/openstack/compute/v2/images/urls.go
new file mode 100644
index 0000000..57787fb
--- /dev/null
+++ b/openstack/compute/v2/images/urls.go
@@ -0,0 +1,15 @@
+package images
+
+import "github.com/gophercloud/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/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/errors.go b/openstack/compute/v2/servers/errors.go
new file mode 100644
index 0000000..c9f0e3c
--- /dev/null
+++ b/openstack/compute/v2/servers/errors.go
@@ -0,0 +1,71 @@
+package servers
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// ErrNeitherImageIDNorImageNameProvided is the error when neither the image
+// ID nor the image name is provided for a server operation
+type ErrNeitherImageIDNorImageNameProvided struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNeitherImageIDNorImageNameProvided) Error() string {
+ return "One and only one of the image ID and the image name must be provided."
+}
+
+// ErrNeitherFlavorIDNorFlavorNameProvided is the error when neither the flavor
+// ID nor the flavor name is provided for a server operation
+type ErrNeitherFlavorIDNorFlavorNameProvided struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNeitherFlavorIDNorFlavorNameProvided) Error() string {
+ return "One and only one of the flavor ID and the flavor name must be provided."
+}
+
+type ErrNoClientProvidedForIDByName struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNoClientProvidedForIDByName) Error() string {
+ return "A service client must be provided to find a resource ID by name."
+}
+
+// ErrInvalidHowParameterProvided is the error when an unknown value is given
+// for the `how` argument
+type ErrInvalidHowParameterProvided struct{ gophercloud.ErrInvalidInput }
+
+// ErrNoAdminPassProvided is the error when an administrative password isn't
+// provided for a server operation
+type ErrNoAdminPassProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrNoImageIDProvided is the error when an image ID isn't provided for a server
+// operation
+type ErrNoImageIDProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrNoIDProvided is the error when a server ID isn't provided for a server
+// operation
+type ErrNoIDProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrServer is a generic error type for servers HTTP operations.
+type ErrServer struct {
+ gophercloud.ErrUnexpectedResponseCode
+ ID string
+}
+
+func (se ErrServer) Error() string {
+ return fmt.Sprintf("Error while executing HTTP request for server [%s]", se.ID)
+}
+
+// Error404 overrides the generic 404 error message.
+func (se ErrServer) Error404(e gophercloud.ErrUnexpectedResponseCode) error {
+ se.ErrUnexpectedResponseCode = e
+ return &ErrServerNotFound{se}
+}
+
+// ErrServerNotFound is the error when a 404 is received during server HTTP
+// operations.
+type ErrServerNotFound struct {
+ ErrServer
+}
+
+func (e ErrServerNotFound) Error() string {
+ return fmt.Sprintf("I couldn't find server [%s]", e.ID)
+}
diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go
new file mode 100644
index 0000000..9618637
--- /dev/null
+++ b/openstack/compute/v2/servers/requests.go
@@ -0,0 +1,741 @@
+package servers
+
+import (
+ "encoding/base64"
+ "encoding/json"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/flavors"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ServerPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// 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 is the name to assign to the newly launched server.
+ Name string `json:"name" required:"true"`
+
+ // 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 `json:"imageRef"`
+
+ // 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 `json:"-"`
+
+ // 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 `json:"flavorRef"`
+
+ // FlavorName [optional; required if FlavorRef is not provided] is the name of
+ // the flavor that describes the server's specs.
+ FlavorName string `json:"-"`
+
+ // SecurityGroups lists the names of the security groups to which this server should belong.
+ SecurityGroups []string `json:"-"`
+
+ // UserData contains configuration information or scripts to use upon launch.
+ // Create will base64-encode it for you, if it isn't already.
+ UserData []byte `json:"-"`
+
+ // AvailabilityZone in which to launch the server.
+ AvailabilityZone string `json:"availability_zone,omitempty"`
+
+ // Networks 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 `json:"-"`
+
+ // Metadata contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string `json:"metadata,omitempty"`
+
+ // Personality includes files to inject into the server at launch.
+ // Create will base64-encode file contents for you.
+ Personality Personality `json:"personality,omitempty"`
+
+ // ConfigDrive enables metadata injection through a configuration drive.
+ ConfigDrive *bool `json:"config_drive,omitempty"`
+
+ // AdminPass sets the root user password. If not set, a randomly-generated
+ // password will be created and returned in the rponse.
+ AdminPass string `json:"adminPass,omitempty"`
+
+ // AccessIPv4 specifies an IPv4 address for the instance.
+ AccessIPv4 string `json:"accessIPv4,omitempty"`
+
+ // AccessIPv6 pecifies an IPv6 address for the instance.
+ AccessIPv6 string `json:"accessIPv6,omitempty"`
+
+ // ServiceClient will allow calls to be made to retrieve an image or
+ // flavor ID by name.
+ ServiceClient *gophercloud.ServiceClient `json:"-"`
+}
+
+// ToServerCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) {
+ sc := opts.ServiceClient
+ opts.ServiceClient = nil
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.UserData != nil {
+ var userData string
+ if _, err := base64.StdEncoding.DecodeString(string(opts.UserData)); err != nil {
+ userData = base64.StdEncoding.EncodeToString(opts.UserData)
+ } else {
+ userData = string(opts.UserData)
+ }
+ b["user_data"] = &userData
+ }
+
+ 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}
+ }
+ b["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
+ }
+ }
+ b["networks"] = networks
+ }
+
+ // If ImageRef isn't provided, check if ImageName was provided to ascertain
+ // the image ID.
+ if opts.ImageRef == "" {
+ if opts.ImageName != "" {
+ if sc == nil {
+ err := ErrNoClientProvidedForIDByName{}
+ err.Argument = "ServiceClient"
+ return nil, err
+ }
+ imageID, err := images.IDFromName(sc, opts.ImageName)
+ if err != nil {
+ return nil, err
+ }
+ b["imageRef"] = imageID
+ }
+ }
+
+ // If FlavorRef isn't provided, use FlavorName to ascertain the flavor ID.
+ if opts.FlavorRef == "" {
+ if opts.FlavorName == "" {
+ err := ErrNeitherFlavorIDNorFlavorNameProvided{}
+ err.Argument = "FlavorRef/FlavorName"
+ return nil, err
+ }
+ if sc == nil {
+ err := ErrNoClientProvidedForIDByName{}
+ err.Argument = "ServiceClient"
+ return nil, err
+ }
+ flavorID, err := flavors.IDFromName(sc, opts.FlavorName)
+ if err != nil {
+ return nil, err
+ }
+ b["flavorRef"] = flavorID
+ }
+
+ return map[string]interface{}{"server": b}, nil
+}
+
+// Create requests a server to be provisioned to the user in the current tenant.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ reqBody, err := opts.ToServerCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(listURL(client), reqBody, &r.Body, nil)
+ return
+}
+
+// Delete requests that a server previously provisioned be removed from your account.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// ForceDelete forces the deletion of a server
+func ForceDelete(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+ _, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"forceDelete": ""}, nil, nil)
+ return
+}
+
+// Get requests details on a single server, by ID.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ })
+ return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToServerUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts struct {
+ // Name 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 `json:"name,omitempty"`
+
+ // AccessIPv4 provides a new IPv4 address for the instance.
+ AccessIPv4 string `json:"accessIPv4,omitempty"`
+
+ // AccessIPv6 provides a new IPv6 address for the instance.
+ AccessIPv6 string `json:"accessIPv6,omitempty"`
+}
+
+// ToServerUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToServerUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "server")
+}
+
+// Update requests that various attributes of the indicated server be changed.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToServerUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// ChangeAdminPassword alters the administrator or root password for a specified server.
+func ChangeAdminPassword(client *gophercloud.ServiceClient, id, newPassword string) (r ActionResult) {
+ b := map[string]interface{}{
+ "changePassword": map[string]string{
+ "adminPass": newPassword,
+ },
+ }
+ _, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+ return
+}
+
+// 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
+)
+
+// RebootOptsBuilder is an interface that options must satisfy in order to be
+// used when rebooting a server instance
+type RebootOptsBuilder interface {
+ ToServerRebootMap() (map[string]interface{}, error)
+}
+
+// RebootOpts satisfies the RebootOptsBuilder interface
+type RebootOpts struct {
+ Type RebootMethod `json:"type" required:"true"`
+}
+
+// ToServerRebootMap allows RebootOpts to satisfiy the RebootOptsBuilder
+// interface
+func (opts *RebootOpts) ToServerRebootMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "reboot")
+}
+
+// Reboot requests that a given server reboot.
+// Two methods exist for rebooting a server:
+//
+// HardReboot (aka PowerCycle) starts 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 rtored or the VM instance rtarted.
+//
+// SoftReboot (aka OSReboot) simply tells the OS to rtart under its own procedur.
+// E.g., in Linux, asking it to enter runlevel 6, or executing "sudo shutdown -r now", or by asking Windows to rtart the machine.
+func Reboot(client *gophercloud.ServiceClient, id string, opts RebootOptsBuilder) (r ActionResult) {
+ b, err := opts.ToServerRebootMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+ return
+}
+
+// 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 {
+ // The server's admin password
+ AdminPass string `json:"adminPass,omitempty"`
+ // The ID of the image you want your server to be provisioned on
+ ImageID string `json:"imageRef"`
+ ImageName string `json:"-"`
+ // Name to set the server to
+ Name string `json:"name,omitempty"`
+ // AccessIPv4 [optional] provides a new IPv4 address for the instance.
+ AccessIPv4 string `json:"accessIPv4,omitempty"`
+ // AccessIPv6 [optional] provides a new IPv6 address for the instance.
+ AccessIPv6 string `json:"accessIPv6,omitempty"`
+ // Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+ Metadata map[string]string `json:"metadata,omitempty"`
+ // Personality [optional] includes files to inject into the server at launch.
+ // Rebuild will base64-encode file contents for you.
+ Personality Personality `json:"personality,omitempty"`
+ ServiceClient *gophercloud.ServiceClient `json:"-"`
+}
+
+// ToServerRebuildMap formats a RebuildOpts struct into a map for use in JSON
+func (opts RebuildOpts) ToServerRebuildMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ // If ImageRef isn't provided, check if ImageName was provided to ascertain
+ // the image ID.
+ if opts.ImageID == "" {
+ if opts.ImageName != "" {
+ if opts.ServiceClient == nil {
+ err := ErrNoClientProvidedForIDByName{}
+ err.Argument = "ServiceClient"
+ return nil, err
+ }
+ imageID, err := images.IDFromName(opts.ServiceClient, opts.ImageName)
+ if err != nil {
+ return nil, err
+ }
+ b["imageRef"] = imageID
+ }
+ }
+
+ return map[string]interface{}{"rebuild": b}, 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) (r RebuildResult) {
+ b, err := opts.ToServerRebuildMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(actionURL(client, id), b, &r.Body, nil)
+ return
+}
+
+// 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 `json:"flavorRef" required:"true"`
+}
+
+// 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) {
+ return gophercloud.BuildRequestBody(opts, "resize")
+}
+
+// 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) (r ActionResult) {
+ b, err := opts.ToServerResizeMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+ return
+}
+
+// ConfirmResize confirms a previous resize operation on a server.
+// See Resize() for more details.
+func ConfirmResize(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+ _, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"confirmResize": nil}, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{201, 202, 204},
+ })
+ return
+}
+
+// RevertResize cancels a previous resize operation on a server.
+// See Resize() for more details.
+func RevertResize(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+ _, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"revertResize": nil}, nil, nil)
+ return
+}
+
+// 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 `json:"adminPass,omitempty"`
+}
+
+// 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) {
+ return gophercloud.BuildRequestBody(opts, "rescue")
+}
+
+// Rescue instructs the provider to place the server into RESCUE mode.
+func Rescue(client *gophercloud.ServiceClient, id string, opts RescueOptsBuilder) (r RescueResult) {
+ b, err := opts.ToServerRescueMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(actionURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// 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) (r ResetMetadataResult) {
+ b, err := opts.ToMetadataResetMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(metadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Metadata requests all the metadata for the given server ID.
+func Metadata(client *gophercloud.ServiceClient, id string) (r GetMetadataResult) {
+ _, r.Err = client.Get(metadataURL(client, id), &r.Body, nil)
+ return
+}
+
+// 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) (r UpdateMetadataResult) {
+ b, err := opts.ToMetadataUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(metadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// 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 {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "servers.MetadatumOpts"
+ err.Info = "Must have 1 and only 1 key-value pair"
+ return nil, "", err
+ }
+ 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) (r CreateMetadatumResult) {
+ b, key, err := opts.ToMetadatumCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(metadatumURL(client, id, key), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Metadatum requests the key-value pair with the given key for the given server ID.
+func Metadatum(client *gophercloud.ServiceClient, id, key string) (r GetMetadatumResult) {
+ _, r.Err = client.Get(metadatumURL(client, id, key), &r.Body, nil)
+ return
+}
+
+// DeleteMetadatum will delete the key-value pair with the given key for the given server ID.
+func DeleteMetadatum(client *gophercloud.ServiceClient, id, key string) (r DeleteMetadatumResult) {
+ _, r.Err = client.Delete(metadatumURL(client, id, key), nil)
+ return
+}
+
+// ListAddresses makes a request against the API to list the servers IP addresses.
+func ListAddresses(client *gophercloud.ServiceClient, id string) pagination.Pager {
+ return pagination.NewPager(client, listAddressesURL(client, id), func(r pagination.PageResult) pagination.Page {
+ return AddressPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 pagination.NewPager(client, listAddressesByNetworkURL(client, id, network), func(r pagination.PageResult) pagination.Page {
+ return NetworkAddressPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// CreateImageOptsBuilder is the interface types must satisfy in order to be
+// used as CreateImage options
+type CreateImageOptsBuilder interface {
+ ToServerCreateImageMap() (map[string]interface{}, error)
+}
+
+// CreateImageOpts satisfies the CreateImageOptsBuilder
+type CreateImageOpts struct {
+ // Name of the image/snapshot
+ Name string `json:"name" required:"true"`
+ // Metadata contains key-value pairs (up to 255 bytes each) to attach to the created image.
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// ToServerCreateImageMap formats a CreateImageOpts structure into a request body.
+func (opts CreateImageOpts) ToServerCreateImageMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "createImage")
+}
+
+// CreateImage makes a request against the nova API to schedule an image to be created of the server
+func CreateImage(client *gophercloud.ServiceClient, id string, opts CreateImageOptsBuilder) (r CreateImageResult) {
+ b, err := opts.ToServerCreateImageMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ resp, err := client.Post(actionURL(client, id), b, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{202},
+ })
+ r.Err = err
+ r.Header = resp.Header
+ return
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ allPages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractServers(allPages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, f := range all {
+ if f.Name == name {
+ count++
+ id = f.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "server"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "server"}
+ }
+}
+
+// GetPassword makes a request against the nova API to get the encrypted administrative password.
+func GetPassword(client *gophercloud.ServiceClient, serverId string) (r GetPasswordResult) {
+ _, r.Err = client.Get(passwordURL(client, serverId), &r.Body, nil)
+ return
+}
diff --git a/openstack/compute/v2/servers/results.go b/openstack/compute/v2/servers/results.go
new file mode 100644
index 0000000..1ae1e91
--- /dev/null
+++ b/openstack/compute/v2/servers/results.go
@@ -0,0 +1,350 @@
+package servers
+
+import (
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "path"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type serverResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any serverResult as a Server, if possible.
+func (r serverResult) Extract() (*Server, error) {
+ var s Server
+ err := r.ExtractInto(&s)
+ return &s, err
+}
+
+func (r serverResult) ExtractInto(v interface{}) error {
+ return r.Result.ExtractIntoStructPtr(v, "server")
+}
+
+func ExtractServersInto(r pagination.Page, v interface{}) error {
+ return r.(ServerPage).Result.ExtractIntoSlicePtr(v, "servers")
+}
+
+// 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
+}
+
+// GetPasswordResult represent the result of a get os-server-password operation.
+type GetPasswordResult struct {
+ gophercloud.Result
+}
+
+// ExtractPassword gets the encrypted password.
+// If privateKey != nil the password is decrypted with the private key.
+// If privateKey == nil the encrypted password is returned and can be decrypted with:
+// echo '<pwd>' | base64 -D | openssl rsautl -decrypt -inkey <private_key>
+func (r GetPasswordResult) ExtractPassword(privateKey *rsa.PrivateKey) (string, error) {
+ var s struct {
+ Password string `json:"password"`
+ }
+ err := r.ExtractInto(&s)
+ if err == nil && privateKey != nil && s.Password != "" {
+ return decryptPassword(s.Password, privateKey)
+ }
+ return s.Password, err
+}
+
+func decryptPassword(encryptedPassword string, privateKey *rsa.PrivateKey) (string, error) {
+ b64EncryptedPassword := make([]byte, base64.StdEncoding.DecodedLen(len(encryptedPassword)))
+
+ n, err := base64.StdEncoding.Decode(b64EncryptedPassword, []byte(encryptedPassword))
+ if err != nil {
+ return "", fmt.Errorf("Failed to base64 decode encrypted password: %s", err)
+ }
+ password, err := rsa.DecryptPKCS1v15(nil, privateKey, b64EncryptedPassword[0:n])
+ if err != nil {
+ return "", fmt.Errorf("Failed to decrypt password: %s", err)
+ }
+
+ return string(password), nil
+}
+
+// ExtractImageID gets the ID of the newly created server image from the header
+func (r CreateImageResult) ExtractImageID() (string, error) {
+ if r.Err != nil {
+ return "", r.Err
+ }
+ // Get the image id from the header
+ u, err := url.ParseRequestURI(r.Header.Get("Location"))
+ if err != nil {
+ return "", err
+ }
+ 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) {
+ var s struct {
+ AdminPass string `json:"adminPass"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 `json:"id"`
+ // TenantID identifies the tenant owning this server resource.
+ TenantID string `json:"tenant_id"`
+ // UserID uniquely identifies the user account owning the tenant.
+ UserID string `json:"user_id"`
+ // Name contains the human-readable name for the server.
+ Name string `json:"name"`
+ // Updated and Created contain ISO-8601 timestamps of when the state of the server last changed, and when it was created.
+ Updated time.Time `json:"updated"`
+ Created time.Time `json:"created"`
+ HostID string `json:"hostid"`
+ // Status contains the current operational status of the server, such as IN_PROGRESS or ACTIVE.
+ Status string `json:"status"`
+ // Progress ranges from 0..100.
+ // A request made against the server completes only once Progress reaches 100.
+ Progress int `json:"progress"`
+ // AccessIPv4 and AccessIPv6 contain the IP addresses of the server, suitable for remote access for administration.
+ AccessIPv4 string `json:"accessIPv4"`
+ AccessIPv6 string `json:"accessIPv6"`
+ // Image refers to a JSON object, which itself indicates the OS image used to deploy the server.
+ Image map[string]interface{} `json:"-"`
+ // Flavor refers to a JSON object, which itself indicates the hardware configuration of the deployed server.
+ Flavor map[string]interface{} `json:"flavor"`
+ // Addresses includes a list of all IP addresses assigned to the server, keyed by pool.
+ Addresses map[string]interface{} `json:"addresses"`
+ // Metadata includes a list of all user-specified key-value pairs attached to the server.
+ Metadata map[string]string `json:"metadata"`
+ // Links includes HTTP references to the itself, useful for passing along to other APIs that might want a server reference.
+ Links []interface{} `json:"links"`
+ // KeyName indicates which public key was injected into the server on launch.
+ KeyName string `json:"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"`
+ // SecurityGroups includes the security groups that this instance has applied to it
+ SecurityGroups []map[string]interface{} `json:"security_groups"`
+}
+
+func (r *Server) UnmarshalJSON(b []byte) error {
+ type tmp Server
+ var s struct {
+ tmp
+ Image interface{} `json:"image"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = Server(s.tmp)
+
+ switch t := s.Image.(type) {
+ case map[string]interface{}:
+ r.Image = t
+ case string:
+ switch t {
+ case "":
+ r.Image = nil
+ }
+ }
+
+ return err
+}
+
+// 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 (r ServerPage) IsEmpty() (bool, error) {
+ s, err := ExtractServers(r)
+ return len(s) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r ServerPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"servers_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractServers interprets the results of a single page from a List() call, producing a slice of Server entities.
+func ExtractServers(r pagination.Page) ([]Server, error) {
+ var s []Server
+ err := ExtractServersInto(r, &s)
+ return s, 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) {
+ var s struct {
+ Metadata map[string]string `json:"metadata"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Metadata, err
+}
+
+// Extract interprets any MetadatumResult as a Metadatum, if possible.
+func (r MetadatumResult) Extract() (map[string]string, error) {
+ var s struct {
+ Metadatum map[string]string `json:"meta"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Metadatum, err
+}
+
+// Address represents an IP address.
+type Address struct {
+ Version int `json:"version"`
+ Address string `json:"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)
+ return len(addresses) == 0, err
+}
+
+// ExtractAddresses interprets the results of a single page from a ListAddresses() call,
+// producing a map of addresses.
+func ExtractAddresses(r pagination.Page) (map[string][]Address, error) {
+ var s struct {
+ Addresses map[string][]Address `json:"addresses"`
+ }
+ err := (r.(AddressPage)).ExtractInto(&s)
+ return s.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)
+ return len(addresses) == 0, err
+}
+
+// ExtractNetworkAddresses interprets the results of a single page from a ListAddressesByNetwork() call,
+// producing a slice of addresses.
+func ExtractNetworkAddresses(r pagination.Page) ([]Address, error) {
+ var s map[string][]Address
+ err := (r.(NetworkAddressPage)).ExtractInto(&s)
+ if err != nil {
+ return nil, err
+ }
+
+ var key string
+ for k := range s {
+ key = k
+ }
+
+ return s[key], err
+}
diff --git a/openstack/compute/v2/servers/testing/doc.go b/openstack/compute/v2/servers/testing/doc.go
new file mode 100644
index 0000000..c7c5982
--- /dev/null
+++ b/openstack/compute/v2/servers/testing/doc.go
@@ -0,0 +1,2 @@
+// compute_servers_v2
+package testing
diff --git a/openstack/compute/v2/servers/testing/fixtures.go b/openstack/compute/v2/servers/testing/fixtures.go
new file mode 100644
index 0000000..40d5ed2
--- /dev/null
+++ b/openstack/compute/v2/servers/testing/fixtures.go
@@ -0,0 +1,971 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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": {}
+ },
+ {
+ "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": "",
+ "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-72c93aa682bb",
+ "security_groups": [
+ {
+ "name": "default"
+ }
+ ],
+ "OS-SRV-USG:terminated_at": null,
+ "OS-EXT-AZ:availability_zone": "nova",
+ "user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+ "name": "merp",
+ "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": {}
+ }
+}
+`
+
+const ServerPasswordBody = `
+{
+ "password": "xlozO3wLCBRWAa2yDjCCVx8vwNPypxnypmRYDa/zErlQ+EzPe1S/Gz6nfmC52mOlOSCRuUOmG7kqqgejPof6M7bOezS387zjq4LSvvwp28zUknzy4YzfFGhnHAdai3TxUJ26pfQCYrq8UTzmKF2Bq8ioSEtVVzM0A96pDh8W2i7BOz6MdoiVyiev/I1K2LsuipfxSJR7Wdke4zNXJjHHP2RfYsVbZ/k9ANu+Nz4iIH8/7Cacud/pphH7EjrY6a4RZNrjQskrhKYed0YERpotyjYk1eDtRe72GrSiXteqCM4biaQ5w3ruS+AcX//PXk3uJ5kC7d67fPXaVz4WaQRYMg=="
+}
+`
+
+var (
+ herpTimeCreated, _ = time.Parse(time.RFC3339, "2014-09-25T13:10:02Z")
+ herpTimeUpdated, _ = time.Parse(time.RFC3339, "2014-09-25T13:10:10Z")
+ // ServerHerp is a Server struct that should correspond to the first result in ServerListBody.
+ ServerHerp = servers.Server{
+ Status: "ACTIVE",
+ Updated: herpTimeUpdated,
+ 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: herpTimeCreated,
+ TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+ Metadata: map[string]string{},
+ SecurityGroups: []map[string]interface{}{
+ map[string]interface{}{
+ "name": "default",
+ },
+ },
+ }
+
+ derpTimeCreated, _ = time.Parse(time.RFC3339, "2014-09-25T13:04:41Z")
+ derpTimeUpdated, _ = time.Parse(time.RFC3339, "2014-09-25T13:04:49Z")
+ // ServerDerp is a Server struct that should correspond to the second server in ServerListBody.
+ ServerDerp = servers.Server{
+ Status: "ACTIVE",
+ Updated: derpTimeUpdated,
+ 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: derpTimeCreated,
+ TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+ Metadata: map[string]string{},
+ SecurityGroups: []map[string]interface{}{
+ map[string]interface{}{
+ "name": "default",
+ },
+ },
+ }
+
+ merpTimeCreated, _ = time.Parse(time.RFC3339, "2014-09-25T13:04:41Z")
+ merpTimeUpdated, _ = time.Parse(time.RFC3339, "2014-09-25T13:04:49Z")
+ // ServerMerp is a Server struct that should correspond to the second server in ServerListBody.
+ ServerMerp = servers.Server{
+ Status: "ACTIVE",
+ Updated: merpTimeUpdated,
+ 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: nil,
+ 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-72c93aa682bb",
+ UserID: "9349aff8be7545ac9d2f1d00999a23cd",
+ Name: "merp",
+ Created: merpTimeCreated,
+ TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+ Metadata: map[string]string{},
+ SecurityGroups: []map[string]interface{}{
+ map[string]interface{}{
+ "name": "default",
+ },
+ },
+ }
+)
+
+type CreateOptsWithCustomField struct {
+ servers.CreateOpts
+ Foo string `json:"foo,omitempty"`
+}
+
+func (opts CreateOptsWithCustomField) ToServerCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "server")
+}
+
+// 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)
+ })
+
+ 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, `
+ {
+ "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
+ },
+ {
+ "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
+ }
+ ]
+ }
+ `)
+ case "2":
+ fmt.Fprintf(w, `{ "images": [] }`)
+ default:
+ t.Fatalf("Unexpected marker: [%s]", marker)
+ }
+ })
+
+ 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, `
+ {
+ "flavors": [
+ {
+ "id": "1",
+ "name": "m1.tiny",
+ "disk": 1,
+ "ram": 512,
+ "vcpus": 1,
+ "swap":""
+ },
+ {
+ "id": "2",
+ "name": "m2.small",
+ "disk": 10,
+ "ram": 1024,
+ "vcpus": 2,
+ "swap": 1000
+ }
+ ],
+ "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)
+ }
+ })
+}
+
+// HandleServerCreationWithCustomFieldSuccessfully sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationWithCustomFieldSuccessfully(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",
+ "foo": "bar"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleServerCreationWithUserdata sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationWithUserdata(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",
+ "user_data": "dXNlcmRhdGEgc3RyaW5n"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleServerCreationWithMetadata sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationWithMetadata(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",
+ "metadata": {
+ "abc": "def"
+ }
+ }
+ }`)
+
+ 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)
+ })
+}
+
+// HandleServerForceDeletionSuccessfully sets up the test server to respond to a server force deletion
+// request.
+func HandleServerForceDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/asdfasdfasdf/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, `{ "forceDelete": "" }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ })
+}
+
+// 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][]servers.Address{
+ "public": []servers.Address{
+ {
+ Version: 4,
+ Address: "80.56.136.39",
+ },
+ {
+ Version: 6,
+ Address: "2001:4800:790e:510:be76:4eff:fe04:82a8",
+ },
+ },
+ "private": []servers.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 = []servers.Address{
+ {
+ Version: 4,
+ Address: "50.56.176.35",
+ },
+ {
+ 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)
+ })
+}
+
+// HandlePasswordGetSuccessfully sets up the test server to respond to a password Get request.
+func HandlePasswordGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/servers/1234asdf/os-server-password", 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, ServerPasswordBody)
+ })
+}
diff --git a/openstack/compute/v2/servers/testing/requests_test.go b/openstack/compute/v2/servers/testing/requests_test.go
new file mode 100644
index 0000000..05712f7
--- /dev/null
+++ b/openstack/compute/v2/servers/testing/requests_test.go
@@ -0,0 +1,522 @@
+package testing
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/availabilityzones"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListServers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerListSuccessfully(t)
+
+ pages := 0
+ err := servers.List(client.ServiceClient(), servers.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := servers.ExtractServers(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 3 {
+ t.Fatalf("Expected 3 servers, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, ServerHerp, actual[0])
+ th.CheckDeepEquals(t, ServerDerp, actual[1])
+ th.CheckDeepEquals(t, ServerMerp, actual[2])
+
+ 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 := servers.List(client.ServiceClient(), servers.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := servers.ExtractServers(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ServerHerp, actual[0])
+ th.CheckDeepEquals(t, ServerDerp, actual[1])
+}
+
+func TestListAllServersWithExtensions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerListSuccessfully(t)
+
+ type ServerWithExt struct {
+ servers.Server
+ availabilityzones.ServerExt
+ }
+
+ allPages, err := servers.List(client.ServiceClient(), servers.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+
+ var actual []ServerWithExt
+ err = servers.ExtractServersInto(allPages, &actual)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 3, len(actual))
+ th.AssertEquals(t, "nova", actual[0].AvailabilityZone)
+}
+
+func TestCreateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationSuccessfully(t, SingleServerBody)
+
+ actual, err := servers.Create(client.ServiceClient(), servers.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithCustomField(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationWithCustomFieldSuccessfully(t, SingleServerBody)
+
+ actual, err := servers.Create(client.ServiceClient(), CreateOptsWithCustomField{
+ CreateOpts: servers.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ },
+ Foo: "bar",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationWithMetadata(t, SingleServerBody)
+
+ actual, err := servers.Create(client.ServiceClient(), servers.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ Metadata: map[string]string{
+ "abc": "def",
+ },
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithUserdataString(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationWithUserdata(t, SingleServerBody)
+
+ actual, err := servers.Create(client.ServiceClient(), servers.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ UserData: []byte("userdata string"),
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithUserdataEncoded(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationWithUserdata(t, SingleServerBody)
+
+ encoded := base64.StdEncoding.EncodeToString([]byte("userdata string"))
+
+ actual, err := servers.Create(client.ServiceClient(), servers.CreateOpts{
+ Name: "derp",
+ ImageRef: "f90f6034-2570-4974-8351-6b49732ef2eb",
+ FlavorRef: "1",
+ UserData: []byte(encoded),
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithImageNameAndFlavorName(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerCreationSuccessfully(t, SingleServerBody)
+
+ actual, err := servers.Create(client.ServiceClient(), servers.CreateOpts{
+ Name: "derp",
+ ImageName: "cirros-0.3.2-x86_64-disk",
+ FlavorName: "m1.tiny",
+ ServiceClient: client.ServiceClient(),
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerDeletionSuccessfully(t)
+
+ res := servers.Delete(client.ServiceClient(), "asdfasdfasdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestForceDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerForceDeletionSuccessfully(t)
+
+ res := servers.ForceDelete(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 := servers.Get(client, "1234asdf").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestGetServerWithExtensions(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerGetSuccessfully(t)
+
+ var s struct {
+ servers.Server
+ availabilityzones.ServerExt
+ }
+
+ err := servers.Get(client.ServiceClient(), "1234asdf").ExtractInto(&s)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "nova", s.AvailabilityZone)
+
+ err = servers.Get(client.ServiceClient(), "1234asdf").ExtractInto(s)
+ if err == nil {
+ t.Errorf("Expected error when providing non-pointer struct")
+ }
+}
+
+func TestUpdateServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleServerUpdateSuccessfully(t)
+
+ client := client.ServiceClient()
+ actual, err := servers.Update(client, "1234asdf", servers.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 := servers.ChangeAdminPassword(client.ServiceClient(), "1234asdf", "new-password")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestGetPassword(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePasswordGetSuccessfully(t)
+
+ res := servers.GetPassword(client.ServiceClient(), "1234asdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRebootServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRebootSuccessfully(t)
+
+ res := servers.Reboot(client.ServiceClient(), "1234asdf", &servers.RebootOpts{
+ Type: servers.SoftReboot,
+ })
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRebuildServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRebuildSuccessfully(t, SingleServerBody)
+
+ opts := 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",
+ }
+
+ actual, err := servers.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 := servers.Resize(client.ServiceClient(), "1234asdf", servers.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 := servers.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 := servers.RevertResize(client.ServiceClient(), "1234asdf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestRescue(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleServerRescueSuccessfully(t)
+
+ res := servers.Rescue(client.ServiceClient(), "1234asdf", servers.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 := servers.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 := servers.CreateMetadatum(client.ServiceClient(), "1234asdf", servers.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 := servers.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 := servers.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 := servers.ResetMetadata(client.ServiceClient(), "1234asdf", servers.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 := servers.UpdateMetadata(client.ServiceClient(), "1234asdf", servers.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 := servers.ListAddresses(client.ServiceClient(), "asdfasdfasdf").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := servers.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 := servers.ListAddressesByNetwork(client.ServiceClient(), "asdfasdfasdf", "public").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := servers.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 := servers.CreateImage(client.ServiceClient(), "serverimage", servers.CreateImageOpts{Name: "test"}).ExtractImageID()
+ th.AssertNoErr(t, err)
+}
+
+func TestMarshalPersonality(t *testing.T) {
+ name := "/etc/test"
+ contents := []byte("asdfasdf")
+
+ personality := servers.Personality{
+ &servers.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/testing/results_test.go b/openstack/compute/v2/servers/testing/results_test.go
new file mode 100644
index 0000000..5866957
--- /dev/null
+++ b/openstack/compute/v2/servers/testing/results_test.go
@@ -0,0 +1,110 @@
+package testing
+
+import (
+ "crypto/rsa"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+ "golang.org/x/crypto/ssh"
+)
+
+// Fail - No password in JSON.
+func TestExtractPassword_no_pwd_data(t *testing.T) {
+
+ var dejson interface{}
+ err := json.Unmarshal([]byte(`{ "Crappy data": ".-.-." }`), &dejson)
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+ resp := servers.GetPasswordResult{gophercloud.Result{Body: dejson}}
+
+ pwd, err := resp.ExtractPassword(nil)
+ th.AssertEquals(t, pwd, "")
+}
+
+// Ok - return encrypted password when no private key is given.
+func TestExtractPassword_encrypted_pwd(t *testing.T) {
+
+ var dejson interface{}
+ sejson := []byte(`{"password":"PP8EnwPO9DhEc8+O/6CKAkPF379mKsUsfFY6yyw0734XXvKsSdV9KbiHQ2hrBvzeZxtGMrlFaikVunCRizyLLWLMuOi4hoH+qy9F9sQid61gQIGkxwDAt85d/7Eau2/KzorFnZhgxArl7IiqJ67X6xjKkR3zur+Yp3V/mtVIehpPYIaAvPbcp2t4mQXl1I9J8yrQfEZOctLL1L4heDEVXnxvNihVLK6pivlVggp6SZCtjj9cduZGrYGsxsOCso1dqJQr7GCojfwvuLOoG0OYwEGuWVTZppxWxi/q1QgeHFhGKA5QUXlz7pS71oqpjYsTeViuHnfvlqb5TVYZpQ1haw=="}`)
+
+ err := json.Unmarshal(sejson, &dejson)
+ fmt.Printf("%v\n", dejson)
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+ resp := servers.GetPasswordResult{gophercloud.Result{Body: dejson}}
+
+ pwd, err := resp.ExtractPassword(nil)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "PP8EnwPO9DhEc8+O/6CKAkPF379mKsUsfFY6yyw0734XXvKsSdV9KbiHQ2hrBvzeZxtGMrlFaikVunCRizyLLWLMuOi4hoH+qy9F9sQid61gQIGkxwDAt85d/7Eau2/KzorFnZhgxArl7IiqJ67X6xjKkR3zur+Yp3V/mtVIehpPYIaAvPbcp2t4mQXl1I9J8yrQfEZOctLL1L4heDEVXnxvNihVLK6pivlVggp6SZCtjj9cduZGrYGsxsOCso1dqJQr7GCojfwvuLOoG0OYwEGuWVTZppxWxi/q1QgeHFhGKA5QUXlz7pS71oqpjYsTeViuHnfvlqb5TVYZpQ1haw==", pwd)
+}
+
+// Ok - return decrypted password when private key is given.
+// Decrytion can be verified by:
+// echo "<enc_pwd>" | base64 -D | openssl rsautl -decrypt -inkey <privateKey.pem>
+func TestExtractPassword_decrypted_pwd(t *testing.T) {
+
+ privateKey, err := ssh.ParseRawPrivateKey([]byte(`
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAo1ODZgwMVdTJYim9UYuYhowoPMhGEuV5IRZjcJ315r7RBSC+
+yEiBb1V+jhf+P8fzAyU35lkBzZGDr7E3jxSesbOuYT8cItQS4ErUnI1LGuqvMxwv
+X3GMyE/HmOcaiODF1XZN3Ur5pMJdVknnmczgUsW0hT98Udrh3MQn9WSuh/6LRy6+
+x1QsKHOCLFPnkhWa3LKyxmpQq/Gvhz+6NLe+gt8MFullA5mKQxBJ/K6laVHeaMlw
+JG3GCX0EZhRlvzoV8koIBKZtbKFolFr8ZtxBm3R5LvnyrtOvp22sa+xeItUT5kG1
+ZnbGNdK87oYW+VigEUfzT/+8R1i6E2QIXoeZiQIDAQABAoIBAQCVZ70IqbbTAW8j
+RAlyQh/J3Qal65LmkFJJKUDX8TfT1/Q/G6BKeMEmxm+Zrmsfj1pHI1HKftt+YEG1
+g4jOc09kQXkgbmnfll6aHPn3J+1vdwXD3GGdjrL5PrnYrngAhJWU2r8J0x8hT8ew
+OrUJZXhDX6XuSpAAFRmOKUZgXbSmo4X+LZX76ACnarselJt5FL724ECvpWJ7xxC4
+FMzvp4RqMmNFvv/Uq9lE/EmoSk4dviYyIZZ16DbDNyc9k/sGqCAMktCEwZ3EQm//
+S5bkNhgP6oUXjluWy53aPRgykEylgDWo5SSdSEyKnw/fciU0xdprA9JrBGIcTyHS
+/k2kgD4xAoGBANTkJ88Q0YrxX3fZNZVqcn00XKTxPGmxN5LRs7eV743q30AxK5Db
+QU8iwaAA1IKUWV5DLhgUTNsDCOPUPue4aOSBD3/sj+WEmvIhj7afDL5didkYHsqf
+fDnhFHq7y/3i57d428C7BwwR79pGWVyi7vH3pfu9A1iwl1aNOae+zvbVAoGBAMRm
+AmwQ9fJ3Qc44jysFK/yliLRGdShjkMMah5G3JlrelwfPtwPwEL2EHHhJB/C1acMs
+n6Q6RaoF6WNSZUY65ksQg7aPOYf2X0FTFwQJvwDJ4qlWjmq7w+tQ0AoGJG+dVUmQ
+zHZ/Y+HokSXzz9c4oevk4v/rMgAQ00WHrTdtIhnlAoGBALIJJ72D7CkNGHCq5qPQ
+xHQukPejgolFGhufYXM7YX3GmPMe67cVlTVv9Isxhoa5N0+cUPT0LR3PGOUm/4Bb
+eOT3hZXOqLwhvE6XgI8Rzd95bClwgXekDoh80dqeKMdmta961BQGlKskaPiacmsF
+G1yhZV70P9Mwwy8vpbLB4GUNAoGAbTwbjsWkNfa0qCF3J8NZoszjCvnBQfSW2J1R
+1+8ZKyNwt0yFi3Ajr3TibNiZzPzp1T9lj29FvfpJxA9Y+sXZvthxmcFxizix5GB1
+ha5yCNtA8VSOI7lJkAFDpL+j1lyYyjD6N9JE2KqEyKoh6J+8F7sXsqW7CqRRDfQX
+mKNfey0CgYEAxcEoNoADN2hRl7qY9rbQfVvQb3RkoQkdHhl9gpLFCcV32IP8R4xg
+09NbQK5OmgcIuZhLVNzTmUHJbabEGeXqIFIV0DsqECAt3WzbDyKQO23VJysFD46c
+KSde3I0ybDz7iS2EtceKB7m4C0slYd+oBkm4efuF00rCOKDwpFq45m0=
+-----END RSA PRIVATE KEY-----
+`))
+ if err != nil {
+ t.Fatalf("Error parsing private key: %s\n", err)
+ }
+
+ var dejson interface{}
+ sejson := []byte(`{"password":"PP8EnwPO9DhEc8+O/6CKAkPF379mKsUsfFY6yyw0734XXvKsSdV9KbiHQ2hrBvzeZxtGMrlFaikVunCRizyLLWLMuOi4hoH+qy9F9sQid61gQIGkxwDAt85d/7Eau2/KzorFnZhgxArl7IiqJ67X6xjKkR3zur+Yp3V/mtVIehpPYIaAvPbcp2t4mQXl1I9J8yrQfEZOctLL1L4heDEVXnxvNihVLK6pivlVggp6SZCtjj9cduZGrYGsxsOCso1dqJQr7GCojfwvuLOoG0OYwEGuWVTZppxWxi/q1QgeHFhGKA5QUXlz7pS71oqpjYsTeViuHnfvlqb5TVYZpQ1haw=="}`)
+
+ err = json.Unmarshal(sejson, &dejson)
+ fmt.Printf("%v\n", dejson)
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+ resp := servers.GetPasswordResult{gophercloud.Result{Body: dejson}}
+
+ pwd, err := resp.ExtractPassword(privateKey.(*rsa.PrivateKey))
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "ruZKK0tqxRfYm5t7lSJq", pwd)
+}
+
+func TestListAddressesAllPages(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAddressListSuccessfully(t)
+
+ allPages, err := servers.ListAddresses(client.ServiceClient(), "asdfasdfasdf").AllPages()
+ th.AssertNoErr(t, err)
+ _, err = servers.ExtractAddresses(allPages)
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/servers/urls.go b/openstack/compute/v2/servers/urls.go
new file mode 100644
index 0000000..e892e8d
--- /dev/null
+++ b/openstack/compute/v2/servers/urls.go
@@ -0,0 +1,51 @@
+package servers
+
+import "github.com/gophercloud/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)
+}
+
+func passwordURL(client *gophercloud.ServiceClient, id string) string {
+ return client.ServiceURL("servers", id, "os-server-password")
+}
diff --git a/openstack/compute/v2/servers/util.go b/openstack/compute/v2/servers/util.go
new file mode 100644
index 0000000..494a0e4
--- /dev/null
+++ b/openstack/compute/v2/servers/util.go
@@ -0,0 +1,20 @@
+package servers
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/configurations/requests.go
new file mode 100644
index 0000000..8fc8295
--- /dev/null
+++ b/openstack/db/v1/configurations/requests.go
@@ -0,0 +1,167 @@
+package configurations
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will list all of the available configurations.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+ return ConfigPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 {
+ // The type of datastore. Defaults to "MySQL".
+ Type string `json:"type,omitempty"`
+ // The specific version of a datastore. Defaults to "5.6".
+ Version string `json:"version,omitempty"`
+}
+
+// CreateOpts is the struct responsible for configuring new configurations.
+type CreateOpts struct {
+ // The configuration group name
+ Name string `json:"name" required:"true"`
+ // 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{} `json:"values" required:"true"`
+ // Associates the configuration group with a particular datastore.
+ Datastore *DatastoreOpts `json:"datastore,omitempty"`
+ // A human-readable explanation for the group.
+ Description string `json:"description,omitempty"`
+}
+
+// ToConfigCreateMap casts a CreateOpts struct into a JSON map.
+func (opts CreateOpts) ToConfigCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "configuration")
+}
+
+// Create will create a new configuration group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToConfigCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(baseURL(client), &b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return
+}
+
+// Get will retrieve the details for a specified configuration group.
+func Get(client *gophercloud.ServiceClient, configID string) (r GetResult) {
+ _, r.Err = client.Get(resourceURL(client, configID), &r.Body, nil)
+ return
+}
+
+// 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 {
+ // The configuration group name
+ Name string `json:"name,omitempty"`
+ // 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{} `json:"values,omitempty"`
+ // Associates the configuration group with a particular datastore.
+ Datastore *DatastoreOpts `json:"datastore,omitempty"`
+ // A human-readable explanation for the group.
+ Description string `json:"description,omitempty"`
+}
+
+// ToConfigUpdateMap will cast an UpdateOpts struct into a JSON map.
+func (opts UpdateOpts) ToConfigUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "configuration")
+}
+
+// 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) (r UpdateResult) {
+ b, err := opts.ToConfigUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Patch(resourceURL(client, configID), &b, nil, nil)
+ return
+}
+
+// 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) (r ReplaceResult) {
+ b, err := opts.ToConfigUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(resourceURL(client, configID), &b, nil, nil)
+ return
+}
+
+// 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) (r DeleteResult) {
+ _, r.Err = client.Delete(resourceURL(client, configID), nil)
+ return
+}
+
+// ListInstances will list all the instances associated with a particular
+// configuration group.
+func ListInstances(client *gophercloud.ServiceClient, configID string) pagination.Pager {
+ return pagination.NewPager(client, instancesURL(client, configID), func(r pagination.PageResult) pagination.Page {
+ return instances.InstancePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// 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 pagination.NewPager(client, listDSParamsURL(client, datastoreID, versionID), func(r pagination.PageResult) pagination.Page {
+ return ParamPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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) (r ParamResult) {
+ _, r.Err = client.Get(getDSParamURL(client, datastoreID, versionID, paramID), &r.Body, nil)
+ return
+}
+
+// ListGlobalParams is similar to ListDatastoreParams but does not require a
+// DatastoreID.
+func ListGlobalParams(client *gophercloud.ServiceClient, versionID string) pagination.Pager {
+ return pagination.NewPager(client, listGlobalParamsURL(client, versionID), func(r pagination.PageResult) pagination.Page {
+ return ParamPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// GetGlobalParam is similar to GetDatastoreParam but does not require a
+// DatastoreID.
+func GetGlobalParam(client *gophercloud.ServiceClient, versionID, paramID string) (r ParamResult) {
+ _, r.Err = client.Get(getGlobalParamURL(client, versionID, paramID), &r.Body, nil)
+ return
+}
diff --git a/openstack/db/v1/configurations/results.go b/openstack/db/v1/configurations/results.go
new file mode 100644
index 0000000..c52a654
--- /dev/null
+++ b/openstack/db/v1/configurations/results.go
@@ -0,0 +1,121 @@
+package configurations
+
+import (
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Config represents a configuration group API resource.
+type Config struct {
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ DatastoreName string `json:"datastore_name"`
+ DatastoreVersionID string `json:"datastore_version_id"`
+ DatastoreVersionName string `json:"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)
+ return len(is) == 0, err
+}
+
+// ExtractConfigs will retrieve a slice of Config structs from a page.
+func ExtractConfigs(r pagination.Page) ([]Config, error) {
+ var s struct {
+ Configs []Config `json:"configurations"`
+ }
+ err := (r.(ConfigPage)).ExtractInto(&s)
+ return s.Configs, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will retrieve a Config resource from an operation result.
+func (r commonResult) Extract() (*Config, error) {
+ var s struct {
+ Config *Config `json:"configuration"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 float64
+ Min float64
+ Name string
+ RestartRequired bool `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)
+ return len(is) == 0, err
+}
+
+// ExtractParams will retrieve a slice of Param structs from a page.
+func ExtractParams(r pagination.Page) ([]Param, error) {
+ var s struct {
+ Params []Param `json:"configuration-parameters"`
+ }
+ err := (r.(ParamPage)).ExtractInto(&s)
+ return s.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) {
+ var s *Param
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/db/v1/configurations/testing/doc.go b/openstack/db/v1/configurations/testing/doc.go
new file mode 100644
index 0000000..60c997a
--- /dev/null
+++ b/openstack/db/v1/configurations/testing/doc.go
@@ -0,0 +1,2 @@
+// db_configurations_v1
+package testing
diff --git a/openstack/db/v1/configurations/testing/fixtures.go b/openstack/db/v1/configurations/testing/fixtures.go
new file mode 100644
index 0000000..56e10f4
--- /dev/null
+++ b/openstack/db/v1/configurations/testing/fixtures.go
@@ -0,0 +1,159 @@
+package testing
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/gophercloud/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 = configurations.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 = configurations.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/testing/requests_test.go b/openstack/db/v1/configurations/testing/requests_test.go
new file mode 100644
index 0000000..643f363
--- /dev/null
+++ b/openstack/db/v1/configurations/testing/requests_test.go
@@ -0,0 +1,237 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/db/v1/configurations"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+ "github.com/gophercloud/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 := configurations.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := configurations.ExtractConfigs(page)
+ th.AssertNoErr(t, err)
+
+ expected := []configurations.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 := configurations.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 := configurations.CreateOpts{
+ Datastore: &configurations.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 := configurations.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 := configurations.UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := configurations.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 := configurations.UpdateOpts{
+ Values: map[string]interface{}{
+ "connect_timeout": 300,
+ },
+ }
+
+ err := configurations.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 := configurations.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 := configurations.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 := configurations.ListDatastoreParams(fake.ServiceClient(), dsID, versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := configurations.ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []configurations.Param{
+ {Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ {Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ {Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ {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 := configurations.GetDatastoreParam(fake.ServiceClient(), dsID, versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &configurations.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 := configurations.ListGlobalParams(fake.ServiceClient(), versionID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := configurations.ExtractParams(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []configurations.Param{
+ {Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+ {Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+ {Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+ {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 := configurations.GetGlobalParam(fake.ServiceClient(), versionID, paramID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &configurations.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/urls.go b/openstack/db/v1/configurations/urls.go
new file mode 100644
index 0000000..0a69253
--- /dev/null
+++ b/openstack/db/v1/configurations/urls.go
@@ -0,0 +1,31 @@
+package configurations
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/databases/requests.go
new file mode 100644
index 0000000..ef5394f
--- /dev/null
+++ b/openstack/db/v1/databases/requests.go
@@ -0,0 +1,89 @@
+package databases
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder builds create options
+type CreateOptsBuilder interface {
+ ToDBCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the struct responsible for configuring a database; often in
+// the context of an instance.
+type CreateOpts struct {
+ // 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 `json:"name" required:"true"`
+ // 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 `json:"character_set,omitempty"`
+ // 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 `json:"collate,omitempty"`
+}
+
+// ToMap is a helper function to convert individual DB create opt structures
+// into sub-maps.
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+ if len(opts.Name) > 64 {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "databases.CreateOpts.Name"
+ err.Value = opts.Name
+ err.Info = "Must be less than 64 chars long"
+ return nil, err
+ }
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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]interface{}, 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) (r CreateResult) {
+ b, err := opts.ToDBCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(baseURL(client, instanceID), &b, nil, nil)
+ return
+}
+
+// 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 {
+ return pagination.NewPager(client, baseURL(client, instanceID), func(r pagination.PageResult) pagination.Page {
+ return DBPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// 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) (r DeleteResult) {
+ _, r.Err = client.Delete(dbURL(client, instanceID, dbName), nil)
+ return
+}
diff --git a/openstack/db/v1/databases/results.go b/openstack/db/v1/databases/results.go
new file mode 100644
index 0000000..0479d0e
--- /dev/null
+++ b/openstack/db/v1/databases/results.go
@@ -0,0 +1,63 @@
+package databases
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return len(dbs) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page DBPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"databases_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractDBs will convert a generic pagination struct into a more
+// relevant slice of DB structs.
+func ExtractDBs(page pagination.Page) ([]Database, error) {
+ r := page.(DBPage)
+ var s struct {
+ Databases []Database `json:"databases"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Databases, err
+}
diff --git a/openstack/db/v1/databases/testing/doc.go b/openstack/db/v1/databases/testing/doc.go
new file mode 100644
index 0000000..abdfab9
--- /dev/null
+++ b/openstack/db/v1/databases/testing/doc.go
@@ -0,0 +1,2 @@
+// db_databases_v1
+package testing
diff --git a/openstack/db/v1/databases/testing/fixtures.go b/openstack/db/v1/databases/testing/fixtures.go
new file mode 100644
index 0000000..02b9ecc
--- /dev/null
+++ b/openstack/db/v1/databases/testing/fixtures.go
@@ -0,0 +1,61 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/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/testing/requests_test.go b/openstack/db/v1/databases/testing/requests_test.go
new file mode 100644
index 0000000..a470ffa
--- /dev/null
+++ b/openstack/db/v1/databases/testing/requests_test.go
@@ -0,0 +1,67 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := databases.BatchCreateOpts{
+ databases.CreateOpts{Name: "testingdb", CharSet: "utf8", Collate: "utf8_general_ci"},
+ databases.CreateOpts{Name: "sampledb"},
+ }
+
+ res := databases.Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ expectedDBs := []databases.Database{
+ {Name: "anotherexampledb"},
+ {Name: "exampledb"},
+ {Name: "nextround"},
+ {Name: "sampledb"},
+ {Name: "testingdb"},
+ }
+
+ pages := 0
+ err := databases.List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := databases.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 := databases.Delete(fake.ServiceClient(), instanceID, "{dbName}").ExtractErr()
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/db/v1/databases/urls.go b/openstack/db/v1/databases/urls.go
new file mode 100644
index 0000000..aba42c9
--- /dev/null
+++ b/openstack/db/v1/databases/urls.go
@@ -0,0 +1,11 @@
+package databases
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/datastores/requests.go
new file mode 100644
index 0000000..134e309
--- /dev/null
+++ b/openstack/db/v1/datastores/requests.go
@@ -0,0 +1,33 @@
+package datastores
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will list all available datastore types that instances can use.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+ return DatastorePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get will retrieve the details of a specified datastore type.
+func Get(client *gophercloud.ServiceClient, datastoreID string) (r GetResult) {
+ _, r.Err = client.Get(resourceURL(client, datastoreID), &r.Body, nil)
+ return
+}
+
+// ListVersions will list all of the available versions for a specified
+// datastore type.
+func ListVersions(client *gophercloud.ServiceClient, datastoreID string) pagination.Pager {
+ return pagination.NewPager(client, versionsURL(client, datastoreID), func(r pagination.PageResult) pagination.Page {
+ return VersionPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// GetVersion will retrieve the details of a specified datastore version.
+func GetVersion(client *gophercloud.ServiceClient, datastoreID, versionID string) (r GetVersionResult) {
+ _, r.Err = client.Get(versionURL(client, datastoreID, versionID), &r.Body, nil)
+ return
+}
diff --git a/openstack/db/v1/datastores/results.go b/openstack/db/v1/datastores/results.go
new file mode 100644
index 0000000..a6e27d2
--- /dev/null
+++ b/openstack/db/v1/datastores/results.go
@@ -0,0 +1,100 @@
+package datastores
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+ 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"`
+}
+
+// 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)
+ return len(is) == 0, err
+}
+
+// ExtractDatastores retrieves a slice of datastore structs from a paginated
+// collection.
+func ExtractDatastores(r pagination.Page) ([]Datastore, error) {
+ var s struct {
+ Datastores []Datastore `json:"datastores"`
+ }
+ err := (r.(DatastorePage)).ExtractInto(&s)
+ return s.Datastores, err
+}
+
+// Extract retrieves a single Datastore struct from an operation result.
+func (r GetResult) Extract() (*Datastore, error) {
+ var s struct {
+ Datastore *Datastore `json:"datastore"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Datastore, err
+}
+
+// VersionPage 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)
+ return len(is) == 0, err
+}
+
+// ExtractVersions retrieves a slice of versions from a paginated collection.
+func ExtractVersions(r pagination.Page) ([]Version, error) {
+ var s struct {
+ Versions []Version `json:"versions"`
+ }
+ err := (r.(VersionPage)).ExtractInto(&s)
+ return s.Versions, err
+}
+
+// Extract retrieves a single Version struct from an operation result.
+func (r GetVersionResult) Extract() (*Version, error) {
+ var s struct {
+ Version *Version `json:"version"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Version, err
+}
diff --git a/openstack/db/v1/datastores/testing/doc.go b/openstack/db/v1/datastores/testing/doc.go
new file mode 100644
index 0000000..8f06f86
--- /dev/null
+++ b/openstack/db/v1/datastores/testing/doc.go
@@ -0,0 +1,2 @@
+// db_datastores_v1
+package testing
diff --git a/openstack/db/v1/datastores/testing/fixtures.go b/openstack/db/v1/datastores/testing/fixtures.go
new file mode 100644
index 0000000..3b82646
--- /dev/null
+++ b/openstack/db/v1/datastores/testing/fixtures.go
@@ -0,0 +1,101 @@
+package testing
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+)
+
+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 = datastores.Version{
+ ID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+ Links: []gophercloud.Link{
+ {Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+ {Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+ },
+ Name: "5.1",
+}
+
+var exampleVersion2 = datastores.Version{
+ ID: "c00000b0-00c0-0c00-00c0-000b000000cc",
+ Links: []gophercloud.Link{
+ {Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+ {Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+ },
+ Name: "5.2",
+}
+
+var ExampleVersions = []datastores.Version{ExampleVersion1, exampleVersion2}
+
+var ExampleDatastore = datastores.Datastore{
+ DefaultVersion: "c00000b0-00c0-0c00-00c0-000b000000cc",
+ ID: "10000000-0000-0000-0000-000000000001",
+ Links: []gophercloud.Link{
+ {Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/10000000-0000-0000-0000-000000000001"},
+ {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/testing/requests_test.go b/openstack/db/v1/datastores/testing/requests_test.go
new file mode 100644
index 0000000..b505726
--- /dev/null
+++ b/openstack/db/v1/datastores/testing/requests_test.go
@@ -0,0 +1,79 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+ "github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ fixture.SetupHandler(t, "/datastores", "GET", "", ListDSResp, 200)
+
+ pages := 0
+
+ err := datastores.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := datastores.ExtractDatastores(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []datastores.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 := datastores.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 := datastores.ListVersions(fake.ServiceClient(), "{dsID}").EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := datastores.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 := datastores.GetVersion(fake.ServiceClient(), "{dsID}", "{versionID}").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, &ExampleVersion1, ds)
+}
diff --git a/openstack/db/v1/datastores/urls.go b/openstack/db/v1/datastores/urls.go
new file mode 100644
index 0000000..06d1b3d
--- /dev/null
+++ b/openstack/db/v1/datastores/urls.go
@@ -0,0 +1,19 @@
+package datastores
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/flavors/requests.go
new file mode 100644
index 0000000..7fac56a
--- /dev/null
+++ b/openstack/db/v1/flavors/requests.go
@@ -0,0 +1,21 @@
+package flavors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get will retrieve information for a specified hardware flavor.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/db/v1/flavors/results.go b/openstack/db/v1/flavors/results.go
new file mode 100644
index 0000000..0ba515c
--- /dev/null
+++ b/openstack/db/v1/flavors/results.go
@@ -0,0 +1,71 @@
+package flavors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 (r GetResult) Extract() (*Flavor, error) {
+ var s struct {
+ Flavor *Flavor `json:"flavor"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Flavor, err
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+ // The flavor's unique identifier.
+ // Contains 0 if the ID is not an integer.
+ ID int `json:"id"`
+
+ // The RAM capacity for the flavor.
+ RAM int `json:"ram"`
+
+ // The Name field provides a human-readable moniker for the flavor.
+ Name string `json:"name"`
+
+ // Links to access the flavor.
+ Links []gophercloud.Link
+
+ // The flavor's unique identifier as a string
+ StrID string `json:"str_id"`
+}
+
+// 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 (page FlavorPage) IsEmpty() (bool, error) {
+ flavors, err := ExtractFlavors(page)
+ return len(flavors) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page FlavorPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"flavors_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+ var s struct {
+ Flavors []Flavor `json:"flavors"`
+ }
+ err := (r.(FlavorPage)).ExtractInto(&s)
+ return s.Flavors, err
+}
diff --git a/openstack/db/v1/flavors/testing/doc.go b/openstack/db/v1/flavors/testing/doc.go
new file mode 100644
index 0000000..0809266
--- /dev/null
+++ b/openstack/db/v1/flavors/testing/doc.go
@@ -0,0 +1,2 @@
+// db_flavors_v1
+package testing
diff --git a/openstack/db/v1/flavors/testing/fixtures.go b/openstack/db/v1/flavors/testing/fixtures.go
new file mode 100644
index 0000000..9c323b8
--- /dev/null
+++ b/openstack/db/v1/flavors/testing/fixtures.go
@@ -0,0 +1,52 @@
+package testing
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+const flavor = `
+{
+ "id": %s,
+ "links": [
+ {
+ "href": "https://openstack.example.com/v1.0/1234/flavors/%s",
+ "rel": "self"
+ },
+ {
+ "href": "https://openstack.example.com/flavors/%s",
+ "rel": "bookmark"
+ }
+ ],
+ "name": "%s",
+ "ram": %d,
+ "str_id": "%s"
+}
+`
+
+var (
+ flavorID = "{flavorID}"
+ _baseURL = "/flavors"
+ resURL = "/flavors/" + flavorID
+)
+
+var (
+ flavor1 = fmt.Sprintf(flavor, "1", "1", "1", "m1.tiny", 512, "1")
+ flavor2 = fmt.Sprintf(flavor, "2", "2", "2", "m1.small", 1024, "2")
+ flavor3 = fmt.Sprintf(flavor, "3", "3", "3", "m1.medium", 2048, "3")
+ flavor4 = fmt.Sprintf(flavor, "4", "4", "4", "m1.large", 4096, "4")
+ flavor5 = fmt.Sprintf(flavor, "null", "d1", "d1", "ds512M", 512, "d1")
+
+ listFlavorsResp = fmt.Sprintf(`{"flavors":[%s, %s, %s, %s, %s]}`, flavor1, flavor2, flavor3, flavor4, flavor5)
+ 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/testing/requests_test.go b/openstack/db/v1/flavors/testing/requests_test.go
new file mode 100644
index 0000000..e8b580a
--- /dev/null
+++ b/openstack/db/v1/flavors/testing/requests_test.go
@@ -0,0 +1,107 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/flavors"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListFlavors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ pages := 0
+ err := flavors.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := flavors.ExtractFlavors(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []flavors.Flavor{
+ {
+ ID: 1,
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ {Href: "https://openstack.example.com/flavors/1", Rel: "bookmark"},
+ },
+ StrID: "1",
+ },
+ {
+ ID: 2,
+ Name: "m1.small",
+ RAM: 1024,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/2", Rel: "self"},
+ {Href: "https://openstack.example.com/flavors/2", Rel: "bookmark"},
+ },
+ StrID: "2",
+ },
+ {
+ ID: 3,
+ Name: "m1.medium",
+ RAM: 2048,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/3", Rel: "self"},
+ {Href: "https://openstack.example.com/flavors/3", Rel: "bookmark"},
+ },
+ StrID: "3",
+ },
+ {
+ ID: 4,
+ Name: "m1.large",
+ RAM: 4096,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/4", Rel: "self"},
+ {Href: "https://openstack.example.com/flavors/4", Rel: "bookmark"},
+ },
+ StrID: "4",
+ },
+ {
+ ID: 0,
+ Name: "ds512M",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/d1", Rel: "self"},
+ {Href: "https://openstack.example.com/flavors/d1", Rel: "bookmark"},
+ },
+ StrID: "d1",
+ },
+ }
+
+ 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 := flavors.Get(fake.ServiceClient(), flavorID).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &flavors.Flavor{
+ ID: 1,
+ Name: "m1.tiny",
+ RAM: 512,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ },
+ StrID: "1",
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/db/v1/flavors/urls.go b/openstack/db/v1/flavors/urls.go
new file mode 100644
index 0000000..a24301b
--- /dev/null
+++ b/openstack/db/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/instances/requests.go
new file mode 100644
index 0000000..f8afb73
--- /dev/null
+++ b/openstack/db/v1/instances/requests.go
@@ -0,0 +1,205 @@
+package instances
+
+import (
+ "github.com/gophercloud/gophercloud"
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+ "github.com/gophercloud/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 `json:"version"`
+ Type string `json:"type"`
+}
+
+// ToMap converts a DatastoreOpts to a map[string]string (for a request body)
+func (opts DatastoreOpts) ToMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// NetworkOpts is used within CreateOpts to control a new server's network attachments.
+type NetworkOpts struct {
+ // UUID of a nova-network to attach to the newly provisioned server.
+ // Required unless Port is provided.
+ UUID string `json:"net-id,omitempty"`
+
+ // Port of a neutron network to attach to the newly provisioned server.
+ // Required unless UUID is provided.
+ Port string `json:"port-id,omitempty"`
+
+ // V4FixedIP [optional] specifies a fixed IPv4 address to be used on this network.
+ V4FixedIP string `json:"v4-fixed-ip,omitempty"`
+
+ // V6FixedIP [optional] specifies a fixed IPv6 address to be used on this network.
+ V6FixedIP string `json:"v6-fixed-ip,omitempty"`
+}
+
+// ToMap converts a NetworkOpts to a map[string]string (for a request body)
+func (opts NetworkOpts) ToMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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
+ // Networks dictates how this server will be attached to available networks.
+ Networks []NetworkOpts
+}
+
+// ToInstanceCreateMap will render a JSON map.
+func (opts CreateOpts) ToInstanceCreateMap() (map[string]interface{}, error) {
+ if opts.Size > 300 || opts.Size < 1 {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "instances.CreateOpts.Size"
+ err.Value = opts.Size
+ err.Info = "Size (GB) must be between 1-300"
+ return nil, err
+ }
+
+ if opts.FlavorRef == "" {
+ return nil, gophercloud.ErrMissingInput{Argument: "instances.CreateOpts.FlavorRef"}
+ }
+
+ 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"]
+ }
+ if opts.Datastore != nil {
+ datastore, err := opts.Datastore.ToMap()
+ if err != nil {
+ return nil, err
+ }
+ instance["datastore"] = datastore
+ }
+
+ if len(opts.Networks) > 0 {
+ networks := make([]map[string]interface{}, len(opts.Networks))
+ for i, net := range opts.Networks {
+ var err error
+ networks[i], err = net.ToMap()
+ if err != nil {
+ return nil, err
+ }
+ }
+ instance["nics"] = networks
+ }
+
+ 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) (r CreateResult) {
+ b, err := opts.ToInstanceCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(baseURL(client), &b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return
+}
+
+// List retrieves the status and information for all database instances.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+ return InstancePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get retrieves the status and information for a specified database instance.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+ return
+}
+
+// Delete permanently destroys the database instance.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(resourceURL(client, id), nil)
+ return
+}
+
+// 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) (r EnableRootUserResult) {
+ _, r.Err = client.Post(userRootURL(client, id), nil, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return
+}
+
+// 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) (r IsRootEnabledResult) {
+ _, r.Err = client.Get(userRootURL(client, id), &r.Body, nil)
+ return
+}
+
+// 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) (r ActionResult) {
+ b := map[string]interface{}{"restart": struct{}{}}
+ _, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+ return
+}
+
+// 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) (r ActionResult) {
+ b := map[string]interface{}{"resize": map[string]string{"flavorRef": flavorRef}}
+ _, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+ return
+}
+
+// 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) (r ActionResult) {
+ b := map[string]interface{}{"resize": map[string]interface{}{"volume": map[string]int{"size": size}}}
+ _, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+ return
+}
diff --git a/openstack/db/v1/instances/results.go b/openstack/db/v1/instances/results.go
new file mode 100644
index 0000000..6bfde15
--- /dev/null
+++ b/openstack/db/v1/instances/results.go
@@ -0,0 +1,181 @@
+package instances
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+ "github.com/gophercloud/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
+}
+
+// Flavor represents (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+ // The flavor's unique identifier.
+ ID string
+ // Links to access the flavor.
+ Links []gophercloud.Link
+}
+
+// Instance represents a remote MySQL instance.
+type Instance struct {
+ // Indicates the datetime that the instance was created
+ Created time.Time `json:"-"`
+
+ // Indicates the most recent datetime that the instance was updated.
+ Updated time.Time `json:"-"`
+
+ // Indicates the hardware flavor the instance uses.
+ Flavor 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
+
+ // The IP addresses associated with the database instance
+ // Is empty if the instance has a hostname
+ IP []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
+}
+
+func (r *Instance) UnmarshalJSON(b []byte) error {
+ type tmp Instance
+ var s struct {
+ tmp
+ Created gophercloud.JSONRFC3339NoZ `json:"created"`
+ Updated gophercloud.JSONRFC3339NoZ `json:"updated"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Instance(s.tmp)
+
+ r.Created = time.Time(s.Created)
+ r.Updated = time.Time(s.Updated)
+
+ return nil
+}
+
+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) {
+ var s struct {
+ Instance *Instance `json:"instance"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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)
+ return len(instances) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page InstancePage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"instances_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractInstances will convert a generic pagination struct into a more
+// relevant slice of Instance structs.
+func ExtractInstances(r pagination.Page) ([]Instance, error) {
+ var s struct {
+ Instances []Instance `json:"instances"`
+ }
+ err := (r.(InstancePage)).ExtractInto(&s)
+ return s.Instances, err
+}
+
+// EnableRootUserResult represents the result of an operation to enable the root user.
+type EnableRootUserResult struct {
+ gophercloud.Result
+}
+
+// Extract will extract root user information from a UserRootResult.
+func (r EnableRootUserResult) Extract() (*users.User, error) {
+ var s struct {
+ User *users.User `json:"user"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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
+}
+
+// IsRootEnabledResult is the result of a call to IsRootEnabled. To see if
+// root is enabled, call the type's Extract method.
+type IsRootEnabledResult struct {
+ gophercloud.Result
+}
+
+// Extract is used to extract the data from a IsRootEnabledResult.
+func (r IsRootEnabledResult) Extract() (bool, error) {
+ return r.Body.(map[string]interface{})["rootEnabled"] == true, r.Err
+}
diff --git a/openstack/db/v1/instances/testing/doc.go b/openstack/db/v1/instances/testing/doc.go
new file mode 100644
index 0000000..386ac58
--- /dev/null
+++ b/openstack/db/v1/instances/testing/doc.go
@@ -0,0 +1,2 @@
+// db_instances_v1
+package testing
diff --git a/openstack/db/v1/instances/testing/fixtures.go b/openstack/db/v1/instances/testing/fixtures.go
new file mode 100644
index 0000000..9347ee1
--- /dev/null
+++ b/openstack/db/v1/instances/testing/fixtures.go
@@ -0,0 +1,169 @@
+package testing
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ "github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+var (
+ timestamp = "2015-11-12T14:22:42"
+ timeVal, _ = time.Parse(gophercloud.RFC3339NoZ, timestamp)
+)
+
+var instance = `
+{
+ "created": "` + timestamp + `",
+ "datastore": {
+ "type": "mysql",
+ "version": "5.6"
+ },
+ "flavor": {
+ "id": "1",
+ "links": [
+ {
+ "href": "https://openstack.example.com/v1.0/1234/flavors/1",
+ "rel": "self"
+ },
+ {
+ "href": "https://openstack.example.com/v1.0/1234/flavors/1",
+ "rel": "bookmark"
+ }
+ ]
+ },
+ "links": [
+ {
+ "href": "https://openstack.example.com/v1.0/1234/instances/1",
+ "rel": "self"
+ }
+ ],
+ "hostname": "e09ad9a3f73309469cf1f43d11e79549caf9acf2.openstack.example.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 = instances.Instance{
+ Created: timeVal,
+ Updated: timeVal,
+ Flavor: instances.Flavor{
+ ID: "1",
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+ {Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "bookmark"},
+ },
+ },
+ Hostname: "e09ad9a3f73309469cf1f43d11e79549caf9acf2.openstack.example.com",
+ ID: instanceID,
+ Links: []gophercloud.Link{
+ {Href: "https://openstack.example.com/v1.0/1234/instances/1", Rel: "self"},
+ },
+ Name: "json_rack_instance",
+ Status: "BUILD",
+ Volume: instances.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/testing/requests_test.go b/openstack/db/v1/instances/testing/requests_test.go
new file mode 100644
index 0000000..e3c81e3
--- /dev/null
+++ b/openstack/db/v1/instances/testing/requests_test.go
@@ -0,0 +1,134 @@
+package testing
+
+import (
+ "testing"
+
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := instances.CreateOpts{
+ Name: "json_rack_instance",
+ FlavorRef: "1",
+ Databases: db.BatchCreateOpts{
+ {CharSet: "utf8", Collate: "utf8_general_ci", Name: "sampledb"},
+ {Name: "nextround"},
+ },
+ Users: users.BatchCreateOpts{
+ {
+ Name: "demouser",
+ Password: "demopassword",
+ Databases: db.BatchCreateOpts{
+ {Name: "sampledb"},
+ },
+ },
+ },
+ Size: 2,
+ }
+
+ instance, err := instances.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 := instances.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := instances.ExtractInstances(page)
+ if err != nil {
+ return false, err
+ }
+
+ th.CheckDeepEquals(t, []instances.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 := instances.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 := instances.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 := instances.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 := instances.IsRootEnabled(fake.ServiceClient(), instanceID).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, true, isEnabled)
+}
+
+func TestRestart(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleRestart(t)
+
+ res := instances.Restart(fake.ServiceClient(), instanceID)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResize(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleResize(t)
+
+ res := instances.Resize(fake.ServiceClient(), instanceID, "2")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestResizeVolume(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleResizeVol(t)
+
+ res := instances.ResizeVolume(fake.ServiceClient(), instanceID, 4)
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/instances/urls.go b/openstack/db/v1/instances/urls.go
new file mode 100644
index 0000000..76d1ca5
--- /dev/null
+++ b/openstack/db/v1/instances/urls.go
@@ -0,0 +1,19 @@
+package instances
+
+import "github.com/gophercloud/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/requests.go b/openstack/db/v1/users/requests.go
new file mode 100644
index 0000000..d342de3
--- /dev/null
+++ b/openstack/db/v1/users/requests.go
@@ -0,0 +1,91 @@
+package users
+
+import (
+ "github.com/gophercloud/gophercloud"
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/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 {
+ // 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 `json:"name" required:"true"`
+ // Specifies a password for the user.
+ Password string `json:"password" required:"true"`
+ // An array of databases that this user will connect to. The
+ // "name" field is the only requirement for each option.
+ Databases db.BatchCreateOpts `json:"databases,omitempty"`
+ // 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 `json:"host,omitempty"`
+}
+
+// ToMap is a convenience function for creating sub-maps for individual users.
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+ if opts.Name == "root" {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "users.CreateOpts.Name"
+ err.Value = "root"
+ err.Info = "root is a reserved user name and cannot be used"
+ return nil, err
+ }
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToUserCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(baseURL(client, instanceID), &b, nil, nil)
+ return
+}
+
+// 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 {
+ return pagination.NewPager(client, baseURL(client, instanceID), func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Delete will permanently delete a user from a specified database instance.
+func Delete(client *gophercloud.ServiceClient, instanceID, userName string) (r DeleteResult) {
+ _, r.Err = client.Delete(userURL(client, instanceID, userName), nil)
+ return
+}
diff --git a/openstack/db/v1/users/results.go b/openstack/db/v1/users/results.go
new file mode 100644
index 0000000..d12a681
--- /dev/null
+++ b/openstack/db/v1/users/results.go
@@ -0,0 +1,62 @@
+package users
+
+import (
+ "github.com/gophercloud/gophercloud"
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/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)
+ return len(users) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page UserPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"users_links"`
+ }
+ err := page.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractUsers will convert a generic pagination struct into a more
+// relevant slice of User structs.
+func ExtractUsers(r pagination.Page) ([]User, error) {
+ var s struct {
+ Users []User `json:"users"`
+ }
+ err := (r.(UserPage)).ExtractInto(&s)
+ return s.Users, err
+}
diff --git a/openstack/db/v1/users/testing/doc.go b/openstack/db/v1/users/testing/doc.go
new file mode 100644
index 0000000..3c98966
--- /dev/null
+++ b/openstack/db/v1/users/testing/doc.go
@@ -0,0 +1,2 @@
+// db_users_v1
+package testing
diff --git a/openstack/db/v1/users/testing/fixtures.go b/openstack/db/v1/users/testing/fixtures.go
new file mode 100644
index 0000000..f49f46f
--- /dev/null
+++ b/openstack/db/v1/users/testing/fixtures.go
@@ -0,0 +1,37 @@
+package testing
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gophercloud/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/testing/requests_test.go b/openstack/db/v1/users/testing/requests_test.go
new file mode 100644
index 0000000..952f245
--- /dev/null
+++ b/openstack/db/v1/users/testing/requests_test.go
@@ -0,0 +1,85 @@
+package testing
+
+import (
+ "testing"
+
+ db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+ "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreate(t)
+
+ opts := users.BatchCreateOpts{
+ {
+ Databases: db.BatchCreateOpts{
+ db.CreateOpts{Name: "databaseA"},
+ },
+ Name: "dbuser3",
+ Password: "secretsecret",
+ },
+ {
+ Databases: db.BatchCreateOpts{
+ {Name: "databaseB"},
+ {Name: "databaseC"},
+ },
+ Name: "dbuser4",
+ Password: "secretsecret",
+ },
+ }
+
+ res := users.Create(fake.ServiceClient(), instanceID, opts)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUserList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleList(t)
+
+ expectedUsers := []users.User{
+ {
+ Databases: []db.Database{
+ db.Database{Name: "databaseA"},
+ },
+ Name: "dbuser3",
+ },
+ {
+ Databases: []db.Database{
+ {Name: "databaseB"},
+ {Name: "databaseC"},
+ },
+ Name: "dbuser4",
+ },
+ }
+
+ pages := 0
+ err := users.List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := users.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 := users.Delete(fake.ServiceClient(), instanceID, "{userName}")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/users/urls.go b/openstack/db/v1/users/urls.go
new file mode 100644
index 0000000..8c36a39
--- /dev/null
+++ b/openstack/db/v1/users/urls.go
@@ -0,0 +1,11 @@
+package users
+
+import "github.com/gophercloud/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/dns/v2/zones/doc.go b/openstack/dns/v2/zones/doc.go
new file mode 100644
index 0000000..1302cb9
--- /dev/null
+++ b/openstack/dns/v2/zones/doc.go
@@ -0,0 +1,6 @@
+// Package tokens provides information and interaction with the zone API
+// resource for the OpenStack DNS service.
+//
+// For more information, see:
+// http://developer.openstack.org/api-ref/dns/#zone
+package zones
diff --git a/openstack/dns/v2/zones/requests.go b/openstack/dns/v2/zones/requests.go
new file mode 100644
index 0000000..6db7831
--- /dev/null
+++ b/openstack/dns/v2/zones/requests.go
@@ -0,0 +1,57 @@
+package zones
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type ListOptsBuilder interface {
+ ToZoneListQuery() (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.
+// https://developer.openstack.org/api-ref/dns/
+type ListOpts struct {
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+
+ // UUID of the zone at which you want to set a marker.
+ Marker string `q:"marker"`
+
+ Description string `q:"description"`
+ Email string `q:"email"`
+ Name string `q:"name"`
+ SortDir string `q:"sort_dir"`
+ SortKey string `q:"sort_key"`
+ Status string `q:"status"`
+ TTL int `q:"ttl"`
+ Type string `q:"type"`
+}
+
+func (opts ListOpts) ToZoneListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client)
+ if opts != nil {
+ query, err := opts.ToZoneListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ZonePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get returns additional information about a zone, given its ID.
+func Get(client *gophercloud.ServiceClient, zoneID string) (r GetResult) {
+ _, r.Err = client.Get(zoneURL(client, zoneID), &r.Body, nil)
+ return
+}
diff --git a/openstack/dns/v2/zones/results.go b/openstack/dns/v2/zones/results.go
new file mode 100644
index 0000000..4693b09
--- /dev/null
+++ b/openstack/dns/v2/zones/results.go
@@ -0,0 +1,144 @@
+package zones
+
+import (
+ "encoding/json"
+ "strconv"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets a GetResult, CreateResult or UpdateResult as a concrete Zone.
+// An error is returned if the original call or the extraction failed.
+func (r commonResult) Extract() (*Zone, error) {
+ var s *Zone
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// GetResult is the deferred result of a Get call.
+type GetResult struct {
+ commonResult
+}
+
+// ZonePage is a single page of Zone results.
+type ZonePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (r ZonePage) IsEmpty() (bool, error) {
+ s, err := ExtractZones(r)
+ return len(s) == 0, err
+}
+
+// ExtractZones extracts a slice of Services from a Collection acquired from List.
+func ExtractZones(r pagination.Page) ([]Zone, error) {
+ var s struct {
+ Zones []Zone `json:"zones"`
+ }
+ err := (r.(ZonePage)).ExtractInto(&s)
+ return s.Zones, err
+}
+
+// Zone represents a DNS zone.
+type Zone struct {
+ // ID uniquely identifies this zone amongst all other zones, including those not accessible to the current tenant.
+ ID string `json:"id"`
+
+ // PoolID is the ID for the pool hosting this zone.
+ PoolID string `json:"pool_id"`
+
+ // ProjectID identifies the project/tenant owning this resource.
+ ProjectID string `json:"project_id"`
+
+ // Name is the DNS Name for the zone.
+ Name string `json:"name"`
+
+ // Email for the zone. Used in SOA records for the zone.
+ Email string `json:"email"`
+
+ // Description for this zone.
+ Description string `json:"description"`
+
+ // TTL is the Time to Live for the zone.
+ TTL int `json:"ttl"`
+
+ // Serial is the current serial number for the zone.
+ Serial int `json:"-"`
+
+ // Status is the status of the resource.
+ Status string `json:"status"`
+
+ // Action is the current action in progress on the resource.
+ Action string `json:"action"`
+
+ // Version of the resource.
+ Version int `json:"version"`
+
+ // Attributes for the zone.
+ Attributes map[string]string `json:"attributes"`
+
+ // Type of zone. Primary is controlled by Designate.
+ // Secondary zones are slaved from another DNS Server.
+ // Defaults to Primary.
+ Type string `json:"type"`
+
+ // Masters is the servers for slave servers to get DNS information from.
+ Masters []string `json:"masters"`
+
+ // CreatedAt is the date when the zone was created.
+ CreatedAt time.Time `json:"-"`
+
+ // UpdatedAt is the date when the last change was made to the zone.
+ UpdatedAt time.Time `json:"-"`
+
+ // TransferredAt is the last time an update was retrieved from the master servers.
+ TransferredAt time.Time `json:"-"`
+
+ // Links includes HTTP references to the itself, useful for passing along to other APIs that might want a server reference.
+ Links map[string]interface{} `json:"links"`
+}
+
+func (r *Zone) UnmarshalJSON(b []byte) error {
+ type tmp Zone
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"`
+ TransferredAt gophercloud.JSONRFC3339MilliNoZ `json:"transferred_at"`
+ Serial interface{} `json:"serial"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Zone(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+ r.UpdatedAt = time.Time(s.UpdatedAt)
+ r.TransferredAt = time.Time(s.TransferredAt)
+
+ switch t := s.Serial.(type) {
+ case float64:
+ r.Serial = int(t)
+ case string:
+ switch t {
+ case "":
+ r.Serial = 0
+ default:
+ serial, err := strconv.ParseFloat(t, 64)
+ if err != nil {
+ return err
+ }
+ r.Serial = int(serial)
+ }
+ }
+
+ return err
+}
diff --git a/openstack/dns/v2/zones/testing/doc.go b/openstack/dns/v2/zones/testing/doc.go
new file mode 100644
index 0000000..54a0d21
--- /dev/null
+++ b/openstack/dns/v2/zones/testing/doc.go
@@ -0,0 +1,2 @@
+// dns_zones_v2
+package testing
diff --git a/openstack/dns/v2/zones/testing/fixtures.go b/openstack/dns/v2/zones/testing/fixtures.go
new file mode 100644
index 0000000..473ca90
--- /dev/null
+++ b/openstack/dns/v2/zones/testing/fixtures.go
@@ -0,0 +1,165 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/dns/v2/zones"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// List Output is a sample response to a List call.
+const ListOutput = `
+{
+ "links": {
+ "self": "http://example.com:9001/v2/zones"
+ },
+ "metadata": {
+ "total_count": 2
+ },
+ "zones": [
+ {
+ "id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
+ "pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
+ "project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
+ "name": "example.org.",
+ "email": "joe@example.org",
+ "ttl": 7200,
+ "serial": 1404757531,
+ "status": "ACTIVE",
+ "action": "CREATE",
+ "description": "This is an example zone.",
+ "masters": [],
+ "type": "PRIMARY",
+ "transferred_at": null,
+ "version": 1,
+ "created_at": "2014-07-07T18:25:31.275934",
+ "updated_at": null,
+ "links": {
+ "self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
+ }
+ },
+ {
+ "id": "34c4561c-9205-4386-9df5-167436f5a222",
+ "pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
+ "project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
+ "name": "foo.example.com.",
+ "email": "joe@foo.example.com",
+ "ttl": 7200,
+ "serial": 1488053571,
+ "status": "ACTIVE",
+ "action": "CREATE",
+ "description": "This is another example zone.",
+ "masters": ["example.com."],
+ "type": "PRIMARY",
+ "transferred_at": null,
+ "version": 1,
+ "created_at": "2014-07-07T18:25:31.275934",
+ "updated_at": "2015-02-25T20:23:01.234567",
+ "links": {
+ "self": "https://127.0.0.1:9001/v2/zones/34c4561c-9205-4386-9df5-167436f5a222"
+ }
+ }
+ ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+ "id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
+ "pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
+ "project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
+ "name": "example.org.",
+ "email": "joe@example.org",
+ "ttl": 7200,
+ "serial": 1404757531,
+ "status": "ACTIVE",
+ "action": "CREATE",
+ "description": "This is an example zone.",
+ "masters": [],
+ "type": "PRIMARY",
+ "transferred_at": null,
+ "version": 1,
+ "created_at": "2014-07-07T18:25:31.275934",
+ "updated_at": null,
+ "links": {
+ "self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
+ }
+}
+`
+
+// FirstZone is the first result in ListOutput
+var FirstZoneCreatedAt, _ = time.Parse(gophercloud.RFC3339MilliNoZ, "2014-07-07T18:25:31.275934")
+var FirstZone = zones.Zone{
+ ID: "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
+ PoolID: "572ba08c-d929-4c70-8e42-03824bb24ca2",
+ ProjectID: "4335d1f0-f793-11e2-b778-0800200c9a66",
+ Name: "example.org.",
+ Email: "joe@example.org",
+ TTL: 7200,
+ Serial: 1404757531,
+ Status: "ACTIVE",
+ Action: "CREATE",
+ Description: "This is an example zone.",
+ Masters: []string{},
+ Type: "PRIMARY",
+ Version: 1,
+ CreatedAt: FirstZoneCreatedAt,
+ Links: map[string]interface{}{
+ "self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
+ },
+}
+
+var SecondZoneCreatedAt, _ = time.Parse(gophercloud.RFC3339MilliNoZ, "2014-07-07T18:25:31.275934")
+var SecondZoneUpdatedAt, _ = time.Parse(gophercloud.RFC3339MilliNoZ, "2015-02-25T20:23:01.234567")
+var SecondZone = zones.Zone{
+ ID: "34c4561c-9205-4386-9df5-167436f5a222",
+ PoolID: "572ba08c-d929-4c70-8e42-03824bb24ca2",
+ ProjectID: "4335d1f0-f793-11e2-b778-0800200c9a66",
+ Name: "foo.example.com.",
+ Email: "joe@foo.example.com",
+ TTL: 7200,
+ Serial: 1488053571,
+ Status: "ACTIVE",
+ Action: "CREATE",
+ Description: "This is another example zone.",
+ Masters: []string{"example.com."},
+ Type: "PRIMARY",
+ Version: 1,
+ CreatedAt: SecondZoneCreatedAt,
+ UpdatedAt: SecondZoneUpdatedAt,
+ Links: map[string]interface{}{
+ "self": "https://127.0.0.1:9001/v2/zones/34c4561c-9205-4386-9df5-167436f5a222",
+ },
+}
+
+// ExpectedZonesSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedZonesSlice = []zones.Zone{FirstZone, SecondZone}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/zones", 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 List request.
+func HandleGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3", 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/dns/v2/zones/testing/requests_test.go b/openstack/dns/v2/zones/testing/requests_test.go
new file mode 100644
index 0000000..b7dd667
--- /dev/null
+++ b/openstack/dns/v2/zones/testing/requests_test.go
@@ -0,0 +1,50 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/dns/v2/zones"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ count := 0
+ err := zones.List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := zones.ExtractZones(page)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedZonesSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 1, count)
+}
+
+func TestListAllPages(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListSuccessfully(t)
+
+ allPages, err := zones.List(client.ServiceClient(), nil).AllPages()
+ th.AssertNoErr(t, err)
+ allZones, err := zones.ExtractZones(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, 2, len(allZones))
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t)
+
+ actual, err := zones.Get(client.ServiceClient(), "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &FirstZone, actual)
+}
diff --git a/openstack/dns/v2/zones/urls.go b/openstack/dns/v2/zones/urls.go
new file mode 100644
index 0000000..0cd4796
--- /dev/null
+++ b/openstack/dns/v2/zones/urls.go
@@ -0,0 +1,11 @@
+package zones
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("zones")
+}
+
+func zoneURL(c *gophercloud.ServiceClient, zoneID string) string {
+ return c.ServiceURL("zones", zoneID)
+}
diff --git a/openstack/endpoint_location.go b/openstack/endpoint_location.go
new file mode 100644
index 0000000..ea37f5b
--- /dev/null
+++ b/openstack/endpoint_location.go
@@ -0,0 +1,99 @@
+package openstack
+
+import (
+ "github.com/gophercloud/gophercloud"
+ tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/gophercloud/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 {
+ err := &ErrMultipleMatchingEndpointsV2{}
+ err.Endpoints = endpoints
+ return "", err
+ }
+
+ // 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:
+ err := &ErrInvalidAvailabilityProvided{}
+ err.Argument = "Availability"
+ err.Value = opts.Availability
+ return "", err
+ }
+ }
+
+ // Report an error if there were no matching endpoints.
+ err := &gophercloud.ErrEndpointNotFound{}
+ return "", err
+}
+
+// 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 {
+ err := &ErrInvalidAvailabilityProvided{}
+ err.Argument = "Availability"
+ err.Value = opts.Availability
+ return "", err
+ }
+ 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 "", ErrMultipleMatchingEndpointsV3{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.
+ err := &gophercloud.ErrEndpointNotFound{}
+ return "", err
+}
diff --git a/openstack/errors.go b/openstack/errors.go
new file mode 100644
index 0000000..df410b1
--- /dev/null
+++ b/openstack/errors.go
@@ -0,0 +1,71 @@
+package openstack
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+ tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+)
+
+// ErrEndpointNotFound is the error when no suitable endpoint can be found
+// in the user's catalog
+type ErrEndpointNotFound struct{ gophercloud.BaseError }
+
+func (e ErrEndpointNotFound) Error() string {
+ return "No suitable endpoint could be found in the service catalog."
+}
+
+// ErrInvalidAvailabilityProvided is the error when an invalid endpoint
+// availability is provided
+type ErrInvalidAvailabilityProvided struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrInvalidAvailabilityProvided) Error() string {
+ return fmt.Sprintf("Unexpected availability in endpoint query: %s", e.Value)
+}
+
+// ErrMultipleMatchingEndpointsV2 is the error when more than one endpoint
+// for the given options is found in the v2 catalog
+type ErrMultipleMatchingEndpointsV2 struct {
+ gophercloud.BaseError
+ Endpoints []tokens2.Endpoint
+}
+
+func (e ErrMultipleMatchingEndpointsV2) Error() string {
+ return fmt.Sprintf("Discovered %d matching endpoints: %#v", len(e.Endpoints), e.Endpoints)
+}
+
+// ErrMultipleMatchingEndpointsV3 is the error when more than one endpoint
+// for the given options is found in the v3 catalog
+type ErrMultipleMatchingEndpointsV3 struct {
+ gophercloud.BaseError
+ Endpoints []tokens3.Endpoint
+}
+
+func (e ErrMultipleMatchingEndpointsV3) Error() string {
+ return fmt.Sprintf("Discovered %d matching endpoints: %#v", len(e.Endpoints), e.Endpoints)
+}
+
+// ErrNoAuthURL is the error when the OS_AUTH_URL environment variable is not
+// found
+type ErrNoAuthURL struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoAuthURL) Error() string {
+ return "Environment variable OS_AUTH_URL needs to be set."
+}
+
+// ErrNoUsername is the error when the OS_USERNAME environment variable is not
+// found
+type ErrNoUsername struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoUsername) Error() string {
+ return "Environment variable OS_USERNAME needs to be set."
+}
+
+// ErrNoPassword is the error when the OS_PASSWORD environment variable is not
+// found
+type ErrNoPassword struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoPassword) Error() string {
+ return "Environment variable OS_PASSWORD needs to be set."
+}
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/requests.go b/openstack/identity/v2/extensions/admin/roles/requests.go
new file mode 100644
index 0000000..50228c9
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests.go
@@ -0,0 +1,32 @@
+package roles
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// AddUser 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 AddUser(client *gophercloud.ServiceClient, tenantID, userID, roleID string) (r UserRoleResult) {
+ _, r.Err = client.Put(userRoleURL(client, tenantID, userID, roleID), nil, nil, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// DeleteUser 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 DeleteUser(client *gophercloud.ServiceClient, tenantID, userID, roleID string) (r UserRoleResult) {
+ _, r.Err = client.Delete(userRoleURL(client, tenantID, userID, roleID), nil)
+ return
+}
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..28de6bb
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/results.go
@@ -0,0 +1,47 @@
+package roles
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 (r RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(r)
+ return len(users) == 0, err
+}
+
+// ExtractRoles returns a slice of roles contained in a single page of results.
+func ExtractRoles(r pagination.Page) ([]Role, error) {
+ var s struct {
+ Roles []Role `json:"roles"`
+ }
+ err := (r.(RolePage)).ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/identity/v2/extensions/admin/roles/testing/doc.go
new file mode 100644
index 0000000..70ba643
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_extensions_admin_roles_v2
+package testing
diff --git a/openstack/identity/v2/extensions/admin/roles/testing/fixtures.go b/openstack/identity/v2/extensions/admin/roles/testing/fixtures.go
new file mode 100644
index 0000000..498c161
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/testing/fixtures.go
@@ -0,0 +1,48 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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/testing/requests_test.go b/openstack/identity/v2/extensions/admin/roles/testing/requests_test.go
new file mode 100644
index 0000000..8cf5395
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/testing/requests_test.go
@@ -0,0 +1,65 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListRoleResponse(t)
+
+ count := 0
+
+ err := roles.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := roles.ExtractRoles(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []roles.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 TestAddUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAddUserRoleResponse(t)
+
+ err := roles.AddUser(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteUserRoleResponse(t)
+
+ err := roles.DeleteUser(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
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..e4661e8
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/urls.go
@@ -0,0 +1,21 @@
+package roles
+
+import "github.com/gophercloud/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..cf6cc81
--- /dev/null
+++ b/openstack/identity/v2/extensions/delegate.go
@@ -0,0 +1,46 @@
+package extensions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ "github.com/gophercloud/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)
+ return len(is) == 0, err
+}
+
+// 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 s struct {
+ Extensions struct {
+ Values []common.Extension `json:"values"`
+ } `json:"extensions"`
+ }
+ err := page.(ExtensionPage).ExtractInto(&s)
+ return s.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/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/testing/delegate_test.go b/openstack/identity/v2/extensions/testing/delegate_test.go
new file mode 100644
index 0000000..e7869d8
--- /dev/null
+++ b/openstack/identity/v2/extensions/testing/delegate_test.go
@@ -0,0 +1,39 @@
+package testing
+
+import (
+ "testing"
+
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions/testing"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListExtensionsSuccessfully(t)
+
+ count := 0
+ err := extensions.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := extensions.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 := extensions.Get(client.ServiceClient(), "agent").Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, common.SingleExtension, actual)
+}
diff --git a/openstack/identity/v2/extensions/testing/doc.go b/openstack/identity/v2/extensions/testing/doc.go
new file mode 100644
index 0000000..6d4b67d
--- /dev/null
+++ b/openstack/identity/v2/extensions/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_extensions_v2
+package testing
diff --git a/openstack/identity/v2/extensions/testing/fixtures.go b/openstack/identity/v2/extensions/testing/fixtures.go
new file mode 100644
index 0000000..60afb74
--- /dev/null
+++ b/openstack/identity/v2/extensions/testing/fixtures.go
@@ -0,0 +1,58 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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/requests.go b/openstack/identity/v2/tenants/requests.go
new file mode 100644
index 0000000..b9d7de6
--- /dev/null
+++ b/openstack/identity/v2/tenants/requests.go
@@ -0,0 +1,29 @@
+package tenants
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 {
+ 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, func(r pagination.PageResult) pagination.Page {
+ return TenantPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
diff --git a/openstack/identity/v2/tenants/results.go b/openstack/identity/v2/tenants/results.go
new file mode 100644
index 0000000..3ce1e67
--- /dev/null
+++ b/openstack/identity/v2/tenants/results.go
@@ -0,0 +1,53 @@
+package tenants
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"id"`
+
+ // Name is a friendlier user-facing name for this tenant.
+ Name string `json:"name"`
+
+ // Description is a human-readable explanation of this Tenant's purpose.
+ Description string `json:"description"`
+
+ // Enabled indicates whether or not a tenant is active.
+ Enabled bool `json:"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 (r TenantPage) IsEmpty() (bool, error) {
+ tenants, err := ExtractTenants(r)
+ return len(tenants) == 0, err
+}
+
+// NextPageURL extracts the "next" link from the tenants_links section of the result.
+func (r TenantPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"tenants_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractTenants returns a slice of Tenants contained in a single page of results.
+func ExtractTenants(r pagination.Page) ([]Tenant, error) {
+ var s struct {
+ Tenants []Tenant `json:"tenants"`
+ }
+ err := (r.(TenantPage)).ExtractInto(&s)
+ return s.Tenants, err
+}
diff --git a/openstack/identity/v2/tenants/testing/doc.go b/openstack/identity/v2/tenants/testing/doc.go
new file mode 100644
index 0000000..57aaa1f
--- /dev/null
+++ b/openstack/identity/v2/tenants/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_tenants_v2
+package testing
diff --git a/openstack/identity/v2/tenants/testing/fixtures.go b/openstack/identity/v2/tenants/testing/fixtures.go
new file mode 100644
index 0000000..7ddba45
--- /dev/null
+++ b/openstack/identity/v2/tenants/testing/fixtures.go
@@ -0,0 +1,64 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 = tenants.Tenant{
+ ID: "1234",
+ Name: "Red Team",
+ Description: "The team that is red",
+ Enabled: true,
+}
+
+// BlueTeam is a Tenant fixture.
+var BlueTeam = tenants.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 = []tenants.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/testing/requests_test.go b/openstack/identity/v2/tenants/testing/requests_test.go
new file mode 100644
index 0000000..2a9b71c
--- /dev/null
+++ b/openstack/identity/v2/tenants/testing/requests_test.go
@@ -0,0 +1,30 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListTenants(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListTenantsSuccessfully(t)
+
+ count := 0
+ err := tenants.List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+
+ actual, err := tenants.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/urls.go b/openstack/identity/v2/tenants/urls.go
new file mode 100644
index 0000000..101599b
--- /dev/null
+++ b/openstack/identity/v2/tenants/urls.go
@@ -0,0 +1,7 @@
+package tenants
+
+import "github.com/gophercloud/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/requests.go b/openstack/identity/v2/tokens/requests.go
new file mode 100644
index 0000000..4983031
--- /dev/null
+++ b/openstack/identity/v2/tokens/requests.go
@@ -0,0 +1,99 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+type PasswordCredentialsV2 struct {
+ Username string `json:"username" required:"true"`
+ Password string `json:"password" required:"true"`
+}
+
+type TokenCredentialsV2 struct {
+ ID string `json:"id,omitempty" required:"true"`
+}
+
+// AuthOptionsV2 wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+// interface.
+type AuthOptionsV2 struct {
+ PasswordCredentials *PasswordCredentialsV2 `json:"passwordCredentials,omitempty" xor:"TokenCredentials"`
+
+ // 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 string `json:"tenantId,omitempty"`
+ TenantName string `json:"tenantName,omitempty"`
+
+ // TokenCredentials allows users to authenticate (possibly as another user) with an
+ // authentication token ID.
+ TokenCredentials *TokenCredentialsV2 `json:"token,omitempty" xor:"PasswordCredentials"`
+}
+
+// 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.
+ ToTokenV2CreateMap() (map[string]interface{}, error)
+}
+
+// AuthOptions are the valid options for Openstack Identity v2 authentication.
+// For field descriptions, see gophercloud.AuthOptions.
+type AuthOptions struct {
+ IdentityEndpoint string `json:"-"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ TenantID string `json:"tenantId,omitempty"`
+ TenantName string `json:"tenantName,omitempty"`
+ AllowReauth bool `json:"-"`
+ TokenID string
+}
+
+// ToTokenV2CreateMap allows AuthOptions to satisfy the AuthOptionsBuilder
+// interface in the v2 tokens package
+func (opts AuthOptions) ToTokenV2CreateMap() (map[string]interface{}, error) {
+ v2Opts := AuthOptionsV2{
+ TenantID: opts.TenantID,
+ TenantName: opts.TenantName,
+ }
+
+ if opts.Password != "" {
+ v2Opts.PasswordCredentials = &PasswordCredentialsV2{
+ Username: opts.Username,
+ Password: opts.Password,
+ }
+ } else {
+ v2Opts.TokenCredentials = &TokenCredentialsV2{
+ ID: opts.TokenID,
+ }
+ }
+
+ b, err := gophercloud.BuildRequestBody(v2Opts, "auth")
+ if err != nil {
+ return nil, err
+ }
+ return b, 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) (r CreateResult) {
+ b, err := auth.ToTokenV2CreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(CreateURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ MoreHeaders: map[string]string{"X-Auth-Token": ""},
+ })
+ return
+}
+
+// Get validates and retrieves information for user's token.
+func Get(client *gophercloud.ServiceClient, token string) (r GetResult) {
+ _, r.Err = client.Get(GetURL(client, token), &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 203},
+ })
+ return
+}
diff --git a/openstack/identity/v2/tokens/results.go b/openstack/identity/v2/tokens/results.go
new file mode 100644
index 0000000..6b36493
--- /dev/null
+++ b/openstack/identity/v2/tokens/results.go
@@ -0,0 +1,144 @@
+package tokens
+
+import (
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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
+}
+
+// Role is a role for a user.
+type Role struct {
+ Name string `json:"name"`
+}
+
+// User is an OpenStack user.
+type User struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ UserName string `json:"username"`
+ Roles []Role `json:"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 `json:"tenantId"`
+ PublicURL string `json:"publicURL"`
+ InternalURL string `json:"internalURL"`
+ AdminURL string `json:"adminURL"`
+ Region string `json:"region"`
+ VersionID string `json:"versionId"`
+ VersionInfo string `json:"versionInfo"`
+ VersionList string `json:"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 `json:"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 `json:"type"`
+
+ // Endpoints will let the caller iterate over all the different endpoints that may exist for
+ // the service.
+ Endpoints []Endpoint `json:"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 (r CreateResult) ExtractToken() (*Token, error) {
+ var s struct {
+ Access struct {
+ Token struct {
+ Expires string `json:"expires"`
+ ID string `json:"id"`
+ Tenant tenants.Tenant `json:"tenant"`
+ } `json:"token"`
+ } `json:"access"`
+ }
+
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return nil, err
+ }
+
+ expiresTs, err := time.Parse(gophercloud.RFC3339Milli, s.Access.Token.Expires)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Token{
+ ID: s.Access.Token.ID,
+ ExpiresAt: expiresTs,
+ Tenant: s.Access.Token.Tenant,
+ }, nil
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (r CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+ var s struct {
+ Access struct {
+ Entries []CatalogEntry `json:"serviceCatalog"`
+ } `json:"access"`
+ }
+ err := r.ExtractInto(&s)
+ return &ServiceCatalog{Entries: s.Access.Entries}, err
+}
+
+// ExtractUser returns the User from a GetResult.
+func (r GetResult) ExtractUser() (*User, error) {
+ var s struct {
+ Access struct {
+ User User `json:"user"`
+ } `json:"access"`
+ }
+ err := r.ExtractInto(&s)
+ return &s.Access.User, err
+}
diff --git a/openstack/identity/v2/tokens/testing/doc.go b/openstack/identity/v2/tokens/testing/doc.go
new file mode 100644
index 0000000..f9767eb
--- /dev/null
+++ b/openstack/identity/v2/tokens/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_tokens_v2
+package testing
diff --git a/openstack/identity/v2/tokens/testing/fixtures.go b/openstack/identity/v2/tokens/testing/fixtures.go
new file mode 100644
index 0000000..d3a8f24
--- /dev/null
+++ b/openstack/identity/v2/tokens/testing/fixtures.go
@@ -0,0 +1,194 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ thclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ExpectedToken is the token that should be parsed from TokenCreationResponse.
+var ExpectedToken = &tokens.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 = &tokens.ServiceCatalog{
+ Entries: []tokens.CatalogEntry{
+ {
+ Name: "inscrutablewalrus",
+ Type: "something",
+ Endpoints: []tokens.Endpoint{
+ {
+ PublicURL: "http://something0:1234/v2/",
+ Region: "region0",
+ },
+ {
+ PublicURL: "http://something1:1234/v2/",
+ Region: "region1",
+ },
+ },
+ },
+ {
+ Name: "arbitrarypenguin",
+ Type: "else",
+ Endpoints: []tokens.Endpoint{
+ {
+ PublicURL: "http://else0:4321/v3/",
+ Region: "region0",
+ },
+ },
+ },
+ },
+}
+
+// ExpectedUser is the token that should be parsed from TokenGetResponse.
+var ExpectedUser = &tokens.User{
+ ID: "a530fefc3d594c4ba2693a4ecd6be74e",
+ Name: "apiserver",
+ Roles: []tokens.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 tokens.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 tokens.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/testing/requests_test.go b/openstack/identity/v2/tokens/testing/requests_test.go
new file mode 100644
index 0000000..b687a92
--- /dev/null
+++ b/openstack/identity/v2/tokens/testing/requests_test.go
@@ -0,0 +1,104 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) tokens.CreateResult {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenPost(t, requestJSON)
+
+ return tokens.Create(client.ServiceClient(), options)
+}
+
+func tokenPostErr(t *testing.T, options gophercloud.AuthOptions, expectedErr error) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenPost(t, "")
+
+ actualErr := tokens.Create(client.ServiceClient(), 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 TestRequireUsername(t *testing.T) {
+ options := gophercloud.AuthOptions{
+ Password: "thing",
+ }
+
+ tokenPostErr(t, options, gophercloud.ErrMissingInput{Argument: "Username"})
+}
+
+func tokenGet(t *testing.T, tokenId string) tokens.GetResult {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleTokenGet(t, tokenId)
+ return tokens.Get(client.ServiceClient(), tokenId)
+}
+
+func TestGetWithToken(t *testing.T) {
+ GetIsSuccessful(t, tokenGet(t, "db22caf43c934e6c829087c41ff8d8d6"))
+}
diff --git a/openstack/identity/v2/tokens/urls.go b/openstack/identity/v2/tokens/urls.go
new file mode 100644
index 0000000..ee0a28f
--- /dev/null
+++ b/openstack/identity/v2/tokens/urls.go
@@ -0,0 +1,13 @@
+package tokens
+
+import "github.com/gophercloud/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/requests.go b/openstack/identity/v2/users/requests.go
new file mode 100644
index 0000000..37fcd38
--- /dev/null
+++ b/openstack/identity/v2/users/requests.go
@@ -0,0 +1,106 @@
+package users
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List lists the existing users.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// 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 string `json:"name,omitempty"`
+ Username string `json:"username,omitempty"`
+ // The ID of the tenant to which you want to assign this user.
+ TenantID string `json:"tenantId,omitempty"`
+ // Indicates whether this user is enabled or not.
+ Enabled *bool `json:"enabled,omitempty"`
+ // The email address of this user.
+ Email string `json:"email,omitempty"`
+}
+
+// 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) {
+ if opts.Name == "" && opts.Username == "" {
+ err := gophercloud.ErrMissingInput{}
+ err.Argument = "users.CreateOpts.Name/users.CreateOpts.Username"
+ err.Info = "Either a Name or Username must be provided"
+ return nil, err
+ }
+ return gophercloud.BuildRequestBody(opts, "user")
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToUserCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(ResourceURL(client, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToUserUpdateMap() (map[string]interface{}, error)
+}
+
+// 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{}, error) {
+ return gophercloud.BuildRequestBody(opts, "user")
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToUserUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(ResourceURL(client, id), &b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(ResourceURL(client, id), nil)
+ return
+}
+
+// ListRoles lists the existing roles that can be assigned to users.
+func ListRoles(client *gophercloud.ServiceClient, tenantID, userID string) pagination.Pager {
+ return pagination.NewPager(client, listRolesURL(client, tenantID, userID), func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/identity/v2/users/results.go b/openstack/identity/v2/users/results.go
new file mode 100644
index 0000000..c493383
--- /dev/null
+++ b/openstack/identity/v2/users/results.go
@@ -0,0 +1,110 @@
+package users
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"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 (r UserPage) IsEmpty() (bool, error) {
+ users, err := ExtractUsers(r)
+ return len(users) == 0, err
+}
+
+// ExtractUsers returns a slice of Tenants contained in a single page of results.
+func ExtractUsers(r pagination.Page) ([]User, error) {
+ var s struct {
+ Users []User `json:"users"`
+ }
+ err := (r.(UserPage)).ExtractInto(&s)
+ return s.Users, err
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (r RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(r)
+ return len(users) == 0, err
+}
+
+// ExtractRoles returns a slice of Roles contained in a single page of results.
+func ExtractRoles(r pagination.Page) ([]Role, error) {
+ var s struct {
+ Roles []Role `json:"roles"`
+ }
+ err := (r.(RolePage)).ExtractInto(&s)
+ return s.Roles, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as a User, if possible.
+func (r commonResult) Extract() (*User, error) {
+ var s struct {
+ User *User `json:"user"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/identity/v2/users/testing/doc.go
new file mode 100644
index 0000000..a007def
--- /dev/null
+++ b/openstack/identity/v2/users/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_users_v2
+package testing
diff --git a/openstack/identity/v2/users/testing/fixtures.go b/openstack/identity/v2/users/testing/fixtures.go
new file mode 100644
index 0000000..8626da2
--- /dev/null
+++ b/openstack/identity/v2/users/testing/fixtures.go
@@ -0,0 +1,163 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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",
+ "tenantId": "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/testing/requests_test.go b/openstack/identity/v2/users/testing/requests_test.go
new file mode 100644
index 0000000..3cb047e
--- /dev/null
+++ b/openstack/identity/v2/users/testing/requests_test.go
@@ -0,0 +1,161 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/identity/v2/users"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListUserResponse(t)
+
+ count := 0
+
+ err := users.List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := users.ExtractUsers(page)
+ th.AssertNoErr(t, err)
+
+ expected := []users.User{
+ {
+ ID: "u1000",
+ Name: "John Smith",
+ Username: "jqsmith",
+ Email: "john.smith@example.org",
+ Enabled: true,
+ TenantID: "12345",
+ },
+ {
+ 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 := users.CreateOpts{
+ Name: "new_user",
+ TenantID: "12345",
+ Enabled: gophercloud.Disabled,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := users.Create(client.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &users.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 := users.Get(client.ServiceClient(), "new_user").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &users.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 := users.UpdateOpts{
+ Name: "new_name",
+ Enabled: gophercloud.Enabled,
+ Email: "new_email@foo.com",
+ }
+
+ user, err := users.Update(client.ServiceClient(), id, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &users.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 := users.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 := users.ListRoles(client.ServiceClient(), tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+ actual, err := users.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ expected := []users.Role{
+ {ID: "9fe2ff9ee4384b1894a90878d3e92bab", Name: "foo_role"},
+ {ID: "1ea3d56793574b668e85960fbf651e13", Name: "admin"},
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/users/urls.go b/openstack/identity/v2/users/urls.go
new file mode 100644
index 0000000..89f66f2
--- /dev/null
+++ b/openstack/identity/v2/users/urls.go
@@ -0,0 +1,21 @@
+package users
+
+import "github.com/gophercloud/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/requests.go b/openstack/identity/v3/endpoints/requests.go
new file mode 100644
index 0000000..fc44365
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -0,0 +1,102 @@
+package endpoints
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type CreateOptsBuilder interface {
+ ToEndpointCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains the subset of Endpoint attributes that should be used to create an Endpoint.
+type CreateOpts struct {
+ Availability gophercloud.Availability `json:"interface" required:"true"`
+ Name string `json:"name" required:"true"`
+ Region string `json:"region,omitempty"`
+ URL string `json:"url" required:"true"`
+ ServiceID string `json:"service_id" required:"true"`
+}
+
+func (opts CreateOpts) ToEndpointCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "endpoint")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToEndpointCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(listURL(client), &b, &r.Body, nil)
+ return
+}
+
+type ListOptsBuilder interface {
+ ToEndpointListParams() (string, error)
+}
+
+// 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"`
+}
+
+func (opts ListOpts) ToEndpointListParams() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ u := listURL(client)
+ if opts != nil {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u += q.String()
+ }
+ return pagination.NewPager(client, u, func(r pagination.PageResult) pagination.Page {
+ return EndpointPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+type UpdateOptsBuilder interface {
+ ToEndpointUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the subset of Endpoint attributes that should be used to update an Endpoint.
+type UpdateOpts struct {
+ Availability gophercloud.Availability `json:"interface,omitempty"`
+ Name string `json:"name,omitempty"`
+ Region string `json:"region,omitempty"`
+ URL string `json:"url,omitempty"`
+ ServiceID string `json:"service_id,omitempty"`
+}
+
+func (opts UpdateOpts) ToEndpointUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "endpoint")
+}
+
+// Update changes an existing endpoint with new data.
+func Update(client *gophercloud.ServiceClient, endpointID string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToEndpointUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Patch(endpointURL(client, endpointID), &b, &r.Body, nil)
+ return
+}
+
+// Delete removes an endpoint from the service catalog.
+func Delete(client *gophercloud.ServiceClient, endpointID string) (r DeleteResult) {
+ _, r.Err = client.Delete(endpointURL(client, endpointID), nil)
+ return
+}
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
new file mode 100644
index 0000000..f769881
--- /dev/null
+++ b/openstack/identity/v3/endpoints/results.go
@@ -0,0 +1,65 @@
+package endpoints
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Endpoint *Endpoint `json:"endpoint"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Endpoint, err
+}
+
+// CreateResult is the deferred result of a Create call.
+type CreateResult 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
+}
+
+// Endpoint describes the entry point for another service's API.
+type Endpoint struct {
+ ID string `json:"id"`
+ Availability gophercloud.Availability `json:"interface"`
+ Name string `json:"name"`
+ Region string `json:"region"`
+ ServiceID string `json:"service_id"`
+ URL string `json:"url"`
+}
+
+// EndpointPage is a single page of Endpoint results.
+type EndpointPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if no Endpoints were returned.
+func (r EndpointPage) IsEmpty() (bool, error) {
+ es, err := ExtractEndpoints(r)
+ return len(es) == 0, err
+}
+
+// ExtractEndpoints extracts an Endpoint slice from a Page.
+func ExtractEndpoints(r pagination.Page) ([]Endpoint, error) {
+ var s struct {
+ Endpoints []Endpoint `json:"endpoints"`
+ }
+ err := (r.(EndpointPage)).ExtractInto(&s)
+ return s.Endpoints, err
+}
diff --git a/openstack/identity/v3/endpoints/testing/doc.go b/openstack/identity/v3/endpoints/testing/doc.go
new file mode 100644
index 0000000..b6e89ef
--- /dev/null
+++ b/openstack/identity/v3/endpoints/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_endpoints_v3
+package testing
diff --git a/openstack/identity/v3/endpoints/testing/requests_test.go b/openstack/identity/v3/endpoints/testing/requests_test.go
new file mode 100644
index 0000000..53d8488
--- /dev/null
+++ b/openstack/identity/v3/endpoints/testing/requests_test.go
@@ -0,0 +1,214 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/endpoints"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/endpoints", 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, `
+ {
+ "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 := endpoints.Create(client.ServiceClient(), endpoints.CreateOpts{
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ URL: "https://1.2.3.4:9000/",
+ ServiceID: "asdfasdfasdfasdf",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &endpoints.Endpoint{
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListEndpoints(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/endpoints", 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, `
+ {
+ "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
+ endpoints.List(client.ServiceClient(), endpoints.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := endpoints.ExtractEndpoints(page)
+ if err != nil {
+ t.Errorf("Failed to extract endpoints: %v", err)
+ return false, err
+ }
+
+ expected := []endpoints.Endpoint{
+ {
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "the-endiest-of-points",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ },
+ {
+ ID: "13",
+ Availability: gophercloud.AvailabilityInternal,
+ Name: "shhhh",
+ Region: "underground",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9001/",
+ },
+ }
+ th.AssertDeepEquals(t, expected, actual)
+ return true, nil
+ })
+ th.AssertEquals(t, 1, count)
+}
+
+func TestUpdateEndpoint(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.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 := endpoints.Update(client.ServiceClient(), "12", endpoints.UpdateOpts{
+ Name: "renamed",
+ Region: "somewhere-else",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Update: %v", err)
+ }
+
+ expected := &endpoints.Endpoint{
+ ID: "12",
+ Availability: gophercloud.AvailabilityPublic,
+ Name: "renamed",
+ Region: "somewhere-else",
+ ServiceID: "asdfasdfasdfasdf",
+ URL: "https://1.2.3.4:9000/",
+ }
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteEndpoint(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/endpoints/34", 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)
+ })
+
+ res := endpoints.Delete(client.ServiceClient(), "34")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/endpoints/urls.go b/openstack/identity/v3/endpoints/urls.go
new file mode 100644
index 0000000..80cf57e
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls.go
@@ -0,0 +1,11 @@
+package endpoints
+
+import "github.com/gophercloud/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/extensions/trusts/requests.go b/openstack/identity/v3/extensions/trusts/requests.go
new file mode 100644
index 0000000..999dd73
--- /dev/null
+++ b/openstack/identity/v3/extensions/trusts/requests.go
@@ -0,0 +1,34 @@
+package trusts
+
+import "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+
+type AuthOptsExt struct {
+ tokens.AuthOptionsBuilder
+ TrustID string `json:"id"`
+}
+
+func (opts AuthOptsExt) ToTokenV3CreateMap(scope map[string]interface{}) (map[string]interface{}, error) {
+ return opts.AuthOptionsBuilder.ToTokenV3CreateMap(scope)
+}
+
+func (opts AuthOptsExt) ToTokenV3ScopeMap() (map[string]interface{}, error) {
+ b, err := opts.AuthOptionsBuilder.ToTokenV3ScopeMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.TrustID != "" {
+ if b == nil {
+ b = make(map[string]interface{})
+ }
+ b["OS-TRUST:trust"] = map[string]interface{}{
+ "id": opts.TrustID,
+ }
+ }
+
+ return b, nil
+}
+
+func (opts AuthOptsExt) CanReauth() bool {
+ return opts.AuthOptionsBuilder.CanReauth()
+}
diff --git a/openstack/identity/v3/extensions/trusts/results.go b/openstack/identity/v3/extensions/trusts/results.go
new file mode 100644
index 0000000..bdd8e84
--- /dev/null
+++ b/openstack/identity/v3/extensions/trusts/results.go
@@ -0,0 +1,22 @@
+package trusts
+
+type TrusteeUser struct {
+ ID string `json:"id"`
+}
+
+type TrustorUser struct {
+ ID string `json:"id"`
+}
+
+type Trust struct {
+ ID string `json:"id"`
+ Impersonation bool `json:"impersonation"`
+ TrusteeUser TrusteeUser `json:"trustee_user"`
+ TrustorUser TrustorUser `json:"trustor_user"`
+ RedelegatedTrustID string `json:"redelegated_trust_id"`
+ RedelegationCount int `json:"redelegation_count"`
+}
+
+type TokenExt struct {
+ Trust Trust `json:"OS-TRUST:trust"`
+}
diff --git a/openstack/identity/v3/extensions/trusts/testing/doc.go b/openstack/identity/v3/extensions/trusts/testing/doc.go
new file mode 100644
index 0000000..e660e20
--- /dev/null
+++ b/openstack/identity/v3/extensions/trusts/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_extensions_trusts_v3
+package testing
diff --git a/openstack/identity/v3/extensions/trusts/testing/fixtures.go b/openstack/identity/v3/extensions/trusts/testing/fixtures.go
new file mode 100644
index 0000000..e311526
--- /dev/null
+++ b/openstack/identity/v3/extensions/trusts/testing/fixtures.go
@@ -0,0 +1,67 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// HandleCreateTokenWithTrustID verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func HandleCreateTokenWithTrustID(t *testing.T, options tokens.AuthOptionsBuilder, requestJSON string) {
+ 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": "2013-02-27T18:30:59.999999Z",
+ "issued_at": "2013-02-27T16:30:59.999999Z",
+ "methods": [
+ "password"
+ ],
+ "OS-TRUST:trust": {
+ "id": "fe0aef",
+ "impersonation": false,
+ "redelegated_trust_id": "3ba234",
+ "redelegation_count": 2,
+ "links": {
+ "self": "http://example.com/identity/v3/trusts/fe0aef"
+ },
+ "trustee_user": {
+ "id": "0ca8f6",
+ "links": {
+ "self": "http://example.com/identity/v3/users/0ca8f6"
+ }
+ },
+ "trustor_user": {
+ "id": "bd263c",
+ "links": {
+ "self": "http://example.com/identity/v3/users/bd263c"
+ }
+ }
+ },
+ "user": {
+ "domain": {
+ "id": "1789d1",
+ "links": {
+ "self": "http://example.com/identity/v3/domains/1789d1"
+ },
+ "name": "example.com"
+ },
+ "email": "joe@example.com",
+ "id": "0ca8f6",
+ "links": {
+ "self": "http://example.com/identity/v3/users/0ca8f6"
+ },
+ "name": "Joe"
+ }
+ }
+}`)
+ })
+}
diff --git a/openstack/identity/v3/extensions/trusts/testing/requests_test.go b/openstack/identity/v3/extensions/trusts/testing/requests_test.go
new file mode 100644
index 0000000..f8a65ad
--- /dev/null
+++ b/openstack/identity/v3/extensions/trusts/testing/requests_test.go
@@ -0,0 +1,74 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/extensions/trusts"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateUserIDPasswordTrustID(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ ao := trusts.AuthOptsExt{
+ TrustID: "de0945a",
+ AuthOptionsBuilder: &tokens.AuthOptions{
+ UserID: "me",
+ Password: "squirrel!",
+ },
+ }
+ HandleCreateTokenWithTrustID(t, ao, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": { "id": "me", "password": "squirrel!" }
+ }
+ },
+ "scope": {
+ "OS-TRUST:trust": {
+ "id": "de0945a"
+ }
+ }
+ }
+ }
+ `)
+
+ var actual struct {
+ tokens.Token
+ trusts.TokenExt
+ }
+ err := tokens.Create(client.ServiceClient(), ao).ExtractInto(&actual)
+ if err != nil {
+ t.Errorf("Create returned an error: %v", err)
+ }
+ expected := struct {
+ tokens.Token
+ trusts.TokenExt
+ }{
+ tokens.Token{
+ ExpiresAt: time.Date(2013, 02, 27, 18, 30, 59, 999999000, time.UTC),
+ },
+ trusts.TokenExt{
+ Trust: trusts.Trust{
+ ID: "fe0aef",
+ Impersonation: false,
+ TrusteeUser: trusts.TrusteeUser{
+ ID: "0ca8f6",
+ },
+ TrustorUser: trusts.TrustorUser{
+ ID: "bd263c",
+ },
+ RedelegatedTrustID: "3ba234",
+ RedelegationCount: 2,
+ },
+ },
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/identity/v3/projects/requests.go b/openstack/identity/v3/projects/requests.go
new file mode 100644
index 0000000..74a9b15
--- /dev/null
+++ b/openstack/identity/v3/projects/requests.go
@@ -0,0 +1,179 @@
+package projects
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to
+// the List request
+type ListOptsBuilder interface {
+ ToProjectListQuery() (string, error)
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+ // DomainID filters the response by a domain ID.
+ DomainID string `q:"domain_id"`
+
+ // Enabled filters the response by enabled projects.
+ Enabled *bool `q:"enabled"`
+
+ // IsDomain filters the response by projects that are domains.
+ // Setting this to true is effectively listing domains.
+ IsDomain *bool `q:"is_domain"`
+
+ // Name filters the response by project name.
+ Name string `q:"name"`
+
+ // ParentID filters the response by projects of a given parent project.
+ ParentID string `q:"parent_id"`
+}
+
+// ToProjectListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToProjectListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List enumerats the Projects to which the current token has access.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(client)
+ if opts != nil {
+ query, err := opts.ToProjectListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ProjectPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// GetOptsBuilder allows extensions to add additional parameters to
+// the Get request.
+type GetOptsBuilder interface {
+ ToProjectGetQuery() (string, error)
+}
+
+// GetOpts allows you to modify the details included in the Get request.
+type GetOpts struct{}
+
+// ToProjectGetQuery formats a GetOpts into a query string.
+func (opts GetOpts) ToProjectGetQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// Get retrieves details on a single project, by ID.
+func Get(client *gophercloud.ServiceClient, id string, opts GetOptsBuilder) (r GetResult) {
+ url := getURL(client, id)
+ if opts != nil {
+ query, err := opts.ToProjectGetQuery()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ url += query
+ }
+
+ _, r.Err = client.Get(url, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to
+// the Create request.
+type CreateOptsBuilder interface {
+ ToProjectCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts allows you to modify the details included in the Create request.
+type CreateOpts struct {
+ // DomainID is the ID this project will belong under.
+ DomainID string `json:"domain_id,omitempty"`
+
+ // Enabled sets the project status to enabled or disabled.
+ Enabled *bool `json:"enabled,omitempty"`
+
+ // IsDomain indicates if this project is a domain.
+ IsDomain *bool `json:"is_domain,omitempty"`
+
+ // Name is the name of the project.
+ Name string `json:"name,required"`
+
+ // ParentID specifies the parent project of this new project.
+ ParentID string `json:"parent_id,omitempty"`
+
+ // Description is the description of the project.
+ Description string `json:"description,omitempty"`
+}
+
+// ToProjectCreateMap formats a CreateOpts into a create request.
+func (opts CreateOpts) ToProjectCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "project")
+}
+
+// Create creates a new Project.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToProjectCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), &b, &r.Body, nil)
+ return
+}
+
+// Delete deletes a project.
+func Delete(client *gophercloud.ServiceClient, projectID string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, projectID), nil)
+ return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to
+// the Update request.
+type UpdateOptsBuilder interface {
+ ToProjectUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts allows you to modify the details included in the Update request.
+type UpdateOpts struct {
+ // DomainID is the ID this project will belong under.
+ DomainID string `json:"domain_id,omitempty"`
+
+ // Enabled sets the project status to enabled or disabled.
+ Enabled *bool `json:"enabled,omitempty"`
+
+ // IsDomain indicates if this project is a domain.
+ IsDomain *bool `json:"is_domain,omitempty"`
+
+ // Name is the name of the project.
+ Name string `json:"name,omitempty"`
+
+ // ParentID specifies the parent project of this new project.
+ ParentID string `json:"parent_id,omitempty"`
+
+ // Description is the description of the project.
+ Description string `json:"description,omitempty"`
+}
+
+// ToUpdateCreateMap formats a UpdateOpts into an update request.
+func (opts UpdateOpts) ToProjectUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "project")
+}
+
+// Update modifies the attributes of a project.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToProjectUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Patch(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
diff --git a/openstack/identity/v3/projects/results.go b/openstack/identity/v3/projects/results.go
new file mode 100644
index 0000000..a441e7f
--- /dev/null
+++ b/openstack/identity/v3/projects/results.go
@@ -0,0 +1,98 @@
+package projects
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type projectResult struct {
+ gophercloud.Result
+}
+
+// GetResult temporarily contains the response from the Get call.
+type GetResult struct {
+ projectResult
+}
+
+// CreateResult temporarily contains the reponse from the Create call.
+type CreateResult struct {
+ projectResult
+}
+
+// DeleteResult temporarily contains the response from the Delete call.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// UpdateResult temporarily contains the response from the Update call.
+type UpdateResult struct {
+ projectResult
+}
+
+// Project is a base unit of ownership.
+type Project struct {
+ // IsDomain indicates whether the project is a domain.
+ IsDomain bool `json:"is_domain"`
+
+ // Description is the description of the project.
+ Description string `json:"description"`
+
+ // DomainID is the domain ID the project belongs to.
+ DomainID string `json:"domain_id"`
+
+ // Enabled is whether or not the project is enabled.
+ Enabled bool `json:"enabled"`
+
+ // ID is the unique ID of the project.
+ ID string `json:"id"`
+
+ // Name is the name of the project.
+ Name string `json:"name"`
+
+ // ParentID is the parent_id of the project.
+ ParentID string `json:"parent_id"`
+}
+
+// ProjectPage is a single page of Project results.
+type ProjectPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty determines whether or not a page of Projects contains any results.
+func (r ProjectPage) IsEmpty() (bool, error) {
+ projects, err := ExtractProjects(r)
+ return len(projects) == 0, err
+}
+
+// NextPageURL extracts the "next" link from the links section of the result.
+func (r ProjectPage) NextPageURL() (string, error) {
+ var s struct {
+ Links struct {
+ Next string `json:"next"`
+ Previous string `json:"previous"`
+ } `json:"links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return s.Links.Next, err
+}
+
+// ExtractProjects returns a slice of Projects contained in a single page of results.
+func ExtractProjects(r pagination.Page) ([]Project, error) {
+ var s struct {
+ Projects []Project `json:"projects"`
+ }
+ err := (r.(ProjectPage)).ExtractInto(&s)
+ return s.Projects, err
+}
+
+// Extract interprets any projectResults as a Project.
+func (r projectResult) Extract() (*Project, error) {
+ var s struct {
+ Project *Project `json:"project"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Project, err
+}
diff --git a/openstack/identity/v3/projects/testing/fixtures.go b/openstack/identity/v3/projects/testing/fixtures.go
new file mode 100644
index 0000000..caa5567
--- /dev/null
+++ b/openstack/identity/v3/projects/testing/fixtures.go
@@ -0,0 +1,192 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/projects"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Project results.
+const ListOutput = `
+{
+ "projects": [
+ {
+ "is_domain": false,
+ "description": "The team that is red",
+ "domain_id": "default",
+ "enabled": true,
+ "id": "1234",
+ "name": "Red Team",
+ "parent_id": null
+ },
+ {
+ "is_domain": false,
+ "description": "The team that is blue",
+ "domain_id": "default",
+ "enabled": true,
+ "id": "9876",
+ "name": "Blue Team",
+ "parent_id": null
+ }
+ ],
+ "links": {
+ "next": null,
+ "previous": null
+ }
+}
+`
+
+// GetOutput provides a Get result.
+const GetOutput = `
+{
+ "project": {
+ "is_domain": false,
+ "description": "The team that is red",
+ "domain_id": "default",
+ "enabled": true,
+ "id": "1234",
+ "name": "Red Team",
+ "parent_id": null
+ }
+}
+`
+
+// CreateRequest provides the input to a Create request.
+const CreateRequest = `
+{
+ "project": {
+ "description": "The team that is red",
+ "name": "Red Team"
+ }
+}
+`
+
+// UpdateRequest provides the input to an Update request.
+const UpdateRequest = `
+{
+ "project": {
+ "description": "The team that is bright red",
+ "name": "Bright Red Team"
+ }
+}
+`
+
+// UpdateOutput provides an Update response.
+const UpdateOutput = `
+{
+ "project": {
+ "is_domain": false,
+ "description": "The team that is bright red",
+ "domain_id": "default",
+ "enabled": true,
+ "id": "1234",
+ "name": "Bright Red Team",
+ "parent_id": null
+ }
+}
+`
+
+// RedTeam is a Project fixture.
+var RedTeam = projects.Project{
+ IsDomain: false,
+ Description: "The team that is red",
+ DomainID: "default",
+ Enabled: true,
+ ID: "1234",
+ Name: "Red Team",
+ ParentID: "",
+}
+
+// BlueTeam is a Project fixture.
+var BlueTeam = projects.Project{
+ IsDomain: false,
+ Description: "The team that is blue",
+ DomainID: "default",
+ Enabled: true,
+ ID: "9876",
+ Name: "Blue Team",
+ ParentID: "",
+}
+
+// UpdatedRedTeam is a Project Fixture.
+var UpdatedRedTeam = projects.Project{
+ IsDomain: false,
+ Description: "The team that is bright red",
+ DomainID: "default",
+ Enabled: true,
+ ID: "1234",
+ Name: "Bright Red Team",
+ ParentID: "",
+}
+
+// ExpectedProjectSlice is the slice of projects expected to be returned from ListOutput.
+var ExpectedProjectSlice = []projects.Project{RedTeam, BlueTeam}
+
+// HandleListProjectsSuccessfully creates an HTTP handler at `/projects` on the
+// test handler mux that responds with a list of two tenants.
+func HandleListProjectsSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/projects", 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)
+ })
+}
+
+// HandleGetProjectSuccessfully creates an HTTP handler at `/projects` on the
+// test handler mux that responds with a single project.
+func HandleGetProjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/projects/1234", 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, GetOutput)
+ })
+}
+
+// HandleCreateProjectSuccessfully creates an HTTP handler at `/projects` on the
+// test handler mux that tests project creation.
+func HandleCreateProjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/projects", 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, CreateRequest)
+
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, GetOutput)
+ })
+}
+
+// HandleDeleteProjectSuccessfully creates an HTTP handler at `/projects` on the
+// test handler mux that tests project deletion.
+func HandleDeleteProjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/projects/1234", 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)
+ })
+}
+
+// HandleUpdateProjectSuccessfully creates an HTTP handler at `/projects` on the
+// test handler mux that tests project updates.
+func HandleUpdateProjectSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/projects/1234", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, UpdateRequest)
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, UpdateOutput)
+ })
+}
diff --git a/openstack/identity/v3/projects/testing/requests_test.go b/openstack/identity/v3/projects/testing/requests_test.go
new file mode 100644
index 0000000..5782480
--- /dev/null
+++ b/openstack/identity/v3/projects/testing/requests_test.go
@@ -0,0 +1,79 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/projects"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListProjects(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListProjectsSuccessfully(t)
+
+ count := 0
+ err := projects.List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+
+ actual, err := projects.ExtractProjects(page)
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ExpectedProjectSlice, actual)
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, count, 1)
+}
+
+func TestGetProject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetProjectSuccessfully(t)
+
+ actual, err := projects.Get(client.ServiceClient(), "1234", nil).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, RedTeam, *actual)
+}
+
+func TestCreateProject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateProjectSuccessfully(t)
+
+ createOpts := projects.CreateOpts{
+ Name: "Red Team",
+ Description: "The team that is red",
+ }
+
+ actual, err := projects.Create(client.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, RedTeam, *actual)
+}
+
+func TestDeleteProject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteProjectSuccessfully(t)
+
+ res := projects.Delete(client.ServiceClient(), "1234")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateProject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateProjectSuccessfully(t)
+
+ updateOpts := projects.UpdateOpts{
+ Name: "Bright Red Team",
+ Description: "The team that is bright red",
+ }
+
+ actual, err := projects.Update(client.ServiceClient(), "1234", updateOpts).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, UpdatedRedTeam, *actual)
+}
diff --git a/openstack/identity/v3/projects/urls.go b/openstack/identity/v3/projects/urls.go
new file mode 100644
index 0000000..e26cf36
--- /dev/null
+++ b/openstack/identity/v3/projects/urls.go
@@ -0,0 +1,23 @@
+package projects
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("projects")
+}
+
+func getURL(client *gophercloud.ServiceClient, projectID string) string {
+ return client.ServiceURL("projects", projectID)
+}
+
+func createURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("projects")
+}
+
+func deleteURL(client *gophercloud.ServiceClient, projectID string) string {
+ return client.ServiceURL("projects", projectID)
+}
+
+func updateURL(client *gophercloud.ServiceClient, projectID string) string {
+ return client.ServiceURL("projects", projectID)
+}
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..de65c51
--- /dev/null
+++ b/openstack/identity/v3/roles/requests.go
@@ -0,0 +1,47 @@
+package roles
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return q.String(), err
+}
+
+// ListAssignments enumerates the roles assigned to a specified resource.
+func ListAssignments(client *gophercloud.ServiceClient, opts ListAssignmentsOptsBuilder) pagination.Pager {
+ url := listAssignmentsURL(client)
+ if opts != nil {
+ query, err := opts.ToRolesListAssignmentsQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return RoleAssignmentPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
diff --git a/openstack/identity/v3/roles/results.go b/openstack/identity/v3/roles/results.go
new file mode 100644
index 0000000..e8a3aa9
--- /dev/null
+++ b/openstack/identity/v3/roles/results.go
@@ -0,0 +1,67 @@
+package roles
+
+import "github.com/gophercloud/gophercloud/pagination"
+
+// 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:"project,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"`
+}
+
+// RoleAssignmentPage is a single page of RoleAssignments results.
+type RoleAssignmentPage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (r RoleAssignmentPage) IsEmpty() (bool, error) {
+ roleAssignments, err := ExtractRoleAssignments(r)
+ return len(roleAssignments) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r RoleAssignmentPage) NextPageURL() (string, error) {
+ var s struct {
+ Links struct {
+ Next string `json:"next"`
+ } `json:"links"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Links.Next, err
+}
+
+// ExtractRoleAssignments extracts a slice of RoleAssignments from a Collection acquired from List.
+func ExtractRoleAssignments(r pagination.Page) ([]RoleAssignment, error) {
+ var s struct {
+ RoleAssignments []RoleAssignment `json:"role_assignments"`
+ }
+ err := (r.(RoleAssignmentPage)).ExtractInto(&s)
+ return s.RoleAssignments, err
+}
diff --git a/openstack/identity/v3/roles/testing/doc.go b/openstack/identity/v3/roles/testing/doc.go
new file mode 100644
index 0000000..37bcb85
--- /dev/null
+++ b/openstack/identity/v3/roles/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_roles_v3
+package testing
diff --git a/openstack/identity/v3/roles/testing/requests_test.go b/openstack/identity/v3/roles/testing/requests_test.go
new file mode 100644
index 0000000..dd9b704
--- /dev/null
+++ b/openstack/identity/v3/roles/testing/requests_test.go
@@ -0,0 +1,105 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/roles"
+ "github.com/gophercloud/gophercloud/pagination"
+ "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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 := roles.ListAssignments(client.ServiceClient(), roles.ListAssignmentsOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := roles.ExtractRoleAssignments(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []roles.RoleAssignment{
+ {
+ Role: roles.Role{ID: "123456"},
+ Scope: roles.Scope{Domain: roles.Domain{ID: "161718"}},
+ User: roles.User{ID: "313233"},
+ Group: roles.Group{},
+ },
+ {
+ Role: roles.Role{ID: "123456"},
+ Scope: roles.Scope{Project: roles.Project{ID: "456789"}},
+ User: roles.User{ID: "313233"},
+ Group: roles.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/urls.go b/openstack/identity/v3/roles/urls.go
new file mode 100644
index 0000000..8d87b6e
--- /dev/null
+++ b/openstack/identity/v3/roles/urls.go
@@ -0,0 +1,7 @@
+package roles
+
+import "github.com/gophercloud/gophercloud"
+
+func listAssignmentsURL(client *gophercloud.ServiceClient) string {
+ return client.ServiceURL("role_assignments")
+}
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..bb7bb04
--- /dev/null
+++ b/openstack/identity/v3/services/requests.go
@@ -0,0 +1,64 @@
+package services
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Create adds a new service of the requested type to the catalog.
+func Create(client *gophercloud.ServiceClient, serviceType string) (r CreateResult) {
+ b := map[string]string{"type": serviceType}
+ _, r.Err = client.Post(listURL(client), b, &r.Body, nil)
+ return
+}
+
+type ListOptsBuilder interface {
+ ToServiceListMap() (string, error)
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+ ServiceType string `q:"type"`
+ PerPage int `q:"perPage"`
+ Page int `q:"page"`
+}
+
+func (opts ListOpts) ToServiceListMap() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List enumerates the services available to a specific user.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ u := listURL(client)
+ if opts != nil {
+ q, err := opts.ToServiceListMap()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ u += q
+ }
+ return pagination.NewPager(client, u, func(r pagination.PageResult) pagination.Page {
+ return ServicePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// Get returns additional information about a service, given its ID.
+func Get(client *gophercloud.ServiceClient, serviceID string) (r GetResult) {
+ _, r.Err = client.Get(serviceURL(client, serviceID), &r.Body, nil)
+ return
+}
+
+// Update changes the service type of an existing service.
+func Update(client *gophercloud.ServiceClient, serviceID string, serviceType string) (r UpdateResult) {
+ b := map[string]string{"type": serviceType}
+ _, r.Err = client.Patch(serviceURL(client, serviceID), &b, &r.Body, nil)
+ return
+}
+
+// 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) (r DeleteResult) {
+ _, r.Err = client.Delete(serviceURL(client, serviceID), nil)
+ return
+}
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
new file mode 100644
index 0000000..9ebcc20
--- /dev/null
+++ b/openstack/identity/v3/services/results.go
@@ -0,0 +1,68 @@
+package services
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+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) {
+ var s struct {
+ Service *Service `json:"service"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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`
+ 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)
+ return len(services) == 0, err
+}
+
+// ExtractServices extracts a slice of Services from a Collection acquired from List.
+func ExtractServices(r pagination.Page) ([]Service, error) {
+ var s struct {
+ Services []Service `json:"services"`
+ }
+ err := (r.(ServicePage)).ExtractInto(&s)
+ return s.Services, err
+}
diff --git a/openstack/identity/v3/services/testing/doc.go b/openstack/identity/v3/services/testing/doc.go
new file mode 100644
index 0000000..e4f1167
--- /dev/null
+++ b/openstack/identity/v3/services/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_services_v3
+package testing
diff --git a/openstack/identity/v3/services/testing/requests_test.go b/openstack/identity/v3/services/testing/requests_test.go
new file mode 100644
index 0000000..0a065a2
--- /dev/null
+++ b/openstack/identity/v3/services/testing/requests_test.go
@@ -0,0 +1,187 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/services"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/services", 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, `{ "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"
+ }
+ }`)
+ })
+
+ expected := &services.Service{
+ Description: "Here's your service",
+ ID: "1234",
+ Name: "InscrutableOpenStackProjectName",
+ Type: "compute",
+ }
+
+ actual, err := services.Create(client.ServiceClient(), "compute").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected error from Create: %v", err)
+ }
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListSinglePage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/services", 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, `
+ {
+ "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 := services.List(client.ServiceClient(), services.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := services.ExtractServices(page)
+ if err != nil {
+ return false, err
+ }
+
+ expected := []services.Service{
+ {
+ Description: "Service One",
+ ID: "1234",
+ Name: "service-one",
+ Type: "identity",
+ },
+ {
+ Description: "Service Two",
+ ID: "9876",
+ Name: "service-two",
+ Type: "compute",
+ },
+ }
+ th.AssertDeepEquals(t, expected, actual)
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestGetSuccessful(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/services/12345", 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, `
+ {
+ "service": {
+ "description": "Service One",
+ "id": "12345",
+ "name": "service-one",
+ "type": "identity"
+ }
+ }
+ `)
+ })
+
+ actual, err := services.Get(client.ServiceClient(), "12345").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &services.Service{
+ ID: "12345",
+ Description: "Service One",
+ Name: "service-one",
+ Type: "identity",
+ }
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateSuccessful(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+ th.TestJSONRequest(t, r, `{ "type": "lasermagic" }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `
+ {
+ "service": {
+ "id": "12345",
+ "type": "lasermagic"
+ }
+ }
+ `)
+ })
+
+ expected := &services.Service{
+ ID: "12345",
+ Type: "lasermagic",
+ }
+
+ actual, err := services.Update(client.ServiceClient(), "12345", "lasermagic").Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteSuccessful(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ th.Mux.HandleFunc("/services/12345", 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)
+ })
+
+ res := services.Delete(client.ServiceClient(), "12345")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/services/urls.go b/openstack/identity/v3/services/urls.go
new file mode 100644
index 0000000..c5ae268
--- /dev/null
+++ b/openstack/identity/v3/services/urls.go
@@ -0,0 +1,11 @@
+package services
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/identity/v3/tokens/requests.go
new file mode 100644
index 0000000..ba4363b
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests.go
@@ -0,0 +1,200 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+// Scope allows a created token to be limited to a specific domain or project.
+type Scope struct {
+ ProjectID string `json:"scope.project.id,omitempty" not:"ProjectName,DomainID,DomainName"`
+ ProjectName string `json:"scope.project.name,omitempty"`
+ DomainID string `json:"scope.project.id,omitempty" not:"ProjectName,ProjectID,DomainName"`
+ DomainName string `json:"scope.project.id,omitempty"`
+}
+
+// AuthOptionsBuilder describes any argument that may be passed to the Create call.
+type AuthOptionsBuilder interface {
+ // ToTokenV3CreateMap assembles the Create request body, returning an error if parameters are
+ // missing or inconsistent.
+ ToTokenV3CreateMap(map[string]interface{}) (map[string]interface{}, error)
+ ToTokenV3ScopeMap() (map[string]interface{}, error)
+ CanReauth() bool
+}
+
+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 `json:"-"`
+
+ // 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 string `json:"username,omitempty"`
+ UserID string `json:"id,omitempty"`
+
+ Password string `json:"password,omitempty"`
+
+ // At most one of DomainID and DomainName must be provided if using Username
+ // with Identity V3. Otherwise, either are optional.
+ DomainID string `json:"id,omitempty"`
+ DomainName string `json:"name,omitempty"`
+
+ // 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 `json:"-"`
+
+ // TokenID allows users to authenticate (possibly as another user) with an
+ // authentication token ID.
+ TokenID string `json:"-"`
+
+ Scope Scope `json:"-"`
+}
+
+func (opts *AuthOptions) ToTokenV3CreateMap(scope map[string]interface{}) (map[string]interface{}, error) {
+ gophercloudAuthOpts := gophercloud.AuthOptions{
+ Username: opts.Username,
+ UserID: opts.UserID,
+ Password: opts.Password,
+ DomainID: opts.DomainID,
+ DomainName: opts.DomainName,
+ AllowReauth: opts.AllowReauth,
+ TokenID: opts.TokenID,
+ }
+
+ return gophercloudAuthOpts.ToTokenV3CreateMap(scope)
+}
+
+func (opts *AuthOptions) ToTokenV3ScopeMap() (map[string]interface{}, error) {
+ if opts.Scope.ProjectName != "" {
+ // ProjectName provided: either DomainID or DomainName must also be supplied.
+ // ProjectID may not be supplied.
+ if opts.Scope.DomainID == "" && opts.Scope.DomainName == "" {
+ return nil, gophercloud.ErrScopeDomainIDOrDomainName{}
+ }
+ if opts.Scope.ProjectID != "" {
+ return nil, gophercloud.ErrScopeProjectIDOrProjectName{}
+ }
+
+ if opts.Scope.DomainID != "" {
+ // ProjectName + DomainID
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "name": &opts.Scope.ProjectName,
+ "domain": map[string]interface{}{"id": &opts.Scope.DomainID},
+ },
+ }, nil
+ }
+
+ if opts.Scope.DomainName != "" {
+ // ProjectName + DomainName
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "name": &opts.Scope.ProjectName,
+ "domain": map[string]interface{}{"name": &opts.Scope.DomainName},
+ },
+ }, nil
+ }
+ } else if opts.Scope.ProjectID != "" {
+ // ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+ if opts.Scope.DomainID != "" {
+ return nil, gophercloud.ErrScopeProjectIDAlone{}
+ }
+ if opts.Scope.DomainName != "" {
+ return nil, gophercloud.ErrScopeProjectIDAlone{}
+ }
+
+ // ProjectID
+ return map[string]interface{}{
+ "project": map[string]interface{}{
+ "id": &opts.Scope.ProjectID,
+ },
+ }, nil
+ } else if opts.Scope.DomainID != "" {
+ // DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+ if opts.Scope.DomainName != "" {
+ return nil, gophercloud.ErrScopeDomainIDOrDomainName{}
+ }
+
+ // DomainID
+ return map[string]interface{}{
+ "domain": map[string]interface{}{
+ "id": &opts.Scope.DomainID,
+ },
+ }, nil
+ } else if opts.Scope.DomainName != "" {
+ return nil, gophercloud.ErrScopeDomainName{}
+ }
+
+ return nil, nil
+}
+
+func (opts *AuthOptions) CanReauth() bool {
+ return opts.AllowReauth
+}
+
+func subjectTokenHeaders(c *gophercloud.ServiceClient, subjectToken string) map[string]string {
+ return map[string]string{
+ "X-Subject-Token": subjectToken,
+ }
+}
+
+// Create authenticates and either generates a new token, or changes the Scope of an existing token.
+func Create(c *gophercloud.ServiceClient, opts AuthOptionsBuilder) (r CreateResult) {
+ scope, err := opts.ToTokenV3ScopeMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ b, err := opts.ToTokenV3CreateMap(scope)
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ resp, err := c.Post(tokenURL(c), b, &r.Body, &gophercloud.RequestOpts{
+ MoreHeaders: map[string]string{"X-Auth-Token": ""},
+ })
+ r.Err = err
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ return
+}
+
+// Get validates and retrieves information about another token.
+func Get(c *gophercloud.ServiceClient, token string) (r GetResult) {
+ resp, err := c.Get(tokenURL(c), &r.Body, &gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{200, 203},
+ })
+ if resp != nil {
+ r.Err = err
+ r.Header = resp.Header
+ }
+ return
+}
+
+// Validate determines if a specified token is valid or not.
+func Validate(c *gophercloud.ServiceClient, token string) (bool, error) {
+ resp, err := c.Request("HEAD", tokenURL(c), &gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ OkCodes: []int{204, 404},
+ })
+ if err != nil {
+ return false, err
+ }
+
+ return resp.StatusCode == 204, nil
+}
+
+// Revoke immediately makes specified token invalid.
+func Revoke(c *gophercloud.ServiceClient, token string) (r RevokeResult) {
+ _, r.Err = c.Delete(tokenURL(c), &gophercloud.RequestOpts{
+ MoreHeaders: subjectTokenHeaders(c, token),
+ })
+ return
+}
diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go
new file mode 100644
index 0000000..0f1e8c2
--- /dev/null
+++ b/openstack/identity/v3/tokens/results.go
@@ -0,0 +1,103 @@
+package tokens
+
+import (
+ "time"
+
+ "github.com/gophercloud/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 `json:"id"`
+ Region string `json:"region"`
+ Interface string `json:"interface"`
+ URL string `json:"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 `json:"id"`
+ // Name will contain the provider-specified name for the service.
+ Name string `json:"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 `json:"type"`
+ // Endpoints will let the caller iterate over all the different endpoints that may exist for
+ // the service.
+ Endpoints []Endpoint `json:"endpoints"`
+}
+
+// ServiceCatalog provides a view into the service catalog from a previous, successful authentication.
+type ServiceCatalog struct {
+ Entries []CatalogEntry `json:"catalog"`
+}
+
+// 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) {
+ var s Token
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse the token itself from the stored headers.
+ s.ID = r.Header.Get("X-Subject-Token")
+
+ return &s, err
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (r CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+ var s ServiceCatalog
+ err := r.ExtractInto(&s)
+ return &s, err
+}
+
+// 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
+}
+
+// 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 `json:"id"`
+ // ExpiresAt is the timestamp at which this token will no longer be accepted.
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (r commonResult) ExtractInto(v interface{}) error {
+ return r.ExtractIntoStructPtr(v, "token")
+}
diff --git a/openstack/identity/v3/tokens/testing/doc.go b/openstack/identity/v3/tokens/testing/doc.go
new file mode 100644
index 0000000..ad1d35d
--- /dev/null
+++ b/openstack/identity/v3/tokens/testing/doc.go
@@ -0,0 +1,2 @@
+// identity_tokens_v3
+package testing
diff --git a/openstack/identity/v3/tokens/testing/requests_test.go b/openstack/identity/v3/tokens/testing/requests_test.go
new file mode 100644
index 0000000..ddffeb4
--- /dev/null
+++ b/openstack/identity/v3/tokens/testing/requests_test.go
@@ -0,0 +1,532 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// authTokenPost verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func authTokenPost(t *testing.T, options tokens.AuthOptions, scope *tokens.Scope, requestJSON string) {
+ 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) {
+ 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"
+ }
+ }`)
+ })
+
+ if scope != nil {
+ options.Scope = *scope
+ }
+
+ expected := &tokens.Token{
+ ExpiresAt: time.Date(2014, 10, 2, 13, 45, 0, 0, time.UTC),
+ }
+ actual, err := tokens.Create(&client, &options).Extract()
+ testhelper.AssertNoErr(t, err)
+ testhelper.CheckDeepEquals(t, expected, actual)
+}
+
+func authTokenPostErr(t *testing.T, options tokens.AuthOptions, scope *tokens.Scope, includeToken bool, expectedErr error) {
+ testhelper.SetupHTTP()
+ defer testhelper.TeardownHTTP()
+
+ client := gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{},
+ Endpoint: testhelper.Endpoint(),
+ }
+ if includeToken {
+ client.TokenID = "abcdef123456"
+ }
+
+ if scope != nil {
+ options.Scope = *scope
+ }
+
+ _, err := tokens.Create(&client, &options).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, tokens.AuthOptions{UserID: "me", Password: "squirrel!"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["password"],
+ "password": {
+ "user": { "id": "me", "password": "squirrel!" }
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateUsernameDomainIDPassword(t *testing.T) {
+ authTokenPost(t, tokens.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, tokens.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, tokens.AuthOptions{TokenID: "12345abcdef"}, nil, `
+ {
+ "auth": {
+ "identity": {
+ "methods": ["token"],
+ "token": {
+ "id": "12345abcdef"
+ }
+ }
+ }
+ }
+ `)
+}
+
+func TestCreateProjectIDScope(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &tokens.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 := tokens.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &tokens.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 := tokens.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &tokens.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 := tokens.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+ scope := &tokens.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 := tokens.AuthOptions{UserID: "me", Password: "shhh"}
+ token, err := tokens.Create(&client, &options).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, tokens.AuthOptions{}, nil, false, gophercloud.ErrMissingPassword{})
+}
+
+func TestCreateFailureTokenIDUsername(t *testing.T) {
+ authTokenPostErr(t, tokens.AuthOptions{Username: "something", TokenID: "12345"}, nil, true, gophercloud.ErrUsernameWithToken{})
+}
+
+func TestCreateFailureTokenIDUserID(t *testing.T) {
+ authTokenPostErr(t, tokens.AuthOptions{UserID: "something", TokenID: "12345"}, nil, true, gophercloud.ErrUserIDWithToken{})
+}
+
+func TestCreateFailureTokenIDDomainID(t *testing.T) {
+ authTokenPostErr(t, tokens.AuthOptions{DomainID: "something", TokenID: "12345"}, nil, true, gophercloud.ErrDomainIDWithToken{})
+}
+
+func TestCreateFailureTokenIDDomainName(t *testing.T) {
+ authTokenPostErr(t, tokens.AuthOptions{DomainName: "something", TokenID: "12345"}, nil, true, gophercloud.ErrDomainNameWithToken{})
+}
+
+func TestCreateFailureMissingUser(t *testing.T) {
+ options := tokens.AuthOptions{Password: "supersecure"}
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrUsernameOrUserID{})
+}
+
+func TestCreateFailureBothUser(t *testing.T) {
+ options := tokens.AuthOptions{
+ Password: "supersecure",
+ Username: "oops",
+ UserID: "redundancy",
+ }
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrUsernameOrUserID{})
+}
+
+func TestCreateFailureMissingDomain(t *testing.T) {
+ options := tokens.AuthOptions{
+ Password: "supersecure",
+ Username: "notuniqueenough",
+ }
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrDomainIDOrDomainName{})
+}
+
+func TestCreateFailureBothDomain(t *testing.T) {
+ options := tokens.AuthOptions{
+ Password: "supersecure",
+ Username: "someone",
+ DomainID: "hurf",
+ DomainName: "durf",
+ }
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrDomainIDOrDomainName{})
+}
+
+func TestCreateFailureUserIDDomainID(t *testing.T) {
+ options := tokens.AuthOptions{
+ UserID: "100",
+ Password: "stuff",
+ DomainID: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrDomainIDWithUserID{})
+}
+
+func TestCreateFailureUserIDDomainName(t *testing.T) {
+ options := tokens.AuthOptions{
+ UserID: "100",
+ Password: "sssh",
+ DomainName: "oops",
+ }
+ authTokenPostErr(t, options, nil, false, gophercloud.ErrDomainNameWithUserID{})
+}
+
+func TestCreateFailureScopeProjectNameAlone(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{ProjectName: "notenough"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeDomainIDOrDomainName{})
+}
+
+func TestCreateFailureScopeProjectNameAndID(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{ProjectName: "whoops", ProjectID: "toomuch", DomainID: "1234"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeProjectIDOrProjectName{})
+}
+
+func TestCreateFailureScopeProjectIDAndDomainID(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{ProjectID: "toomuch", DomainID: "notneeded"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeProjectIDAlone{})
+}
+
+func TestCreateFailureScopeProjectIDAndDomainNAme(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{ProjectID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeProjectIDAlone{})
+}
+
+func TestCreateFailureScopeDomainIDAndDomainName(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{DomainID: "toomuch", DomainName: "notneeded"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeDomainIDOrDomainName{})
+}
+
+func TestCreateFailureScopeDomainNameAlone(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{DomainName: "notenough"}
+ authTokenPostErr(t, options, scope, false, gophercloud.ErrScopeDomainName{})
+}
+
+/*
+func TestCreateFailureEmptyScope(t *testing.T) {
+ options := tokens.AuthOptions{UserID: "myself", Password: "swordfish"}
+ scope := &tokens.Scope{}
+ authTokenPostErr(t, options, scope, false, gophercloud.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 := tokens.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), time.Time(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 := tokens.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 := tokens.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 := tokens.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 := tokens.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 := tokens.Revoke(&client, "abcdef12345")
+ if res.Err == nil {
+ t.Errorf("Missing expected error from Revoke")
+ }
+}
+
+func TestNoTokenInResponse(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.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{}`)
+ })
+
+ options := tokens.AuthOptions{UserID: "me", Password: "squirrel!"}
+ _, err := tokens.Create(&client, &options).Extract()
+ testhelper.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v3/tokens/urls.go b/openstack/identity/v3/tokens/urls.go
new file mode 100644
index 0000000..2f864a3
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls.go
@@ -0,0 +1,7 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+func tokenURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("auth", "tokens")
+}
diff --git a/openstack/imageservice/v2/README.md b/openstack/imageservice/v2/README.md
new file mode 100644
index 0000000..05c19be
--- /dev/null
+++ b/openstack/imageservice/v2/README.md
@@ -0,0 +1 @@
+This provides a Go API which wraps any service implementing the [OpenStack Image Service API, version 2](http://developer.openstack.org/api-ref-image-v2.html).
diff --git a/openstack/imageservice/v2/imagedata/requests.go b/openstack/imageservice/v2/imagedata/requests.go
new file mode 100644
index 0000000..7fd6951
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/requests.go
@@ -0,0 +1,28 @@
+package imagedata
+
+import (
+ "io"
+ "net/http"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// Upload uploads image file
+func Upload(client *gophercloud.ServiceClient, id string, data io.Reader) (r UploadResult) {
+ _, r.Err = client.Put(uploadURL(client, id), data, nil, &gophercloud.RequestOpts{
+ MoreHeaders: map[string]string{"Content-Type": "application/octet-stream"},
+ OkCodes: []int{204},
+ })
+ return
+}
+
+// Download retrieves file
+func Download(client *gophercloud.ServiceClient, id string) (r DownloadResult) {
+ var resp *http.Response
+ resp, r.Err = client.Get(downloadURL(client, id), nil, nil)
+ if resp != nil {
+ r.Body = resp.Body
+ r.Header = resp.Header
+ }
+ return
+}
diff --git a/openstack/imageservice/v2/imagedata/results.go b/openstack/imageservice/v2/imagedata/results.go
new file mode 100644
index 0000000..970b226
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/results.go
@@ -0,0 +1,26 @@
+package imagedata
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// UploadResult is the result of an upload image operation
+type UploadResult struct {
+ gophercloud.ErrResult
+}
+
+// DownloadResult is the result of a download image operation
+type DownloadResult struct {
+ gophercloud.Result
+}
+
+// Extract builds images model from io.Reader
+func (r DownloadResult) Extract() (io.Reader, error) {
+ if r, ok := r.Body.(io.Reader); ok {
+ return r, nil
+ }
+ return nil, fmt.Errorf("Expected io.Reader but got: %T(%#v)", r.Body, r.Body)
+}
diff --git a/openstack/imageservice/v2/imagedata/testing/fixtures.go b/openstack/imageservice/v2/imagedata/testing/fixtures.go
new file mode 100644
index 0000000..fe93fc9
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/testing/fixtures.go
@@ -0,0 +1,40 @@
+package testing
+
+import (
+ "io/ioutil"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandlePutImageDataSuccessfully setup
+func HandlePutImageDataSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("Unable to read request body: %v", err)
+ }
+
+ th.AssertByteArrayEquals(t, []byte{5, 3, 7, 24}, b)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleGetImageDataSuccessfully setup
+func HandleGetImageDataSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusOK)
+
+ _, err := w.Write([]byte{34, 87, 0, 23, 23, 23, 56, 255, 254, 0})
+ th.AssertNoErr(t, err)
+ })
+}
diff --git a/openstack/imageservice/v2/imagedata/testing/requests_test.go b/openstack/imageservice/v2/imagedata/testing/requests_test.go
new file mode 100644
index 0000000..4ac42d0
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/testing/requests_test.go
@@ -0,0 +1,87 @@
+package testing
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/imageservice/v2/imagedata"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestUpload(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandlePutImageDataSuccessfully(t)
+
+ err := imagedata.Upload(
+ fakeclient.ServiceClient(),
+ "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ readSeekerOfBytes([]byte{5, 3, 7, 24})).ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func readSeekerOfBytes(bs []byte) io.ReadSeeker {
+ return &RS{bs: bs}
+}
+
+// implements io.ReadSeeker
+type RS struct {
+ bs []byte
+ offset int
+}
+
+func (rs *RS) Read(p []byte) (int, error) {
+ leftToRead := len(rs.bs) - rs.offset
+
+ if 0 < leftToRead {
+ bytesToWrite := min(leftToRead, len(p))
+ for i := 0; i < bytesToWrite; i++ {
+ p[i] = rs.bs[rs.offset]
+ rs.offset++
+ }
+ return bytesToWrite, nil
+ }
+ return 0, io.EOF
+}
+
+func min(a int, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func (rs *RS) Seek(offset int64, whence int) (int64, error) {
+ var offsetInt = int(offset)
+ if whence == 0 {
+ rs.offset = offsetInt
+ } else if whence == 1 {
+ rs.offset = rs.offset + offsetInt
+ } else if whence == 2 {
+ rs.offset = len(rs.bs) - offsetInt
+ } else {
+ return 0, fmt.Errorf("For parameter `whence`, expected value in {0,1,2} but got: %#v", whence)
+ }
+
+ return int64(rs.offset), nil
+}
+
+func TestDownload(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleGetImageDataSuccessfully(t)
+
+ rdr, err := imagedata.Download(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea").Extract()
+ th.AssertNoErr(t, err)
+
+ bs, err := ioutil.ReadAll(rdr)
+ th.AssertNoErr(t, err)
+
+ th.AssertByteArrayEquals(t, []byte{34, 87, 0, 23, 23, 23, 56, 255, 254, 0}, bs)
+}
diff --git a/openstack/imageservice/v2/imagedata/urls.go b/openstack/imageservice/v2/imagedata/urls.go
new file mode 100644
index 0000000..ccd6416
--- /dev/null
+++ b/openstack/imageservice/v2/imagedata/urls.go
@@ -0,0 +1,13 @@
+package imagedata
+
+import "github.com/gophercloud/gophercloud"
+
+// `imageDataURL(c,i)` is the URL for the binary image data for the
+// image identified by ID `i` in the service `c`.
+func uploadURL(c *gophercloud.ServiceClient, imageID string) string {
+ return c.ServiceURL("images", imageID, "file")
+}
+
+func downloadURL(c *gophercloud.ServiceClient, imageID string) string {
+ return uploadURL(c, imageID)
+}
diff --git a/openstack/imageservice/v2/images/requests.go b/openstack/imageservice/v2/images/requests.go
new file mode 100644
index 0000000..044b5cb
--- /dev/null
+++ b/openstack/imageservice/v2/images/requests.go
@@ -0,0 +1,238 @@
+package images
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToImageListQuery() (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.
+//http://developer.openstack.org/api-ref-image-v2.html
+type ListOpts struct {
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+
+ // UUID of the server at which you want to set a marker.
+ Marker string `q:"marker"`
+
+ Name string `q:"name"`
+ Visibility ImageVisibility `q:"visibility"`
+ MemberStatus ImageMemberStatus `q:"member_status"`
+ Owner string `q:"owner"`
+ Status ImageStatus `q:"status"`
+ SizeMin int64 `q:"size_min"`
+ SizeMax int64 `q:"size_max"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+ Tag string `q:"tag"`
+}
+
+// ToImageListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToImageListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List implements image list request
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToImageListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+// The CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ // Returns value that can be passed to json.Marshal
+ ToImageCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts implements CreateOptsBuilder
+type CreateOpts struct {
+ // Name is the name of the new image.
+ Name string `json:"name" required:"true"`
+
+ // Id is the the image ID.
+ ID string `json:"id,omitempty"`
+
+ // Visibility defines who can see/use the image.
+ Visibility *ImageVisibility `json:"visibility,omitempty"`
+
+ // Tags is a set of image tags.
+ Tags []string `json:"tags,omitempty"`
+
+ // ContainerFormat is the format of the
+ // container. Valid values are ami, ari, aki, bare, and ovf.
+ ContainerFormat string `json:"container_format,omitempty"`
+
+ // DiskFormat is the format of the disk. If set,
+ // valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi,
+ // and iso.
+ DiskFormat string `json:"disk_format,omitempty"`
+
+ // MinDisk is the amount of disk space in
+ // GB that is required to boot the image.
+ MinDisk int `json:"min_disk,omitempty"`
+
+ // MinRAM is the amount of RAM in MB that
+ // is required to boot the image.
+ MinRAM int `json:"min_ram,omitempty"`
+
+ // protected is whether the image is not deletable.
+ Protected *bool `json:"protected,omitempty"`
+
+ // properties is a set of properties, if any, that
+ // are associated with the image.
+ Properties map[string]string `json:"-"`
+}
+
+// ToImageCreateMap assembles a request body based on the contents of
+// a CreateOpts.
+func (opts CreateOpts) ToImageCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.Properties != nil {
+ for k, v := range opts.Properties {
+ b[k] = v
+ }
+ }
+ return b, nil
+}
+
+// Create implements create image request
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToImageCreateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{201}})
+ return
+}
+
+// Delete implements image delete request
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// Get implements image get request
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// Update implements image updated request
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToImageUpdateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ _, r.Err = client.Patch(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ MoreHeaders: map[string]string{"Content-Type": "application/openstack-images-v2.1-json-patch"},
+ })
+ return
+}
+
+// UpdateOptsBuilder implements UpdateOptsBuilder
+type UpdateOptsBuilder interface {
+ // returns value implementing json.Marshaler which when marshaled matches the patch schema:
+ // http://specs.openstack.org/openstack/glance-specs/specs/api/v2/http-patch-image-api-v2.html
+ ToImageUpdateMap() ([]interface{}, error)
+}
+
+// UpdateOpts implements UpdateOpts
+type UpdateOpts []Patch
+
+// ToImageUpdateMap builder
+func (opts UpdateOpts) ToImageUpdateMap() ([]interface{}, error) {
+ m := make([]interface{}, len(opts))
+ for i, patch := range opts {
+ patchJSON := patch.ToImagePatchMap()
+ m[i] = patchJSON
+ }
+ return m, nil
+}
+
+// Patch represents a single update to an existing image. Multiple updates to an image can be
+// submitted at the same time.
+type Patch interface {
+ ToImagePatchMap() map[string]interface{}
+}
+
+// UpdateVisibility updated visibility
+type UpdateVisibility struct {
+ Visibility ImageVisibility
+}
+
+// ToImagePatchMap builder
+func (u UpdateVisibility) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/visibility",
+ "value": u.Visibility,
+ }
+}
+
+// ReplaceImageName implements Patch
+type ReplaceImageName struct {
+ NewName string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageName) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/name",
+ "value": r.NewName,
+ }
+}
+
+// ReplaceImageChecksum implements Patch
+type ReplaceImageChecksum struct {
+ Checksum string
+}
+
+// ReplaceImageChecksum builder
+func (rc ReplaceImageChecksum) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/checksum",
+ "value": rc.Checksum,
+ }
+}
+
+// ReplaceImageTags implements Patch
+type ReplaceImageTags struct {
+ NewTags []string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageTags) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/tags",
+ "value": r.NewTags,
+ }
+}
diff --git a/openstack/imageservice/v2/images/results.go b/openstack/imageservice/v2/images/results.go
new file mode 100644
index 0000000..09996f4
--- /dev/null
+++ b/openstack/imageservice/v2/images/results.go
@@ -0,0 +1,174 @@
+package images
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Image model
+// Does not include the literal image data; just metadata.
+// returned by listing images, and by fetching a specific image.
+type Image struct {
+ // ID is the image UUID
+ ID string `json:"id"`
+
+ // Name is the human-readable display name for the image.
+ Name string `json:"name"`
+
+ // Status is the image status. It can be "queued" or "active"
+ // See imageservice/v2/images/type.go
+ Status ImageStatus `json:"status"`
+
+ // Tags is a list of image tags. Tags are arbitrarily defined strings
+ // attached to an image.
+ Tags []string `json:"tags"`
+
+ // ContainerFormat is the format of the container.
+ // Valid values are ami, ari, aki, bare, and ovf.
+ ContainerFormat string `json:"container_format"`
+
+ // DiskFormat is the format of the disk.
+ // If set, valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi, and iso.
+ DiskFormat string `json:"disk_format"`
+
+ // MinDiskGigabytes is the amount of disk space in GB that is required to boot the image.
+ MinDiskGigabytes int `json:"min_disk"`
+
+ // MinRAMMegabytes [optional] is the amount of RAM in MB that is required to boot the image.
+ MinRAMMegabytes int `json:"min_ram"`
+
+ // Owner is the tenant the image belongs to.
+ Owner string `json:"owner"`
+
+ // Protected is whether the image is deletable or not.
+ Protected bool `json:"protected"`
+
+ // Visibility defines who can see/use the image.
+ Visibility ImageVisibility `json:"visibility"`
+
+ // Checksum is the checksum of the data that's associated with the image
+ Checksum string `json:"checksum"`
+
+ // SizeBytes is the size of the data that's associated with the image.
+ SizeBytes int64 `json:"size"`
+
+ // Metadata is a set of metadata associated with the image.
+ // Image metadata allow for meaningfully define the image properties
+ // and tags. See http://docs.openstack.org/developer/glance/metadefs-concepts.html.
+ Metadata map[string]string `json:"metadata"`
+
+ // Properties is a set of key-value pairs, if any, that are associated with the image.
+ Properties map[string]string `json:"properties"`
+
+ // CreatedAt is the date when the image has been created.
+ CreatedAt time.Time `json:"created_at"`
+
+ // UpdatedAt is the date when the last change has been made to the image or it's properties.
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // File is the trailing path after the glance endpoint that represent the location
+ // of the image or the path to retrieve it.
+ File string `json:"file"`
+
+ // Schema is the path to the JSON-schema that represent the image or image entity.
+ Schema string `json:"schema"`
+}
+
+func (r *Image) UnmarshalJSON(b []byte) error {
+ type tmp Image
+ var s struct {
+ tmp
+ SizeBytes interface{} `json:"size"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Image(s.tmp)
+
+ switch t := s.SizeBytes.(type) {
+ case nil:
+ return nil
+ case float32:
+ r.SizeBytes = int64(t)
+ case float64:
+ r.SizeBytes = int64(t)
+ default:
+ return fmt.Errorf("Unknown type for SizeBytes: %v (value: %v)", reflect.TypeOf(t), t)
+ }
+
+ return err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as an Image.
+func (r commonResult) Extract() (*Image, error) {
+ var s *Image
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+ commonResult
+}
+
+//DeleteResult model
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ImagePage represents page
+type ImagePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Images results.
+func (r ImagePage) IsEmpty() (bool, error) {
+ images, err := ExtractImages(r)
+ return len(images) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r ImagePage) NextPageURL() (string, error) {
+ var s struct {
+ Next string `json:"next"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+
+ if s.Next == "" {
+ return "", nil
+ }
+
+ return nextPageURL(r.URL.String(), s.Next), nil
+}
+
+// ExtractImages interprets the results of a single page from a List() call, producing a slice of Image entities.
+func ExtractImages(r pagination.Page) ([]Image, error) {
+ var s struct {
+ Images []Image `json:"images"`
+ }
+ err := (r.(ImagePage)).ExtractInto(&s)
+ return s.Images, err
+}
diff --git a/openstack/imageservice/v2/images/testing/fixtures.go b/openstack/imageservice/v2/images/testing/fixtures.go
new file mode 100644
index 0000000..10a87b4
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/fixtures.go
@@ -0,0 +1,329 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+type imageEntry struct {
+ ID string
+ JSON string
+}
+
+// HandleImageListSuccessfully test setup
+func HandleImageListSuccessfully(t *testing.T) {
+
+ images := make([]imageEntry, 3)
+
+ images[0] = imageEntry{"cirros-0.3.4-x86_64-uec",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec",
+ "tags": [],
+ "kernel_id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "container_format": "ami",
+ "created_at": "2015-07-15T11:43:35Z",
+ "ramdisk_id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "disk_format": "ami",
+ "updated_at": "2015-07-15T11:43:35Z",
+ "visibility": "public",
+ "self": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431",
+ "min_disk": 0,
+ "protected": false,
+ "id": "07aa21a9-fa1a-430e-9a33-185be5982431",
+ "size": 25165824,
+ "file": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431/file",
+ "checksum": "eb9139e4942121f22bbc2afc0400b2a4",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+ images[1] = imageEntry{"cirros-0.3.4-x86_64-uec-ramdisk",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec-ramdisk",
+ "tags": [],
+ "container_format": "ari",
+ "created_at": "2015-07-15T11:43:32Z",
+ "size": 3740163,
+ "disk_format": "ari",
+ "updated_at": "2015-07-15T11:43:32Z",
+ "visibility": "public",
+ "self": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "min_disk": 0,
+ "protected": false,
+ "id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "file": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b/file",
+ "checksum": "be575a2b939972276ef675752936977f",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+ images[2] = imageEntry{"cirros-0.3.4-x86_64-uec-kernel",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec-kernel",
+ "tags": [],
+ "container_format": "aki",
+ "created_at": "2015-07-15T11:43:29Z",
+ "size": 4979632,
+ "disk_format": "aki",
+ "updated_at": "2015-07-15T11:43:30Z",
+ "visibility": "public",
+ "self": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "file": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4/file",
+ "checksum": "8a40c862b5735975d82605c1dd395796",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ w.WriteHeader(http.StatusOK)
+
+ limit := 10
+ var err error
+ if r.FormValue("limit") != "" {
+ limit, err = strconv.Atoi(r.FormValue("limit"))
+ if err != nil {
+ t.Errorf("Error value for 'limit' parameter %v (error: %v)", r.FormValue("limit"), err)
+ }
+
+ }
+
+ marker := ""
+ newMarker := ""
+
+ if r.Form["marker"] != nil {
+ marker = r.Form["marker"][0]
+ }
+
+ t.Logf("limit = %v marker = %v", limit, marker)
+
+ selected := 0
+ addNext := false
+ var imageJSON []string
+
+ fmt.Fprintf(w, `{"images": [`)
+
+ for _, i := range images {
+ if marker == "" || addNext {
+ t.Logf("Adding image %v to page", i.ID)
+ imageJSON = append(imageJSON, i.JSON)
+ newMarker = i.ID
+ selected++
+ } else {
+ if strings.Contains(i.JSON, marker) {
+ addNext = true
+ }
+ }
+
+ if selected == limit {
+ break
+ }
+ }
+ t.Logf("Writing out %v image(s)", len(imageJSON))
+ fmt.Fprintf(w, strings.Join(imageJSON, ","))
+
+ fmt.Fprintf(w, `],
+ "next": "/images?marker=%s&limit=%v",
+ "schema": "/schemas/images",
+ "first": "/images?limit=%v"}`, newMarker, limit, limit)
+
+ })
+}
+
+// HandleImageCreationSuccessfully test setup
+func HandleImageCreationSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "name": "Ubuntu 12.10",
+ "architecture": "x86_64",
+ "tags": [
+ "ubuntu",
+ "quantal"
+ ]
+ }`)
+
+ w.WriteHeader(http.StatusCreated)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "queued",
+ "name": "Ubuntu 12.10",
+ "protected": false,
+ "tags": ["ubuntu","quantal"],
+ "container_format": "bare",
+ "created_at": "2014-11-11T20:47:55Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-11-11T20:47:55Z",
+ "visibility": "private",
+ "self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+ "owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "size": 0,
+ "checksum": "",
+ "virtual_size": 0
+ }`)
+ })
+}
+
+// HandleImageCreationSuccessfullyNulls test setup
+// JSON null values could be also returned according to behaviour https://bugs.launchpad.net/glance/+bug/1481512
+func HandleImageCreationSuccessfullyNulls(t *testing.T) {
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "name": "Ubuntu 12.10",
+ "tags": [
+ "ubuntu",
+ "quantal"
+ ]
+ }`)
+
+ w.WriteHeader(http.StatusCreated)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "queued",
+ "name": "Ubuntu 12.10",
+ "protected": false,
+ "tags": ["ubuntu","quantal"],
+ "container_format": "bare",
+ "created_at": "2014-11-11T20:47:55Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-11-11T20:47:55Z",
+ "visibility": "private",
+ "self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+ "owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "size": null,
+ "checksum": null,
+ "virtual_size": null
+ }`)
+ })
+}
+
+// HandleImageGetSuccessfully test setup
+func HandleImageGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "active",
+ "name": "cirros-0.3.2-x86_64-disk",
+ "tags": [],
+ "container_format": "bare",
+ "created_at": "2014-05-05T17:15:10Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-05-05T17:15:11Z",
+ "visibility": "public",
+ "self": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ "min_disk": 0,
+ "protected": false,
+ "id": "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ "file": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27/file",
+ "checksum": "64d7c1cd2b6f60c92c14662941cb7913",
+ "owner": "5ef70662f8b34079a6eddb8da9d75fe8",
+ "size": 13167616,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "virtual_size": "None"
+ }`)
+ })
+}
+
+// HandleImageDeleteSuccessfully test setup
+func HandleImageDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleImageUpdateSuccessfully setup
+func HandleImageUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ th.TestJSONRequest(t, r, `[
+ {
+ "op": "replace",
+ "path": "/name",
+ "value": "Fedora 17"
+ },
+ {
+ "op": "replace",
+ "path": "/tags",
+ "value": [
+ "fedora",
+ "beefy"
+ ]
+ }
+ ]`)
+
+ th.AssertEquals(t, "application/openstack-images-v2.1-json-patch", r.Header.Get("Content-Type"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "name": "Fedora 17",
+ "status": "active",
+ "visibility": "public",
+ "size": 2254249,
+ "checksum": "2cec138d7dae2aa59038ef8c9aec2390",
+ "tags": [
+ "fedora",
+ "beefy"
+ ],
+ "created_at": "2012-08-10T19:23:50Z",
+ "updated_at": "2012-08-12T11:11:33Z",
+ "self": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "file": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file",
+ "schema": "/v2/schemas/image",
+ "owner": "",
+ "min_ram": 0,
+ "min_disk": 0,
+ "disk_format": "",
+ "virtual_size": 0,
+ "container_format": ""
+ }`)
+ })
+}
diff --git a/openstack/imageservice/v2/images/testing/requests_test.go b/openstack/imageservice/v2/images/testing/requests_test.go
new file mode 100644
index 0000000..0371e4c
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/requests_test.go
@@ -0,0 +1,275 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageListSuccessfully(t)
+
+ t.Logf("Test setup %+v\n", th.Server)
+
+ t.Logf("Id\tName\tOwner\tChecksum\tSizeBytes")
+
+ pager := images.List(fakeclient.ServiceClient(), images.ListOpts{Limit: 1})
+ t.Logf("Pager state %v", pager)
+ count, pages := 0, 0
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ t.Logf("Page %v", page)
+ images, err := images.ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range images {
+ t.Logf("%s\t%s\t%s\t%s\t%v\t\n", i.ID, i.Name, i.Owner, i.Checksum, i.SizeBytes)
+ count++
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ t.Logf("--------\n%d images listed on %d pages.\n", count, pages)
+ th.AssertEquals(t, 3, pages)
+ th.AssertEquals(t, 3, count)
+}
+
+func TestAllPagesImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageListSuccessfully(t)
+
+ pages, err := images.List(fakeclient.ServiceClient(), nil).AllPages()
+ th.AssertNoErr(t, err)
+ images, err := images.ExtractImages(pages)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 3, len(images))
+}
+
+func TestCreateImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageCreationSuccessfully(t)
+
+ id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+ name := "Ubuntu 12.10"
+
+ actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+ ID: id,
+ Name: name,
+ Properties: map[string]string{
+ "architecture": "x86_64",
+ },
+ Tags: []string{"ubuntu", "quantal"},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ Name: "Ubuntu 12.10",
+ Tags: []string{"ubuntu", "quantal"},
+
+ Status: images.ImageStatusQueued,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Visibility: images.ImageVisibilityPrivate,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestCreateImageNulls(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageCreationSuccessfullyNulls(t)
+
+ id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+ name := "Ubuntu 12.10"
+
+ actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+ ID: id,
+ Name: name,
+ Tags: []string{"ubuntu", "quantal"},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ Name: "Ubuntu 12.10",
+ Tags: []string{"ubuntu", "quantal"},
+
+ Status: images.ImageStatusQueued,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Visibility: images.ImageVisibilityPrivate,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestGetImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageGetSuccessfully(t)
+
+ actualImage, err := images.Get(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27").Extract()
+
+ th.AssertNoErr(t, err)
+
+ checksum := "64d7c1cd2b6f60c92c14662941cb7913"
+ sizeBytes := int64(13167616)
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ owner := "5ef70662f8b34079a6eddb8da9d75fe8"
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ Name: "cirros-0.3.2-x86_64-disk",
+ Tags: []string{},
+
+ Status: images.ImageStatusActive,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Protected: false,
+ Visibility: images.ImageVisibilityPublic,
+
+ Checksum: checksum,
+ SizeBytes: sizeBytes,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestDeleteImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageDeleteSuccessfully(t)
+
+ result := images.Delete(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27")
+ th.AssertNoErr(t, result.Err)
+}
+
+func TestUpdateImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageUpdateSuccessfully(t)
+
+ actualImage, err := images.Update(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea", images.UpdateOpts{
+ images.ReplaceImageName{NewName: "Fedora 17"},
+ images.ReplaceImageTags{NewTags: []string{"fedora", "beefy"}},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ sizebytes := int64(2254249)
+ checksum := "2cec138d7dae2aa59038ef8c9aec2390"
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ Name: "Fedora 17",
+ Status: images.ImageStatusActive,
+ Visibility: images.ImageVisibilityPublic,
+
+ SizeBytes: sizebytes,
+ Checksum: checksum,
+
+ Tags: []string{
+ "fedora",
+ "beefy",
+ },
+
+ Owner: "",
+ MinRAMMegabytes: 0,
+ MinDiskGigabytes: 0,
+
+ DiskFormat: "",
+ ContainerFormat: "",
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
diff --git a/openstack/imageservice/v2/images/types.go b/openstack/imageservice/v2/images/types.go
new file mode 100644
index 0000000..086e7e5
--- /dev/null
+++ b/openstack/imageservice/v2/images/types.go
@@ -0,0 +1,75 @@
+package images
+
+// ImageStatus image statuses
+// http://docs.openstack.org/developer/glance/statuses.html
+type ImageStatus string
+
+const (
+ // ImageStatusQueued is a status for an image which identifier has
+ // been reserved for an image in the image registry.
+ ImageStatusQueued ImageStatus = "queued"
+
+ // ImageStatusSaving denotes that an image’s raw data is currently being uploaded to Glance
+ ImageStatusSaving ImageStatus = "saving"
+
+ // ImageStatusActive denotes an image that is fully available in Glance.
+ ImageStatusActive ImageStatus = "active"
+
+ // ImageStatusKilled denotes that an error occurred during the uploading
+ // of an image’s data, and that the image is not readable.
+ ImageStatusKilled ImageStatus = "killed"
+
+ // ImageStatusDeleted is used for an image that is no longer available to use.
+ // The image information is retained in the image registry.
+ ImageStatusDeleted ImageStatus = "deleted"
+
+ // ImageStatusPendingDelete is similar to Delete, but the image is not yet deleted.
+ ImageStatusPendingDelete ImageStatus = "pending_delete"
+
+ // ImageStatusDeactivated denotes that access to image data is not allowed to any non-admin user.
+ ImageStatusDeactivated ImageStatus = "deactivated"
+)
+
+// ImageVisibility denotes an image that is fully available in Glance.
+// This occurs when the image data is uploaded, or the image size
+// is explicitly set to zero on creation.
+// According to design
+// https://wiki.openstack.org/wiki/Glance-v2-community-image-visibility-design
+type ImageVisibility string
+
+const (
+ // ImageVisibilityPublic all users
+ ImageVisibilityPublic ImageVisibility = "public"
+
+ // ImageVisibilityPrivate users with tenantId == tenantId(owner)
+ ImageVisibilityPrivate ImageVisibility = "private"
+
+ // ImageVisibilityShared images are visible to:
+ // - users with tenantId == tenantId(owner)
+ // - users with tenantId in the member-list of the image
+ // - users with tenantId in the member-list with member_status == 'accepted'
+ ImageVisibilityShared ImageVisibility = "shared"
+
+ // ImageVisibilityCommunity images:
+ // - all users can see and boot it
+ // - users with tenantId in the member-list of the image with member_status == 'accepted'
+ // have this image in their default image-list
+ ImageVisibilityCommunity ImageVisibility = "community"
+)
+
+// MemberStatus is a status for adding a new member (tenant) to an image member list.
+type ImageMemberStatus string
+
+const (
+ // ImageMemberStatusAccepted is the status for an accepted image member.
+ ImageMemberStatusAccepted ImageMemberStatus = "accepted"
+
+ // ImageMemberStatusPending shows that the member addition is pending
+ ImageMemberStatusPending ImageMemberStatus = "pending"
+
+ // ImageMemberStatusAccepted is the status for a rejected image member
+ ImageMemberStatusRejected ImageMemberStatus = "rejected"
+
+ // ImageMemberStatusAll
+ ImageMemberStatusAll ImageMemberStatus = "all"
+)
diff --git a/openstack/imageservice/v2/images/urls.go b/openstack/imageservice/v2/images/urls.go
new file mode 100644
index 0000000..58cb8f7
--- /dev/null
+++ b/openstack/imageservice/v2/images/urls.go
@@ -0,0 +1,44 @@
+package images
+
+import (
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// `listURL` is a pure function. `listURL(c)` is a URL for which a GET
+// request will respond with a list of images in the service `c`.
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("images")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("images")
+}
+
+// `imageURL(c,i)` is the URL for the image identified by ID `i` in
+// the service `c`.
+func imageURL(c *gophercloud.ServiceClient, imageID string) string {
+ return c.ServiceURL("images", imageID)
+}
+
+// `getURL(c,i)` is a URL for which a GET request will respond with
+// information about the image identified by ID `i` in the service
+// `c`.
+func getURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+func updateURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+// builds next page full url based on current url
+func nextPageURL(currentURL string, next string) string {
+ base := currentURL[:strings.Index(currentURL, "/images")]
+ return base + next
+}
diff --git a/openstack/imageservice/v2/members/requests.go b/openstack/imageservice/v2/members/requests.go
new file mode 100644
index 0000000..8c667cb
--- /dev/null
+++ b/openstack/imageservice/v2/members/requests.go
@@ -0,0 +1,77 @@
+package members
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Create member for specific image
+//
+// Preconditions
+// The specified images must exist.
+// You can only add a new member to an image which 'visibility' attribute is private.
+// You must be the owner of the specified image.
+// Synchronous Postconditions
+// With correct permissions, you can see the member status of the image as pending through API calls.
+//
+// More details here: http://developer.openstack.org/api-ref-image-v2.html#createImageMember-v2
+func Create(client *gophercloud.ServiceClient, id string, member string) (r CreateResult) {
+ b := map[string]interface{}{"member": member}
+ _, r.Err = client.Post(createMemberURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 409, 403},
+ })
+ return
+}
+
+// List members returns list of members for specifed image id
+// More details: http://developer.openstack.org/api-ref-image-v2.html#listImageMembers-v2
+func List(client *gophercloud.ServiceClient, id string) pagination.Pager {
+ return pagination.NewPager(client, listMembersURL(client, id), func(r pagination.PageResult) pagination.Page {
+ return MemberPage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get image member details.
+// More details: http://developer.openstack.org/api-ref-image-v2.html#getImageMember-v2
+func Get(client *gophercloud.ServiceClient, imageID string, memberID string) (r DetailsResult) {
+ _, r.Err = client.Get(getMemberURL(client, imageID, memberID), &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return
+}
+
+// Delete membership for given image.
+// Callee should be image owner
+// More details: http://developer.openstack.org/api-ref-image-v2.html#deleteImageMember-v2
+func Delete(client *gophercloud.ServiceClient, imageID string, memberID string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteMemberURL(client, imageID, memberID), &gophercloud.RequestOpts{OkCodes: []int{204, 403}})
+ return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToImageMemberUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts implements UpdateOptsBuilder
+type UpdateOpts struct {
+ Status string
+}
+
+// ToMemberUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToImageMemberUpdateMap() (map[string]interface{}, error) {
+ return map[string]interface{}{
+ "status": opts.Status,
+ }, nil
+}
+
+// Update function updates member
+// More details: http://developer.openstack.org/api-ref-image-v2.html#updateImageMember-v2
+func Update(client *gophercloud.ServiceClient, imageID string, memberID string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToImageMemberUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateMemberURL(client, imageID, memberID), b, &r.Body,
+ &gophercloud.RequestOpts{OkCodes: []int{200}})
+ return
+}
diff --git a/openstack/imageservice/v2/members/results.go b/openstack/imageservice/v2/members/results.go
new file mode 100644
index 0000000..d3cc1ce
--- /dev/null
+++ b/openstack/imageservice/v2/members/results.go
@@ -0,0 +1,69 @@
+package members
+
+import (
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Member model
+type Member struct {
+ CreatedAt time.Time `json:"created_at"`
+ ImageID string `json:"image_id"`
+ MemberID string `json:"member_id"`
+ Schema string `json:"schema"`
+ Status string `json:"status"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Extract Member model from request if possible
+func (r commonResult) Extract() (*Member, error) {
+ var s *Member
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// MemberPage is a single page of Members results.
+type MemberPage struct {
+ pagination.SinglePageBase
+}
+
+// ExtractMembers returns a slice of Members contained in a single page of results.
+func ExtractMembers(r pagination.Page) ([]Member, error) {
+ var s struct {
+ Members []Member `json:"members"`
+ }
+ err := r.(MemberPage).ExtractInto(&s)
+ return s.Members, err
+}
+
+// IsEmpty determines whether or not a page of Members contains any results.
+func (r MemberPage) IsEmpty() (bool, error) {
+ members, err := ExtractMembers(r)
+ return len(members) == 0, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// CreateResult result model
+type CreateResult struct {
+ commonResult
+}
+
+// DetailsResult model
+type DetailsResult struct {
+ commonResult
+}
+
+// UpdateResult model
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult model
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/imageservice/v2/members/testing/fixtures.go b/openstack/imageservice/v2/members/testing/fixtures.go
new file mode 100644
index 0000000..c08fc5e
--- /dev/null
+++ b/openstack/imageservice/v2/members/testing/fixtures.go
@@ -0,0 +1,138 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleCreateImageMemberSuccessfully setup
+func HandleCreateImageMemberSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ th.TestJSONRequest(t, r, `{"member": "8989447062e04a818baf9e073fd04fa7"}`)
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{
+ "created_at": "2013-09-20T19:22:19Z",
+ "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "member_id": "8989447062e04a818baf9e073fd04fa7",
+ "schema": "/v2/schemas/member",
+ "status": "pending",
+ "updated_at": "2013-09-20T19:25:31Z"
+ }`)
+
+ })
+}
+
+// HandleImageMemberList happy path setup
+func HandleImageMemberList(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "members": [
+ {
+ "created_at": "2013-10-07T17:58:03Z",
+ "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "member_id": "123456789",
+ "schema": "/v2/schemas/member",
+ "status": "pending",
+ "updated_at": "2013-10-07T17:58:03Z"
+ },
+ {
+ "created_at": "2013-10-07T17:58:55Z",
+ "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "member_id": "987654321",
+ "schema": "/v2/schemas/member",
+ "status": "accepted",
+ "updated_at": "2013-10-08T12:08:55Z"
+ }
+ ],
+ "schema": "/v2/schemas/members"
+ }`)
+ })
+}
+
+// HandleImageMemberEmptyList happy path setup
+func HandleImageMemberEmptyList(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "members": [],
+ "schema": "/v2/schemas/members"
+ }`)
+ })
+}
+
+// HandleImageMemberDetails setup
+func HandleImageMemberDetails(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{
+ "status": "pending",
+ "created_at": "2013-11-26T07:21:21Z",
+ "updated_at": "2013-11-26T07:21:21Z",
+ "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "member_id": "8989447062e04a818baf9e073fd04fa7",
+ "schema": "/v2/schemas/member"
+ }`)
+ })
+}
+
+// HandleImageMemberDeleteSuccessfully setup
+func HandleImageMemberDeleteSuccessfully(t *testing.T) *CallsCounter {
+ var counter CallsCounter
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+ counter.Counter = counter.Counter + 1
+
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+ return &counter
+}
+
+// HandleImageMemberUpdate setup
+func HandleImageMemberUpdate(t *testing.T) *CallsCounter {
+ var counter CallsCounter
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/members/8989447062e04a818baf9e073fd04fa7", func(w http.ResponseWriter, r *http.Request) {
+ counter.Counter = counter.Counter + 1
+
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ th.TestJSONRequest(t, r, `{"status": "accepted"}`)
+
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `{
+ "status": "accepted",
+ "created_at": "2013-11-26T07:21:21Z",
+ "updated_at": "2013-11-26T07:21:21Z",
+ "image_id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "member_id": "8989447062e04a818baf9e073fd04fa7",
+ "schema": "/v2/schemas/member"
+ }`)
+ })
+ return &counter
+}
+
+// CallsCounter for checking if request handler was called at all
+type CallsCounter struct {
+ Counter int
+}
diff --git a/openstack/imageservice/v2/members/testing/requests_test.go b/openstack/imageservice/v2/members/testing/requests_test.go
new file mode 100644
index 0000000..04624c9
--- /dev/null
+++ b/openstack/imageservice/v2/members/testing/requests_test.go
@@ -0,0 +1,172 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/imageservice/v2/members"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const createdAtString = "2013-09-20T19:22:19Z"
+const updatedAtString = "2013-09-20T19:25:31Z"
+
+func TestCreateMemberSuccessfully(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleCreateImageMemberSuccessfully(t)
+ im, err := members.Create(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "8989447062e04a818baf9e073fd04fa7").Extract()
+ th.AssertNoErr(t, err)
+
+ createdAt, err := time.Parse(time.RFC3339, createdAtString)
+ th.AssertNoErr(t, err)
+
+ updatedAt, err := time.Parse(time.RFC3339, updatedAtString)
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, members.Member{
+ CreatedAt: createdAt,
+ ImageID: "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ MemberID: "8989447062e04a818baf9e073fd04fa7",
+ Schema: "/v2/schemas/member",
+ Status: "pending",
+ UpdatedAt: updatedAt,
+ }, *im)
+
+}
+
+func TestMemberListSuccessfully(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageMemberList(t)
+
+ pager := members.List(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea")
+ t.Logf("Pager state %v", pager)
+ count, pages := 0, 0
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ t.Logf("Page %v", page)
+ members, err := members.ExtractMembers(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range members {
+ t.Logf("%s\t%s\t%s\t%s\t\n", i.ImageID, i.MemberID, i.Status, i.Schema)
+ count++
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, pages)
+ th.AssertEquals(t, 2, count)
+}
+
+func TestMemberListEmpty(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageMemberEmptyList(t)
+
+ pager := members.List(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea")
+ t.Logf("Pager state %v", pager)
+ count, pages := 0, 0
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ t.Logf("Page %v", page)
+ members, err := members.ExtractMembers(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range members {
+ t.Logf("%s\t%s\t%s\t%s\t\n", i.ImageID, i.MemberID, i.Status, i.Schema)
+ count++
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 0, pages)
+ th.AssertEquals(t, 0, count)
+}
+
+func TestShowMemberDetails(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageMemberDetails(t)
+ md, err := members.Get(fakeclient.ServiceClient(),
+ "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "8989447062e04a818baf9e073fd04fa7").Extract()
+
+ th.AssertNoErr(t, err)
+ if md == nil {
+ t.Errorf("Expected non-nil value for md")
+ }
+
+ createdAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+ th.AssertNoErr(t, err)
+
+ updatedAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, members.Member{
+ CreatedAt: createdAt,
+ ImageID: "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ MemberID: "8989447062e04a818baf9e073fd04fa7",
+ Schema: "/v2/schemas/member",
+ Status: "pending",
+ UpdatedAt: updatedAt,
+ }, *md)
+}
+
+func TestDeleteMember(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ counter := HandleImageMemberDeleteSuccessfully(t)
+
+ result := members.Delete(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "8989447062e04a818baf9e073fd04fa7")
+ th.AssertEquals(t, 1, counter.Counter)
+ th.AssertNoErr(t, result.Err)
+}
+
+func TestMemberUpdateSuccessfully(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ counter := HandleImageMemberUpdate(t)
+ im, err := members.Update(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "8989447062e04a818baf9e073fd04fa7",
+ members.UpdateOpts{
+ Status: "accepted",
+ }).Extract()
+ th.AssertEquals(t, 1, counter.Counter)
+ th.AssertNoErr(t, err)
+
+ createdAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+ th.AssertNoErr(t, err)
+
+ updatedAt, err := time.Parse(time.RFC3339, "2013-11-26T07:21:21Z")
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, members.Member{
+ CreatedAt: createdAt,
+ ImageID: "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ MemberID: "8989447062e04a818baf9e073fd04fa7",
+ Schema: "/v2/schemas/member",
+ Status: "accepted",
+ UpdatedAt: updatedAt,
+ }, *im)
+
+}
diff --git a/openstack/imageservice/v2/members/urls.go b/openstack/imageservice/v2/members/urls.go
new file mode 100644
index 0000000..0898364
--- /dev/null
+++ b/openstack/imageservice/v2/members/urls.go
@@ -0,0 +1,31 @@
+package members
+
+import "github.com/gophercloud/gophercloud"
+
+func imageMembersURL(c *gophercloud.ServiceClient, imageID string) string {
+ return c.ServiceURL("images", imageID, "members")
+}
+
+func listMembersURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageMembersURL(c, imageID)
+}
+
+func createMemberURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageMembersURL(c, imageID)
+}
+
+func imageMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+ return c.ServiceURL("images", imageID, "members", memberID)
+}
+
+func getMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+ return imageMemberURL(c, imageID, memberID)
+}
+
+func updateMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+ return imageMemberURL(c, imageID, memberID)
+}
+
+func deleteMemberURL(c *gophercloud.ServiceClient, imageID string, memberID string) string {
+ return imageMemberURL(c, imageID, memberID)
+}
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/requests.go b/openstack/networking/v2/apiversions/requests.go
new file mode 100644
index 0000000..59ece85
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests.go
@@ -0,0 +1,21 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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/results.go b/openstack/networking/v2/apiversions/results.go
new file mode 100644
index 0000000..eff4485
--- /dev/null
+++ b/openstack/networking/v2/apiversions/results.go
@@ -0,0 +1,66 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/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 `son:"status"`
+ ID string `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)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]APIVersion, error) {
+ var s struct {
+ Versions []APIVersion `json:"versions"`
+ }
+ err := (r.(APIVersionPage)).ExtractInto(&s)
+ return s.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 `json:"name"`
+ Collection string `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)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]APIVersionResource, error) {
+ var s struct {
+ APIVersionResources []APIVersionResource `json:"resources"`
+ }
+ err := (r.(APIVersionResourcePage)).ExtractInto(&s)
+ return s.APIVersionResources, err
+}
diff --git a/openstack/networking/v2/apiversions/testing/doc.go b/openstack/networking/v2/apiversions/testing/doc.go
new file mode 100644
index 0000000..0accd99
--- /dev/null
+++ b/openstack/networking/v2/apiversions/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_apiversions_v2
+package testing
diff --git a/openstack/networking/v2/apiversions/testing/requests_test.go b/openstack/networking/v2/apiversions/testing/requests_test.go
new file mode 100644
index 0000000..5a66a2a
--- /dev/null
+++ b/openstack/networking/v2/apiversions/testing/requests_test.go
@@ -0,0 +1,183 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/apiversions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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
+
+ apiversions.ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := apiversions.ExtractAPIVersions(page)
+ if err != nil {
+ t.Errorf("Failed to extract API versions: %v", err)
+ return false, err
+ }
+
+ expected := []apiversions.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)
+ })
+
+ apiversions.ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := apiversions.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
+
+ apiversions.ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := apiversions.ExtractVersionResources(page)
+ if err != nil {
+ t.Errorf("Failed to extract version resources: %v", err)
+ return false, err
+ }
+
+ expected := []apiversions.APIVersionResource{
+ {
+ Name: "subnet",
+ Collection: "subnets",
+ },
+ {
+ Name: "network",
+ Collection: "networks",
+ },
+ {
+ 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)
+ })
+
+ apiversions.ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := apiversions.ExtractVersionResources(page); err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+ return true, nil
+ })
+}
diff --git a/openstack/networking/v2/apiversions/urls.go b/openstack/networking/v2/apiversions/urls.go
new file mode 100644
index 0000000..0fa7437
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+ "strings"
+
+ "github.com/gophercloud/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/common/common_tests.go b/openstack/networking/v2/common/common_tests.go
new file mode 100644
index 0000000..7e1d917
--- /dev/null
+++ b/openstack/networking/v2/common/common_tests.go
@@ -0,0 +1,14 @@
+package common
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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..0c43689
--- /dev/null
+++ b/openstack/networking/v2/extensions/delegate.go
@@ -0,0 +1,41 @@
+package extensions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ "github.com/gophercloud/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/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..1ee39d2
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/requests.go
@@ -0,0 +1,32 @@
+package external
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+// 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 {
+ networks.CreateOpts
+ External *bool `json:"router:external,omitempty"`
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// 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 {
+ networks.UpdateOpts
+ External *bool `json:"router:external,omitempty"`
+}
+
+// ToNetworkUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "network")
+}
diff --git a/openstack/networking/v2/extensions/external/results.go b/openstack/networking/v2/extensions/external/results.go
new file mode 100644
index 0000000..7e10c6d
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/results.go
@@ -0,0 +1,76 @@
+package external
+
+import (
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/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 `json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `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 `json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `json:"shared"`
+
+ // Specifies whether the network is an external network or not.
+ External bool `json:"router:external"`
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExternal, error) {
+ var s struct {
+ Network *NetworkExternal `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Network, err
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExternal, error) {
+ var s struct {
+ Network *NetworkExternal `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Network, err
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExternal, error) {
+ var s struct {
+ Network *NetworkExternal `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Network, err
+}
+
+// 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(r pagination.Page) ([]NetworkExternal, error) {
+ var s struct {
+ Networks []NetworkExternal `json:"networks" json:"networks"`
+ }
+ err := (r.(networks.NetworkPage)).ExtractInto(&s)
+ return s.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/external/testing/doc.go b/openstack/networking/v2/extensions/external/testing/doc.go
new file mode 100644
index 0000000..8a30f6b
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_external_v2
+package testing
diff --git a/openstack/networking/v2/extensions/external/testing/results_test.go b/openstack/networking/v2/extensions/external/testing/results_test.go
new file mode 100644
index 0000000..2ddc3ac
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/testing/results_test.go
@@ -0,0 +1,256 @@
+package testing
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/external"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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 := external.ExtractList(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []external.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,
+ },
+ {
+ 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 := external.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 := external.CreateOpts{networks.CreateOpts{Name: "ext_net", AdminStateUp: gophercloud.Enabled}, gophercloud.Enabled}
+ res := networks.Create(fake.ServiceClient(), options)
+
+ n, err := external.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 := external.UpdateOpts{networks.UpdateOpts{Name: "new_name"}, gophercloud.Enabled}
+ res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+ n, err := external.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 := external.ExtractGet(gr); err == nil {
+ t.Fatalf("Expected error, got one")
+ }
+
+ ur := networks.UpdateResult{}
+ ur.Err = errors.New("")
+
+ if _, err := external.ExtractUpdate(ur); err == nil {
+ t.Fatalf("Expected error, got one")
+ }
+
+ cr := networks.CreateResult{}
+ cr.Err = errors.New("")
+
+ if _, err := external.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..21ceb4e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/requests.go
@@ -0,0 +1,140 @@
+package firewalls
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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)
+ return q.String(), err
+}
+
+// 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 {
+ PolicyID string `json:"firewall_policy_id" required:"true"`
+ // Only required if the caller has an admin role and wants to create a firewall
+ // for another tenant.
+ TenantID string `json:"tenant_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+}
+
+// ToFirewallCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToFirewallCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "firewall")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToFirewallCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular firewall based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ PolicyID string `json:"firewall_policy_id" required:"true"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+}
+
+// ToFirewallUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToFirewallUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "firewall")
+}
+
+// Update allows firewalls to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToFirewallUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular firewall based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..ab6cf8e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/results.go
@@ -0,0 +1,87 @@
+package firewalls
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Firewall is an OpenStack firewall.
+type Firewall struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ AdminStateUp bool `json:"admin_state_up"`
+ Status string `json:"status"`
+ PolicyID string `json:"firewall_policy_id"`
+ TenantID string `json:"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) {
+ var s struct {
+ Firewall *Firewall `json:"firewall"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 (r FirewallPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"firewalls_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a FirewallPage struct is empty.
+func (r FirewallPage) IsEmpty() (bool, error) {
+ is, err := ExtractFirewalls(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Firewall, error) {
+ var s struct {
+ Firewalls []Firewall `json:"firewalls" json:"firewalls"`
+ }
+ err := (r.(FirewallPage)).ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/fwaas/firewalls/testing/doc.go
new file mode 100644
index 0000000..6b46bba
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_fwaas_firewalls_v2
+package testing
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/testing/requests_test.go b/openstack/networking/v2/extensions/fwaas/firewalls/testing/requests_test.go
new file mode 100644
index 0000000..01ab732
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/testing/requests_test.go
@@ -0,0 +1,241 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ firewalls.List(fake.ServiceClient(), firewalls.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := firewalls.ExtractFirewalls(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []firewalls.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",
+ },
+ {
+ 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 := firewalls.CreateOpts{
+ TenantID: "b4eedccc6fb74fa8a7ad6b08382b852b",
+ Name: "fw",
+ Description: "OpenStack firewall",
+ AdminStateUp: gophercloud.Enabled,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+ _, err := firewalls.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 := firewalls.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 := firewalls.UpdateOpts{
+ Name: "fw",
+ Description: "updated fw",
+ AdminStateUp: gophercloud.Disabled,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+
+ _, err := firewalls.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 := firewalls.Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..807ea1a
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/urls.go
@@ -0,0 +1,16 @@
+package firewalls
+
+import "github.com/gophercloud/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..437d124
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/requests.go
@@ -0,0 +1,173 @@
+package policies
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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)
+ return q.String(), err
+}
+
+// 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 {
+ ToFirewallPolicyCreateMap() (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 `json:"tenant_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+ Audited *bool `json:"audited,omitempty"`
+ Rules []string `json:"firewall_rules,omitempty"`
+}
+
+// ToFirewallPolicyCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToFirewallPolicyCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "firewall_policy")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall policy
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToFirewallPolicyCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular firewall policy based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ ToFirewallPolicyUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall policy.
+type UpdateOpts struct {
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+ Audited *bool `json:"audited,omitempty"`
+ Rules []string `json:"firewall_rules,omitempty"`
+}
+
+// ToFirewallPolicyUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToFirewallPolicyUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "firewall_policy")
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToFirewallPolicyUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular firewall policy based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+type InsertRuleOptsBuilder interface {
+ ToFirewallPolicyInsertRuleMap() (map[string]interface{}, error)
+}
+
+type InsertRuleOpts struct {
+ ID string `json:"firewall_rule_id" required:"true"`
+ BeforeRuleID string `json:"insert_before,omitempty"`
+ AfterRuleID string `json:"insert_after,omitempty"`
+}
+
+func (opts InsertRuleOpts) ToFirewallPolicyInsertRuleMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+func AddRule(c *gophercloud.ServiceClient, id string, opts InsertRuleOptsBuilder) (r InsertRuleResult) {
+ b, err := opts.ToFirewallPolicyInsertRuleMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(insertURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+func RemoveRule(c *gophercloud.ServiceClient, id, ruleID string) (r RemoveRuleResult) {
+ b := map[string]interface{}{"firewall_rule_id": ruleID}
+ _, r.Err = c.Put(removeURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
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..9c5b186
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/results.go
@@ -0,0 +1,97 @@
+package policies
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Policy is a firewall policy.
+type Policy struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ TenantID string `json:"tenant_id"`
+ Audited bool `json:"audited"`
+ Shared bool `json:"shared"`
+ Rules []string `json:"firewall_rules,omitempty"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall policy.
+func (r commonResult) Extract() (*Policy, error) {
+ var s struct {
+ Policy *Policy `json:"firewall_policy"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 (r PolicyPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"firewall_policies_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PolicyPage struct is empty.
+func (r PolicyPage) IsEmpty() (bool, error) {
+ is, err := ExtractPolicies(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Policy, error) {
+ var s struct {
+ Policies []Policy `json:"firewall_policies"`
+ }
+ err := (r.(PolicyPage)).ExtractInto(&s)
+ return s.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
+}
+
+// InsertRuleResult represents the result of an InsertRule operation.
+type InsertRuleResult struct {
+ commonResult
+}
+
+// RemoveRuleResult represents the result of a RemoveRule operation.
+type RemoveRuleResult struct {
+ commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/testing/doc.go b/openstack/networking/v2/extensions/fwaas/policies/testing/doc.go
new file mode 100644
index 0000000..d2707f0
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_fwaas_policies_v2
+package testing
diff --git a/openstack/networking/v2/extensions/fwaas/policies/testing/requests_test.go b/openstack/networking/v2/extensions/fwaas/policies/testing/requests_test.go
new file mode 100644
index 0000000..11b9848
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/testing/requests_test.go
@@ -0,0 +1,274 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ policies.List(fake.ServiceClient(), policies.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := policies.ExtractPolicies(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []policies.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",
+ },
+ {
+ 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 := policies.CreateOpts{
+ TenantID: "9145d91459d248b1b02fdaca97c6a75d",
+ Name: "policy",
+ Description: "Firewall policy",
+ Shared: gophercloud.Disabled,
+ Audited: gophercloud.Enabled,
+ Rules: []string{
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32",
+ },
+ }
+
+ _, err := policies.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 := policies.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 := policies.UpdateOpts{
+ Name: "policy",
+ Description: "Firewall policy",
+ Rules: []string{
+ "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+ "11a58c87-76be-ae7c-a74e-b77fffb88a32",
+ },
+ }
+
+ _, err := policies.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 := policies.Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..c252b79
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/urls.go
@@ -0,0 +1,26 @@
+package policies
+
+import "github.com/gophercloud/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/routerinsertion/doc.go b/openstack/networking/v2/extensions/fwaas/routerinsertion/doc.go
new file mode 100644
index 0000000..9b847e2
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/routerinsertion/doc.go
@@ -0,0 +1,2 @@
+// Package routerinsertion implements the fwaasrouterinsertion FWaaS extension.
+package routerinsertion
diff --git a/openstack/networking/v2/extensions/fwaas/routerinsertion/requests.go b/openstack/networking/v2/extensions/fwaas/routerinsertion/requests.go
new file mode 100644
index 0000000..3a5942e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/routerinsertion/requests.go
@@ -0,0 +1,51 @@
+package routerinsertion
+
+import (
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+)
+
+// CreateOptsExt adds a RouterIDs option to the base CreateOpts.
+type CreateOptsExt struct {
+ firewalls.CreateOptsBuilder
+ RouterIDs []string `json:"router_ids"`
+}
+
+// ToFirewallCreateMap adds router_ids to the base firewall creation options.
+func (opts CreateOptsExt) ToFirewallCreateMap() (map[string]interface{}, error) {
+ base, err := opts.CreateOptsBuilder.ToFirewallCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(opts.RouterIDs) == 0 {
+ return base, nil
+ }
+
+ firewallMap := base["firewall"].(map[string]interface{})
+ firewallMap["router_ids"] = opts.RouterIDs
+
+ return base, nil
+}
+
+// UpdateOptsExt updates a RouterIDs option to the base UpdateOpts.
+type UpdateOptsExt struct {
+ firewalls.UpdateOptsBuilder
+ RouterIDs []string `json:"router_ids"`
+}
+
+// ToFirewallUpdateMap adds router_ids to the base firewall update options.
+func (opts UpdateOptsExt) ToFirewallUpdateMap() (map[string]interface{}, error) {
+ base, err := opts.UpdateOptsBuilder.ToFirewallUpdateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(opts.RouterIDs) == 0 {
+ return base, nil
+ }
+
+ firewallMap := base["firewall"].(map[string]interface{})
+ firewallMap["router_ids"] = opts.RouterIDs
+
+ return base, nil
+}
diff --git a/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/doc.go b/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/doc.go
new file mode 100644
index 0000000..36a6c1c
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_fwaas_extensions_routerinsertion_v2
+package testing
diff --git a/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/requests_test.go b/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/requests_test.go
new file mode 100644
index 0000000..a4890ee
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/routerinsertion/testing/requests_test.go
@@ -0,0 +1,126 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/routerinsertion"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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",
+ "router_ids": [
+ "8a3a0d6a-34b5-4a92-b65d-6375a4c1e9e8"
+ ]
+ }
+}
+ `)
+
+ 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"
+ }
+}
+ `)
+ })
+
+ firewallCreateOpts := firewalls.CreateOpts{
+ TenantID: "b4eedccc6fb74fa8a7ad6b08382b852b",
+ Name: "fw",
+ Description: "OpenStack firewall",
+ AdminStateUp: gophercloud.Enabled,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+ createOpts := routerinsertion.CreateOptsExt{
+ firewallCreateOpts,
+ []string{"8a3a0d6a-34b5-4a92-b65d-6375a4c1e9e8"},
+ }
+
+ _, err := firewalls.Create(fake.ServiceClient(), createOpts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+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",
+ "router_ids": [
+ "8a3a0d6a-34b5-4a92-b65d-6375a4c1e9e8"
+ ]
+ }
+}
+ `)
+
+ 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"
+ }
+}
+ `)
+ })
+
+ firewallUpdateOpts := firewalls.UpdateOpts{
+ Name: "fw",
+ Description: "updated fw",
+ AdminStateUp: gophercloud.Disabled,
+ PolicyID: "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+ }
+ updateOpts := routerinsertion.UpdateOptsExt{
+ firewallUpdateOpts,
+ []string{"8a3a0d6a-34b5-4a92-b65d-6375a4c1e9e8"},
+ }
+
+ _, err := firewalls.Update(fake.ServiceClient(), "ea5b5315-64f6-4ea3-8e58-981cc37c6576", updateOpts).Extract()
+ th.AssertNoErr(t, err)
+}
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..c1784b7
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/requests.go
@@ -0,0 +1,188 @@
+package rules
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type (
+ // Protocol represents a valid rule protocol
+ Protocol string
+)
+
+const (
+ // ProtocolAny is to allow any protocol
+ ProtocolAny Protocol = "any"
+
+ // ProtocolICMP is to allow the ICMP protocol
+ ProtocolICMP Protocol = "icmp"
+
+ // ProtocolTCP is to allow the TCP protocol
+ ProtocolTCP Protocol = "tcp"
+
+ // ProtocolUDP is to allow the UDP protocol
+ ProtocolUDP Protocol = "udp"
+)
+
+// 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 {
+ Protocol Protocol `json:"protocol" required:"true"`
+ Action string `json:"action" required:"true"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ IPVersion gophercloud.IPVersion `json:"ip_version,omitempty"`
+ SourceIPAddress string `json:"source_ip_address,omitempty"`
+ DestinationIPAddress string `json:"destination_ip_address,omitempty"`
+ SourcePort string `json:"source_port,omitempty"`
+ DestinationPort string `json:"destination_port,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// ToRuleCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "firewall_rule")
+ if err != nil {
+ return nil, err
+ }
+
+ if m := b["firewall_rule"].(map[string]interface{}); m["protocol"] == "any" {
+ m["protocol"] = nil
+ }
+
+ return b, nil
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall rule
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToRuleCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular firewall rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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.
+type UpdateOpts struct {
+ Protocol *string `json:"protocol,omitempty"`
+ Action *string `json:"action,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Description *string `json:"description,omitempty"`
+ IPVersion *gophercloud.IPVersion `json:"ip_version,omitempty"`
+ SourceIPAddress *string `json:"source_ip_address,omitempty"`
+ DestinationIPAddress *string `json:"destination_ip_address,omitempty"`
+ SourcePort *string `json:"source_port,omitempty"`
+ DestinationPort *string `json:"destination_port,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// ToRuleUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToRuleUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "firewall_rule")
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToRuleUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular firewall rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..c44e5a9
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/results.go
@@ -0,0 +1,95 @@
+package rules
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Rule represents a firewall rule
+type Rule struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ Protocol string `json:"protocol"`
+ Action string `json:"action"`
+ IPVersion int `json:"ip_version,omitempty"`
+ SourceIPAddress string `json:"source_ip_address,omitempty"`
+ DestinationIPAddress string `json:"destination_ip_address,omitempty"`
+ SourcePort string `json:"source_port,omitempty"`
+ DestinationPort string `json:"destination_port,omitempty"`
+ Shared bool `json:"shared,omitempty"`
+ Enabled bool `json:"enabled,omitempty"`
+ PolicyID string `json:"firewall_policy_id"`
+ Position int `json:"position"`
+ TenantID string `json:"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 (r RulePage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"firewall_rules_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a RulePage struct is empty.
+func (r RulePage) IsEmpty() (bool, error) {
+ is, err := ExtractRules(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Rule, error) {
+ var s struct {
+ Rules []Rule `json:"firewall_rules"`
+ }
+ err := (r.(RulePage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Rule *Rule `json:"firewall_rule"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/fwaas/rules/testing/doc.go
new file mode 100644
index 0000000..481ae2e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_fwaas_rules_v2
+package testing
diff --git a/openstack/networking/v2/extensions/fwaas/rules/testing/requests_test.go b/openstack/networking/v2/extensions/fwaas/rules/testing/requests_test.go
new file mode 100644
index 0000000..2fedfa8
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/testing/requests_test.go
@@ -0,0 +1,381 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ rules.List(fake.ServiceClient(), rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := rules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []rules.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,
+ },
+ {
+ 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 := rules.CreateOpts{
+ TenantID: "80cf934d6ffb4ef5b244f1c512ad1e61",
+ Protocol: rules.ProtocolTCP,
+ Description: "ssh rule",
+ DestinationIPAddress: "192.168.1.0/24",
+ DestinationPort: "22",
+ Name: "ssh_form_any",
+ Action: "allow",
+ }
+
+ _, err := rules.Create(fake.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestCreateAnyProtocol(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": null,
+ "description": "any to 192.168.1.0/24",
+ "destination_ip_address": "192.168.1.0/24",
+ "name": "any_to_192.168.1.0/24",
+ "action": "allow",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "firewall_rule":{
+ "protocol": null,
+ "description": "any to 192.168.1.0/24",
+ "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": null,
+ "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+ "name": "any_to_192.168.1.0/24",
+ "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+ "enabled": true,
+ "action": "allow",
+ "ip_version": 4,
+ "shared": false
+ }
+}
+ `)
+ })
+
+ options := rules.CreateOpts{
+ TenantID: "80cf934d6ffb4ef5b244f1c512ad1e61",
+ Protocol: rules.ProtocolAny,
+ Description: "any to 192.168.1.0/24",
+ DestinationIPAddress: "192.168.1.0/24",
+ Name: "any_to_192.168.1.0/24",
+ Action: "allow",
+ }
+
+ _, err := rules.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 := rules.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",
+ "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",
+ "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
+ }
+}
+ `)
+ })
+
+ newProtocol := "tcp"
+ newDescription := "ssh rule"
+ newDestinationIP := "192.168.1.0/24"
+ newDestintionPort := "22"
+ newName := "ssh_form_any"
+ newAction := "allow"
+
+ options := rules.UpdateOpts{
+ Protocol: &newProtocol,
+ Description: &newDescription,
+ DestinationIPAddress: &newDestinationIP,
+ DestinationPort: &newDestintionPort,
+ Name: &newName,
+ Action: &newAction,
+ Enabled: gophercloud.Disabled,
+ }
+
+ _, err := rules.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 := rules.Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..79654be
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/urls.go
@@ -0,0 +1,16 @@
+package rules
+
+import "github.com/gophercloud/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..8393087
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
@@ -0,0 +1,146 @@
+package floatingips
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+ RouterID string `q:"router_id"`
+}
+
+// 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}}
+ })
+}
+
+// CreateOptsBuilder is the interface type must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+ ToFloatingIPCreateMap() (map[string]interface{}, error)
+}
+
+// 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 `json:"floating_network_id" required:"true"`
+ 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"`
+}
+
+// ToFloatingIPCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToFloatingIPCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "floatingip")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToFloatingIPCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular floating IP resource based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder is the interface type must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+ ToFloatingIPUpdateMap() (map[string]interface{}, error)
+}
+
+// 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 `json:"port_id"`
+}
+
+// ToFloatingIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder
+// interface
+func (opts UpdateOpts) ToFloatingIPUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "floatingip")
+}
+
+// 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 UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToFloatingIPUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// 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) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..29d5b56
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/results.go
@@ -0,0 +1,110 @@
+package floatingips
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+
+ // UUID of the external network where the floating IP is to be created.
+ FloatingNetworkID string `json:"floating_network_id"`
+
+ // Address of the floating IP on the external network.
+ FloatingIP string `json:"floating_ip_address"`
+
+ // UUID of the port on an internal network that is associated with the floating IP.
+ PortID string `json:"port_id"`
+
+ // The specific IP address of the internal port which should be associated
+ // with the floating IP.
+ FixedIP string `json:"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"`
+
+ // The condition of the API resource.
+ Status string `json:"status"`
+
+ //The ID of the router used for this Floating-IP
+ RouterID string `json:"router_id"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract a result and extracts a FloatingIP resource.
+func (r commonResult) Extract() (*FloatingIP, error) {
+ var s struct {
+ FloatingIP *FloatingIP `json:"floatingip"`
+ }
+ err := r.ExtractInto(&s)
+ return s.FloatingIP, 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 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 (r FloatingIPPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"floatingips_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (r FloatingIPPage) IsEmpty() (bool, error) {
+ is, err := ExtractFloatingIPs(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]FloatingIP, error) {
+ var s struct {
+ FloatingIPs []FloatingIP `json:"floatingips"`
+ }
+ err := (r.(FloatingIPPage)).ExtractInto(&s)
+ return s.FloatingIPs, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/testing/doc.go b/openstack/networking/v2/extensions/layer3/floatingips/testing/doc.go
new file mode 100644
index 0000000..aa13338
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_layer3_floatingips_v2
+package testing
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go
new file mode 100644
index 0000000..c665a2e
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go
@@ -0,0 +1,363 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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",
+ "router_id": "1117c30a-ddb4-49a1-bec3-a65b286b4170"
+ },
+ {
+ "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",
+ "router_id": "2227c30a-ddb4-49a1-bec3-a65b286b4170"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ floatingips.List(fake.ServiceClient(), floatingips.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := floatingips.ExtractFloatingIPs(page)
+ if err != nil {
+ t.Errorf("Failed to extract floating IPs: %v", err)
+ return false, err
+ }
+
+ expected := []floatingips.FloatingIP{
+ {
+ FloatingNetworkID: "6d67c30a-ddb4-49a1-bec3-a65b286b4170",
+ FixedIP: "",
+ FloatingIP: "192.0.0.4",
+ TenantID: "017d8de156df4177889f31a9bd6edc00",
+ Status: "DOWN",
+ PortID: "",
+ ID: "2f95fd2b-9f6a-4e8e-9e9a-2cbe286cbf9e",
+ RouterID: "1117c30a-ddb4-49a1-bec3-a65b286b4170",
+ },
+ {
+ 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",
+ RouterID: "2227c30a-ddb4-49a1-bec3-a65b286b4170",
+ },
+ }
+
+ 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": {}}`)
+ })
+
+ floatingips.List(fake.ServiceClient(), floatingips.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ floatingips.ExtractFloatingIPs(page)
+ return true, nil
+ })
+}
+
+func TestRequiredFieldsForCreate(t *testing.T) {
+ res1 := floatingips.Create(fake.ServiceClient(), floatingips.CreateOpts{FloatingNetworkID: ""})
+ if res1.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res2 := floatingips.Create(fake.ServiceClient(), floatingips.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 := floatingips.CreateOpts{
+ FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+ PortID: "ce705c24-c1ef-408a-bda3-7bbd946164ab",
+ }
+
+ ip, err := floatingips.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 := floatingips.CreateOpts{
+ FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+ }
+
+ ip, err := floatingips.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",
+ "router_id": "1117c30a-ddb4-49a1-bec3-a65b286b4170"
+ }
+}
+ `)
+ })
+
+ ip, err := floatingips.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)
+ th.AssertEquals(t, "1117c30a-ddb4-49a1-bec3-a65b286b4170", ip.RouterID)
+}
+
+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"
+ }
+}
+ `)
+ })
+
+ portID := "423abc8d-2991-4a55-ba98-2aaea84cc72e"
+ ip, err := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{PortID: &portID}).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, portID, 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 := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{PortID: nil}).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 := floatingips.Delete(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7")
+ th.AssertNoErr(t, res.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..1318a18
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/urls.go
@@ -0,0 +1,13 @@
+package floatingips
+
+import "github.com/gophercloud/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..71b2f62
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/requests.go
@@ -0,0 +1,223 @@
+package routers
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+ Distributed *bool `q:"distributed"`
+ 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}}
+ })
+}
+
+// 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 {
+ ToRouterCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new router. There are
+// no required values.
+type CreateOpts struct {
+ Name string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Distributed *bool `json:"distributed,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"`
+}
+
+func (opts CreateOpts) ToRouterCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "router")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToRouterCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular router based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+type UpdateOptsBuilder interface {
+ ToRouterUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a router.
+type UpdateOpts struct {
+ Name string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Distributed *bool `json:"distributed,omitempty"`
+ GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"`
+ Routes []Route `json:"routes"`
+}
+
+func (opts UpdateOpts) ToRouterUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "router")
+}
+
+// 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 UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToRouterUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular router based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+// AddInterfaceOptsBuilder is what types must satisfy to be used as AddInterface
+// options.
+type AddInterfaceOptsBuilder interface {
+ ToRouterAddInterfaceMap() (map[string]interface{}, error)
+}
+
+// AddInterfaceOpts allow you to work with operations that either add
+// an internal interface from a router.
+type AddInterfaceOpts struct {
+ SubnetID string `json:"subnet_id,omitempty" xor:"PortID"`
+ PortID string `json:"port_id,omitempty" xor:"SubnetID"`
+}
+
+// ToRouterAddInterfaceMap allows InterfaceOpts to satisfy the InterfaceOptsBuilder
+// interface
+func (opts AddInterfaceOpts) ToRouterAddInterfaceMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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 AddInterfaceOptsBuilder) (r InterfaceResult) {
+ b, err := opts.ToRouterAddInterfaceMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(addInterfaceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// RemoveInterfaceOptsBuilder is what types must satisfy to be used as RemoveInterface
+// options.
+type RemoveInterfaceOptsBuilder interface {
+ ToRouterRemoveInterfaceMap() (map[string]interface{}, error)
+}
+
+// RemoveInterfaceOpts allow you to work with operations that either add or remote
+// an internal interface from a router.
+type RemoveInterfaceOpts struct {
+ SubnetID string `json:"subnet_id,omitempty" or:"PortID"`
+ PortID string `json:"port_id,omitempty" or:"SubnetID"`
+}
+
+// ToRouterRemoveInterfaceMap allows RemoveInterfaceOpts to satisfy the RemoveInterfaceOptsBuilder
+// interface
+func (opts RemoveInterfaceOpts) ToRouterRemoveInterfaceMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// 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 RemoveInterfaceOptsBuilder) (r InterfaceResult) {
+ b, err := opts.ToRouterRemoveInterfaceMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(removeInterfaceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
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..d849d45
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/results.go
@@ -0,0 +1,152 @@
+package routers
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// GatewayInfo represents the information of an external gateway for any
+// particular network router.
+type GatewayInfo struct {
+ NetworkID string `json:"network_id"`
+}
+
+// Route is a possible route in a router.
+type Route struct {
+ NextHop string `json:"nexthop"`
+ DestinationCIDR string `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"`
+
+ // Information on external gateway for the router.
+ GatewayInfo GatewayInfo `json:"external_gateway_info"`
+
+ // Administrative state of the router.
+ AdminStateUp bool `json:"admin_state_up"`
+
+ // Whether router is disitrubted or not..
+ Distributed bool `json:"distributed"`
+
+ // Human readable name for the router. Does not have to be unique.
+ Name string `json:"name"`
+
+ // Unique identifier for the router.
+ ID string `json:"id"`
+
+ // Owner of the router. Only admin users can specify a tenant identifier
+ // other than its own.
+ TenantID string `json:"tenant_id"`
+
+ Routes []Route `json:"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 (r RouterPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"routers_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a RouterPage struct is empty.
+func (r RouterPage) IsEmpty() (bool, error) {
+ is, err := ExtractRouters(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Router, error) {
+ var s struct {
+ Routers []Router `json:"routers"`
+ }
+ err := (r.(RouterPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Router *Router `json:"router"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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"`
+
+ // The ID of the port that is a part of the subnet.
+ PortID string `json:"port_id"`
+
+ // The UUID of the interface.
+ ID string `json:"id"`
+
+ // Owner of the interface.
+ TenantID string `json:"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) {
+ var s InterfaceInfo
+ err := r.ExtractInto(&s)
+ return &s, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/testing/doc.go b/openstack/networking/v2/extensions/layer3/routers/testing/doc.go
new file mode 100644
index 0000000..ef44c2e
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_layer3_routers_v2
+package testing
diff --git a/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go
new file mode 100644
index 0000000..bf7f35e
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go
@@ -0,0 +1,407 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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",
+ "distributed": false,
+ "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",
+ "distributed": false,
+ "id": "a9254bdb-2613-4a13-ac4c-adc581fba50d"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ routers.List(fake.ServiceClient(), routers.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := routers.ExtractRouters(page)
+ if err != nil {
+ t.Errorf("Failed to extract routers: %v", err)
+ return false, err
+ }
+
+ expected := []routers.Router{
+ {
+ Status: "ACTIVE",
+ GatewayInfo: routers.GatewayInfo{NetworkID: ""},
+ AdminStateUp: true,
+ Distributed: false,
+ Name: "second_routers",
+ ID: "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b",
+ TenantID: "6b96ff0cb17a4b859e1e575d221683d3",
+ },
+ {
+ Status: "ACTIVE",
+ GatewayInfo: routers.GatewayInfo{NetworkID: "3c5bcddd-6af9-4e6b-9c3e-c153e521cab8"},
+ AdminStateUp: true,
+ Distributed: false,
+ 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",
+ "distributed": false,
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e"
+ }
+}
+ `)
+ })
+
+ asu := false
+ gwi := routers.GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+
+ options := routers.CreateOpts{
+ Name: "foo_router",
+ AdminStateUp: &asu,
+ GatewayInfo: &gwi,
+ }
+ r, err := routers.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, routers.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",
+ "distributed": false,
+ "id": "a07eea83-7710-4860-931b-5fe220fae533"
+ }
+}
+ `)
+ })
+
+ n, err := routers.Get(fake.ServiceClient(), "a07eea83-7710-4860-931b-5fe220fae533").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Status, "ACTIVE")
+ th.AssertDeepEquals(t, n.GatewayInfo, routers.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, []routers.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",
+ "distributed": false,
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+ "routes": [
+ {
+ "nexthop": "10.1.0.10",
+ "destination": "40.0.1.0/24"
+ }
+ ]
+ }
+}
+ `)
+ })
+
+ gwi := routers.GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+ r := []routers.Route{{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}}
+ options := routers.UpdateOpts{Name: "new_name", GatewayInfo: &gwi, Routes: r}
+
+ n, err := routers.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, routers.GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"})
+ th.AssertDeepEquals(t, n.Routes, []routers.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",
+ "distributed": false,
+ "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+ "routes": []
+ }
+}
+ `)
+ })
+
+ r := []routers.Route{}
+ options := routers.UpdateOpts{Routes: r}
+
+ n, err := routers.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, n.Routes, []routers.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 := routers.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 := routers.AddInterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+ res, err := routers.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 := routers.AddInterface(fake.ServiceClient(), "foo", routers.AddInterfaceOpts{}).Extract()
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ _, err = routers.AddInterface(fake.ServiceClient(), "foo", routers.AddInterfaceOpts{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 := routers.RemoveInterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+ res, err := routers.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/urls.go b/openstack/networking/v2/extensions/layer3/routers/urls.go
new file mode 100644
index 0000000..f9e9da3
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/urls.go
@@ -0,0 +1,21 @@
+package routers
+
+import "github.com/gophercloud/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..7e7b768
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/requests.go
@@ -0,0 +1,115 @@
+package members
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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}}
+ })
+}
+
+type CreateOptsBuilder interface {
+ ToLBMemberCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new pool member.
+type CreateOpts struct {
+ // The IP address of the member.
+ Address string `json:"address" required:"true"`
+ // The port on which the application is hosted.
+ ProtocolPort int `json:"protocol_port" required:"true"`
+ // The pool to which this member will belong.
+ PoolID string `json:"pool_id" required:"true"`
+ // Only required if the caller has an admin role and wants to create a pool
+ // for another tenant.
+ TenantID string `json:"tenant_id,omitempty"`
+}
+
+func (opts CreateOpts) ToLBMemberCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// load balancer pool member.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToLBMemberCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular pool member based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+type UpdateOptsBuilder interface {
+ ToLBMemberUpdateMap() (map[string]interface{}, error)
+}
+
+// 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 `json:"admin_state_up,omitempty"`
+}
+
+func (opts UpdateOpts) ToLBMemberUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// Update allows members to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToLBMemberUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular member based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..933e1ae
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/results.go
@@ -0,0 +1,104 @@
+package members
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+
+ // Owner of the member. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // The pool to which the member belongs.
+ PoolID string `json:"pool_id"`
+
+ // The IP address of the member.
+ Address string
+
+ // The port on which the application is hosted.
+ ProtocolPort int `json:"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 (r MemberPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"members_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a MemberPage struct is empty.
+func (r MemberPage) IsEmpty() (bool, error) {
+ is, err := ExtractMembers(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Member, error) {
+ var s struct {
+ Members []Member `json:"members"`
+ }
+ err := (r.(MemberPage)).ExtractInto(&s)
+ return s.Members, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Member, error) {
+ var s struct {
+ Member *Member `json:"member"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/lbaas/members/testing/doc.go
new file mode 100644
index 0000000..3878904
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_members_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas/members/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas/members/testing/requests_test.go
new file mode 100644
index 0000000..3e4f1d4
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/testing/requests_test.go
@@ -0,0 +1,238 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/members"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ members.List(fake.ServiceClient(), members.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := members.ExtractMembers(page)
+ if err != nil {
+ t.Errorf("Failed to extract members: %v", err)
+ return false, err
+ }
+
+ expected := []members.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",
+ },
+ {
+ 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 := members.CreateOpts{
+ TenantID: "453105b9-1754-413f-aab1-55f1af620750",
+ Address: "192.0.2.14",
+ ProtocolPort: 8080,
+ PoolID: "foo",
+ }
+ _, err := members.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 := members.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 := members.UpdateOpts{AdminStateUp: gophercloud.Disabled}
+
+ _, err := members.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 := members.Delete(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853")
+ th.AssertNoErr(t, res.Err)
+}
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..e2248f8
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/urls.go
@@ -0,0 +1,16 @@
+package members
+
+import "github.com/gophercloud/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..f1b964b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/requests.go
@@ -0,0 +1,210 @@
+package monitors
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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}}
+ })
+}
+
+// MonitorType is the type for all the types of LB monitors
+type MonitorType string
+
+// Constants that represent approved monitoring types.
+const (
+ TypePING MonitorType = "PING"
+ TypeTCP MonitorType = "TCP"
+ TypeHTTP MonitorType = "HTTP"
+ TypeHTTPS MonitorType = "HTTPS"
+)
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+ ToLBMonitorCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new health monitor.
+type CreateOpts struct {
+ // 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 MonitorType `json:"type" required:"true"`
+ // Required. The time, in seconds, between sending probes to members.
+ Delay int `json:"delay" required:"true"`
+ // 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 `json:"timeout" required:"true"`
+ // Required. Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int `json:"max_retries" required:"true"`
+ // Required for HTTP(S) types. URI path that will be accessed if monitor type
+ // is HTTP or HTTPS.
+ URLPath string `json:"url_path,omitempty"`
+ // 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 `json:"http_method,omitempty"`
+ // 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 `json:"expected_codes,omitempty"`
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string `json:"tenant_id,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToLBMonitorCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToLBMonitorCreateMap() (map[string]interface{}, error) {
+ if opts.Type == TypeHTTP || opts.Type == TypeHTTPS {
+ if opts.URLPath == "" {
+ err := gophercloud.ErrMissingInput{}
+ err.Argument = "monitors.CreateOpts.URLPath"
+ return nil, err
+ }
+ if opts.ExpectedCodes == "" {
+ err := gophercloud.ErrMissingInput{}
+ err.Argument = "monitors.CreateOpts.ExpectedCodes"
+ return nil, err
+ }
+ }
+ if opts.Delay < opts.Timeout {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "monitors.CreateOpts.Delay/monitors.CreateOpts.Timeout"
+ err.Info = "Delay must be greater than or equal to timeout"
+ return nil, err
+ }
+ return gophercloud.BuildRequestBody(opts, "health_monitor")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToLBMonitorCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular health monitor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder is what types must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+ ToLBMonitorUpdateMap() (map[string]interface{}, error)
+}
+
+// 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 {
+ // The time, in seconds, between sending probes to members.
+ Delay int `json:"delay,omitempty"`
+ // 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 `json:"timeout,omitempty"`
+ // Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int `json:"max_retries,omitempty"`
+ // URI path that will be accessed if monitor type
+ // is HTTP or HTTPS.
+ URLPath string `json:"url_path,omitempty"`
+ // The HTTP method used for requests by the
+ // monitor. If this attribute is not specified, it defaults to "GET".
+ HTTPMethod string `json:"http_method,omitempty"`
+ // 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 `json:"expected_codes,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToLBMonitorUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder
+// interface
+func (opts UpdateOpts) ToLBMonitorUpdateMap() (map[string]interface{}, error) {
+ if opts.Delay > 0 && opts.Timeout > 0 && opts.Delay < opts.Timeout {
+ err := gophercloud.ErrInvalidInput{}
+ err.Argument = "monitors.CreateOpts.Delay/monitors.CreateOpts.Timeout"
+ err.Value = fmt.Sprintf("%d/%d", opts.Delay, opts.Timeout)
+ err.Info = "Delay must be greater than or equal to timeout"
+ return nil, err
+ }
+ return gophercloud.BuildRequestBody(opts, "health_monitor")
+}
+
+// Update is an operation which modifies the attributes of the specified monitor.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToLBMonitorUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular monitor based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..0385942
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/results.go
@@ -0,0 +1,136 @@
+package monitors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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
+
+ // Monitor name. Does not have to be unique.
+ Name string
+
+ // Owner of the VIP. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"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"`
+
+ // The HTTP method that the monitor uses for requests.
+ HTTPMethod string `json:"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"`
+
+ // Expected HTTP codes for a passing HTTP(S) monitor.
+ ExpectedCodes string `json:"expected_codes"`
+
+ // The administrative state of the health monitor, which is up (true) or down (false).
+ AdminStateUp bool `json:"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 (r MonitorPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"health_monitors_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (r MonitorPage) IsEmpty() (bool, error) {
+ is, err := ExtractMonitors(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Monitor, error) {
+ var s struct {
+ Monitors []Monitor `json:"health_monitors"`
+ }
+ err := (r.(MonitorPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Monitor *Monitor `json:"health_monitor"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/lbaas/monitors/testing/doc.go
new file mode 100644
index 0000000..5ee866b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_monitors_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas/monitors/testing/requests_test.go
new file mode 100644
index 0000000..f736074
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/testing/requests_test.go
@@ -0,0 +1,310 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ monitors.List(fake.ServiceClient(), monitors.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := monitors.ExtractMonitors(page)
+ if err != nil {
+ t.Errorf("Failed to extract monitors: %v", err)
+ return false, err
+ }
+
+ expected := []monitors.Monitor{
+ {
+ AdminStateUp: true,
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 10,
+ MaxRetries: 1,
+ Timeout: 1,
+ Type: "PING",
+ ID: "466c8345-28d8-4f84-a246-e04380b0461d",
+ },
+ {
+ 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 := monitors.Create(fake.ServiceClient(), monitors.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 = monitors.Update(fake.ServiceClient(), "453105b9-1754-413f-aab1-55f1af620750", monitors.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 := monitors.Create(fake.ServiceClient(), monitors.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 := monitors.Create(fake.ServiceClient(), monitors.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = monitors.Create(fake.ServiceClient(), monitors.CreateOpts{Type: monitors.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 := monitors.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": 30,
+ "timeout": 20,
+ "max_retries": 10,
+ "url_path": "/another_check",
+ "expected_codes": "301",
+ "admin_state_up": true
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+{
+ "health_monitor": {
+ "admin_state_up": true,
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "delay": 30,
+ "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 := monitors.Update(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7", monitors.UpdateOpts{
+ Delay: 30,
+ Timeout: 20,
+ MaxRetries: 10,
+ URLPath: "/another_check",
+ ExpectedCodes: "301",
+ AdminStateUp: gophercloud.Enabled,
+ }).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 := monitors.Delete(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7")
+ th.AssertNoErr(t, res.Err)
+}
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..e9d90fc
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/urls.go
@@ -0,0 +1,16 @@
+package monitors
+
+import "github.com/gophercloud/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..2a75737
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/requests.go
@@ -0,0 +1,170 @@
+package pools
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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}}
+ })
+}
+
+// LBMethod is a type used for possible load balancing methods
+type LBMethod string
+
+// LBProtocol is a type used for possible load balancing protocols
+type LBProtocol string
+
+// Supported attributes for create/update operations.
+const (
+ LBMethodRoundRobin LBMethod = "ROUND_ROBIN"
+ LBMethodLeastConnections LBMethod = "LEAST_CONNECTIONS"
+
+ ProtocolTCP LBProtocol = "TCP"
+ ProtocolHTTP LBProtocol = "HTTP"
+ ProtocolHTTPS LBProtocol = "HTTPS"
+)
+
+// CreateOptsBuilder is the interface types must satisfy to be used as options
+// for the Create function
+type CreateOptsBuilder interface {
+ ToLBPoolCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new pool.
+type CreateOpts struct {
+ // Name of the pool.
+ Name string `json:"name" required:"true"`
+ // The protocol used by the pool members, you can use either
+ // ProtocolTCP, ProtocolHTTP, or ProtocolHTTPS.
+ Protocol LBProtocol `json:"protocol" required:"true"`
+ // Only required if the caller has an admin role and wants to create a pool
+ // for another tenant.
+ TenantID string `json:"tenant_id,omitempty"`
+ // 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,omitempty"`
+ // 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 LBMethod `json:"lb_method" required:"true"`
+
+ // The provider of the pool
+ Provider string `json:"provider,omitempty"`
+}
+
+// ToLBPoolCreateMap allows CreateOpts to satisfy the CreateOptsBuilder interface
+func (opts CreateOpts) ToLBPoolCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Create accepts a CreateOptsBuilder and uses the values to create a new
+// load balancer pool.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToLBPoolCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular pool based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder is the interface types must satisfy to be used as options
+// for the Update function
+type UpdateOptsBuilder interface {
+ ToLBPoolUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a pool.
+type UpdateOpts struct {
+ // Name of the pool.
+ Name string `json:"name,omitempty"`
+ // 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 LBMethod `json:"lb_method,omitempty"`
+}
+
+// ToLBPoolUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder interface
+func (opts UpdateOpts) ToLBPoolUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Update allows pools to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToLBPoolUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular pool based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+// 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) (r AssociateResult) {
+ b := map[string]interface{}{"health_monitor": map[string]string{"id": monitorID}}
+ _, r.Err = c.Post(associateURL(c, poolID), b, &r.Body, nil)
+ return
+}
+
+// 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) (r AssociateResult) {
+ _, r.Err = c.Delete(disassociateURL(c, poolID, monitorID), nil)
+ return
+}
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..2ca1963
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/results.go
@@ -0,0 +1,131 @@
+package pools
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+
+ // 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"`
+
+ // 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"`
+
+ // Owner of the pool. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // The administrative state of the pool, which is up (true) or down (false).
+ AdminStateUp bool `json:"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"`
+
+ // The unique ID for the pool.
+ ID string
+
+ // The ID of the virtual IP associated with this pool
+ VIPID string `json:"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 (r PoolPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"pools_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (r PoolPage) IsEmpty() (bool, error) {
+ is, err := ExtractPools(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Pool, error) {
+ var s struct {
+ Pools []Pool `json:"pools"`
+ }
+ err := (r.(PoolPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Pool *Pool `json:"pool"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/lbaas/pools/testing/doc.go
new file mode 100644
index 0000000..415dd2c
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_pools_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas/pools/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas/pools/testing/requests_test.go
new file mode 100644
index 0000000..de038cb
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/testing/requests_test.go
@@ -0,0 +1,316 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ pools.List(fake.ServiceClient(), pools.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := pools.ExtractPools(page)
+ if err != nil {
+ t.Errorf("Failed to extract pools: %v", err)
+ return false, err
+ }
+
+ expected := []pools.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",
+ "provider": "haproxy"
+ }
+}
+ `)
+
+ 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": [],
+ "provider": "haproxy"
+ }
+}
+ `)
+ })
+
+ options := pools.CreateOpts{
+ LBMethod: pools.LBMethodRoundRobin,
+ Protocol: "HTTP",
+ Name: "Example pool",
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ Provider: "haproxy",
+ }
+ p, err := pools.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)
+ th.AssertEquals(t, "haproxy", p.Provider)
+}
+
+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 := pools.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 := pools.UpdateOpts{Name: "SuperPool", LBMethod: pools.LBMethodLeastConnections}
+
+ n, err := pools.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 := pools.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 := pools.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 := pools.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/urls.go b/openstack/networking/v2/extensions/lbaas/pools/urls.go
new file mode 100644
index 0000000..fe3601b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/urls.go
@@ -0,0 +1,25 @@
+package pools
+
+import "github.com/gophercloud/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..f89d769
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/requests.go
@@ -0,0 +1,163 @@
+package vips
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+ 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}}
+ })
+}
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+ ToVIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new virtual IP.
+type CreateOpts struct {
+ // Human-readable name for the VIP. Does not have to be unique.
+ Name string `json:"name" required:"true"`
+ // 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 `json:"subnet_id" required:"true"`
+ // The protocol - can either be TCP, HTTP or HTTPS.
+ Protocol string `json:"protocol" required:"true"`
+ // The port on which to listen for client traffic.
+ ProtocolPort int `json:"protocol_port" required:"true"`
+ // The ID of the pool with which the VIP is associated.
+ PoolID string `json:"pool_id" required:"true"`
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string `json:"tenant_id,omitempty"`
+ // The IP address of the VIP.
+ Address string `json:"address,omitempty"`
+ // Human-readable description for the VIP.
+ Description string `json:"description,omitempty"`
+ // Omit this field to prevent session persistence.
+ Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+ // The maximum number of connections allowed for the VIP.
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ // The administrative state of the VIP. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToVIPCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToVIPCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "vip")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToVIPCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular virtual IP based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder is what types must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+ ToVIPUpdateMap() (map[string]interface{}, error)
+}
+
+// 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 `json:"name,omitempty"`
+ // The ID of the pool with which the VIP is associated.
+ PoolID *string `json:"pool_id,omitempty"`
+ // Human-readable description for the VIP.
+ Description *string `json:"description,omitempty"`
+ // Omit this field to prevent session persistence.
+ Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+ // The maximum number of connections allowed for the VIP.
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ // The administrative state of the VIP. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToVIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder interface
+func (opts UpdateOpts) ToVIPUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "vip")
+}
+
+// Update is an operation which modifies the attributes of the specified VIP.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToVIPUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular virtual IP based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..7ac7e79
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/results.go
@@ -0,0 +1,151 @@
+package vips
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"type"`
+
+ // Name of cookie if persistence mode is set appropriately
+ CookieName string `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 `json:"id"`
+
+ // Owner of the VIP. Only an admin user can specify a tenant ID other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // Human-readable name for the VIP. Does not have to be unique.
+ Name string `json:"name"`
+
+ // Human-readable description for the VIP.
+ Description string `json:"description"`
+
+ // The ID of the subnet on which to allocate the VIP address.
+ SubnetID string `json:"subnet_id"`
+
+ // The IP address of the VIP.
+ Address string `json:"address"`
+
+ // The protocol of the VIP address. A valid value is TCP, HTTP, or HTTPS.
+ Protocol string `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 `json:"protocol_port"`
+
+ // The ID of the pool with which the VIP is associated.
+ PoolID string `json:"pool_id"`
+
+ // The ID of the port which belongs to the load balancer
+ PortID string `json:"port_id"`
+
+ // Indicates whether connections in the same session will be processed by the
+ // same pool member or not.
+ Persistence SessionPersistence `json:"session_persistence"`
+
+ // The maximum number of connections allowed for the VIP. Default is -1,
+ // meaning no limit.
+ ConnLimit int `json:"connection_limit"`
+
+ // The administrative state of the VIP. A valid value is true (UP) or false (DOWN).
+ AdminStateUp bool `json:"admin_state_up"`
+
+ // The status of the VIP. Indicates whether the VIP is operational.
+ Status string `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 (r VIPPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"vips_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a VIPPage struct is empty.
+func (r VIPPage) IsEmpty() (bool, error) {
+ is, err := ExtractVIPs(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]VirtualIP, error) {
+ var s struct {
+ VIPs []VirtualIP `json:"vips"`
+ }
+ err := (r.(VIPPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ VirtualIP *VirtualIP `json:"vip" json:"vip"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/lbaas/vips/testing/doc.go
new file mode 100644
index 0000000..8e91e78
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_vips_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas/vips/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas/vips/testing/requests_test.go
new file mode 100644
index 0000000..7f9b6dd
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/testing/requests_test.go
@@ -0,0 +1,330 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/vips"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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
+
+ vips.List(fake.ServiceClient(), vips.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := vips.ExtractVIPs(page)
+ if err != nil {
+ t.Errorf("Failed to extract LBs: %v", err)
+ return false, err
+ }
+
+ expected := []vips.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: vips.SessionPersistence{},
+ ConnLimit: 0,
+ AdminStateUp: true,
+ Status: "ACTIVE",
+ },
+ {
+ 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: vips.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 := vips.CreateOpts{
+ Protocol: "HTTP",
+ Name: "NewVip",
+ AdminStateUp: gophercloud.Enabled,
+ SubnetID: "8032909d-47a1-4715-90af-5153ffe39861",
+ PoolID: "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+ ProtocolPort: 80,
+ Persistence: &vips.SessionPersistence{Type: "SOURCE_IP"},
+ }
+
+ r, err := vips.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 := vips.Create(fake.ServiceClient(), vips.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = vips.Create(fake.ServiceClient(), vips.CreateOpts{Name: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = vips.Create(fake.ServiceClient(), vips.CreateOpts{Name: "foo", SubnetID: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = vips.Create(fake.ServiceClient(), vips.CreateOpts{Name: "foo", SubnetID: "bar", Protocol: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = vips.Create(fake.ServiceClient(), vips.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 := vips.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, vips.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 := vips.UpdateOpts{
+ ConnLimit: &i1000,
+ Persistence: &vips.SessionPersistence{Type: "SOURCE_IP"},
+ }
+ vip, err := vips.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 := vips.Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..584a1cf
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/urls.go
@@ -0,0 +1,16 @@
+package vips
+
+import "github.com/gophercloud/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/lbaas_v2/doc.go b/openstack/networking/v2/extensions/lbaas_v2/doc.go
new file mode 100644
index 0000000..247a75f
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/doc.go
@@ -0,0 +1,5 @@
+// Package lbaas_v2 provides information and interaction with the Load Balancer
+// as a Service v2 extension for the OpenStack Networking service.
+// lbaas v2 api docs: http://developer.openstack.org/api-ref-networking-v2-ext.html#lbaas-v2.0
+// lbaas v2 api schema: https://github.com/openstack/neutron-lbaas/blob/master/neutron_lbaas/extensions/loadbalancerv2.py
+package lbaas_v2
diff --git a/openstack/networking/v2/extensions/lbaas_v2/listeners/requests.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/requests.go
new file mode 100644
index 0000000..4a78447
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/requests.go
@@ -0,0 +1,182 @@
+package listeners
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type Protocol string
+
+// Supported attributes for create/update operations.
+const (
+ ProtocolTCP Protocol = "TCP"
+ ProtocolHTTP Protocol = "HTTP"
+ ProtocolHTTPS Protocol = "HTTPS"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToListenerListQuery() (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 floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular listener 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"`
+ TenantID string `q:"tenant_id"`
+ LoadbalancerID string `q:"loadbalancer_id"`
+ DefaultPoolID string `q:"default_pool_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"`
+}
+
+// ToListenerListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToListenerListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// 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 ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+ if opts != nil {
+ query, err := opts.ToListenerListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return ListenerPage{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 {
+ ToListenerCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // The load balancer on which to provision this listener.
+ LoadbalancerID string `json:"loadbalancer_id" required:"true"`
+ // The protocol - can either be TCP, HTTP or HTTPS.
+ Protocol Protocol `json:"protocol" required:"true"`
+ // The port on which to listen for client traffic.
+ ProtocolPort int `json:"protocol_port" required:"true"`
+ // Indicates the owner of the Listener. Required for admins.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Human-readable name for the Listener. Does not have to be unique.
+ Name string `json:"name,omitempty"`
+ // The ID of the default pool with which the Listener is associated.
+ DefaultPoolID string `json:"default_pool_id,omitempty"`
+ // Human-readable description for the Listener.
+ Description string `json:"description,omitempty"`
+ // The maximum number of connections allowed for the Listener.
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ // A reference to a container of TLS secrets.
+ DefaultTlsContainerRef string `json:"default_tls_container_ref,omitempty"`
+ // A list of references to TLS secrets.
+ SniContainerRefs []string `json:"sni_container_refs,omitempty"`
+ // The administrative state of the Listener. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToListenerCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToListenerCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "listener")
+}
+
+// Create is an operation which provisions a new Listeners 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.
+//
+// Users with an admin role can create Listeners on behalf of other tenants by
+// specifying a TenantID attribute different than their own.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToListenerCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular Listeners based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ ToListenerUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ // Human-readable name for the Listener. Does not have to be unique.
+ Name string `json:"name,omitempty"`
+ // Human-readable description for the Listener.
+ Description string `json:"description,omitempty"`
+ // The maximum number of connections allowed for the Listener.
+ ConnLimit *int `json:"connection_limit,omitempty"`
+ // A reference to a container of TLS secrets.
+ DefaultTlsContainerRef string `json:"default_tls_container_ref,omitempty"`
+ // A list of references to TLS secrets.
+ SniContainerRefs []string `json:"sni_container_refs,omitempty"`
+ // The administrative state of the Listener. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToListenerUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToListenerUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "listener")
+}
+
+// Update is an operation which modifies the attributes of the specified Listener.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) (r UpdateResult) {
+ b, err := opts.ToListenerUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular Listeners based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/listeners/results.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/results.go
new file mode 100644
index 0000000..aa8ed1b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/results.go
@@ -0,0 +1,114 @@
+package listeners
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type LoadBalancerID struct {
+ ID string `json:"id"`
+}
+
+// Listener is the primary load balancing configuration object that specifies
+// the loadbalancer and port on which client traffic is received, as well
+// as other details such as the load balancing method to be use, protocol, etc.
+type Listener struct {
+ // The unique ID for the Listener.
+ ID string `json:"id"`
+ // Owner of the Listener. Only an admin user can specify a tenant ID other than its own.
+ TenantID string `json:"tenant_id"`
+ // Human-readable name for the Listener. Does not have to be unique.
+ Name string `json:"name"`
+ // Human-readable description for the Listener.
+ Description string `json:"description"`
+ // The protocol to loadbalance. A valid value is TCP, HTTP, or HTTPS.
+ Protocol string `json:"protocol"`
+ // The port on which to listen to client traffic that is associated with the
+ // Loadbalancer. A valid value is from 0 to 65535.
+ ProtocolPort int `json:"protocol_port"`
+ // The UUID of default pool. Must have compatible protocol with listener.
+ DefaultPoolID string `json:"default_pool_id"`
+ // A list of load balancer IDs.
+ Loadbalancers []LoadBalancerID `json:"loadbalancers"`
+ // The maximum number of connections allowed for the Loadbalancer. Default is -1,
+ // meaning no limit.
+ ConnLimit int `json:"connection_limit"`
+ // The list of references to TLS secrets.
+ SniContainerRefs []string `json:"sni_container_refs"`
+ // Optional. A reference to a container of TLS secrets.
+ DefaultTlsContainerRef string `json:"default_tls_container_ref"`
+ // The administrative state of the Listener. A valid value is true (UP) or false (DOWN).
+ AdminStateUp bool `json:"admin_state_up"`
+ Pools []pools.Pool `json:"pools"`
+}
+
+// ListenerPage is the page returned by a pager when traversing over a
+// collection of routers.
+type ListenerPage 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 (r ListenerPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"listeners_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a RouterPage struct is empty.
+func (r ListenerPage) IsEmpty() (bool, error) {
+ is, err := ExtractListeners(r)
+ return len(is) == 0, err
+}
+
+// ExtractListeners accepts a Page struct, specifically a ListenerPage struct,
+// and extracts the elements into a slice of Listener structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractListeners(r pagination.Page) ([]Listener, error) {
+ var s struct {
+ Listeners []Listener `json:"listeners"`
+ }
+ err := (r.(ListenerPage)).ExtractInto(&s)
+ return s.Listeners, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Listener, error) {
+ var s struct {
+ Listener *Listener `json:"listener"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Listener, 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_v2/listeners/testing/doc.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/doc.go
new file mode 100644
index 0000000..c74a4de
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_v2_listeners_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/fixtures.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/fixtures.go
new file mode 100644
index 0000000..fa4fa25
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/fixtures.go
@@ -0,0 +1,213 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListenersListBody contains the canned body of a listeners list response.
+const ListenersListBody = `
+{
+ "listeners":[
+ {
+ "id": "db902c0c-d5ff-4753-b465-668ad9656918",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "web",
+ "description": "listener config for the web tier",
+ "loadbalancers": [{"id": "53306cda-815d-4354-9444-59e09da9c3c5"}],
+ "protocol": "HTTP",
+ "protocol_port": 80,
+ "default_pool_id": "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ "admin_state_up": true,
+ "default_tls_container_ref": "2c433435-20de-4411-84ae-9cc8917def76",
+ "sni_container_refs": ["3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"]
+ },
+ {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "db",
+ "description": "listener config for the db tier",
+ "loadbalancers": [{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "protocol": "TCP",
+ "protocol_port": 3306,
+ "default_pool_id": "41efe233-7591-43c5-9cf7-923964759f9e",
+ "connection_limit": 2000,
+ "admin_state_up": true,
+ "default_tls_container_ref": "2c433435-20de-4411-84ae-9cc8917def76",
+ "sni_container_refs": ["3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"]
+ }
+ ]
+}
+`
+
+// SingleServerBody is the canned body of a Get request on an existing listener.
+const SingleListenerBody = `
+{
+ "listener": {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "db",
+ "description": "listener config for the db tier",
+ "loadbalancers": [{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "protocol": "TCP",
+ "protocol_port": 3306,
+ "default_pool_id": "41efe233-7591-43c5-9cf7-923964759f9e",
+ "connection_limit": 2000,
+ "admin_state_up": true,
+ "default_tls_container_ref": "2c433435-20de-4411-84ae-9cc8917def76",
+ "sni_container_refs": ["3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"]
+ }
+}
+`
+
+// PostUpdateListenerBody is the canned response body of a Update request on an existing listener.
+const PostUpdateListenerBody = `
+{
+ "listener": {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+ "name": "NewListenerName",
+ "description": "listener config for the db tier",
+ "loadbalancers": [{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "protocol": "TCP",
+ "protocol_port": 3306,
+ "default_pool_id": "41efe233-7591-43c5-9cf7-923964759f9e",
+ "connection_limit": 1000,
+ "admin_state_up": true,
+ "default_tls_container_ref": "2c433435-20de-4411-84ae-9cc8917def76",
+ "sni_container_refs": ["3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"]
+ }
+}
+`
+
+var (
+ ListenerWeb = listeners.Listener{
+ ID: "db902c0c-d5ff-4753-b465-668ad9656918",
+ TenantID: "310df60f-2a10-4ee5-9554-98393092194c",
+ Name: "web",
+ Description: "listener config for the web tier",
+ Loadbalancers: []listeners.LoadBalancerID{{ID: "53306cda-815d-4354-9444-59e09da9c3c5"}},
+ Protocol: "HTTP",
+ ProtocolPort: 80,
+ DefaultPoolID: "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ AdminStateUp: true,
+ DefaultTlsContainerRef: "2c433435-20de-4411-84ae-9cc8917def76",
+ SniContainerRefs: []string{"3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"},
+ }
+ ListenerDb = listeners.Listener{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ TenantID: "310df60f-2a10-4ee5-9554-98393092194c",
+ Name: "db",
+ Description: "listener config for the db tier",
+ Loadbalancers: []listeners.LoadBalancerID{{ID: "79e05663-7f03-45d2-a092-8b94062f22ab"}},
+ Protocol: "TCP",
+ ProtocolPort: 3306,
+ DefaultPoolID: "41efe233-7591-43c5-9cf7-923964759f9e",
+ ConnLimit: 2000,
+ AdminStateUp: true,
+ DefaultTlsContainerRef: "2c433435-20de-4411-84ae-9cc8917def76",
+ SniContainerRefs: []string{"3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"},
+ }
+ ListenerUpdated = listeners.Listener{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ TenantID: "310df60f-2a10-4ee5-9554-98393092194c",
+ Name: "NewListenerName",
+ Description: "listener config for the db tier",
+ Loadbalancers: []listeners.LoadBalancerID{{ID: "79e05663-7f03-45d2-a092-8b94062f22ab"}},
+ Protocol: "TCP",
+ ProtocolPort: 3306,
+ DefaultPoolID: "41efe233-7591-43c5-9cf7-923964759f9e",
+ ConnLimit: 1000,
+ AdminStateUp: true,
+ DefaultTlsContainerRef: "2c433435-20de-4411-84ae-9cc8917def76",
+ SniContainerRefs: []string{"3d328d82-2547-4921-ac2f-61c3b452b5ff", "b3cfd7e3-8c19-455c-8ebb-d78dfd8f7e7d"},
+ }
+)
+
+// HandleListenerListSuccessfully sets up the test server to respond to a listener List request.
+func HandleListenerListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/listeners", 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, ListenersListBody)
+ case "45e08a3e-a78f-4b40-a229-1e7e23eee1ab":
+ fmt.Fprintf(w, `{ "listeners": [] }`)
+ default:
+ t.Fatalf("/v2.0/lbaas/listeners invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandleListenerCreationSuccessfully sets up the test server to respond to a listener creation request
+// with a given response.
+func HandleListenerCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/v2.0/lbaas/listeners", 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, `{
+ "listener": {
+ "loadbalancer_id": "79e05663-7f03-45d2-a092-8b94062f22ab",
+ "protocol": "TCP",
+ "name": "db",
+ "admin_state_up": true,
+ "default_tls_container_ref": "2c433435-20de-4411-84ae-9cc8917def76",
+ "default_pool_id": "41efe233-7591-43c5-9cf7-923964759f9e",
+ "protocol_port": 3306
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleListenerGetSuccessfully sets up the test server to respond to a listener Get request.
+func HandleListenerGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/listeners/4ec89087-d057-4e2c-911f-60a3b47ee304", 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, SingleListenerBody)
+ })
+}
+
+// HandleListenerDeletionSuccessfully sets up the test server to respond to a listener deletion request.
+func HandleListenerDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/listeners/4ec89087-d057-4e2c-911f-60a3b47ee304", 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)
+ })
+}
+
+// HandleListenerUpdateSuccessfully sets up the test server to respond to a listener Update request.
+func HandleListenerUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/listeners/4ec89087-d057-4e2c-911f-60a3b47ee304", 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, `{
+ "listener": {
+ "name": "NewListenerName",
+ "connection_limit": 1001
+ }
+ }`)
+
+ fmt.Fprintf(w, PostUpdateListenerBody)
+ })
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/requests_test.go
new file mode 100644
index 0000000..d463f6e
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/testing/requests_test.go
@@ -0,0 +1,137 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListListeners(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerListSuccessfully(t)
+
+ pages := 0
+ err := listeners.List(fake.ServiceClient(), listeners.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := listeners.ExtractListeners(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 listeners, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, ListenerWeb, actual[0])
+ th.CheckDeepEquals(t, ListenerDb, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllListeners(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerListSuccessfully(t)
+
+ allPages, err := listeners.List(fake.ServiceClient(), listeners.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := listeners.ExtractListeners(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ListenerWeb, actual[0])
+ th.CheckDeepEquals(t, ListenerDb, actual[1])
+}
+
+func TestCreateListener(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerCreationSuccessfully(t, SingleListenerBody)
+
+ actual, err := listeners.Create(fake.ServiceClient(), listeners.CreateOpts{
+ Protocol: "TCP",
+ Name: "db",
+ LoadbalancerID: "79e05663-7f03-45d2-a092-8b94062f22ab",
+ AdminStateUp: gophercloud.Enabled,
+ DefaultTlsContainerRef: "2c433435-20de-4411-84ae-9cc8917def76",
+ DefaultPoolID: "41efe233-7591-43c5-9cf7-923964759f9e",
+ ProtocolPort: 3306,
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, ListenerDb, *actual)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := listeners.Create(fake.ServiceClient(), listeners.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = listeners.Create(fake.ServiceClient(), listeners.CreateOpts{Name: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = listeners.Create(fake.ServiceClient(), listeners.CreateOpts{Name: "foo", TenantID: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = listeners.Create(fake.ServiceClient(), listeners.CreateOpts{Name: "foo", TenantID: "bar", Protocol: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = listeners.Create(fake.ServiceClient(), listeners.CreateOpts{Name: "foo", TenantID: "bar", Protocol: "bar", ProtocolPort: 80})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGetListener(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerGetSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := listeners.Get(client, "4ec89087-d057-4e2c-911f-60a3b47ee304").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, ListenerDb, *actual)
+}
+
+func TestDeleteListener(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerDeletionSuccessfully(t)
+
+ res := listeners.Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateListener(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListenerUpdateSuccessfully(t)
+
+ client := fake.ServiceClient()
+ i1001 := 1001
+ actual, err := listeners.Update(client, "4ec89087-d057-4e2c-911f-60a3b47ee304", listeners.UpdateOpts{
+ Name: "NewListenerName",
+ ConnLimit: &i1001,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, ListenerUpdated, *actual)
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/listeners/urls.go b/openstack/networking/v2/extensions/lbaas_v2/listeners/urls.go
new file mode 100644
index 0000000..02fb1eb
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/listeners/urls.go
@@ -0,0 +1,16 @@
+package listeners
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+ rootPath = "lbaas"
+ resourcePath = "listeners"
+)
+
+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_v2/loadbalancers/requests.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/requests.go
new file mode 100644
index 0000000..bc4a3c6
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/requests.go
@@ -0,0 +1,172 @@
+package loadbalancers
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToLoadBalancerListQuery() (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 Loadbalancer attributes you want to see returned. SortKey allows you to
+// sort by a particular attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ Description string `q:"description"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ TenantID string `q:"tenant_id"`
+ ProvisioningStatus string `q:"provisioning_status"`
+ VipAddress string `q:"vip_address"`
+ VipPortID string `q:"vip_port_id"`
+ VipSubnetID string `q:"vip_subnet_id"`
+ ID string `q:"id"`
+ OperatingStatus string `q:"operating_status"`
+ Name string `q:"name"`
+ Flavor string `q:"flavor"`
+ Provider string `q:"provider"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToLoadbalancerListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToLoadBalancerListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// 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 ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+ if opts != nil {
+ query, err := opts.ToLoadBalancerListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return LoadBalancerPage{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 {
+ ToLoadBalancerCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Optional. Human-readable name for the Loadbalancer. Does not have to be unique.
+ Name string `json:"name,omitempty"`
+ // Optional. Human-readable description for the Loadbalancer.
+ Description string `json:"description,omitempty"`
+ // Required. The network on which to allocate the Loadbalancer's address. A tenant can
+ // only create Loadbalancers on networks authorized by policy (e.g. networks that
+ // belong to them or networks that are shared).
+ VipSubnetID string `json:"vip_subnet_id" required:"true"`
+ // Required for admins. The UUID of the tenant who owns the Loadbalancer.
+ // Only administrative users can specify a tenant UUID other than their own.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Optional. The IP address of the Loadbalancer.
+ VipAddress string `json:"vip_address,omitempty"`
+ // Optional. The administrative state of the Loadbalancer. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ // Optional. The UUID of a flavor.
+ Flavor string `json:"flavor,omitempty"`
+ // Optional. The name of the provider.
+ Provider string `json:"provider,omitempty"`
+}
+
+// ToLoadBalancerCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToLoadBalancerCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "loadbalancer")
+}
+
+// Create is an operation which provisions a new loadbalancer 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.
+//
+// Users with an admin role can create loadbalancers on behalf of other tenants by
+// specifying a TenantID attribute different than their own.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToLoadBalancerCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular Loadbalancer based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ ToLoadBalancerUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ // Optional. Human-readable name for the Loadbalancer. Does not have to be unique.
+ Name string `json:"name,omitempty"`
+ // Optional. Human-readable description for the Loadbalancer.
+ Description string `json:"description,omitempty"`
+ // Optional. The administrative state of the Loadbalancer. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToLoadBalancerUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToLoadBalancerUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "loadbalancer")
+}
+
+// Update is an operation which modifies the attributes of the specified LoadBalancer.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOpts) (r UpdateResult) {
+ b, err := opts.ToLoadBalancerUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular LoadBalancer based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+func GetStatuses(c *gophercloud.ServiceClient, id string) (r GetStatusesResult) {
+ _, r.Err = c.Get(statusRootURL(c, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/results.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/results.go
new file mode 100644
index 0000000..4423c24
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/results.go
@@ -0,0 +1,125 @@
+package loadbalancers
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// LoadBalancer is the primary load balancing configuration object that specifies
+// the virtual IP address on which client traffic is received, as well
+// as other details such as the load balancing method to be use, protocol, etc.
+type LoadBalancer struct {
+ // Human-readable description for the Loadbalancer.
+ Description string `json:"description"`
+ // The administrative state of the Loadbalancer. A valid value is true (UP) or false (DOWN).
+ AdminStateUp bool `json:"admin_state_up"`
+ // Owner of the LoadBalancer. Only an admin user can specify a tenant ID other than its own.
+ TenantID string `json:"tenant_id"`
+ // The provisioning status of the LoadBalancer. This value is ACTIVE, PENDING_CREATE or ERROR.
+ ProvisioningStatus string `json:"provisioning_status"`
+ // The IP address of the Loadbalancer.
+ VipAddress string `json:"vip_address"`
+ // The UUID of the port associated with the IP address.
+ VipPortID string `json:"vip_port_id"`
+ // The UUID of the subnet on which to allocate the virtual IP for the Loadbalancer address.
+ VipSubnetID string `json:"vip_subnet_id"`
+ // The unique ID for the LoadBalancer.
+ ID string `json:"id"`
+ // The operating status of the LoadBalancer. This value is ONLINE or OFFLINE.
+ OperatingStatus string `json:"operating_status"`
+ // Human-readable name for the LoadBalancer. Does not have to be unique.
+ Name string `json:"name"`
+ // The UUID of a flavor if set.
+ Flavor string `json:"flavor"`
+ // The name of the provider.
+ Provider string `json:"provider"`
+ Listeners []listeners.Listener `json:"listeners"`
+}
+
+type StatusTree struct {
+ Loadbalancer *LoadBalancer `json:"loadbalancer"`
+}
+
+// LoadBalancerPage is the page returned by a pager when traversing over a
+// collection of routers.
+type LoadBalancerPage 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 (r LoadBalancerPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"loadbalancers_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a LoadBalancerPage struct is empty.
+func (p LoadBalancerPage) IsEmpty() (bool, error) {
+ is, err := ExtractLoadBalancers(p)
+ return len(is) == 0, err
+}
+
+// ExtractLoadBalancers accepts a Page struct, specifically a LoadbalancerPage struct,
+// and extracts the elements into a slice of LoadBalancer structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractLoadBalancers(r pagination.Page) ([]LoadBalancer, error) {
+ var s struct {
+ LoadBalancers []LoadBalancer `json:"loadbalancers"`
+ }
+ err := (r.(LoadBalancerPage)).ExtractInto(&s)
+ return s.LoadBalancers, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*LoadBalancer, error) {
+ var s struct {
+ LoadBalancer *LoadBalancer `json:"loadbalancer"`
+ }
+ err := r.ExtractInto(&s)
+ return s.LoadBalancer, err
+}
+
+type GetStatusesResult struct {
+ gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a Loadbalancer.
+func (r GetStatusesResult) Extract() (*StatusTree, error) {
+ var s struct {
+ Statuses *StatusTree `json:"statuses"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Statuses, 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_v2/loadbalancers/testing/doc.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/doc.go
new file mode 100644
index 0000000..b06352e
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_v2_loadbalancers_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/fixtures.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/fixtures.go
new file mode 100644
index 0000000..a452236
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/fixtures.go
@@ -0,0 +1,284 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/listeners"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/loadbalancers"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+)
+
+// LoadbalancersListBody contains the canned body of a loadbalancer list response.
+const LoadbalancersListBody = `
+{
+ "loadbalancers":[
+ {
+ "id": "c331058c-6a40-4144-948e-b9fb1df9db4b",
+ "tenant_id": "54030507-44f7-473c-9342-b4d14a95f692",
+ "name": "web_lb",
+ "description": "lb config for the web tier",
+ "vip_subnet_id": "8a49c438-848f-467b-9655-ea1548708154",
+ "vip_address": "10.30.176.47",
+ "vip_port_id": "2a22e552-a347-44fd-b530-1f2b1b2a6735",
+ "flavor": "small",
+ "provider": "haproxy",
+ "admin_state_up": true,
+ "provisioning_status": "ACTIVE",
+ "operating_status": "ONLINE"
+ },
+ {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "54030507-44f7-473c-9342-b4d14a95f692",
+ "name": "db_lb",
+ "description": "lb config for the db tier",
+ "vip_subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ "vip_address": "10.30.176.48",
+ "vip_port_id": "2bf413c8-41a9-4477-b505-333d5cbe8b55",
+ "flavor": "medium",
+ "provider": "haproxy",
+ "admin_state_up": true,
+ "provisioning_status": "PENDING_CREATE",
+ "operating_status": "OFFLINE"
+ }
+ ]
+}
+`
+
+// SingleLoadbalancerBody is the canned body of a Get request on an existing loadbalancer.
+const SingleLoadbalancerBody = `
+{
+ "loadbalancer": {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "54030507-44f7-473c-9342-b4d14a95f692",
+ "name": "db_lb",
+ "description": "lb config for the db tier",
+ "vip_subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ "vip_address": "10.30.176.48",
+ "vip_port_id": "2bf413c8-41a9-4477-b505-333d5cbe8b55",
+ "flavor": "medium",
+ "provider": "haproxy",
+ "admin_state_up": true,
+ "provisioning_status": "PENDING_CREATE",
+ "operating_status": "OFFLINE"
+ }
+}
+`
+
+// PostUpdateLoadbalancerBody is the canned response body of a Update request on an existing loadbalancer.
+const PostUpdateLoadbalancerBody = `
+{
+ "loadbalancer": {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "tenant_id": "54030507-44f7-473c-9342-b4d14a95f692",
+ "name": "NewLoadbalancerName",
+ "description": "lb config for the db tier",
+ "vip_subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ "vip_address": "10.30.176.48",
+ "vip_port_id": "2bf413c8-41a9-4477-b505-333d5cbe8b55",
+ "flavor": "medium",
+ "provider": "haproxy",
+ "admin_state_up": true,
+ "provisioning_status": "PENDING_CREATE",
+ "operating_status": "OFFLINE"
+ }
+}
+`
+
+// SingleLoadbalancerBody is the canned body of a Get request on an existing loadbalancer.
+const LoadbalancerStatuesesTree = `
+{
+ "statuses" : {
+ "loadbalancer": {
+ "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ "name": "db_lb",
+ "provisioning_status": "PENDING_UPDATE",
+ "operating_status": "ACTIVE",
+ "listeners": [{
+ "id": "db902c0c-d5ff-4753-b465-668ad9656918",
+ "name": "db",
+ "pools": [{
+ "id": "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ "name": "db",
+ "healthmonitor": {
+ "id": "67306cda-815d-4354-9fe4-59e09da9c3c5",
+ "type":"PING"
+ },
+ "members":[{
+ "id": "2a280670-c202-4b0b-a562-34077415aabf",
+ "name": "db",
+ "address": "10.0.2.11",
+ "protocol_port": 80
+ }]
+ }]
+ }]
+ }
+ }
+}
+`
+
+var (
+ LoadbalancerWeb = loadbalancers.LoadBalancer{
+ ID: "c331058c-6a40-4144-948e-b9fb1df9db4b",
+ TenantID: "54030507-44f7-473c-9342-b4d14a95f692",
+ Name: "web_lb",
+ Description: "lb config for the web tier",
+ VipSubnetID: "8a49c438-848f-467b-9655-ea1548708154",
+ VipAddress: "10.30.176.47",
+ VipPortID: "2a22e552-a347-44fd-b530-1f2b1b2a6735",
+ Flavor: "small",
+ Provider: "haproxy",
+ AdminStateUp: true,
+ ProvisioningStatus: "ACTIVE",
+ OperatingStatus: "ONLINE",
+ }
+ LoadbalancerDb = loadbalancers.LoadBalancer{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ TenantID: "54030507-44f7-473c-9342-b4d14a95f692",
+ Name: "db_lb",
+ Description: "lb config for the db tier",
+ VipSubnetID: "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ VipAddress: "10.30.176.48",
+ VipPortID: "2bf413c8-41a9-4477-b505-333d5cbe8b55",
+ Flavor: "medium",
+ Provider: "haproxy",
+ AdminStateUp: true,
+ ProvisioningStatus: "PENDING_CREATE",
+ OperatingStatus: "OFFLINE",
+ }
+ LoadbalancerUpdated = loadbalancers.LoadBalancer{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ TenantID: "54030507-44f7-473c-9342-b4d14a95f692",
+ Name: "NewLoadbalancerName",
+ Description: "lb config for the db tier",
+ VipSubnetID: "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ VipAddress: "10.30.176.48",
+ VipPortID: "2bf413c8-41a9-4477-b505-333d5cbe8b55",
+ Flavor: "medium",
+ Provider: "haproxy",
+ AdminStateUp: true,
+ ProvisioningStatus: "PENDING_CREATE",
+ OperatingStatus: "OFFLINE",
+ }
+ LoadbalancerStatusesTree = loadbalancers.LoadBalancer{
+ ID: "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+ Name: "db_lb",
+ ProvisioningStatus: "PENDING_UPDATE",
+ OperatingStatus: "ACTIVE",
+ Listeners: []listeners.Listener{{
+ ID: "db902c0c-d5ff-4753-b465-668ad9656918",
+ Name: "db",
+ Pools: []pools.Pool{{
+ ID: "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ Name: "db",
+ Monitor: monitors.Monitor{
+ ID: "67306cda-815d-4354-9fe4-59e09da9c3c5",
+ Type: "PING",
+ },
+ Members: []pools.Member{{
+ ID: "2a280670-c202-4b0b-a562-34077415aabf",
+ Name: "db",
+ Address: "10.0.2.11",
+ ProtocolPort: 80,
+ }},
+ }},
+ }},
+ }
+)
+
+// HandleLoadbalancerListSuccessfully sets up the test server to respond to a loadbalancer List request.
+func HandleLoadbalancerListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers", 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, LoadbalancersListBody)
+ case "45e08a3e-a78f-4b40-a229-1e7e23eee1ab":
+ fmt.Fprintf(w, `{ "loadbalancers": [] }`)
+ default:
+ t.Fatalf("/v2.0/lbaas/loadbalancers invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandleLoadbalancerCreationSuccessfully sets up the test server to respond to a loadbalancer creation request
+// with a given response.
+func HandleLoadbalancerCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers", 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, `{
+ "loadbalancer": {
+ "name": "db_lb",
+ "vip_subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ "vip_address": "10.30.176.48",
+ "flavor": "medium",
+ "provider": "haproxy",
+ "admin_state_up": true
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleLoadbalancerGetSuccessfully sets up the test server to respond to a loadbalancer Get request.
+func HandleLoadbalancerGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers/36e08a3e-a78f-4b40-a229-1e7e23eee1ab", 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, SingleLoadbalancerBody)
+ })
+}
+
+// HandleLoadbalancerGetStatusesTree sets up the test server to respond to a loadbalancer Get statuses tree request.
+func HandleLoadbalancerGetStatusesTree(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers/36e08a3e-a78f-4b40-a229-1e7e23eee1ab/statuses", 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, LoadbalancerStatuesesTree)
+ })
+}
+
+// HandleLoadbalancerDeletionSuccessfully sets up the test server to respond to a loadbalancer deletion request.
+func HandleLoadbalancerDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers/36e08a3e-a78f-4b40-a229-1e7e23eee1ab", 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)
+ })
+}
+
+// HandleLoadbalancerUpdateSuccessfully sets up the test server to respond to a loadbalancer Update request.
+func HandleLoadbalancerUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/loadbalancers/36e08a3e-a78f-4b40-a229-1e7e23eee1ab", 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, `{
+ "loadbalancer": {
+ "name": "NewLoadbalancerName"
+ }
+ }`)
+
+ fmt.Fprintf(w, PostUpdateLoadbalancerBody)
+ })
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/requests_test.go
new file mode 100644
index 0000000..270bdf5
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/testing/requests_test.go
@@ -0,0 +1,144 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/loadbalancers"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListLoadbalancers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerListSuccessfully(t)
+
+ pages := 0
+ err := loadbalancers.List(fake.ServiceClient(), loadbalancers.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := loadbalancers.ExtractLoadBalancers(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 loadbalancers, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, LoadbalancerWeb, actual[0])
+ th.CheckDeepEquals(t, LoadbalancerDb, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllLoadbalancers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerListSuccessfully(t)
+
+ allPages, err := loadbalancers.List(fake.ServiceClient(), loadbalancers.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := loadbalancers.ExtractLoadBalancers(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, LoadbalancerWeb, actual[0])
+ th.CheckDeepEquals(t, LoadbalancerDb, actual[1])
+}
+
+func TestCreateLoadbalancer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerCreationSuccessfully(t, SingleLoadbalancerBody)
+
+ actual, err := loadbalancers.Create(fake.ServiceClient(), loadbalancers.CreateOpts{
+ Name: "db_lb",
+ AdminStateUp: gophercloud.Enabled,
+ VipSubnetID: "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+ VipAddress: "10.30.176.48",
+ Flavor: "medium",
+ Provider: "haproxy",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, LoadbalancerDb, *actual)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := loadbalancers.Create(fake.ServiceClient(), loadbalancers.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = loadbalancers.Create(fake.ServiceClient(), loadbalancers.CreateOpts{Name: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = loadbalancers.Create(fake.ServiceClient(), loadbalancers.CreateOpts{Name: "foo", Description: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = loadbalancers.Create(fake.ServiceClient(), loadbalancers.CreateOpts{Name: "foo", Description: "bar", VipAddress: "bar"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGetLoadbalancer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerGetSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := loadbalancers.Get(client, "36e08a3e-a78f-4b40-a229-1e7e23eee1ab").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, LoadbalancerDb, *actual)
+}
+
+func TestGetLoadbalancerStatusesTree(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerGetStatusesTree(t)
+
+ client := fake.ServiceClient()
+ actual, err := loadbalancers.GetStatuses(client, "36e08a3e-a78f-4b40-a229-1e7e23eee1ab").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, LoadbalancerStatusesTree, *(actual.Loadbalancer))
+}
+
+func TestDeleteLoadbalancer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerDeletionSuccessfully(t)
+
+ res := loadbalancers.Delete(fake.ServiceClient(), "36e08a3e-a78f-4b40-a229-1e7e23eee1ab")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateLoadbalancer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleLoadbalancerUpdateSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := loadbalancers.Update(client, "36e08a3e-a78f-4b40-a229-1e7e23eee1ab", loadbalancers.UpdateOpts{
+ Name: "NewLoadbalancerName",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, LoadbalancerUpdated, *actual)
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/urls.go b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/urls.go
new file mode 100644
index 0000000..73cf5dc
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/loadbalancers/urls.go
@@ -0,0 +1,21 @@
+package loadbalancers
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+ rootPath = "lbaas"
+ resourcePath = "loadbalancers"
+ statusPath = "statuses"
+)
+
+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 statusRootURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(rootPath, resourcePath, id, statusPath)
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/monitors/requests.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/requests.go
new file mode 100644
index 0000000..1e776bf
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/requests.go
@@ -0,0 +1,233 @@
+package monitors
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToMonitorListQuery() (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 Monitor attributes you want to see returned. SortKey allows you to
+// sort by a particular Monitor 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"`
+ PoolID string `q:"pool_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"`
+}
+
+// ToMonitorListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToMonitorListQuery() (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
+// health monitors. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those health monitors 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.ToMonitorListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, 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 (
+ errDelayMustGETimeout = fmt.Errorf("Delay must be greater than or equal to timeout")
+)
+
+// 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 {
+ ToMonitorCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // Required. The Pool to Monitor.
+ PoolID string `json:"pool_id" required:"true"`
+ // 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 `json:"type" required:"true"`
+ // Required. The time, in seconds, between sending probes to members.
+ Delay int `json:"delay" required:"true"`
+ // 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 `json:"timeout" required:"true"`
+ // Required. Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int `json:"max_retries" required:"true"`
+ // Required for HTTP(S) types. URI path that will be accessed if Monitor type
+ // is HTTP or HTTPS.
+ URLPath string `json:"url_path,omitempty"`
+ // 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 `json:"http_method,omitempty"`
+ // 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 `json:"expected_codes,omitempty"`
+ // Indicates the owner of the Loadbalancer. Required for admins.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Optional. The Name of the Monitor.
+ Name string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToMonitorCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToMonitorCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "healthmonitor")
+ if err != nil {
+ return nil, err
+ }
+
+ switch opts.Type {
+ case TypeHTTP, TypeHTTPS:
+ switch opts.URLPath {
+ case "":
+ return nil, fmt.Errorf("URLPath must be provided for HTTP and HTTPS")
+ }
+ switch opts.ExpectedCodes {
+ case "":
+ return nil, fmt.Errorf("ExpectedCodes must be provided for HTTP and HTTPS")
+ }
+ }
+
+ return b, nil
+}
+
+/*
+ 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", PoolID: "2c946bfc-1804-43ab-a2ff-58f6a762b505"}
+*/
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToMonitorCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular Health Monitor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ ToMonitorUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ // Required. The time, in seconds, between sending probes to members.
+ Delay int `json:"delay,omitempty"`
+ // 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 `json:"timeout,omitempty"`
+ // Required. Number of permissible ping failures before changing the member's
+ // status to INACTIVE. Must be a number between 1 and 10.
+ MaxRetries int `json:"max_retries,omitempty"`
+ // Required for HTTP(S) types. URI path that will be accessed if Monitor type
+ // is HTTP or HTTPS.
+ URLPath string `json:"url_path,omitempty"`
+ // 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 `json:"http_method,omitempty"`
+ // 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 `json:"expected_codes,omitempty"`
+ // Optional. The Name of the Monitor.
+ Name string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToMonitorUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToMonitorUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "healthmonitor")
+}
+
+// Update is an operation which modifies the attributes of the specified Monitor.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToMonitorUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will permanently delete a particular Monitor based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/monitors/results.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/results.go
new file mode 100644
index 0000000..05dcf47
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/results.go
@@ -0,0 +1,144 @@
+package monitors
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type PoolID struct {
+ ID string `json:"id"`
+}
+
+// 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 Monitor.
+ ID string `json:"id"`
+
+ // The Name of the Monitor.
+ Name string `json:"name"`
+
+ // Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"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 `json:"type"`
+
+ // The time, in seconds, between sending probes to members.
+ Delay int `json:"delay"`
+
+ // 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 `json:"timeout"`
+
+ // 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"`
+
+ // The HTTP method that the monitor uses for requests.
+ HTTPMethod string `json:"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" `
+
+ // Expected HTTP codes for a passing HTTP(S) monitor.
+ ExpectedCodes string `json:"expected_codes"`
+
+ // The administrative state of the health monitor, which is up (true) or down (false).
+ AdminStateUp bool `json:"admin_state_up"`
+
+ // The status of the health monitor. Indicates whether the health monitor is
+ // operational.
+ Status string `json:"status"`
+
+ // List of pools that are associated with the health monitor.
+ Pools []PoolID `json:"pools"`
+}
+
+// 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 (r MonitorPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"healthmonitors_links"`
+ }
+
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a MonitorPage struct is empty.
+func (r MonitorPage) IsEmpty() (bool, error) {
+ is, err := ExtractMonitors(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Monitor, error) {
+ var s struct {
+ Monitors []Monitor `json:"healthmonitors"`
+ }
+ err := (r.(MonitorPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Monitor *Monitor `json:"healthmonitor"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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_v2/monitors/testing/doc.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/doc.go
new file mode 100644
index 0000000..443f9ad
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_v2_monitors_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/fixtures.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/fixtures.go
new file mode 100644
index 0000000..6d3eb01
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/fixtures.go
@@ -0,0 +1,215 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HealthmonitorsListBody contains the canned body of a healthmonitor list response.
+const HealthmonitorsListBody = `
+{
+ "healthmonitors":[
+ {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":10,
+ "name":"web",
+ "max_retries":1,
+ "timeout":1,
+ "type":"PING",
+ "pools": [{"id": "84f1b61f-58c4-45bf-a8a9-2dafb9e5214d"}],
+ "id":"466c8345-28d8-4f84-a246-e04380b0461d"
+ },
+ {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":5,
+ "name":"db",
+ "expected_codes":"200",
+ "max_retries":2,
+ "http_method":"GET",
+ "timeout":2,
+ "url_path":"/",
+ "type":"HTTP",
+ "pools": [{"id": "d459f7d8-c6ee-439d-8713-d3fc08aeed8d"}],
+ "id":"5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+ }
+ ]
+}
+`
+
+// SingleHealthmonitorBody is the canned body of a Get request on an existing healthmonitor.
+const SingleHealthmonitorBody = `
+{
+ "healthmonitor": {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":5,
+ "name":"db",
+ "expected_codes":"200",
+ "max_retries":2,
+ "http_method":"GET",
+ "timeout":2,
+ "url_path":"/",
+ "type":"HTTP",
+ "pools": [{"id": "d459f7d8-c6ee-439d-8713-d3fc08aeed8d"}],
+ "id":"5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+ }
+}
+`
+
+// PostUpdateHealthmonitorBody is the canned response body of a Update request on an existing healthmonitor.
+const PostUpdateHealthmonitorBody = `
+{
+ "healthmonitor": {
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "delay":3,
+ "name":"NewHealthmonitorName",
+ "expected_codes":"301",
+ "max_retries":10,
+ "http_method":"GET",
+ "timeout":20,
+ "url_path":"/another_check",
+ "type":"HTTP",
+ "pools": [{"id": "d459f7d8-c6ee-439d-8713-d3fc08aeed8d"}],
+ "id":"5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+ }
+}
+`
+
+var (
+ HealthmonitorWeb = monitors.Monitor{
+ AdminStateUp: true,
+ Name: "web",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 10,
+ MaxRetries: 1,
+ Timeout: 1,
+ Type: "PING",
+ ID: "466c8345-28d8-4f84-a246-e04380b0461d",
+ Pools: []monitors.PoolID{{ID: "84f1b61f-58c4-45bf-a8a9-2dafb9e5214d"}},
+ }
+ HealthmonitorDb = monitors.Monitor{
+ AdminStateUp: true,
+ Name: "db",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 5,
+ ExpectedCodes: "200",
+ MaxRetries: 2,
+ Timeout: 2,
+ URLPath: "/",
+ Type: "HTTP",
+ HTTPMethod: "GET",
+ ID: "5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+ Pools: []monitors.PoolID{{ID: "d459f7d8-c6ee-439d-8713-d3fc08aeed8d"}},
+ }
+ HealthmonitorUpdated = monitors.Monitor{
+ AdminStateUp: true,
+ Name: "NewHealthmonitorName",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ Delay: 3,
+ ExpectedCodes: "301",
+ MaxRetries: 10,
+ Timeout: 20,
+ URLPath: "/another_check",
+ Type: "HTTP",
+ HTTPMethod: "GET",
+ ID: "5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+ Pools: []monitors.PoolID{{ID: "d459f7d8-c6ee-439d-8713-d3fc08aeed8d"}},
+ }
+)
+
+// HandleHealthmonitorListSuccessfully sets up the test server to respond to a healthmonitor List request.
+func HandleHealthmonitorListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/healthmonitors", 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, HealthmonitorsListBody)
+ case "556c8345-28d8-4f84-a246-e04380b0461d":
+ fmt.Fprintf(w, `{ "healthmonitors": [] }`)
+ default:
+ t.Fatalf("/v2.0/lbaas/healthmonitors invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandleHealthmonitorCreationSuccessfully sets up the test server to respond to a healthmonitor creation request
+// with a given response.
+func HandleHealthmonitorCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/v2.0/lbaas/healthmonitors", 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, `{
+ "healthmonitor": {
+ "type":"HTTP",
+ "pool_id":"84f1b61f-58c4-45bf-a8a9-2dafb9e5214d",
+ "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+ "delay":20,
+ "name":"db",
+ "timeout":10,
+ "max_retries":5,
+ "url_path":"/check",
+ "expected_codes":"200-299"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleHealthmonitorGetSuccessfully sets up the test server to respond to a healthmonitor Get request.
+func HandleHealthmonitorGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/healthmonitors/5d4b5228-33b0-4e60-b225-9b727c1a20e7", 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, SingleHealthmonitorBody)
+ })
+}
+
+// HandleHealthmonitorDeletionSuccessfully sets up the test server to respond to a healthmonitor deletion request.
+func HandleHealthmonitorDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/healthmonitors/5d4b5228-33b0-4e60-b225-9b727c1a20e7", 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)
+ })
+}
+
+// HandleHealthmonitorUpdateSuccessfully sets up the test server to respond to a healthmonitor Update request.
+func HandleHealthmonitorUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/healthmonitors/5d4b5228-33b0-4e60-b225-9b727c1a20e7", 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, `{
+ "healthmonitor": {
+ "name": "NewHealthmonitorName",
+ "delay": 3,
+ "timeout": 20,
+ "max_retries": 10,
+ "url_path": "/another_check",
+ "expected_codes": "301"
+ }
+ }`)
+
+ fmt.Fprintf(w, PostUpdateHealthmonitorBody)
+ })
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/requests_test.go
new file mode 100644
index 0000000..743d9c1
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/testing/requests_test.go
@@ -0,0 +1,154 @@
+package testing
+
+import (
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListHealthmonitors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorListSuccessfully(t)
+
+ pages := 0
+ err := monitors.List(fake.ServiceClient(), monitors.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := monitors.ExtractMonitors(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 healthmonitors, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, HealthmonitorWeb, actual[0])
+ th.CheckDeepEquals(t, HealthmonitorDb, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllHealthmonitors(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorListSuccessfully(t)
+
+ allPages, err := monitors.List(fake.ServiceClient(), monitors.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := monitors.ExtractMonitors(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, HealthmonitorWeb, actual[0])
+ th.CheckDeepEquals(t, HealthmonitorDb, actual[1])
+}
+
+func TestCreateHealthmonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorCreationSuccessfully(t, SingleHealthmonitorBody)
+
+ actual, err := monitors.Create(fake.ServiceClient(), monitors.CreateOpts{
+ Type: "HTTP",
+ Name: "db",
+ PoolID: "84f1b61f-58c4-45bf-a8a9-2dafb9e5214d",
+ TenantID: "453105b9-1754-413f-aab1-55f1af620750",
+ Delay: 20,
+ Timeout: 10,
+ MaxRetries: 5,
+ URLPath: "/check",
+ ExpectedCodes: "200-299",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, HealthmonitorDb, *actual)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := monitors.Create(fake.ServiceClient(), monitors.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = monitors.Create(fake.ServiceClient(), monitors.CreateOpts{Type: monitors.TypeHTTP})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestGetHealthmonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorGetSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := monitors.Get(client, "5d4b5228-33b0-4e60-b225-9b727c1a20e7").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, HealthmonitorDb, *actual)
+}
+
+func TestDeleteHealthmonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorDeletionSuccessfully(t)
+
+ res := monitors.Delete(fake.ServiceClient(), "5d4b5228-33b0-4e60-b225-9b727c1a20e7")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateHealthmonitor(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleHealthmonitorUpdateSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := monitors.Update(client, "5d4b5228-33b0-4e60-b225-9b727c1a20e7", monitors.UpdateOpts{
+ Name: "NewHealthmonitorName",
+ Delay: 3,
+ Timeout: 20,
+ MaxRetries: 10,
+ URLPath: "/another_check",
+ ExpectedCodes: "301",
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, HealthmonitorUpdated, *actual)
+}
+
+func TestDelayMustBeGreaterOrEqualThanTimeout(t *testing.T) {
+ _, err := monitors.Create(fake.ServiceClient(), monitors.CreateOpts{
+ Type: "HTTP",
+ PoolID: "d459f7d8-c6ee-439d-8713-d3fc08aeed8d",
+ Delay: 1,
+ Timeout: 10,
+ MaxRetries: 5,
+ URLPath: "/check",
+ ExpectedCodes: "200-299",
+ }).Extract()
+
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ _, err = monitors.Update(fake.ServiceClient(), "453105b9-1754-413f-aab1-55f1af620750", monitors.UpdateOpts{
+ Delay: 1,
+ Timeout: 10,
+ }).Extract()
+
+ if err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/monitors/urls.go b/openstack/networking/v2/extensions/lbaas_v2/monitors/urls.go
new file mode 100644
index 0000000..a222e52
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/monitors/urls.go
@@ -0,0 +1,16 @@
+package monitors
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+ rootPath = "lbaas"
+ resourcePath = "healthmonitors"
+)
+
+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_v2/pools/requests.go b/openstack/networking/v2/extensions/lbaas_v2/pools/requests.go
new file mode 100644
index 0000000..093df6a
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/requests.go
@@ -0,0 +1,334 @@
+package pools
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToPoolListQuery() (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 Pool attributes you want to see returned. SortKey allows you to
+// sort by a particular Pool attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+ LBMethod string `q:"lb_algorithm"`
+ Protocol string `q:"protocol"`
+ TenantID string `q:"tenant_id"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ Name string `q:"name"`
+ ID string `q:"id"`
+ LoadbalancerID string `q:"loadbalancer_id"`
+ ListenerID string `q:"listener_id"`
+ Limit int `q:"limit"`
+ Marker string `q:"marker"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+}
+
+// ToPoolListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPoolListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// 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 ListOptsBuilder) pagination.Pager {
+ url := rootURL(c)
+ if opts != nil {
+ query, err := opts.ToPoolListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return PoolPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+type LBMethod string
+type Protocol string
+
+// Supported attributes for create/update operations.
+const (
+ LBMethodRoundRobin LBMethod = "ROUND_ROBIN"
+ LBMethodLeastConnections LBMethod = "LEAST_CONNECTIONS"
+ LBMethodSourceIp LBMethod = "SOURCE_IP"
+
+ ProtocolTCP Protocol = "TCP"
+ ProtocolHTTP Protocol = "HTTP"
+ ProtocolHTTPS Protocol = "HTTPS"
+)
+
+// 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 {
+ ToPoolCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+ // The algorithm used to distribute load between the members of the pool. The
+ // current specification supports LBMethodRoundRobin, LBMethodLeastConnections
+ // and LBMethodSourceIp as valid values for this attribute.
+ LBMethod LBMethod `json:"lb_algorithm" required:"true"`
+ // The protocol used by the pool members, you can use either
+ // ProtocolTCP, ProtocolHTTP, or ProtocolHTTPS.
+ Protocol Protocol `json:"protocol" required:"true"`
+ // The Loadbalancer on which the members of the pool will be associated with.
+ // Note: one of LoadbalancerID or ListenerID must be provided.
+ LoadbalancerID string `json:"loadbalancer_id,omitempty" xor:"ListenerID"`
+ // The Listener on which the members of the pool will be associated with.
+ // Note: one of LoadbalancerID or ListenerID must be provided.
+ ListenerID string `json:"listener_id,omitempty" xor:"LoadbalancerID"`
+ // Only required if the caller has an admin role and wants to create a pool
+ // for another tenant.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Name of the pool.
+ Name string `json:"name,omitempty"`
+ // Human-readable description for the pool.
+ Description string `json:"description,omitempty"`
+ // Omit this field to prevent session persistence.
+ Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+ // The administrative state of the Pool. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToPoolCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPoolCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// load balancer pool.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToPoolCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular pool based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ ToPoolUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+ // Name of the pool.
+ Name string `json:"name,omitempty"`
+ // Human-readable description for the pool.
+ Description string `json:"description,omitempty"`
+ // The algorithm used to distribute load between the members of the pool. The
+ // current specification supports LBMethodRoundRobin, LBMethodLeastConnections
+ // and LBMethodSourceIp as valid values for this attribute.
+ LBMethod LBMethod `json:"lb_algorithm,omitempty"`
+ // The administrative state of the Pool. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToPoolUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPoolUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Update allows pools to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToPoolUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will permanently delete a particular pool based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+// ListMemberOptsBuilder allows extensions to add additional parameters to the
+// ListMembers request.
+type ListMembersOptsBuilder interface {
+ ToMembersListQuery() (string, error)
+}
+
+// ListMembersOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the Member attributes you want to see returned. SortKey allows you to
+// sort by a particular Member attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListMembersOpts struct {
+ Name string `q:"name"`
+ Weight int `q:"weight"`
+ AdminStateUp *bool `q:"admin_state_up"`
+ TenantID string `q:"tenant_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"`
+}
+
+// ToMemberListQuery formats a ListOpts into a query string.
+func (opts ListMembersOpts) ToMembersListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// ListMembers returns a Pager which allows you to iterate over a collection of
+// members. It accepts a ListMembersOptsBuilder, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those members that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func ListMembers(c *gophercloud.ServiceClient, poolID string, opts ListMembersOptsBuilder) pagination.Pager {
+ url := memberRootURL(c, poolID)
+ if opts != nil {
+ query, err := opts.ToMembersListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return MemberPage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateMemberOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the CreateMember 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 CreateMemberOptsBuilder interface {
+ ToMemberCreateMap() (map[string]interface{}, error)
+}
+
+// CreateMemberOpts is the common options struct used in this package's CreateMember
+// operation.
+type CreateMemberOpts struct {
+ // Required. The IP address of the member to receive traffic from the load balancer.
+ Address string `json:"address" required:"true"`
+ // Required. The port on which to listen for client traffic.
+ ProtocolPort int `json:"protocol_port" required:"true"`
+ // Optional. Name of the Member.
+ Name string `json:"name,omitempty"`
+ // Only required if the caller has an admin role and wants to create a Member
+ // for another tenant.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Optional. A positive integer value that indicates the relative portion of
+ // traffic that this member should receive from the pool. For example, a
+ // member with a weight of 10 receives five times as much traffic as a member
+ // with a weight of 2.
+ Weight int `json:"weight,omitempty"`
+ // Optional. If you omit this parameter, LBaaS uses the vip_subnet_id
+ // parameter value for the subnet UUID.
+ SubnetID string `json:"subnet_id,omitempty"`
+ // Optional. The administrative state of the Pool. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToMemberCreateMap casts a CreateOpts struct to a map.
+func (opts CreateMemberOpts) ToMemberCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// CreateMember will create and associate a Member with a particular Pool.
+func CreateMember(c *gophercloud.ServiceClient, poolID string, opts CreateMemberOpts) (r CreateMemberResult) {
+ b, err := opts.ToMemberCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(memberRootURL(c, poolID), b, &r.Body, nil)
+ return
+}
+
+// GetMember retrieves a particular Pool Member based on its unique ID.
+func GetMember(c *gophercloud.ServiceClient, poolID string, memberID string) (r GetMemberResult) {
+ _, r.Err = c.Get(memberResourceURL(c, poolID, memberID), &r.Body, nil)
+ return
+}
+
+// MemberUpdateOptsBuilder 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 UpdateMemberOptsBuilder interface {
+ ToMemberUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateMemberOpts is the common options struct used in this package's Update
+// operation.
+type UpdateMemberOpts struct {
+ // Optional. Name of the Member.
+ Name string `json:"name,omitempty"`
+ // Optional. A positive integer value that indicates the relative portion of
+ // traffic that this member should receive from the pool. For example, a
+ // member with a weight of 10 receives five times as much traffic as a member
+ // with a weight of 2.
+ Weight int `json:"weight,omitempty"`
+ // Optional. The administrative state of the Pool. A valid value is true (UP)
+ // or false (DOWN).
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToMemberUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateMemberOpts) ToMemberUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// Update allows Member to be updated.
+func UpdateMember(c *gophercloud.ServiceClient, poolID string, memberID string, opts UpdateMemberOptsBuilder) (r UpdateMemberResult) {
+ b, err := opts.ToMemberUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(memberResourceURL(c, poolID, memberID), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201, 202},
+ })
+ return
+}
+
+// DisassociateMember will remove and disassociate a Member from a particular Pool.
+func DeleteMember(c *gophercloud.ServiceClient, poolID string, memberID string) (r DeleteMemberResult) {
+ _, r.Err = c.Delete(memberResourceURL(c, poolID, memberID), nil)
+ return
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/pools/results.go b/openstack/networking/v2/extensions/lbaas_v2/pools/results.go
new file mode 100644
index 0000000..0e0bf36
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/results.go
@@ -0,0 +1,242 @@
+package pools
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/monitors"
+ "github.com/gophercloud/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 `json:"type"`
+
+ // Name of cookie if persistence mode is set appropriately
+ CookieName string `json:"cookie_name,omitempty"`
+}
+
+type LoadBalancerID struct {
+ ID string `json:"id"`
+}
+
+type ListenerID struct {
+ ID string `json:"id"`
+}
+
+// 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.
+type Pool struct {
+ // 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_algorithm"`
+ // The protocol of the Pool, which is TCP, HTTP, or HTTPS.
+ Protocol string `json:"protocol"`
+ // Description for the Pool.
+ Description string `json:"description"`
+ // A list of listeners objects IDs.
+ Listeners []ListenerID `json:"listeners"` //[]map[string]interface{}
+ // A list of member objects IDs.
+ Members []Member `json:"members"`
+ // The ID of associated health monitor.
+ MonitorID string `json:"healthmonitor_id"`
+ // 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"`
+ // Owner of the Pool. Only an administrative user can specify a tenant ID
+ // other than its own.
+ TenantID string `json:"tenant_id"`
+ // The administrative state of the Pool, which is up (true) or down (false).
+ AdminStateUp bool `json:"admin_state_up"`
+ // Pool name. Does not have to be unique.
+ Name string `json:"name"`
+ // The unique ID for the Pool.
+ ID string `json:"id"`
+ // A list of load balancer objects IDs.
+ Loadbalancers []LoadBalancerID `json:"loadbalancers"`
+ // Indicates whether connections in the same session will be processed by the
+ // same Pool member or not.
+ Persistence SessionPersistence `json:"session_persistence"`
+ // The provider
+ Provider string `json:"provider"`
+ Monitor monitors.Monitor `json:"healthmonitor"`
+}
+
+// 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 (r PoolPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"pools_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (r PoolPage) IsEmpty() (bool, error) {
+ is, err := ExtractPools(r)
+ return len(is) == 0, err
+}
+
+// ExtractPools accepts a Page struct, specifically a PoolPage 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(r pagination.Page) ([]Pool, error) {
+ var s struct {
+ Pools []Pool `json:"pools"`
+ }
+ err := (r.(PoolPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ Pool *Pool `json:"pool"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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
+}
+
+// Member represents the application running on a backend server.
+type Member struct {
+ // Name of the Member.
+ Name string `json:"name"`
+ // Weight of Member.
+ Weight int `json:"weight"`
+ // The administrative state of the member, which is up (true) or down (false).
+ AdminStateUp bool `json:"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"`
+ // parameter value for the subnet UUID.
+ SubnetID string `json:"subnet_id"`
+ // The Pool to which the Member belongs.
+ PoolID string `json:"pool_id"`
+ // The IP address of the Member.
+ Address string `json:"address"`
+ // The port on which the application is hosted.
+ ProtocolPort int `json:"protocol_port"`
+ // The unique ID for the Member.
+ ID string `json:"id"`
+}
+
+// MemberPage is the page returned by a pager when traversing over a
+// collection of Members in a Pool.
+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 (r MemberPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"members_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a MemberPage struct is empty.
+func (r MemberPage) IsEmpty() (bool, error) {
+ is, err := ExtractMembers(r)
+ return len(is) == 0, err
+}
+
+// ExtractMembers 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 ExtractMembers(r pagination.Page) ([]Member, error) {
+ var s struct {
+ Members []Member `json:"members"`
+ }
+ err := (r.(MemberPage)).ExtractInto(&s)
+ return s.Members, err
+}
+
+type commonMemberResult struct {
+ gophercloud.Result
+}
+
+// ExtractMember is a function that accepts a result and extracts a router.
+func (r commonMemberResult) Extract() (*Member, error) {
+ var s struct {
+ Member *Member `json:"member"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Member, err
+}
+
+// CreateMemberResult represents the result of a CreateMember operation.
+type CreateMemberResult struct {
+ commonMemberResult
+}
+
+// GetMemberResult represents the result of a GetMember operation.
+type GetMemberResult struct {
+ commonMemberResult
+}
+
+// UpdateMemberResult represents the result of an UpdateMember operation.
+type UpdateMemberResult struct {
+ commonMemberResult
+}
+
+// DeleteMemberResult represents the result of a DeleteMember operation.
+type DeleteMemberResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/pools/testing/doc.go b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/doc.go
new file mode 100644
index 0000000..65eb521
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_lbaas_v2_pools_v2
+package testing
diff --git a/openstack/networking/v2/extensions/lbaas_v2/pools/testing/fixtures.go b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/fixtures.go
new file mode 100644
index 0000000..df9d1fd
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/fixtures.go
@@ -0,0 +1,388 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// PoolsListBody contains the canned body of a pool list response.
+const PoolsListBody = `
+{
+ "pools":[
+ {
+ "lb_algorithm":"ROUND_ROBIN",
+ "protocol":"HTTP",
+ "description":"",
+ "healthmonitor_id": "466c8345-28d8-4f84-a246-e04380b0461d",
+ "members":[{"id": "53306cda-815d-4354-9fe4-59e09da9c3c5"}],
+ "listeners":[{"id": "2a280670-c202-4b0b-a562-34077415aabf"}],
+ "loadbalancers":[{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "id":"72741b06-df4d-4715-b142-276b6bce75ab",
+ "name":"web",
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "provider": "haproxy"
+ },
+ {
+ "lb_algorithm":"LEAST_CONNECTION",
+ "protocol":"HTTP",
+ "description":"",
+ "healthmonitor_id": "5f6c8345-28d8-4f84-a246-e04380b0461d",
+ "members":[{"id": "67306cda-815d-4354-9fe4-59e09da9c3c5"}],
+ "listeners":[{"id": "2a280670-c202-4b0b-a562-34077415aabf"}],
+ "loadbalancers":[{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "id":"c3741b06-df4d-4715-b142-276b6bce75ab",
+ "name":"db",
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "provider": "haproxy"
+ }
+ ]
+}
+`
+
+// SinglePoolBody is the canned body of a Get request on an existing pool.
+const SinglePoolBody = `
+{
+ "pool": {
+ "lb_algorithm":"LEAST_CONNECTION",
+ "protocol":"HTTP",
+ "description":"",
+ "healthmonitor_id": "5f6c8345-28d8-4f84-a246-e04380b0461d",
+ "members":[{"id": "67306cda-815d-4354-9fe4-59e09da9c3c5"}],
+ "listeners":[{"id": "2a280670-c202-4b0b-a562-34077415aabf"}],
+ "loadbalancers":[{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "id":"c3741b06-df4d-4715-b142-276b6bce75ab",
+ "name":"db",
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "provider": "haproxy"
+ }
+}
+`
+
+// PostUpdatePoolBody is the canned response body of a Update request on an existing pool.
+const PostUpdatePoolBody = `
+{
+ "pool": {
+ "lb_algorithm":"LEAST_CONNECTION",
+ "protocol":"HTTP",
+ "description":"",
+ "healthmonitor_id": "5f6c8345-28d8-4f84-a246-e04380b0461d",
+ "members":[{"id": "67306cda-815d-4354-9fe4-59e09da9c3c5"}],
+ "listeners":[{"id": "2a280670-c202-4b0b-a562-34077415aabf"}],
+ "loadbalancers":[{"id": "79e05663-7f03-45d2-a092-8b94062f22ab"}],
+ "id":"c3741b06-df4d-4715-b142-276b6bce75ab",
+ "name":"db",
+ "admin_state_up":true,
+ "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+ "provider": "haproxy"
+ }
+}
+`
+
+var (
+ PoolWeb = pools.Pool{
+ LBMethod: "ROUND_ROBIN",
+ Protocol: "HTTP",
+ Description: "",
+ MonitorID: "466c8345-28d8-4f84-a246-e04380b0461d",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ AdminStateUp: true,
+ Name: "web",
+ Members: []pools.Member{{ID: "53306cda-815d-4354-9fe4-59e09da9c3c5"}},
+ ID: "72741b06-df4d-4715-b142-276b6bce75ab",
+ Loadbalancers: []pools.LoadBalancerID{{ID: "79e05663-7f03-45d2-a092-8b94062f22ab"}},
+ Listeners: []pools.ListenerID{{ID: "2a280670-c202-4b0b-a562-34077415aabf"}},
+ Provider: "haproxy",
+ }
+ PoolDb = pools.Pool{
+ LBMethod: "LEAST_CONNECTION",
+ Protocol: "HTTP",
+ Description: "",
+ MonitorID: "5f6c8345-28d8-4f84-a246-e04380b0461d",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ AdminStateUp: true,
+ Name: "db",
+ Members: []pools.Member{{ID: "67306cda-815d-4354-9fe4-59e09da9c3c5"}},
+ ID: "c3741b06-df4d-4715-b142-276b6bce75ab",
+ Loadbalancers: []pools.LoadBalancerID{{ID: "79e05663-7f03-45d2-a092-8b94062f22ab"}},
+ Listeners: []pools.ListenerID{{ID: "2a280670-c202-4b0b-a562-34077415aabf"}},
+ Provider: "haproxy",
+ }
+ PoolUpdated = pools.Pool{
+ LBMethod: "LEAST_CONNECTION",
+ Protocol: "HTTP",
+ Description: "",
+ MonitorID: "5f6c8345-28d8-4f84-a246-e04380b0461d",
+ TenantID: "83657cfcdfe44cd5920adaf26c48ceea",
+ AdminStateUp: true,
+ Name: "db",
+ Members: []pools.Member{{ID: "67306cda-815d-4354-9fe4-59e09da9c3c5"}},
+ ID: "c3741b06-df4d-4715-b142-276b6bce75ab",
+ Loadbalancers: []pools.LoadBalancerID{{ID: "79e05663-7f03-45d2-a092-8b94062f22ab"}},
+ Listeners: []pools.ListenerID{{ID: "2a280670-c202-4b0b-a562-34077415aabf"}},
+ Provider: "haproxy",
+ }
+)
+
+// HandlePoolListSuccessfully sets up the test server to respond to a pool List request.
+func HandlePoolListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools", 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, PoolsListBody)
+ case "45e08a3e-a78f-4b40-a229-1e7e23eee1ab":
+ fmt.Fprintf(w, `{ "pools": [] }`)
+ default:
+ t.Fatalf("/v2.0/lbaas/pools invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandlePoolCreationSuccessfully sets up the test server to respond to a pool creation request
+// with a given response.
+func HandlePoolCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools", 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": {
+ "lb_algorithm": "ROUND_ROBIN",
+ "protocol": "HTTP",
+ "name": "Example pool",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "loadbalancer_id": "79e05663-7f03-45d2-a092-8b94062f22ab"
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandlePoolGetSuccessfully sets up the test server to respond to a pool Get request.
+func HandlePoolGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/c3741b06-df4d-4715-b142-276b6bce75ab", 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, SinglePoolBody)
+ })
+}
+
+// HandlePoolDeletionSuccessfully sets up the test server to respond to a pool deletion request.
+func HandlePoolDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/c3741b06-df4d-4715-b142-276b6bce75ab", 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)
+ })
+}
+
+// HandlePoolUpdateSuccessfully sets up the test server to respond to a pool Update request.
+func HandlePoolUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/c3741b06-df4d-4715-b142-276b6bce75ab", 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, `{
+ "pool": {
+ "name": "NewPoolName",
+ "lb_algorithm": "LEAST_CONNECTIONS"
+ }
+ }`)
+
+ fmt.Fprintf(w, PostUpdatePoolBody)
+ })
+}
+
+// MembersListBody contains the canned body of a member list response.
+const MembersListBody = `
+{
+ "members":[
+ {
+ "id": "2a280670-c202-4b0b-a562-34077415aabf",
+ "address": "10.0.2.10",
+ "weight": 5,
+ "name": "web",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "admin_state_up":true,
+ "protocol_port": 80
+ },
+ {
+ "id": "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ "address": "10.0.2.11",
+ "weight": 10,
+ "name": "db",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "admin_state_up":false,
+ "protocol_port": 80
+ }
+ ]
+}
+`
+
+// SingleMemberBody is the canned body of a Get request on an existing member.
+const SingleMemberBody = `
+{
+ "member": {
+ "id": "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ "address": "10.0.2.11",
+ "weight": 10,
+ "name": "db",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "admin_state_up":false,
+ "protocol_port": 80
+ }
+}
+`
+
+// PostUpdateMemberBody is the canned response body of a Update request on an existing member.
+const PostUpdateMemberBody = `
+{
+ "member": {
+ "id": "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ "address": "10.0.2.11",
+ "weight": 10,
+ "name": "db",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "admin_state_up":false,
+ "protocol_port": 80
+ }
+}
+`
+
+var (
+ MemberWeb = pools.Member{
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ AdminStateUp: true,
+ Name: "web",
+ ID: "2a280670-c202-4b0b-a562-34077415aabf",
+ Address: "10.0.2.10",
+ Weight: 5,
+ ProtocolPort: 80,
+ }
+ MemberDb = pools.Member{
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ AdminStateUp: false,
+ Name: "db",
+ ID: "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ Address: "10.0.2.11",
+ Weight: 10,
+ ProtocolPort: 80,
+ }
+ MemberUpdated = pools.Member{
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ AdminStateUp: false,
+ Name: "db",
+ ID: "fad389a3-9a4a-4762-a365-8c7038508b5d",
+ Address: "10.0.2.11",
+ Weight: 10,
+ ProtocolPort: 80,
+ }
+)
+
+// HandleMemberListSuccessfully sets up the test server to respond to a member List request.
+func HandleMemberListSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members", 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, MembersListBody)
+ case "45e08a3e-a78f-4b40-a229-1e7e23eee1ab":
+ fmt.Fprintf(w, `{ "members": [] }`)
+ default:
+ t.Fatalf("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members invoked with unexpected marker=[%s]", marker)
+ }
+ })
+}
+
+// HandleMemberCreationSuccessfully sets up the test server to respond to a member creation request
+// with a given response.
+func HandleMemberCreationSuccessfully(t *testing.T, response string) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members", 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, `{
+ "member": {
+ "address": "10.0.2.11",
+ "weight": 10,
+ "name": "db",
+ "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+ "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+ "protocol_port": 80
+ }
+ }`)
+
+ w.WriteHeader(http.StatusAccepted)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, response)
+ })
+}
+
+// HandleMemberGetSuccessfully sets up the test server to respond to a member Get request.
+func HandleMemberGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members/2a280670-c202-4b0b-a562-34077415aabf", 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, SingleMemberBody)
+ })
+}
+
+// HandleMemberDeletionSuccessfully sets up the test server to respond to a member deletion request.
+func HandleMemberDeletionSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members/2a280670-c202-4b0b-a562-34077415aabf", 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)
+ })
+}
+
+// HandleMemberUpdateSuccessfully sets up the test server to respond to a member Update request.
+func HandleMemberUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/v2.0/lbaas/pools/332abe93-f488-41ba-870b-2ac66be7f853/members/2a280670-c202-4b0b-a562-34077415aabf", 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, `{
+ "member": {
+ "name": "newMemberName",
+ "weight": 4
+ }
+ }`)
+
+ fmt.Fprintf(w, PostUpdateMemberBody)
+ })
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/pools/testing/requests_test.go b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/requests_test.go
new file mode 100644
index 0000000..4af00ec
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/testing/requests_test.go
@@ -0,0 +1,262 @@
+package testing
+
+import (
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas_v2/pools"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListPools(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolListSuccessfully(t)
+
+ pages := 0
+ err := pools.List(fake.ServiceClient(), pools.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := pools.ExtractPools(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 pools, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, PoolWeb, actual[0])
+ th.CheckDeepEquals(t, PoolDb, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllPools(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolListSuccessfully(t)
+
+ allPages, err := pools.List(fake.ServiceClient(), pools.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := pools.ExtractPools(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, PoolWeb, actual[0])
+ th.CheckDeepEquals(t, PoolDb, actual[1])
+}
+
+func TestCreatePool(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolCreationSuccessfully(t, SinglePoolBody)
+
+ actual, err := pools.Create(fake.ServiceClient(), pools.CreateOpts{
+ LBMethod: pools.LBMethodRoundRobin,
+ Protocol: "HTTP",
+ Name: "Example pool",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ LoadbalancerID: "79e05663-7f03-45d2-a092-8b94062f22ab",
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, PoolDb, *actual)
+}
+
+func TestGetPool(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolGetSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := pools.Get(client, "c3741b06-df4d-4715-b142-276b6bce75ab").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, PoolDb, *actual)
+}
+
+func TestDeletePool(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolDeletionSuccessfully(t)
+
+ res := pools.Delete(fake.ServiceClient(), "c3741b06-df4d-4715-b142-276b6bce75ab")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdatePool(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandlePoolUpdateSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := pools.Update(client, "c3741b06-df4d-4715-b142-276b6bce75ab", pools.UpdateOpts{
+ Name: "NewPoolName",
+ LBMethod: pools.LBMethodLeastConnections,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, PoolUpdated, *actual)
+}
+
+func TestRequiredPoolCreateOpts(t *testing.T) {
+ res := pools.Create(fake.ServiceClient(), pools.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = pools.Create(fake.ServiceClient(), pools.CreateOpts{
+ LBMethod: pools.LBMethod("invalid"),
+ Protocol: pools.ProtocolHTTPS,
+ LoadbalancerID: "69055154-f603-4a28-8951-7cc2d9e54a9a",
+ })
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+
+ res = pools.Create(fake.ServiceClient(), pools.CreateOpts{
+ LBMethod: pools.LBMethodRoundRobin,
+ Protocol: pools.Protocol("invalid"),
+ LoadbalancerID: "69055154-f603-4a28-8951-7cc2d9e54a9a",
+ })
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+
+ res = pools.Create(fake.ServiceClient(), pools.CreateOpts{
+ LBMethod: pools.LBMethodRoundRobin,
+ Protocol: pools.ProtocolHTTPS,
+ })
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+}
+
+func TestListMembers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberListSuccessfully(t)
+
+ pages := 0
+ err := pools.ListMembers(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", pools.ListMembersOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+
+ actual, err := pools.ExtractMembers(page)
+ if err != nil {
+ return false, err
+ }
+
+ if len(actual) != 2 {
+ t.Fatalf("Expected 2 members, got %d", len(actual))
+ }
+ th.CheckDeepEquals(t, MemberWeb, actual[0])
+ th.CheckDeepEquals(t, MemberDb, actual[1])
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ if pages != 1 {
+ t.Errorf("Expected 1 page, saw %d", pages)
+ }
+}
+
+func TestListAllMembers(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberListSuccessfully(t)
+
+ allPages, err := pools.ListMembers(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", pools.ListMembersOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := pools.ExtractMembers(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, MemberWeb, actual[0])
+ th.CheckDeepEquals(t, MemberDb, actual[1])
+}
+
+func TestCreateMember(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberCreationSuccessfully(t, SingleMemberBody)
+
+ actual, err := pools.CreateMember(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", pools.CreateMemberOpts{
+ Name: "db",
+ SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+ TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+ Address: "10.0.2.11",
+ ProtocolPort: 80,
+ Weight: 10,
+ }).Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, MemberDb, *actual)
+}
+
+func TestRequiredMemberCreateOpts(t *testing.T) {
+ res := pools.CreateMember(fake.ServiceClient(), "", pools.CreateMemberOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = pools.CreateMember(fake.ServiceClient(), "", pools.CreateMemberOpts{Address: "1.2.3.4", ProtocolPort: 80})
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+ res = pools.CreateMember(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", pools.CreateMemberOpts{ProtocolPort: 80})
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+ res = pools.CreateMember(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", pools.CreateMemberOpts{Address: "1.2.3.4"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, but got none")
+ }
+}
+
+func TestGetMember(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberGetSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := pools.GetMember(client, "332abe93-f488-41ba-870b-2ac66be7f853", "2a280670-c202-4b0b-a562-34077415aabf").Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Get error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, MemberDb, *actual)
+}
+
+func TestDeleteMember(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberDeletionSuccessfully(t)
+
+ res := pools.DeleteMember(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", "2a280670-c202-4b0b-a562-34077415aabf")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdateMember(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleMemberUpdateSuccessfully(t)
+
+ client := fake.ServiceClient()
+ actual, err := pools.UpdateMember(client, "332abe93-f488-41ba-870b-2ac66be7f853", "2a280670-c202-4b0b-a562-34077415aabf", pools.UpdateMemberOpts{
+ Name: "newMemberName",
+ Weight: 4,
+ }).Extract()
+ if err != nil {
+ t.Fatalf("Unexpected Update error: %v", err)
+ }
+
+ th.CheckDeepEquals(t, MemberUpdated, *actual)
+}
diff --git a/openstack/networking/v2/extensions/lbaas_v2/pools/urls.go b/openstack/networking/v2/extensions/lbaas_v2/pools/urls.go
new file mode 100644
index 0000000..bceca67
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas_v2/pools/urls.go
@@ -0,0 +1,25 @@
+package pools
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+ rootPath = "lbaas"
+ resourcePath = "pools"
+ memberPath = "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)
+}
+
+func memberRootURL(c *gophercloud.ServiceClient, poolId string) string {
+ return c.ServiceURL(rootPath, resourcePath, poolId, memberPath)
+}
+
+func memberResourceURL(c *gophercloud.ServiceClient, poolID string, memeberID string) string {
+ return c.ServiceURL(rootPath, resourcePath, poolID, memberPath, memeberID)
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/doc.go b/openstack/networking/v2/extensions/portsbinding/doc.go
new file mode 100644
index 0000000..0d2ed58
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/doc.go
@@ -0,0 +1,3 @@
+// Package portsbinding provides information and interaction with the port
+// binding extension for the OpenStack Networking service.
+package portsbinding
diff --git a/openstack/networking/v2/extensions/portsbinding/requests.go b/openstack/networking/v2/extensions/portsbinding/requests.go
new file mode 100644
index 0000000..b46172b
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/requests.go
@@ -0,0 +1,113 @@
+package portsbinding
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+)
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+ return
+}
+
+// CreateOpts represents the attributes used when creating a new
+// port with extended attributes.
+type CreateOpts struct {
+ // CreateOptsBuilder is the interface options structs have to satisfy in order
+ // to be used in the main Create operation in this package.
+ ports.CreateOptsBuilder `json:"-"`
+ // The ID of the host where the port is allocated
+ HostID string `json:"binding:host_id,omitempty"`
+ // The virtual network interface card (vNIC) type that is bound to the
+ // neutron port
+ VNICType string `json:"binding:vnic_type,omitempty"`
+ // A dictionary that enables the application running on the specified
+ // host to pass and receive virtual network interface (VIF) port-specific
+ // information to the plug-in
+ Profile map[string]string `json:"binding:profile,omitempty"`
+}
+
+// ToPortCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPortCreateMap() (map[string]interface{}, error) {
+ b1, err := opts.CreateOptsBuilder.ToPortCreateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ b2, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ port := b1["port"].(map[string]interface{})
+
+ for k, v := range b2 {
+ port[k] = v
+ }
+
+ return map[string]interface{}{"port": port}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new port with extended attributes.
+// You must remember to provide a NetworkID value.
+func Create(c *gophercloud.ServiceClient, opts ports.CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToPortCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+ return
+}
+
+// UpdateOpts represents the attributes used when updating an existing port.
+type UpdateOpts struct {
+ // UpdateOptsBuilder is the interface options structs have to satisfy in order
+ // to be used in the main Update operation in this package.
+ ports.UpdateOptsBuilder `json:"-"`
+ // The ID of the host where the port is allocated
+ HostID string `json:"binding:host_id,omitempty"`
+ // The virtual network interface card (vNIC) type that is bound to the
+ // neutron port
+ VNICType string `json:"binding:vnic_type,omitempty"`
+ // A dictionary that enables the application running on the specified
+ // host to pass and receive virtual network interface (VIF) port-specific
+ // information to the plug-in
+ Profile map[string]string `json:"binding:profile,omitempty"`
+}
+
+// ToPortUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPortUpdateMap() (map[string]interface{}, error) {
+ b1, err := opts.UpdateOptsBuilder.ToPortUpdateMap()
+ if err != nil {
+ return nil, err
+ }
+
+ b2, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ port := b1["port"].(map[string]interface{})
+
+ for k, v := range b2 {
+ port[k] = v
+ }
+
+ return map[string]interface{}{"port": port}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts ports.UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToPortUpdateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ _, r.Err = c.Put(updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/results.go b/openstack/networking/v2/extensions/portsbinding/results.go
new file mode 100644
index 0000000..9527473
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/results.go
@@ -0,0 +1,73 @@
+package portsbinding
+
+import (
+ "github.com/gophercloud/gophercloud"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Port *Port `json:"port"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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
+}
+
+// IP is a sub-struct that represents an individual IP.
+type IP struct {
+ SubnetID string `json:"subnet_id"`
+ IPAddress string `json:"ip_address"`
+}
+
+// Port represents a Neutron port. See package documentation for a top-level
+// description of what this is.
+type Port struct {
+ ports.Port
+ // The ID of the host where the port is allocated
+ HostID string `json:"binding:host_id"`
+ // A dictionary that enables the application to pass information about
+ // functions that the Networking API provides.
+ VIFDetails map[string]interface{} `json:"binding:vif_details"`
+ // The VIF type for the port.
+ VIFType string `json:"binding:vif_type"`
+ // The virtual network interface card (vNIC) type that is bound to the
+ // neutron port
+ VNICType string `json:"binding:vnic_type"`
+ // A dictionary that enables the application running on the specified
+ // host to pass and receive virtual network interface (VIF) port-specific
+ // information to the plug-in
+ Profile map[string]string `json:"binding:profile"`
+}
+
+// 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(r pagination.Page) ([]Port, error) {
+ var s struct {
+ Ports []Port `json:"ports"`
+ }
+ err := (r.(ports.PortPage)).ExtractInto(&s)
+ return s.Ports, err
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/testing/doc.go b/openstack/networking/v2/extensions/portsbinding/testing/doc.go
new file mode 100644
index 0000000..deb52b1
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_portsbinding_v2
+package testing
diff --git a/openstack/networking/v2/extensions/portsbinding/testing/fixtures.go b/openstack/networking/v2/extensions/portsbinding/testing/fixtures.go
new file mode 100644
index 0000000..f688c20
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/testing/fixtures.go
@@ -0,0 +1,207 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func HandleListSuccessfully(t *testing.T) {
+ 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",
+ "binding:vnic_type": "normal"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func HandleGet(t *testing.T) {
+ 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",
+ "binding:host_id": "devstack",
+ "name": "",
+ "allowed_address_pairs": [],
+ "admin_state_up": true,
+ "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ "tenant_id": "7e02058126cc4950b75f9970368ba177",
+ "extra_dhcp_opts": [],
+ "binding:vif_details": {
+ "port_filter": true,
+ "ovs_hybrid_plug": true
+ },
+ "binding:vif_type": "ovs",
+ "device_owner": "network:router_interface",
+ "port_security_enabled": false,
+ "mac_address": "fa:16:3e:23:fd:d7",
+ "binding:profile": {},
+ "binding:vnic_type": "normal",
+ "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"
+ }
+}
+ `)
+ })
+}
+
+func HandleCreate(t *testing.T) {
+ 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"],
+ "binding:host_id": "HOST1",
+ "binding:vnic_type": "normal"
+ }
+}
+ `)
+
+ 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"
+ }
+ ],
+ "binding:host_id": "HOST1",
+ "binding:vnic_type": "normal",
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+}
+
+func HandleUpdate(t *testing.T) {
+ 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"
+ ],
+ "binding:host_id": "HOST1",
+ "binding:vnic_type": "normal",
+ "allowed_address_pairs": null
+ }
+}
+ `)
+
+ 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": "",
+ "binding:host_id": "HOST1",
+ "binding:vnic_type": "normal"
+ }
+}
+ `)
+ })
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/testing/requests_test.go b/openstack/networking/v2/extensions/portsbinding/testing/requests_test.go
new file mode 100644
index 0000000..f41f1cc
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/testing/requests_test.go
@@ -0,0 +1,164 @@
+package testing
+
+import (
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/portsbinding"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleListSuccessfully(t)
+
+ count := 0
+
+ ports.List(fake.ServiceClient(), ports.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := portsbinding.ExtractPorts(page)
+ th.AssertNoErr(t, err)
+
+ expected := []portsbinding.Port{
+ {
+ Port: ports.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: []ports.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",
+ },
+ VNICType: "normal",
+ HostID: "devstack",
+ },
+ }
+
+ 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()
+
+ HandleGet(t)
+
+ n, err := portsbinding.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, []ports.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.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+
+ th.AssertEquals(t, n.HostID, "devstack")
+ th.AssertEquals(t, n.VNICType, "normal")
+ th.AssertEquals(t, n.VIFType, "ovs")
+ th.AssertDeepEquals(t, n.VIFDetails, map[string]interface{}{"port_filter": true, "ovs_hybrid_plug": true})
+}
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleCreate(t)
+
+ asu := true
+ options := portsbinding.CreateOpts{
+ CreateOptsBuilder: ports.CreateOpts{
+ Name: "private-port",
+ AdminStateUp: &asu,
+ NetworkID: "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ },
+ SecurityGroups: []string{"foo"},
+ },
+ HostID: "HOST1",
+ VNICType: "normal",
+ }
+ n, err := portsbinding.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, []ports.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"})
+ th.AssertEquals(t, n.HostID, "HOST1")
+ th.AssertEquals(t, n.VNICType, "normal")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := portsbinding.Create(fake.ServiceClient(), portsbinding.CreateOpts{CreateOptsBuilder: ports.CreateOpts{}})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleUpdate(t)
+
+ options := portsbinding.UpdateOpts{
+ UpdateOptsBuilder: ports.UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+ },
+ HostID: "HOST1",
+ VNICType: "normal",
+ }
+
+ s, err := portsbinding.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, []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+ th.AssertEquals(t, s.HostID, "HOST1")
+ th.AssertEquals(t, s.VNICType, "normal")
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/urls.go b/openstack/networking/v2/extensions/portsbinding/urls.go
new file mode 100644
index 0000000..a531a7e
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/urls.go
@@ -0,0 +1,23 @@
+package portsbinding
+
+import "github.com/gophercloud/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 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)
+}
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 100644
index 0000000..37142e8
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/results.go
@@ -0,0 +1,116 @@
+package provider
+
+import (
+ "encoding/json"
+ "strconv"
+
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// NetworkExtAttrs represents an extended form of a Network with additional fields.
+type NetworkExtAttrs struct {
+ // UUID for the network
+ ID string `json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `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 `json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `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"`
+
+ // 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"`
+
+ // 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"`
+}
+
+func (n *NetworkExtAttrs) UnmarshalJSON(b []byte) error {
+ type tmp NetworkExtAttrs
+ var networkExtAttrs *struct {
+ tmp
+ SegmentationID interface{} `json:"provider:segmentation_id"`
+ }
+
+ if err := json.Unmarshal(b, &networkExtAttrs); err != nil {
+ return err
+ }
+
+ *n = NetworkExtAttrs(networkExtAttrs.tmp)
+
+ switch t := networkExtAttrs.SegmentationID.(type) {
+ case float64:
+ n.SegmentationID = strconv.FormatFloat(t, 'f', -1, 64)
+ case string:
+ n.SegmentationID = string(t)
+ }
+
+ return nil
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExtAttrs, error) {
+ var s struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Network, err
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExtAttrs, error) {
+ var s struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Network, err
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExtAttrs, error) {
+ var s struct {
+ Network *NetworkExtAttrs `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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(r pagination.Page) ([]NetworkExtAttrs, error) {
+ var s struct {
+ Networks []NetworkExtAttrs `json:"networks" json:"networks"`
+ }
+ err := (r.(networks.NetworkPage)).ExtractInto(&s)
+ return s.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/provider/testing/doc.go b/openstack/networking/v2/extensions/provider/testing/doc.go
new file mode 100644
index 0000000..370ce19
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_provider_v2
+package testing
diff --git a/openstack/networking/v2/extensions/provider/testing/results_test.go b/openstack/networking/v2/extensions/provider/testing/results_test.go
new file mode 100644
index 0000000..f6b321a
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/testing/results_test.go
@@ -0,0 +1,255 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/provider"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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 := provider.ExtractList(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []provider.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: "",
+ },
+ {
+ 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 := provider.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: gophercloud.Enabled}
+ res := networks.Create(fake.ServiceClient(), options)
+ n, err := provider.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: gophercloud.Disabled, Shared: &iTrue}
+ res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+ n, err := provider.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..5ca4850
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/requests.go
@@ -0,0 +1,108 @@
+package groups
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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}}
+ })
+}
+
+type CreateOptsBuilder interface {
+ ToSecGroupCreateMap() (map[string]interface{}, error)
+}
+
+// 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 `json:"name" required:"true"`
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string `json:"tenant_id,omitempty"`
+ // Optional. Describes the security group.
+ Description string `json:"description,omitempty"`
+}
+
+func (opts CreateOpts) ToSecGroupCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToSecGroupCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular security group based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// Delete will permanently delete a particular security group based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
+
+// IDFromName is a convenience function that returns a security group's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, ListOpts{}).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractGroups(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "security group"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "security group"}
+ }
+}
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..ea3ad65
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/results.go
@@ -0,0 +1,94 @@
+package groups
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/gophercloud/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"`
+
+ // Owner of the security group. Only admin users can specify a TenantID
+ // other than their own.
+ TenantID string `json:"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 (r SecGroupPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"security_groups_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SecGroupPage struct is empty.
+func (r SecGroupPage) IsEmpty() (bool, error) {
+ is, err := ExtractGroups(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]SecGroup, error) {
+ var s struct {
+ SecGroups []SecGroup `json:"security_groups"`
+ }
+ err := (r.(SecGroupPage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ SecGroup *SecGroup `json:"security_group"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/security/groups/testing/doc.go
new file mode 100644
index 0000000..69d5db7
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_security_groups_v2
+package testing
diff --git a/openstack/networking/v2/extensions/security/groups/testing/requests_test.go b/openstack/networking/v2/extensions/security/groups/testing/requests_test.go
new file mode 100644
index 0000000..acd3230
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/testing/requests_test.go
@@ -0,0 +1,206 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/groups"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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
+
+ groups.List(fake.ServiceClient(), groups.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := groups.ExtractGroups(page)
+ if err != nil {
+ t.Errorf("Failed to extract secgroups: %v", err)
+ return false, err
+ }
+
+ expected := []groups.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 := groups.CreateOpts{Name: "new-webservers", Description: "security group for webservers"}
+ _, err := groups.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 := groups.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 := groups.Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..104cbcc
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/urls.go
@@ -0,0 +1,13 @@
+package groups
+
+import "github.com/gophercloud/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..77f7e37
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/requests.go
@@ -0,0 +1,127 @@
+package rules
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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}}
+ })
+}
+
+type RuleDirection string
+type RuleProtocol string
+type RuleEtherType string
+
+// Constants useful for CreateOpts
+const (
+ DirIngress RuleDirection = "ingress"
+ DirEgress RuleDirection = "egress"
+ ProtocolTCP RuleProtocol = "tcp"
+ ProtocolUDP RuleProtocol = "udp"
+ ProtocolICMP RuleProtocol = "icmp"
+ EtherType4 RuleEtherType = "IPv4"
+ EtherType6 RuleEtherType = "IPv6"
+)
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+ ToSecGroupRuleCreateMap() (map[string]interface{}, error)
+}
+
+// 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 RuleDirection `json:"direction" required:"true"`
+ // Required. Must be "IPv4" or "IPv6", and addresses represented in CIDR must
+ // match the ingress or egress rules.
+ EtherType RuleEtherType `json:"ethertype" required:"true"`
+ // Required. The security group ID to associate with this security group rule.
+ SecGroupID string `json:"security_group_id" required:"true"`
+ // 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 `json:"port_range_max,omitempty"`
+ // 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 `json:"port_range_min,omitempty"`
+ // Optional. The protocol that is matched by the security group rule. Valid
+ // values are "tcp", "udp", "icmp" or an empty string.
+ Protocol RuleProtocol `json:"protocol,omitempty"`
+ // Optional. 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,omitempty"`
+ // 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 `json:"remote_ip_prefix,omitempty"`
+ // Required for admins. Indicates the owner of the VIP.
+ TenantID string `json:"tenant_id,omitempty"`
+}
+
+// ToSecGroupRuleCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToSecGroupRuleCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "security_group_rule")
+}
+
+// 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 CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToSecGroupRuleCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+ return
+}
+
+// Get retrieves a particular security group rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+ return
+}
+
+// Delete will permanently delete a particular security group rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(resourceURL(c, id), nil)
+ return
+}
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..18476a6
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/results.go
@@ -0,0 +1,118 @@
+package rules
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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"`
+
+ // The security group ID to associate with this security group rule.
+ SecGroupID string `json:"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"`
+
+ // 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"`
+
+ // 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"`
+
+ // 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"`
+
+ // The owner of this security group rule.
+ TenantID string `json:"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 (r SecGroupRulePage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"security_group_rules_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SecGroupRulePage struct is empty.
+func (r SecGroupRulePage) IsEmpty() (bool, error) {
+ is, err := ExtractRules(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]SecGroupRule, error) {
+ var s struct {
+ SecGroupRules []SecGroupRule `json:"security_group_rules"`
+ }
+ err := (r.(SecGroupRulePage)).ExtractInto(&s)
+ return s.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) {
+ var s struct {
+ SecGroupRule *SecGroupRule `json:"security_group_rule"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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/testing/doc.go b/openstack/networking/v2/extensions/security/rules/testing/doc.go
new file mode 100644
index 0000000..a4f7b43
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_security_rules_v2
+package testing
diff --git a/openstack/networking/v2/extensions/security/rules/testing/requests_test.go b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go
new file mode 100644
index 0000000..968fd04
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go
@@ -0,0 +1,236 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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
+
+ rules.List(fake.ServiceClient(), rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := rules.ExtractRules(page)
+ if err != nil {
+ t.Errorf("Failed to extract secrules: %v", err)
+ return false, err
+ }
+
+ expected := []rules.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",
+ },
+ {
+ 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 := rules.CreateOpts{
+ Direction: "ingress",
+ PortRangeMin: 80,
+ EtherType: rules.EtherType4,
+ PortRangeMax: 80,
+ Protocol: "tcp",
+ RemoteGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+ SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a",
+ }
+ _, err := rules.Create(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := rules.Create(fake.ServiceClient(), rules.CreateOpts{Direction: rules.DirIngress})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = rules.Create(fake.ServiceClient(), rules.CreateOpts{Direction: rules.DirIngress, EtherType: rules.EtherType4})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = rules.Create(fake.ServiceClient(), rules.CreateOpts{Direction: rules.DirIngress, EtherType: rules.EtherType4})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+ res = rules.Create(fake.ServiceClient(), rules.CreateOpts{Direction: rules.DirIngress, EtherType: rules.EtherType4, 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 := rules.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 := rules.Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+ th.AssertNoErr(t, res.Err)
+}
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..a5ede0e
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/urls.go
@@ -0,0 +1,13 @@
+package rules
+
+import "github.com/gophercloud/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/extensions/testing/delegate_test.go b/openstack/networking/v2/extensions/testing/delegate_test.go
new file mode 100755
index 0000000..618d052
--- /dev/null
+++ b/openstack/networking/v2/extensions/testing/delegate_test.go
@@ -0,0 +1,106 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/extensions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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
+
+ extensions.List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := extensions.ExtractExtensions(page)
+ if err != nil {
+ t.Errorf("Failed to extract extensions: %v", err)
+ }
+
+ expected := []extensions.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 := extensions.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/testing/doc.go b/openstack/networking/v2/extensions/testing/doc.go
new file mode 100644
index 0000000..5a104fb
--- /dev/null
+++ b/openstack/networking/v2/extensions/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_extensions_v2
+package testing
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/requests.go b/openstack/networking/v2/networks/requests.go
new file mode 100644
index 0000000..876a00b
--- /dev/null
+++ b/openstack/networking/v2/networks/requests.go
@@ -0,0 +1,168 @@
+package networks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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)
+ return q.String(), err
+}
+
+// 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) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 satisfies the CreateOptsBuilder interface
+type CreateOpts struct {
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Name string `json:"name,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToNetworkCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+ return
+}
+
+// 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 satisfies the UpdateOptsBuilder interface
+type UpdateOpts struct {
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ Name string `json:"name,omitempty"`
+ Shared *bool `json:"shared,omitempty"`
+}
+
+// ToNetworkUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// 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) (r UpdateResult) {
+ b, err := opts.ToNetworkUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(updateURL(c, networkID), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) (r DeleteResult) {
+ _, r.Err = c.Delete(deleteURL(c, networkID), nil)
+ return
+}
+
+// IDFromName is a convenience function that returns a network's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractNetworks(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "network"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "network"}
+ }
+}
diff --git a/openstack/networking/v2/networks/results.go b/openstack/networking/v2/networks/results.go
new file mode 100644
index 0000000..d928980
--- /dev/null
+++ b/openstack/networking/v2/networks/results.go
@@ -0,0 +1,101 @@
+package networks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Network *Network `json:"network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 `json:"id"`
+
+ // Human-readable name for the network. Might not be unique.
+ Name string `json:"name"`
+
+ // The administrative state of network. If false (down), the network does not forward packets.
+ AdminStateUp bool `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 `json:"status"`
+
+ // Subnets associated with this network.
+ Subnets []string `json:"subnets"`
+
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `json:"tenant_id"`
+
+ // Specifies whether the network resource can be accessed by any tenant or not.
+ Shared bool `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 (r NetworkPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"networks_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (r NetworkPage) IsEmpty() (bool, error) {
+ is, err := ExtractNetworks(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Network, error) {
+ var s struct {
+ Networks []Network `json:"networks"`
+ }
+ err := (r.(NetworkPage)).ExtractInto(&s)
+ return s.Networks, err
+}
diff --git a/openstack/networking/v2/networks/testing/doc.go b/openstack/networking/v2/networks/testing/doc.go
new file mode 100644
index 0000000..860bd7a
--- /dev/null
+++ b/openstack/networking/v2/networks/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_networks_v2
+package testing
diff --git a/openstack/networking/v2/networks/testing/requests_test.go b/openstack/networking/v2/networks/testing/requests_test.go
new file mode 100644
index 0000000..5b9f03d
--- /dev/null
+++ b/openstack/networking/v2/networks/testing/requests_test.go
@@ -0,0 +1,277 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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
+
+ networks.List(client, networks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := networks.ExtractNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract networks: %v", err)
+ return false, err
+ }
+
+ expected := []networks.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",
+ },
+ {
+ 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 := networks.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 := networks.CreateOpts{Name: "sample_network", AdminStateUp: &iTrue}
+ n, err := networks.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 := networks.CreateOpts{Name: "sample_network", AdminStateUp: &iTrue, Shared: &iTrue, TenantID: "12345"}
+ _, err := networks.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 := networks.UpdateOpts{Name: "new_network_name", AdminStateUp: &iFalse, Shared: &iTrue}
+ n, err := networks.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 := networks.Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/networks/urls.go b/openstack/networking/v2/networks/urls.go
new file mode 100644
index 0000000..4a8fb1d
--- /dev/null
+++ b/openstack/networking/v2/networks/urls.go
@@ -0,0 +1,31 @@
+package networks
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/networking/v2/ports/requests.go
new file mode 100644
index 0000000..d353b7e
--- /dev/null
+++ b/openstack/networking/v2/ports/requests.go
@@ -0,0 +1,180 @@
+package ports
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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)
+ return q.String(), err
+}
+
+// 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) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 `json:"network_id" required:"true"`
+ Name string `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ MACAddress string `json:"mac_address,omitempty"`
+ FixedIPs interface{} `json:"fixed_ips,omitempty"`
+ DeviceID string `json:"device_id,omitempty"`
+ DeviceOwner string `json:"device_owner,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ SecurityGroups []string `json:"security_groups,omitempty"`
+ AllowedAddressPairs []AddressPair `json:"allowed_address_pairs,omitempty"`
+}
+
+// ToPortCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPortCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "port")
+}
+
+// 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) (r CreateResult) {
+ b, err := opts.ToPortCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+ return
+}
+
+// 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 `json:"name,omitempty"`
+ AdminStateUp *bool `json:"admin_state_up,omitempty"`
+ FixedIPs interface{} `json:"fixed_ips,omitempty"`
+ DeviceID string `json:"device_id,omitempty"`
+ DeviceOwner string `json:"device_owner,omitempty"`
+ SecurityGroups []string `json:"security_groups"`
+ AllowedAddressPairs []AddressPair `json:"allowed_address_pairs"`
+}
+
+// ToPortUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPortUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "port")
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToPortUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete accepts a unique ID and deletes the port associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(deleteURL(c, id), nil)
+ return
+}
+
+// IDFromName is a convenience function that returns a port's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractPorts(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "port"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "port"}
+ }
+}
diff --git a/openstack/networking/v2/ports/results.go b/openstack/networking/v2/ports/results.go
new file mode 100644
index 0000000..57a1765
--- /dev/null
+++ b/openstack/networking/v2/ports/results.go
@@ -0,0 +1,119 @@
+package ports
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Port *Port `json:"port"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 `json:"subnet_id"`
+ IPAddress string `json:"ip_address,omitempty"`
+}
+
+// AddressPair contains the IP Address and the MAC address.
+type AddressPair struct {
+ IPAddress string `json:"ip_address,omitempty"`
+ MACAddress string `json:"mac_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 `json:"id"`
+ // Network that this port is associated with.
+ NetworkID string `json:"network_id"`
+ // Human-readable name for the port. Might not be unique.
+ Name string `json:"name"`
+ // Administrative state of port. If false (down), port does not forward packets.
+ AdminStateUp bool `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 `json:"status"`
+ // Mac address to use on this port.
+ MACAddress string `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 `json:"fixed_ips"`
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `json:"tenant_id"`
+ // Identifies the entity (e.g.: dhcp agent) using this port.
+ DeviceOwner string `json:"device_owner"`
+ // Specifies the IDs of any security groups associated with a port.
+ SecurityGroups []string `json:"security_groups"`
+ // Identifies the device (e.g., virtual server) using this port.
+ DeviceID string `json:"device_id"`
+ // Identifies the list of IP addresses the port will recognize/accept
+ AllowedAddressPairs []AddressPair `json:"allowed_address_pairs"`
+}
+
+// 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 (r PortPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"ports_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PortPage struct is empty.
+func (r PortPage) IsEmpty() (bool, error) {
+ is, err := ExtractPorts(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Port, error) {
+ var s struct {
+ Ports []Port `json:"ports"`
+ }
+ err := (r.(PortPage)).ExtractInto(&s)
+ return s.Ports, err
+}
diff --git a/openstack/networking/v2/ports/testing/doc.go b/openstack/networking/v2/ports/testing/doc.go
new file mode 100644
index 0000000..70a559a
--- /dev/null
+++ b/openstack/networking/v2/ports/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_ports_v2
+package testing
diff --git a/openstack/networking/v2/ports/testing/requests_test.go b/openstack/networking/v2/ports/testing/requests_test.go
new file mode 100644
index 0000000..1da6ad3
--- /dev/null
+++ b/openstack/networking/v2/ports/testing/requests_test.go
@@ -0,0 +1,519 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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
+
+ ports.List(fake.ServiceClient(), ports.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ports.ExtractPorts(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []ports.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: []ports.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 := ports.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, []ports.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"],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ]
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ fmt.Fprintf(w, `
+{
+ "port": {
+ "status": "DOWN",
+ "name": "private-port",
+ "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"
+ ],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ asu := true
+ options := ports.CreateOpts{
+ Name: "private-port",
+ AdminStateUp: &asu,
+ NetworkID: "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+ },
+ SecurityGroups: []string{"foo"},
+ AllowedAddressPairs: []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ },
+ }
+ n, err := ports.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, []ports.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"})
+ th.AssertDeepEquals(t, n.AllowedAddressPairs, []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ })
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := ports.Create(fake.ServiceClient(), ports.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"
+ }
+ ],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ],
+ "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"
+ }
+ ],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "security_groups": [
+ "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+ ],
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ options := ports.UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+ AllowedAddressPairs: []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ },
+ }
+
+ s, err := ports.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, []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.AllowedAddressPairs, []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ })
+ th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestRemoveSecurityGroups(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"
+ }
+ ],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ],
+ "security_groups": []
+ }
+}
+ `)
+
+ 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"
+ }
+ ],
+ "allowed_address_pairs": [
+ {
+ "ip_address": "10.0.0.4",
+ "mac_address": "fa:16:3e:c9:cb:f0"
+ }
+ ],
+ "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+ "device_id": ""
+ }
+}
+ `)
+ })
+
+ options := ports.UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{},
+ AllowedAddressPairs: []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ },
+ }
+
+ s, err := ports.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, []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.AllowedAddressPairs, []ports.AddressPair{
+ {IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+ })
+ th.AssertDeepEquals(t, s.SecurityGroups, []string(nil))
+}
+
+func TestRemoveAllowedAddressPairs(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"
+ }
+ ],
+ "allowed_address_pairs": [],
+ "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 := ports.UpdateOpts{
+ Name: "new_port_name",
+ FixedIPs: []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ },
+ SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+ AllowedAddressPairs: []ports.AddressPair{},
+ }
+
+ s, err := ports.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, []ports.IP{
+ {SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+ })
+ th.AssertDeepEquals(t, s.AllowedAddressPairs, []ports.AddressPair(nil))
+ 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 := ports.Delete(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/ports/urls.go b/openstack/networking/v2/ports/urls.go
new file mode 100644
index 0000000..600d6f2
--- /dev/null
+++ b/openstack/networking/v2/ports/urls.go
@@ -0,0 +1,31 @@
+package ports
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/networking/v2/subnets/requests.go
new file mode 100644
index 0000000..896f13e
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests.go
@@ -0,0 +1,193 @@
+package subnets
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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)
+ return q.String(), err
+}
+
+// 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) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+ return
+}
+
+// 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 {
+ NetworkID string `json:"network_id" required:"true"`
+ CIDR string `json:"cidr" required:"true"`
+ Name string `json:"name,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ AllocationPools []AllocationPool `json:"allocation_pools,omitempty"`
+ GatewayIP *string `json:"gateway_ip,omitempty"`
+ IPVersion gophercloud.IPVersion `json:"ip_version,omitempty"`
+ EnableDHCP *bool `json:"enable_dhcp,omitempty"`
+ DNSNameservers []string `json:"dns_nameservers,omitempty"`
+ HostRoutes []HostRoute `json:"host_routes,omitempty"`
+}
+
+// ToSubnetCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToSubnetCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "subnet")
+ if err != nil {
+ return nil, err
+ }
+
+ if m := b["subnet"].(map[string]interface{}); m["gateway_ip"] == "" {
+ m["gateway_ip"] = nil
+ }
+
+ return b, 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) (r CreateResult) {
+ b, err := opts.ToSubnetCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+ return
+}
+
+// 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 `json:"name,omitempty"`
+ GatewayIP *string `json:"gateway_ip,omitempty"`
+ DNSNameservers []string `json:"dns_nameservers,omitempty"`
+ HostRoutes []HostRoute `json:"host_routes,omitempty"`
+ EnableDHCP *bool `json:"enable_dhcp,omitempty"`
+}
+
+// ToSubnetUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToSubnetUpdateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "subnet")
+ if err != nil {
+ return nil, err
+ }
+
+ if m := b["subnet"].(map[string]interface{}); m["gateway_ip"] == "" {
+ m["gateway_ip"] = nil
+ }
+
+ return b, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing subnet using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToSubnetUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete accepts a unique ID and deletes the subnet associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = c.Delete(deleteURL(c, id), nil)
+ return
+}
+
+// IDFromName is a convenience function that returns a subnet's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+ count := 0
+ id := ""
+ pages, err := List(client, nil).AllPages()
+ if err != nil {
+ return "", err
+ }
+
+ all, err := ExtractSubnets(pages)
+ if err != nil {
+ return "", err
+ }
+
+ for _, s := range all {
+ if s.Name == name {
+ count++
+ id = s.ID
+ }
+ }
+
+ switch count {
+ case 0:
+ return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "subnet"}
+ case 1:
+ return id, nil
+ default:
+ return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "subnet"}
+ }
+}
diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go
new file mode 100644
index 0000000..ab5cce1
--- /dev/null
+++ b/openstack/networking/v2/subnets/results.go
@@ -0,0 +1,117 @@
+package subnets
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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) {
+ var s struct {
+ Subnet *Subnet `json:"subnet"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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 `json:"destination"`
+ NextHop string `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 `json:"id"`
+ // UUID of the parent network
+ NetworkID string `json:"network_id"`
+ // Human-readable name for the subnet. Might not be unique.
+ Name string `json:"name"`
+ // IP version, either `4' or `6'
+ IPVersion int `json:"ip_version"`
+ // CIDR representing IP range for this subnet, based on IP version
+ CIDR string `json:"cidr"`
+ // Default gateway used by devices in this subnet
+ GatewayIP string `json:"gateway_ip"`
+ // DNS name servers used by hosts in this subnet.
+ DNSNameservers []string `json:"dns_nameservers"`
+ // Sub-ranges of CIDR available for dynamic allocation to ports. See AllocationPool.
+ AllocationPools []AllocationPool `json:"allocation_pools"`
+ // Routes that should be used by devices with IPs from this subnet (not including local subnet route).
+ HostRoutes []HostRoute `json:"host_routes"`
+ // Specifies whether DHCP is enabled for this subnet or not.
+ EnableDHCP bool `json:"enable_dhcp"`
+ // Owner of network. Only admin users can specify a tenant_id other than its own.
+ TenantID string `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 (r SubnetPage) NextPageURL() (string, error) {
+ var s struct {
+ Links []gophercloud.Link `json:"subnets_links"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SubnetPage struct is empty.
+func (r SubnetPage) IsEmpty() (bool, error) {
+ is, err := ExtractSubnets(r)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]Subnet, error) {
+ var s struct {
+ Subnets []Subnet `json:"subnets"`
+ }
+ err := (r.(SubnetPage)).ExtractInto(&s)
+ return s.Subnets, err
+}
diff --git a/openstack/networking/v2/subnets/testing/doc.go b/openstack/networking/v2/subnets/testing/doc.go
new file mode 100644
index 0000000..43be31a
--- /dev/null
+++ b/openstack/networking/v2/subnets/testing/doc.go
@@ -0,0 +1,2 @@
+// networking_subnets_v2
+package testing
diff --git a/openstack/networking/v2/subnets/testing/requests_test.go b/openstack/networking/v2/subnets/testing/requests_test.go
new file mode 100644
index 0000000..9ff9181
--- /dev/null
+++ b/openstack/networking/v2/subnets/testing/requests_test.go
@@ -0,0 +1,687 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/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"
+ },
+ {
+ "name": "my_gatewayless_subnet",
+ "enable_dhcp": true,
+ "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "dns_nameservers": [],
+ "allocation_pools": [
+ {
+ "start": "192.168.1.2",
+ "end": "192.168.1.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": null,
+ "cidr": "192.168.1.0/24",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0c"
+ }
+ ]
+}
+ `)
+ })
+
+ count := 0
+
+ subnets.List(fake.ServiceClient(), subnets.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := subnets.ExtractSubnets(page)
+ if err != nil {
+ t.Errorf("Failed to extract subnets: %v", err)
+ return false, nil
+ }
+
+ expected := []subnets.Subnet{
+ {
+ Name: "private-subnet",
+ EnableDHCP: true,
+ NetworkID: "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+ TenantID: "26a7980765d0414dbc1fc1f88cdb7e6e",
+ DNSNameservers: []string{},
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "10.0.0.2",
+ End: "10.0.0.254",
+ },
+ },
+ HostRoutes: []subnets.HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "10.0.0.1",
+ CIDR: "10.0.0.0/24",
+ ID: "08eae331-0402-425a-923c-34f7cfe39c1b",
+ },
+ {
+ Name: "my_subnet",
+ EnableDHCP: true,
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ DNSNameservers: []string{},
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ },
+ HostRoutes: []subnets.HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "192.0.0.1",
+ CIDR: "192.0.0.0/8",
+ ID: "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
+ },
+ subnets.Subnet{
+ Name: "my_gatewayless_subnet",
+ EnableDHCP: true,
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+ TenantID: "4fd44f30292945e481c7b8a0c8908869",
+ DNSNameservers: []string{},
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "192.168.1.2",
+ End: "192.168.1.254",
+ },
+ },
+ HostRoutes: []subnets.HostRoute{},
+ IPVersion: 4,
+ GatewayIP: "",
+ CIDR: "192.168.1.0/24",
+ ID: "54d6f61d-db07-451c-9ab3-b9609b6b6f0c",
+ },
+ }
+
+ 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 := subnets.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, []subnets.AllocationPool{
+ {
+ Start: "192.0.0.2",
+ End: "192.255.255.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []subnets.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,
+ "gateway_ip": "192.168.199.1",
+ "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"
+ }
+}
+ `)
+ })
+
+ var gatewayIP = "192.168.199.1"
+ opts := subnets.CreateOpts{
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+ IPVersion: 4,
+ CIDR: "192.168.199.0/24",
+ GatewayIP: &gatewayIP,
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ },
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []subnets.HostRoute{
+ {NextHop: "bar"},
+ },
+ }
+ s, err := subnets.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, []subnets.AllocationPool{
+ {
+ Start: "192.168.199.2",
+ End: "192.168.199.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []subnets.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 TestCreateNoGateway(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-6722fc136a23",
+ "ip_version": 4,
+ "cidr": "192.168.1.0/24",
+ "gateway_ip": null,
+ "allocation_pools": [
+ {
+ "start": "192.168.1.2",
+ "end": "192.168.1.254"
+ }
+ ]
+ }
+}
+ `)
+
+ 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-6722fc136a23",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "allocation_pools": [
+ {
+ "start": "192.168.1.2",
+ "end": "192.168.1.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": null,
+ "cidr": "192.168.1.0/24",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0c"
+ }
+}
+ `)
+ })
+
+ var noGateway = ""
+ opts := subnets.CreateOpts{
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+ IPVersion: 4,
+ CIDR: "192.168.1.0/24",
+ GatewayIP: &noGateway,
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "192.168.1.2",
+ End: "192.168.1.254",
+ },
+ },
+ DNSNameservers: []string{},
+ }
+ s, err := subnets.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-6722fc136a23")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.AllocationPools, []subnets.AllocationPool{
+ {
+ Start: "192.168.1.2",
+ End: "192.168.1.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []subnets.HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "")
+ th.AssertEquals(t, s.CIDR, "192.168.1.0/24")
+ th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0c")
+}
+
+func TestCreateDefaultGateway(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-6722fc136a23",
+ "ip_version": 4,
+ "cidr": "192.168.1.0/24",
+ "allocation_pools": [
+ {
+ "start": "192.168.1.2",
+ "end": "192.168.1.254"
+ }
+ ]
+ }
+}
+ `)
+
+ 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-6722fc136a23",
+ "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+ "allocation_pools": [
+ {
+ "start": "192.168.1.2",
+ "end": "192.168.1.254"
+ }
+ ],
+ "host_routes": [],
+ "ip_version": 4,
+ "gateway_ip": "192.168.1.1",
+ "cidr": "192.168.1.0/24",
+ "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0c"
+ }
+}
+ `)
+ })
+
+ opts := subnets.CreateOpts{
+ NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a23",
+ IPVersion: 4,
+ CIDR: "192.168.1.0/24",
+ AllocationPools: []subnets.AllocationPool{
+ {
+ Start: "192.168.1.2",
+ End: "192.168.1.254",
+ },
+ },
+ DNSNameservers: []string{},
+ }
+ s, err := subnets.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-6722fc136a23")
+ th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+ th.AssertDeepEquals(t, s.AllocationPools, []subnets.AllocationPool{
+ {
+ Start: "192.168.1.2",
+ End: "192.168.1.254",
+ },
+ })
+ th.AssertDeepEquals(t, s.HostRoutes, []subnets.HostRoute{})
+ th.AssertEquals(t, s.IPVersion, 4)
+ th.AssertEquals(t, s.GatewayIP, "192.168.1.1")
+ th.AssertEquals(t, s.CIDR, "192.168.1.0/24")
+ th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0c")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+ res := subnets.Create(fake.ServiceClient(), subnets.CreateOpts{})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = subnets.Create(fake.ServiceClient(), subnets.CreateOpts{NetworkID: "foo"})
+ if res.Err == nil {
+ t.Fatalf("Expected error, got none")
+ }
+
+ res = subnets.Create(fake.ServiceClient(), subnets.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 := subnets.UpdateOpts{
+ Name: "my_new_subnet",
+ DNSNameservers: []string{"foo"},
+ HostRoutes: []subnets.HostRoute{
+ {NextHop: "bar"},
+ },
+ }
+ s, err := subnets.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 TestUpdateGateway(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",
+ "gateway_ip": "10.0.0.1"
+ }
+}
+ `)
+
+ 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"
+ }
+}
+ `)
+ })
+
+ var gatewayIP = "10.0.0.1"
+ opts := subnets.UpdateOpts{
+ Name: "my_new_subnet",
+ GatewayIP: &gatewayIP,
+ }
+ s, err := subnets.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")
+ th.AssertEquals(t, s.GatewayIP, "10.0.0.1")
+}
+
+func TestUpdateRemoveGateway(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",
+ "gateway_ip": null
+ }
+}
+ `)
+
+ 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": null,
+ "cidr": "10.0.0.0/24",
+ "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+ }
+}
+ `)
+ })
+
+ var noGateway = ""
+ opts := subnets.UpdateOpts{
+ Name: "my_new_subnet",
+ GatewayIP: &noGateway,
+ }
+ s, err := subnets.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")
+ th.AssertEquals(t, s.GatewayIP, "")
+}
+
+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 := subnets.Delete(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/subnets/testing/results_test.go b/openstack/networking/v2/subnets/testing/results_test.go
new file mode 100644
index 0000000..a227ccd
--- /dev/null
+++ b/openstack/networking/v2/subnets/testing/results_test.go
@@ -0,0 +1,59 @@
+package testing
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+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 := gophercloud.Result{Body: dejson}
+ var subnetWrapper struct {
+ Subnet subnets.Subnet `json:"subnet"`
+ }
+ err = resp.ExtractInto(&subnetWrapper)
+ if err != nil {
+ t.Fatalf("%s", err)
+ }
+ route := subnetWrapper.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..7a4f2f7
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls.go
@@ -0,0 +1,31 @@
+package subnets
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/objectstorage/v1/accounts/requests.go
new file mode 100644
index 0000000..b5beef2
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/requests.go
@@ -0,0 +1,100 @@
+package accounts
+
+import "github.com/gophercloud/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) (r GetResult) {
+ h := make(map[string]string)
+ if opts != nil {
+ headers, err := opts.ToAccountGetMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ 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 {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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) (r UpdateResult) {
+ h := make(map[string]string)
+ if opts != nil {
+ headers, err := opts.ToAccountUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ 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 {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
diff --git a/openstack/objectstorage/v1/accounts/results.go b/openstack/objectstorage/v1/accounts/results.go
new file mode 100644
index 0000000..9bc8340
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/results.go
@@ -0,0 +1,167 @@
+package accounts
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gophercloud/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 int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ TransID string `json:"X-Trans-Id"`
+ Date time.Time `json:"-"`
+}
+
+func (r *UpdateHeader) UnmarshalJSON(b []byte) error {
+ type tmp UpdateHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = UpdateHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return err
+}
+
+// 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 (r UpdateResult) Extract() (*UpdateHeader, error) {
+ var s *UpdateHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ BytesUsed int64 `json:"-"`
+ ContainerCount int64 `json:"-"`
+ ContentLength int64 `json:"-"`
+ ObjectCount int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ TransID string `json:"X-Trans-Id"`
+ TempURLKey string `json:"X-Account-Meta-Temp-URL-Key"`
+ TempURLKey2 string `json:"X-Account-Meta-Temp-URL-Key-2"`
+ Date time.Time `json:"-"`
+}
+
+func (r *GetHeader) UnmarshalJSON(b []byte) error {
+ type tmp GetHeader
+ var s struct {
+ tmp
+ BytesUsed string `json:"X-Account-Bytes-Used"`
+ ContentLength string `json:"Content-Length"`
+ ContainerCount string `json:"X-Account-Container-Count"`
+ ObjectCount string `json:"X-Account-Object-Count"`
+ Date string `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = GetHeader(s.tmp)
+
+ switch s.BytesUsed {
+ case "":
+ r.BytesUsed = 0
+ default:
+ r.BytesUsed, err = strconv.ParseInt(s.BytesUsed, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ switch s.ObjectCount {
+ case "":
+ r.ObjectCount = 0
+ default:
+ r.ObjectCount, err = strconv.ParseInt(s.ObjectCount, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ switch s.ContainerCount {
+ case "":
+ r.ContainerCount = 0
+ default:
+ r.ContainerCount, err = strconv.ParseInt(s.ContainerCount, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ if s.Date != "" {
+ r.Date, err = time.Parse(time.RFC1123, s.Date)
+ }
+
+ return err
+}
+
+// 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 (r GetResult) Extract() (*GetHeader, error) {
+ var s *GetHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *htts.Response)
+// and returns the custom metatdata associated with the account.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ metadata := make(map[string]string)
+ for k, v := range r.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/testing/doc.go b/openstack/objectstorage/v1/accounts/testing/doc.go
new file mode 100644
index 0000000..b8fdf88
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/testing/doc.go
@@ -0,0 +1,2 @@
+// objectstorage_accounts_v1
+package testing
diff --git a/openstack/objectstorage/v1/accounts/testing/fixtures.go b/openstack/objectstorage/v1/accounts/testing/fixtures.go
new file mode 100644
index 0000000..fff3071
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/testing/fixtures.go
@@ -0,0 +1,39 @@
+package testing
+
+import (
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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-Object-Count", "5")
+ w.Header().Set("X-Account-Bytes-Used", "14")
+ w.Header().Set("X-Account-Meta-Subject", "books")
+ w.Header().Set("Date", "Fri, 17 Jan 2014 16:09:56 GMT")
+
+ 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.Header().Set("Date", "Fri, 17 Jan 2014 16:09:56 GMT")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/objectstorage/v1/accounts/testing/requests_test.go b/openstack/objectstorage/v1/accounts/testing/requests_test.go
new file mode 100644
index 0000000..97852f1
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/testing/requests_test.go
@@ -0,0 +1,55 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/accounts"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+var (
+ loc, _ = time.LoadLocation("GMT")
+)
+
+func TestUpdateAccount(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateAccountSuccessfully(t)
+
+ options := &accounts.UpdateOpts{Metadata: map[string]string{"gophercloud-test": "accounts"}}
+ res := accounts.Update(fake.ServiceClient(), options)
+ th.AssertNoErr(t, res.Err)
+
+ expected := &accounts.UpdateHeader{
+ Date: time.Date(2014, time.January, 17, 16, 9, 56, 0, loc), // Fri, 17 Jan 2014 16:09:56 GMT
+ }
+ actual, err := res.Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestGetAccount(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetAccountSuccessfully(t)
+
+ expectedMetadata := map[string]string{"Subject": "books"}
+ res := accounts.Get(fake.ServiceClient(), &accounts.GetOpts{})
+ th.AssertNoErr(t, res.Err)
+ actualMetadata, _ := res.ExtractMetadata()
+ th.CheckDeepEquals(t, expectedMetadata, actualMetadata)
+ _, err := res.Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &accounts.GetHeader{
+ ContainerCount: 2,
+ ObjectCount: 5,
+ BytesUsed: 14,
+ Date: time.Date(2014, time.January, 17, 16, 9, 56, 0, loc), // Fri, 17 Jan 2014 16:09:56 GMT
+ }
+ actual, err := res.Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/accounts/urls.go b/openstack/objectstorage/v1/accounts/urls.go
new file mode 100644
index 0000000..71540b1
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/urls.go
@@ -0,0 +1,11 @@
+package accounts
+
+import "github.com/gophercloud/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/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/requests.go b/openstack/objectstorage/v1/containers/requests.go
new file mode 100644
index 0000000..a668673
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/requests.go
@@ -0,0 +1,191 @@
+package containers
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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)
+ return opts.Full, q.String(), err
+}
+
+// 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"}
+ }
+ }
+
+ pager := pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ p := ContainerPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+ 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) (r CreateResult) {
+ h := make(map[string]string)
+ if opts != nil {
+ headers, err := opts.ToContainerCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ 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 {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// Delete is a function that deletes a container.
+func Delete(c *gophercloud.ServiceClient, containerName string) (r DeleteResult) {
+ _, r.Err = c.Delete(deleteURL(c, containerName), nil)
+ return
+}
+
+// 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) (r UpdateResult) {
+ h := make(map[string]string)
+ if opts != nil {
+ headers, err := opts.ToContainerUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ 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 {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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) (r GetResult) {
+ resp, err := c.Request("HEAD", getURL(c, containerName), &gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
diff --git a/openstack/objectstorage/v1/containers/results.go b/openstack/objectstorage/v1/containers/results.go
new file mode 100644
index 0000000..8c11b8c
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/results.go
@@ -0,0 +1,343 @@
+package containers
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Container represents a container resource.
+type Container struct {
+ // The total number of bytes stored in the container.
+ Bytes int64 `json:"bytes"`
+
+ // The total number of objects stored in the container.
+ Count int64 `json:"count"`
+
+ // The name of the container.
+ Name string `json:"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)
+ return len(names) == 0, err
+}
+
+// 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(r pagination.Page) ([]Container, error) {
+ var s []Container
+ err := (r.(ContainerPage)).ExtractInto(&s)
+ return s, err
+}
+
+// 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 `json:"Accept-Ranges"`
+ BytesUsed int64 `json:"-"`
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ ObjectCount int64 `json:"-"`
+ Read []string `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+ VersionsLocation string `json:"X-Versions-Location"`
+ Write []string `json:"-"`
+}
+
+func (r *GetHeader) UnmarshalJSON(b []byte) error {
+ type tmp GetHeader
+ var s struct {
+ tmp
+ BytesUsed string `json:"X-Container-Bytes-Used"`
+ ContentLength string `json:"Content-Length"`
+ ObjectCount string `json:"X-Container-Object-Count"`
+ Write string `json:"X-Container-Write"`
+ Read string `json:"X-Container-Read"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = GetHeader(s.tmp)
+
+ switch s.BytesUsed {
+ case "":
+ r.BytesUsed = 0
+ default:
+ r.BytesUsed, err = strconv.ParseInt(s.BytesUsed, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ switch s.ObjectCount {
+ case "":
+ r.ObjectCount = 0
+ default:
+ r.ObjectCount, err = strconv.ParseInt(s.ObjectCount, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Read = strings.Split(s.Read, ",")
+ r.Write = strings.Split(s.Write, ",")
+
+ r.Date = time.Time(s.Date)
+
+ return err
+}
+
+// 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 (r GetResult) Extract() (*GetHeader, error) {
+ var s *GetHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *stts.Response)
+// and returns the custom metadata associated with the container.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ metadata := make(map[string]string)
+ for k, v := range r.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 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *CreateHeader) UnmarshalJSON(b []byte) error {
+ type tmp CreateHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = CreateHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return err
+}
+
+// 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 (r CreateResult) Extract() (*CreateHeader, error) {
+ var s *CreateHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *UpdateHeader) UnmarshalJSON(b []byte) error {
+ type tmp UpdateHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = UpdateHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return err
+}
+
+// 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 (r UpdateResult) Extract() (*UpdateHeader, error) {
+ var s *UpdateHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *DeleteHeader) UnmarshalJSON(b []byte) error {
+ type tmp DeleteHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = DeleteHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return err
+}
+
+// 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 (r DeleteResult) Extract() (*DeleteHeader, error) {
+ var s *DeleteHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/objectstorage/v1/containers/testing/doc.go b/openstack/objectstorage/v1/containers/testing/doc.go
new file mode 100644
index 0000000..c27fa49
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/testing/doc.go
@@ -0,0 +1,2 @@
+// objectstorage_containers_v1
+package testing
diff --git a/openstack/objectstorage/v1/containers/testing/fixtures.go b/openstack/objectstorage/v1/containers/testing/fixtures.go
new file mode 100644
index 0000000..b68230a
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/testing/fixtures.go
@@ -0,0 +1,154 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ExpectedListInfo is the result expected from a call to `List` when full
+// info is requested.
+var ExpectedListInfo = []containers.Container{
+ {
+ Count: 0,
+ Bytes: 0,
+ Name: "janeausten",
+ },
+ {
+ 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().Set("Content-Length", "0")
+ w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+ w.Header().Set("Date", "Wed, 17 Aug 2016 19:25:43 GMT")
+ w.Header().Set("X-Trans-Id", "tx554ed59667a64c61866f1-0058b4ba37")
+ 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.Header().Set("Accept-Ranges", "bytes")
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Date", "Wed, 17 Aug 2016 19:25:43 GMT")
+ w.Header().Set("X-Container-Bytes-Used", "100")
+ w.Header().Set("X-Container-Object-Count", "4")
+ w.Header().Set("X-Container-Read", "test")
+ w.Header().Set("X-Container-Write", "test2,user4")
+ w.Header().Set("X-Timestamp", "1471298837.95721")
+ w.Header().Set("X-Trans-Id", "tx554ed59667a64c61866f1-0057b4ba37")
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/objectstorage/v1/containers/testing/requests_test.go b/openstack/objectstorage/v1/containers/testing/requests_test.go
new file mode 100644
index 0000000..bb0c784
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/testing/requests_test.go
@@ -0,0 +1,144 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+var (
+ metadata = map[string]string{"gophercloud-test": "containers"}
+ loc, _ = time.LoadLocation("GMT")
+)
+
+func TestListContainerInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListContainerInfoSuccessfully(t)
+
+ count := 0
+ err := containers.List(fake.ServiceClient(), &containers.ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := containers.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 := containers.List(fake.ServiceClient(), &containers.ListOpts{Full: true}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := containers.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 := containers.List(fake.ServiceClient(), &containers.ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := containers.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 := containers.List(fake.ServiceClient(), &containers.ListOpts{Full: false}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := containers.ExtractNames(allPages)
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, ExpectedListNames, actual)
+}
+
+func TestCreateContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateContainerSuccessfully(t)
+
+ options := containers.CreateOpts{ContentType: "application/json", Metadata: map[string]string{"foo": "bar"}}
+ res := containers.Create(fake.ServiceClient(), "testContainer", options)
+ th.CheckEquals(t, "bar", res.Header["X-Container-Meta-Foo"][0])
+
+ expected := &containers.CreateHeader{
+ ContentLength: 0,
+ ContentType: "text/html; charset=UTF-8",
+ Date: time.Date(2016, time.August, 17, 19, 25, 43, 0, loc), //Wed, 17 Aug 2016 19:25:43 GMT
+ TransID: "tx554ed59667a64c61866f1-0058b4ba37",
+ }
+ actual, err := res.Extract()
+ th.CheckNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteContainerSuccessfully(t)
+
+ res := containers.Delete(fake.ServiceClient(), "testContainer")
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestUpateContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateContainerSuccessfully(t)
+
+ options := &containers.UpdateOpts{Metadata: map[string]string{"foo": "bar"}}
+ res := containers.Update(fake.ServiceClient(), "testContainer", options)
+ th.CheckNoErr(t, res.Err)
+}
+
+func TestGetContainer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetContainerSuccessfully(t)
+
+ res := containers.Get(fake.ServiceClient(), "testContainer")
+ _, err := res.ExtractMetadata()
+ th.CheckNoErr(t, err)
+
+ expected := &containers.GetHeader{
+ AcceptRanges: "bytes",
+ BytesUsed: 100,
+ ContentType: "application/json; charset=utf-8",
+ Date: time.Date(2016, time.August, 17, 19, 25, 43, 0, loc), //Wed, 17 Aug 2016 19:25:43 GMT
+ ObjectCount: 4,
+ Read: []string{"test"},
+ TransID: "tx554ed59667a64c61866f1-0057b4ba37",
+ Write: []string{"test2", "user4"},
+ }
+ actual, err := res.Extract()
+ th.CheckNoErr(t, err)
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/containers/urls.go b/openstack/objectstorage/v1/containers/urls.go
new file mode 100644
index 0000000..9b38047
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/urls.go
@@ -0,0 +1,23 @@
+package containers
+
+import "github.com/gophercloud/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/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/errors.go b/openstack/objectstorage/v1/objects/errors.go
new file mode 100644
index 0000000..5c4ae44
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/errors.go
@@ -0,0 +1,13 @@
+package objects
+
+import "github.com/gophercloud/gophercloud"
+
+// ErrWrongChecksum is the error when the checksum generated for an object
+// doesn't match the ETAG header.
+type ErrWrongChecksum struct {
+ gophercloud.BaseError
+}
+
+func (e ErrWrongChecksum) Error() string {
+ return "Local checksum does not match API ETag header"
+}
diff --git a/openstack/objectstorage/v1/objects/requests.go b/openstack/objectstorage/v1/objects/requests.go
new file mode 100644
index 0000000..0ab5e17
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/requests.go
@@ -0,0 +1,453 @@
+package objects
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/sha1"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/accounts"
+ "github.com/gophercloud/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)
+ return opts.Full, q.String(), err
+}
+
+// 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"}
+ }
+ }
+
+ pager := pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ p := ObjectPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+ 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) (r DownloadResult) {
+ url := downloadURL(c, containerName, objectName)
+ h := make(map[string]string)
+ if opts != nil {
+ headers, query, err := opts.ToObjectDownloadParams()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ for k, v := range headers {
+ h[k] = v
+ }
+ url += query
+ }
+
+ resp, err := c.Get(url, nil, &gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{200, 304},
+ })
+ if resp != nil {
+ r.Header = resp.Header
+ r.Body = resp.Body
+ }
+ r.Err = err
+ return
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToObjectCreateParams() (io.Reader, map[string]string, string, error)
+}
+
+// CreateOpts is a structure that holds parameters for creating an object.
+type CreateOpts struct {
+ Content io.Reader
+ Metadata map[string]string
+ CacheControl string `h:"Cache-Control"`
+ 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() (io.Reader, map[string]string, string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ h, err := gophercloud.BuildHeaders(opts)
+ if err != nil {
+ return nil, nil, "", err
+ }
+
+ for k, v := range opts.Metadata {
+ h["X-Object-Meta-"+k] = v
+ }
+
+ hash := md5.New()
+ buf := bytes.NewBuffer([]byte{})
+ _, err = io.Copy(io.MultiWriter(hash, buf), opts.Content)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ localChecksum := fmt.Sprintf("%x", hash.Sum(nil))
+ h["ETag"] = localChecksum
+
+ return buf, 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, opts CreateOptsBuilder) (r CreateResult) {
+ url := createURL(c, containerName, objectName)
+ h := make(map[string]string)
+ var b io.Reader
+ if opts != nil {
+ tmpB, headers, query, err := opts.ToObjectCreateParams()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ for k, v := range headers {
+ h[k] = v
+ }
+ url += query
+ b = tmpB
+ }
+
+ resp, err := c.Put(url, nil, nil, &gophercloud.RequestOpts{
+ RawBody: b,
+ MoreHeaders: h,
+ })
+ r.Err = err
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ return
+}
+
+// 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:"true"`
+}
+
+// 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
+ }
+ return h, nil
+}
+
+// Copy is a function that copies one object to another.
+func Copy(c *gophercloud.ServiceClient, containerName, objectName string, opts CopyOptsBuilder) (r CopyResult) {
+ h := make(map[string]string)
+ headers, err := opts.ToObjectCopyMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ 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 {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// Delete is a function that deletes an object.
+func Delete(c *gophercloud.ServiceClient, containerName, objectName string, opts DeleteOptsBuilder) (r DeleteResult) {
+ url := deleteURL(c, containerName, objectName)
+ if opts != nil {
+ query, err := opts.ToObjectDeleteQuery()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ url += query
+ }
+ resp, err := c.Delete(url, nil)
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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) (r GetResult) {
+ url := getURL(c, containerName, objectName)
+ if opts != nil {
+ query, err := opts.ToObjectGetQuery()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ url += query
+ }
+ resp, err := c.Request("HEAD", url, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 204},
+ })
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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) (r UpdateResult) {
+ h := make(map[string]string)
+ if opts != nil {
+ headers, err := opts.ToObjectUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+ url := updateURL(c, containerName, objectName)
+ resp, err := c.Post(url, nil, nil, &gophercloud.RequestOpts{
+ MoreHeaders: h,
+ })
+ if resp != nil {
+ r.Header = resp.Header
+ }
+ r.Err = err
+ return
+}
+
+// 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/results.go b/openstack/objectstorage/v1/objects/results.go
new file mode 100644
index 0000000..0dcdbe2
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/results.go
@@ -0,0 +1,493 @@
+package objects
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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"`
+
+ // ContentType is the content type of the object.
+ ContentType string `json:"content_type"`
+
+ // Hash represents the MD5 checksum value of the object's content.
+ Hash string `json:"hash"`
+
+ // LastModified is the time the object was last modified, represented
+ // as a string.
+ LastModified time.Time `json:"-"`
+
+ // Name is the unique name for the object.
+ Name string `json:"name"`
+}
+
+func (r *Object) UnmarshalJSON(b []byte) error {
+ type tmp Object
+ var s *struct {
+ tmp
+ LastModified gophercloud.JSONRFC3339MilliNoZ `json:"last_modified"`
+ }
+
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = Object(s.tmp)
+
+ r.LastModified = time.Time(s.LastModified)
+
+ return nil
+
+}
+
+// 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)
+ return len(names) == 0, err
+}
+
+// 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(r pagination.Page) ([]Object, error) {
+ var s []Object
+ err := (r.(ObjectPage)).ExtractInto(&s)
+ return s, err
+}
+
+// ExtractNames is a function that takes a page of objects and returns only their names.
+func ExtractNames(r pagination.Page) ([]string, error) {
+ casted := r.(ObjectPage)
+ ct := casted.Header.Get("Content-Type")
+ switch {
+ case strings.HasPrefix(ct, "application/json"):
+ parsed, err := ExtractInfo(r)
+ 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(r.(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 `json:"Accept-Ranges"`
+ ContentDisposition string `json:"Content-Disposition"`
+ ContentEncoding string `json:"Content-Encoding"`
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ DeleteAt time.Time `json:"-"`
+ ETag string `json:"Etag"`
+ LastModified time.Time `json:"-"`
+ ObjectManifest string `json:"X-Object-Manifest"`
+ StaticLargeObject bool `json:"X-Static-Large-Object"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *DownloadHeader) UnmarshalJSON(b []byte) error {
+ type tmp DownloadHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ DeleteAt gophercloud.JSONUnix `json:"X-Delete-At"`
+ LastModified gophercloud.JSONRFC1123 `json:"Last-Modified"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = DownloadHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+ r.DeleteAt = time.Time(s.DeleteAt)
+ r.LastModified = time.Time(s.LastModified)
+
+ return nil
+}
+
+// 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 (r DownloadResult) Extract() (*DownloadHeader, error) {
+ var s *DownloadHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// 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 (r *DownloadResult) ExtractContent() ([]byte, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ defer r.Body.Close()
+ body, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ return nil, err
+ }
+ r.Body.Close()
+ return body, nil
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+ ContentDisposition string `json:"Content-Disposition"`
+ ContentEncoding string `json:"Content-Encoding"`
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ DeleteAt time.Time `json:"-"`
+ ETag string `json:"Etag"`
+ LastModified time.Time `json:"-"`
+ ObjectManifest string `json:"X-Object-Manifest"`
+ StaticLargeObject bool `json:"X-Static-Large-Object"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *GetHeader) UnmarshalJSON(b []byte) error {
+ type tmp GetHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ DeleteAt gophercloud.JSONUnix `json:"X-Delete-At"`
+ LastModified gophercloud.JSONRFC1123 `json:"Last-Modified"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = GetHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+ r.DeleteAt = time.Time(s.DeleteAt)
+ r.LastModified = time.Time(s.LastModified)
+
+ return nil
+}
+
+// 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 (r GetResult) Extract() (*GetHeader, error) {
+ var s *GetHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the object.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+ metadata := make(map[string]string)
+ for k, v := range r.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 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ ETag string `json:"Etag"`
+ LastModified time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *CreateHeader) UnmarshalJSON(b []byte) error {
+ type tmp CreateHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ LastModified gophercloud.JSONRFC1123 `json:"Last-Modified"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = CreateHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+ r.LastModified = time.Time(s.LastModified)
+
+ return nil
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+ checksum string
+ 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 (r CreateResult) Extract() (*CreateHeader, error) {
+ //if r.Header.Get("ETag") != fmt.Sprintf("%x", localChecksum) {
+ // return nil, ErrWrongChecksum{}
+ //}
+ var s *CreateHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *UpdateHeader) UnmarshalJSON(b []byte) error {
+ type tmp UpdateHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = UpdateHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return nil
+}
+
+// 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 (r UpdateResult) Extract() (*UpdateHeader, error) {
+ var s *UpdateHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+ ContentLength int64 `json:"Content-Length"`
+ ContentType string `json:"Content-Type"`
+ Date time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *DeleteHeader) UnmarshalJSON(b []byte) error {
+ type tmp DeleteHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = DeleteHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+
+ return nil
+}
+
+// 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 (r DeleteResult) Extract() (*DeleteHeader, error) {
+ var s *DeleteHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// CopyHeader represents the headers returned in the response from a Copy request.
+type CopyHeader struct {
+ ContentLength int64 `json:"-"`
+ ContentType string `json:"Content-Type"`
+ CopiedFrom string `json:"X-Copied-From"`
+ CopiedFromLastModified time.Time `json:"-"`
+ Date time.Time `json:"-"`
+ ETag string `json:"Etag"`
+ LastModified time.Time `json:"-"`
+ TransID string `json:"X-Trans-Id"`
+}
+
+func (r *CopyHeader) UnmarshalJSON(b []byte) error {
+ type tmp CopyHeader
+ var s struct {
+ tmp
+ ContentLength string `json:"Content-Length"`
+ CopiedFromLastModified gophercloud.JSONRFC1123 `json:"X-Copied-From-Last-Modified"`
+ Date gophercloud.JSONRFC1123 `json:"Date"`
+ LastModified gophercloud.JSONRFC1123 `json:"Last-Modified"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = CopyHeader(s.tmp)
+
+ switch s.ContentLength {
+ case "":
+ r.ContentLength = 0
+ default:
+ r.ContentLength, err = strconv.ParseInt(s.ContentLength, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ r.Date = time.Time(s.Date)
+ r.CopiedFromLastModified = time.Time(s.CopiedFromLastModified)
+ r.LastModified = time.Time(s.LastModified)
+
+ return nil
+}
+
+// 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 (r CopyResult) Extract() (*CopyHeader, error) {
+ var s *CopyHeader
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/objectstorage/v1/objects/testing/doc.go b/openstack/objectstorage/v1/objects/testing/doc.go
new file mode 100644
index 0000000..f008a80
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/testing/doc.go
@@ -0,0 +1,2 @@
+// objectstorage_objects_v1
+package testing
diff --git a/openstack/objectstorage/v1/objects/testing/fixtures.go b/openstack/objectstorage/v1/objects/testing/fixtures.go
new file mode 100644
index 0000000..08faab8
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/testing/fixtures.go
@@ -0,0 +1,214 @@
+package testing
+
+import (
+ "crypto/md5"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/objects"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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.Header().Set("Date", "Wed, 10 Nov 2009 23:00:00 GMT")
+ 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 = []objects.Object{
+ {
+ Hash: "451e372e48e0f6b1114fa0724aa79fa1",
+ LastModified: time.Date(2016, time.August, 17, 22, 11, 58, 602650000, time.UTC), //"2016-08-17T22:11:58.602650"
+ Bytes: 14,
+ Name: "goodbye",
+ ContentType: "application/octet-stream",
+ },
+ {
+ Hash: "451e372e48e0f6b1114fa0724aa79fa1",
+ LastModified: time.Date(2016, time.August, 17, 22, 11, 58, 602650000, time.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": "2016-08-17T22:11:58.602650",
+ "bytes": 14,
+ "name": "goodbye",
+ "content_type": "application/octet-stream"
+ },
+ {
+ "hash": "451e372e48e0f6b1114fa0724aa79fa1",
+ "last_modified": "2016-08-17T22:11:58.602650",
+ "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)
+ })
+}
+
+// HandleCreateTextWithCacheControlSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler
+// mux that responds with a `Create` response. A Cache-Control of `max-age="3600", public` is expected.
+func HandleCreateTextWithCacheControlSuccessfully(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, "Cache-Control", `max-age="3600", public`)
+ 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/testing/requests_test.go b/openstack/objectstorage/v1/objects/testing/requests_test.go
new file mode 100644
index 0000000..4f26632
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/testing/requests_test.go
@@ -0,0 +1,196 @@
+package testing
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/objects"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+var (
+ loc, _ = time.LoadLocation("GMT")
+)
+
+func TestDownloadReader(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDownloadObjectSuccessfully(t)
+
+ response := objects.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 := objects.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))
+
+ expected := &objects.DownloadHeader{
+ ContentLength: 36,
+ ContentType: "text/plain; charset=utf-8",
+ Date: time.Date(2009, time.November, 10, 23, 0, 0, 0, loc),
+ }
+ actual, err := response.Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestListObjectInfo(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleListObjectsInfoSuccessfully(t)
+
+ count := 0
+ options := &objects.ListOpts{Full: true}
+ err := objects.List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := objects.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 := &objects.ListOpts{Full: false}
+ err := objects.List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := objects.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 := &objects.CreateOpts{ContentType: "text/plain", Content: strings.NewReader(content)}
+ res := objects.Create(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateObjectWithCacheControl(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ content := "All mimsy were the borogoves"
+
+ HandleCreateTextWithCacheControlSuccessfully(t, content)
+
+ options := &objects.CreateOpts{
+ CacheControl: `max-age="3600", public`,
+ Content: strings.NewReader(content),
+ }
+ res := objects.Create(fake.ServiceClient(), "testContainer", "testObject", 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 := objects.Create(fake.ServiceClient(), "testContainer", "testObject", &objects.CreateOpts{Content: strings.NewReader(content)})
+ 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", &CreateOpts{Content: content})
+
+ 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 := &objects.CopyOpts{Destination: "/newTestContainer/newTestObject"}
+ res := objects.Copy(fake.ServiceClient(), "testContainer", "testObject", options)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestDeleteObject(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleDeleteObjectSuccessfully(t)
+
+ res := objects.Delete(fake.ServiceClient(), "testContainer", "testObject", nil)
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestUpateObjectMetadata(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleUpdateObjectSuccessfully(t)
+
+ options := &objects.UpdateOpts{Metadata: map[string]string{"Gophercloud-Test": "objects"}}
+ res := objects.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 := objects.Get(fake.ServiceClient(), "testContainer", "testObject", nil).ExtractMetadata()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/objects/urls.go b/openstack/objectstorage/v1/objects/urls.go
new file mode 100644
index 0000000..b3ac304
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/urls.go
@@ -0,0 +1,33 @@
+package objects
+
+import (
+ "github.com/gophercloud/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/swauth/requests.go b/openstack/objectstorage/v1/swauth/requests.go
new file mode 100644
index 0000000..e8589ae
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/requests.go
@@ -0,0 +1,70 @@
+package swauth
+
+import "github.com/gophercloud/gophercloud"
+
+// AuthOptsBuilder describes struct types that can be accepted by the Auth call.
+// The AuthOpts struct in this package does.
+type AuthOptsBuilder interface {
+ ToAuthOptsMap() (map[string]string, error)
+}
+
+// AuthOpts specifies an authentication request.
+type AuthOpts struct {
+ // User is an Swauth-based username in username:tenant format.
+ User string `h:"X-Auth-User" required:"true"`
+ // Key is a secret/password to authenticate the User with.
+ Key string `h:"X-Auth-Key" required:"true"`
+}
+
+// ToAuthOptsMap formats an AuthOpts structure into a request body.
+func (opts AuthOpts) ToAuthOptsMap() (map[string]string, error) {
+ return gophercloud.BuildHeaders(opts)
+}
+
+// Auth performs an authentication request for a Swauth-based user.
+func Auth(c *gophercloud.ProviderClient, opts AuthOptsBuilder) (r GetAuthResult) {
+ h := make(map[string]string)
+
+ if opts != nil {
+ headers, err := opts.ToAuthOptsMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ for k, v := range headers {
+ h[k] = v
+ }
+ }
+
+ resp, err := c.Request("GET", getURL(c), &gophercloud.RequestOpts{
+ MoreHeaders: h,
+ OkCodes: []int{200},
+ })
+
+ if resp != nil {
+ r.Header = resp.Header
+ }
+
+ r.Err = err
+
+ return r
+}
+
+// NewObjectStorageV1 creates a Swauth-authenticated *gophercloud.ServiceClient
+// client that can issue ObjectStorage-based API calls.
+func NewObjectStorageV1(pc *gophercloud.ProviderClient, authOpts AuthOpts) (*gophercloud.ServiceClient, error) {
+ auth, err := Auth(pc, authOpts).Extract()
+ if err != nil {
+ return nil, err
+ }
+
+ swiftClient := &gophercloud.ServiceClient{
+ ProviderClient: pc,
+ Endpoint: gophercloud.NormalizeURL(auth.StorageURL),
+ }
+
+ swiftClient.TokenID = auth.Token
+
+ return swiftClient, nil
+}
diff --git a/openstack/objectstorage/v1/swauth/results.go b/openstack/objectstorage/v1/swauth/results.go
new file mode 100644
index 0000000..294c43c
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/results.go
@@ -0,0 +1,27 @@
+package swauth
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// GetAuthResult temporarily contains the response from a Swauth
+// authentication call.
+type GetAuthResult struct {
+ gophercloud.HeaderResult
+}
+
+// AuthResult contains the authentication information from a Swauth
+// authentication request.
+type AuthResult struct {
+ Token string `json:"X-Auth-Token"`
+ StorageURL string `json:"X-Storage-Url"`
+ CDNURL string `json:"X-CDN-Management-Url"`
+}
+
+// Extract is a method that attempts to interpret any Swauth authentication
+// response as a AuthResult struct.
+func (r GetAuthResult) Extract() (*AuthResult, error) {
+ var s *AuthResult
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/objectstorage/v1/swauth/testing/doc.go b/openstack/objectstorage/v1/swauth/testing/doc.go
new file mode 100644
index 0000000..ff3bf37
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/testing/doc.go
@@ -0,0 +1,2 @@
+// objectstorage_swauth_v1
+package testing
diff --git a/openstack/objectstorage/v1/swauth/testing/fixtures.go b/openstack/objectstorage/v1/swauth/testing/fixtures.go
new file mode 100644
index 0000000..79858f5
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/testing/fixtures.go
@@ -0,0 +1,29 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/swauth"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// AuthResult is the expected result of AuthOutput
+var AuthResult = swauth.AuthResult{
+ Token: "AUTH_tk6223e6071f8f4299aa334b48015484a1",
+ StorageURL: "http://127.0.0.1:8080/v1/AUTH_test/",
+}
+
+// HandleAuthSuccessfully configures the test server to respond to an Auth request.
+func HandleAuthSuccessfully(t *testing.T, authOpts swauth.AuthOpts) {
+ th.Mux.HandleFunc("/auth/v1.0", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-User", authOpts.User)
+ th.TestHeader(t, r, "X-Auth-Key", authOpts.Key)
+
+ w.Header().Add("X-Auth-Token", AuthResult.Token)
+ w.Header().Add("X-Storage-Url", AuthResult.StorageURL)
+ fmt.Fprintf(w, "")
+ })
+}
diff --git a/openstack/objectstorage/v1/swauth/testing/requests_test.go b/openstack/objectstorage/v1/swauth/testing/requests_test.go
new file mode 100644
index 0000000..57b5034
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/testing/requests_test.go
@@ -0,0 +1,27 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack"
+ "github.com/gophercloud/gophercloud/openstack/objectstorage/v1/swauth"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAuth(t *testing.T) {
+ authOpts := swauth.AuthOpts{
+ User: "test:tester",
+ Key: "testing",
+ }
+
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleAuthSuccessfully(t, authOpts)
+
+ providerClient, err := openstack.NewClient(th.Endpoint())
+ th.AssertNoErr(t, err)
+
+ swiftClient, err := swauth.NewObjectStorageV1(providerClient, authOpts)
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, swiftClient.TokenID, AuthResult.Token)
+}
diff --git a/openstack/objectstorage/v1/swauth/urls.go b/openstack/objectstorage/v1/swauth/urls.go
new file mode 100644
index 0000000..a30cabd
--- /dev/null
+++ b/openstack/objectstorage/v1/swauth/urls.go
@@ -0,0 +1,7 @@
+package swauth
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(c *gophercloud.ProviderClient) string {
+ return c.IdentityBase + "auth/v1.0"
+}
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..ff383cf
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/requests.go
@@ -0,0 +1,13 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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/results.go b/openstack/orchestration/v1/apiversions/results.go
new file mode 100644
index 0000000..a7c22a2
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/results.go
@@ -0,0 +1,36 @@
+package apiversions
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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 `json:"status"`
+ ID string `json:"id"`
+ Links []gophercloud.Link `json:"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)
+ return len(is) == 0, err
+}
+
+// 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(r pagination.Page) ([]APIVersion, error) {
+ var s struct {
+ APIVersions []APIVersion `json:"versions"`
+ }
+ err := (r.(APIVersionPage)).ExtractInto(&s)
+ return s.APIVersions, err
+}
diff --git a/openstack/orchestration/v1/apiversions/testing/doc.go b/openstack/orchestration/v1/apiversions/testing/doc.go
new file mode 100644
index 0000000..3d545fd
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_apiversions_v1
+package testing
diff --git a/openstack/orchestration/v1/apiversions/testing/requests_test.go b/openstack/orchestration/v1/apiversions/testing/requests_test.go
new file mode 100644
index 0000000..ac59b6c
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/testing/requests_test.go
@@ -0,0 +1,90 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/apiversions"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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
+
+ apiversions.ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := apiversions.ExtractAPIVersions(page)
+ if err != nil {
+ t.Errorf("Failed to extract API versions: %v", err)
+ return false, err
+ }
+
+ expected := []apiversions.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)
+ })
+
+ apiversions.ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ if _, err := apiversions.ExtractAPIVersions(page); err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+ return true, nil
+ })
+}
diff --git a/openstack/orchestration/v1/apiversions/urls.go b/openstack/orchestration/v1/apiversions/urls.go
new file mode 100644
index 0000000..0205405
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/urls.go
@@ -0,0 +1,7 @@
+package apiversions
+
+import "github.com/gophercloud/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/requests.go b/openstack/orchestration/v1/buildinfo/requests.go
new file mode 100644
index 0000000..32f6032
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/requests.go
@@ -0,0 +1,9 @@
+package buildinfo
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient) (r GetResult) {
+ _, r.Err = c.Get(getURL(c), &r.Body, nil)
+ return
+}
diff --git a/openstack/orchestration/v1/buildinfo/results.go b/openstack/orchestration/v1/buildinfo/results.go
new file mode 100644
index 0000000..c3d2cdb
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/results.go
@@ -0,0 +1,29 @@
+package buildinfo
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// Revision represents the API/Engine revision of a Heat deployment.
+type Revision struct {
+ Revision string `json:"revision"`
+}
+
+// BuildInfo represents the build information for a Heat deployment.
+type BuildInfo struct {
+ API Revision `json:"api"`
+ Engine Revision `json:"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) {
+ var s *BuildInfo
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/orchestration/v1/buildinfo/testing/doc.go b/openstack/orchestration/v1/buildinfo/testing/doc.go
new file mode 100644
index 0000000..3655974
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_buildinfo_v1
+package testing
diff --git a/openstack/orchestration/v1/buildinfo/testing/fixtures.go b/openstack/orchestration/v1/buildinfo/testing/fixtures.go
new file mode 100644
index 0000000..c240d5f
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/testing/fixtures.go
@@ -0,0 +1,46 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/buildinfo"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &buildinfo.BuildInfo{
+ API: buildinfo.Revision{
+ Revision: "2.4.5",
+ },
+ Engine: buildinfo.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/testing/requests_test.go b/openstack/orchestration/v1/buildinfo/testing/requests_test.go
new file mode 100644
index 0000000..bd2e164
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/testing/requests_test.go
@@ -0,0 +1,21 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/buildinfo"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := buildinfo.Get(fake.ServiceClient()).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := GetExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/buildinfo/urls.go b/openstack/orchestration/v1/buildinfo/urls.go
new file mode 100644
index 0000000..28a2128
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/urls.go
@@ -0,0 +1,7 @@
+package buildinfo
+
+import "github.com/gophercloud/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/requests.go b/openstack/orchestration/v1/stackevents/requests.go
new file mode 100644
index 0000000..e6e7f79
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/requests.go
@@ -0,0 +1,182 @@
+package stackevents
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Find retrieves stack events for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) (r FindResult) {
+ _, r.Err = c.Get(findURL(c, stackName), &r.Body, nil)
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, stackName, stackID, resourceName, eventID), &r.Body, nil)
+ return
+}
diff --git a/openstack/orchestration/v1/stackevents/results.go b/openstack/orchestration/v1/stackevents/results.go
new file mode 100644
index 0000000..46fb0ff
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/results.go
@@ -0,0 +1,119 @@
+package stackevents
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Event represents a stack event.
+type Event struct {
+ // The name of the resource for which the event occurred.
+ ResourceName string `json:"resource_name"`
+ // The time the event occurred.
+ Time time.Time `json:"-"`
+ // The URLs to the event.
+ Links []gophercloud.Link `json:"links"`
+ // The logical ID of the stack resource.
+ LogicalResourceID string `json:"logical_resource_id"`
+ // The reason of the status of the event.
+ ResourceStatusReason string `json:"resource_status_reason"`
+ // The status of the event.
+ ResourceStatus string `json:"resource_status"`
+ // The physical ID of the stack resource.
+ PhysicalResourceID string `json:"physical_resource_id"`
+ // The event ID.
+ ID string `json:"id"`
+ // Properties of the stack resource.
+ ResourceProperties map[string]interface{} `json:"resource_properties"`
+}
+
+func (r *Event) UnmarshalJSON(b []byte) error {
+ type tmp Event
+ var s struct {
+ tmp
+ Time gophercloud.JSONRFC3339NoZ `json:"event_time"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ *r = Event(s.tmp)
+
+ r.Time = time.Time(s.Time)
+
+ return nil
+}
+
+// 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) {
+ var s struct {
+ Events []Event `json:"events"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Events, err
+}
+
+// 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)
+ return len(events) == 0, err
+}
+
+// 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(r pagination.Page) ([]Event, error) {
+ var s struct {
+ Events []Event `json:"events"`
+ }
+ err := (r.(EventPage)).ExtractInto(&s)
+ return s.Events, err
+}
+
+// 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) {
+ var s struct {
+ Event *Event `json:"event"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Event, err
+}
diff --git a/openstack/orchestration/v1/stackevents/testing/doc.go b/openstack/orchestration/v1/stackevents/testing/doc.go
new file mode 100644
index 0000000..2e22a6c
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_stackevents_v1
+package testing
diff --git a/openstack/orchestration/v1/stackevents/testing/fixtures.go b/openstack/orchestration/v1/stackevents/testing/fixtures.go
new file mode 100644
index 0000000..a40e8d4
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/testing/fixtures.go
@@ -0,0 +1,447 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackevents"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []stackevents.Event{
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ 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 = []stackevents.Event{
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ 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 = []stackevents.Event{
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ LogicalResourceID: "hello_world",
+ ResourceStatusReason: "state changed",
+ ResourceStatus: "CREATE_IN_PROGRESS",
+ PhysicalResourceID: "",
+ ID: "06feb26f-9298-4a9b-8749-9d770e5d577a",
+ },
+ {
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ 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 = &stackevents.Event{
+ ResourceName: "hello_world",
+ Time: time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC),
+ Links: []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",
+ },
+ {
+ 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",
+ },
+ },
+ 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/testing/requests_test.go b/openstack/orchestration/v1/stackevents/testing/requests_test.go
new file mode 100644
index 0000000..0ad3fc3
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/testing/requests_test.go
@@ -0,0 +1,72 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackevents"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestFindEvents(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleFindSuccessfully(t, FindOutput)
+
+ actual, err := stackevents.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 := stackevents.List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := stackevents.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 := stackevents.ListResourceEvents(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := stackevents.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 := stackevents.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/urls.go b/openstack/orchestration/v1/stackevents/urls.go
new file mode 100644
index 0000000..6b6b330
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/urls.go
@@ -0,0 +1,19 @@
+package stackevents
+
+import "github.com/gophercloud/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/requests.go b/openstack/orchestration/v1/stackresources/requests.go
new file mode 100644
index 0000000..f368b76
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/requests.go
@@ -0,0 +1,77 @@
+package stackresources
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Find retrieves stack resources for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) (r FindResult) {
+ _, r.Err = c.Get(findURL(c, stackName), &r.Body, nil)
+ return
+}
+
+// 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)
+ return q.String(), err
+}
+
+// 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
+ }
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ResourcePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, stackName, stackID, resourceName), &r.Body, nil)
+ return
+}
+
+// Metadata retreives the metadata for the given stack resource.
+func Metadata(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) (r MetadataResult) {
+ _, r.Err = c.Get(metadataURL(c, stackName, stackID, resourceName), &r.Body, nil)
+ return
+}
+
+// ListTypes makes a request against the API to list resource types.
+func ListTypes(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listTypesURL(client), func(r pagination.PageResult) pagination.Page {
+ return ResourceTypePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// Schema retreives the schema for the given resource type.
+func Schema(c *gophercloud.ServiceClient, resourceType string) (r SchemaResult) {
+ _, r.Err = c.Get(schemaURL(c, resourceType), &r.Body, nil)
+ return
+}
+
+// Template retreives the template representation for the given resource type.
+func Template(c *gophercloud.ServiceClient, resourceType string) (r TemplateResult) {
+ _, r.Err = c.Get(templateURL(c, resourceType), &r.Body, nil)
+ return
+}
diff --git a/openstack/orchestration/v1/stackresources/results.go b/openstack/orchestration/v1/stackresources/results.go
new file mode 100644
index 0000000..59c02a3
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/results.go
@@ -0,0 +1,185 @@
+package stackresources
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Resource represents a stack resource.
+type Resource struct {
+ Attributes map[string]interface{} `json:"attributes"`
+ CreationTime time.Time `json:"-"`
+ Description string `json:"description"`
+ Links []gophercloud.Link `json:"links"`
+ LogicalID string `json:"logical_resource_id"`
+ Name string `json:"resource_name"`
+ PhysicalID string `json:"physical_resource_id"`
+ RequiredBy []interface{} `json:"required_by"`
+ Status string `json:"resource_status"`
+ StatusReason string `json:"resource_status_reason"`
+ Type string `json:"resource_type"`
+ UpdatedTime time.Time `json:"-"`
+}
+
+func (r *Resource) UnmarshalJSON(b []byte) error {
+ type tmp Resource
+ var s struct {
+ tmp
+ CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+ UpdatedTime gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Resource(s.tmp)
+
+ r.CreationTime = time.Time(s.CreationTime)
+ r.UpdatedTime = time.Time(s.UpdatedTime)
+
+ return nil
+}
+
+// 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) {
+ var s struct {
+ Resources []Resource `json:"resources"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Resources, err
+}
+
+// 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)
+ return len(resources) == 0, err
+}
+
+// ExtractResources interprets the results of a single page from a List() call, producing a slice of Resource entities.
+func ExtractResources(r pagination.Page) ([]Resource, error) {
+ var s struct {
+ Resources []Resource `json:"resources"`
+ }
+ err := (r.(ResourcePage)).ExtractInto(&s)
+ return s.Resources, err
+}
+
+// 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) {
+ var s struct {
+ Resource *Resource `json:"resource"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Resource, err
+}
+
+// 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) {
+ var s struct {
+ Meta map[string]string `json:"metadata"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Meta, err
+}
+
+// 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)
+ return len(rts) == 0, err
+}
+
+// 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(r pagination.Page) (ResourceTypes, error) {
+ var s struct {
+ ResourceTypes ResourceTypes `json:"resource_types"`
+ }
+ err := (r.(ResourceTypePage)).ExtractInto(&s)
+ return s.ResourceTypes, err
+}
+
+// TypeSchema represents a stack resource schema.
+type TypeSchema struct {
+ Attributes map[string]interface{} `json:"attributes"`
+ Properties map[string]interface{} `json:"properties"`
+ ResourceType string `json:"resource_type"`
+ SupportStatus map[string]interface{} `json:"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) {
+ var s *TypeSchema
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// 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, "", " ")
+ return template, err
+}
diff --git a/openstack/orchestration/v1/stackresources/testing/doc.go b/openstack/orchestration/v1/stackresources/testing/doc.go
new file mode 100644
index 0000000..16e1dae
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_stackresources_v1
+package testing
diff --git a/openstack/orchestration/v1/stackresources/testing/fixtures.go b/openstack/orchestration/v1/stackresources/testing/fixtures.go
new file mode 100644
index 0000000..e890337
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/testing/fixtures.go
@@ -0,0 +1,440 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackresources"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []stackresources.Resource{
+ {
+ Name: "hello_world",
+ Links: []gophercloud.Link{
+ {
+ 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",
+ },
+ },
+ 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 = []stackresources.Resource{
+ {
+ Name: "hello_world",
+ Links: []gophercloud.Link{
+ {
+ 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",
+ },
+ },
+ 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 = &stackresources.Resource{
+ Name: "wordpress_instance",
+ Links: []gophercloud.Link{
+ {
+ 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",
+ },
+ },
+ 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 = stackresources.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 = stackresources.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 = &stackresources.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/testing/requests_test.go b/openstack/orchestration/v1/stackresources/testing/requests_test.go
new file mode 100644
index 0000000..c714047
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/testing/requests_test.go
@@ -0,0 +1,112 @@
+package testing
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackresources"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestFindResources(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleFindSuccessfully(t, FindOutput)
+
+ actual, err := stackresources.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 := stackresources.List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := stackresources.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 := stackresources.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 := stackresources.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 := stackresources.ListTypes(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := stackresources.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 := stackresources.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 := stackresources.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/urls.go b/openstack/orchestration/v1/stackresources/urls.go
new file mode 100644
index 0000000..bbddc69
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/urls.go
@@ -0,0 +1,31 @@
+package stackresources
+
+import "github.com/gophercloud/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..8698918
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment.go
@@ -0,0 +1,134 @@
+package stacks
+
+import "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 ErrInvalidEnvironment{Section: 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..a7e3aae
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment_test.go
@@ -0,0 +1,185 @@
+package stacks
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ th "github.com/gophercloud/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/errors.go b/openstack/orchestration/v1/stacks/errors.go
new file mode 100644
index 0000000..cd6c18f
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/errors.go
@@ -0,0 +1,33 @@
+package stacks
+
+import (
+ "fmt"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+type ErrInvalidEnvironment struct {
+ gophercloud.BaseError
+ Section string
+}
+
+func (e ErrInvalidEnvironment) Error() string {
+ return fmt.Sprintf("Environment has wrong section: %s", e.Section)
+}
+
+type ErrInvalidDataFormat struct {
+ gophercloud.BaseError
+}
+
+func (e ErrInvalidDataFormat) Error() string {
+ return fmt.Sprintf("Data in neither json nor yaml format.")
+}
+
+type ErrInvalidTemplateFormatVersion struct {
+ gophercloud.BaseError
+ Version string
+}
+
+func (e ErrInvalidTemplateFormatVersion) Error() string {
+ return fmt.Sprintf("Template format version not found.")
+}
diff --git a/openstack/orchestration/v1/stacks/fixtures.go b/openstack/orchestration/v1/stacks/fixtures.go
new file mode 100644
index 0000000..d6fd075
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/fixtures.go
@@ -0,0 +1,199 @@
+package stacks
+
+// 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"
+ }
+ }
+}
+`
+
+// 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"
+ ]
+ }
+ }
+ }
+ }
+}
+`
+
+// 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
+`
+
+// 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",
+ },
+ },
+ },
+ },
+ },
+}
+
+// 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",
+ },
+ },
+}
diff --git a/openstack/orchestration/v1/stacks/requests.go b/openstack/orchestration/v1/stacks/requests.go
new file mode 100644
index 0000000..91f38ee
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/requests.go
@@ -0,0 +1,440 @@
+package stacks
+
+import (
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// 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 {
+ // The name of the stack. It must start with an alphabetic character.
+ Name string `json:"stack_name" required:"true"`
+ // 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 `json:"-" required:"true"`
+ // 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 *bool `json:"disable_rollback,omitempty"`
+ // A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment `json:"-"`
+ // User-defined parameters to pass to the template.
+ Parameters map[string]string `json:"parameters,omitempty"`
+ // The timeout for stack creation in minutes.
+ Timeout int `json:"timeout_mins,omitempty"`
+ // A list of tags to assosciate with the Stack
+ Tags []string `json:"-"`
+}
+
+// ToStackCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToStackCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ 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()
+ b["template"] = string(opts.TemplateOpts.Bin)
+
+ files := make(map[string]string)
+ 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
+ }
+ b["environment"] = string(opts.EnvironmentOpts.Bin)
+ }
+
+ if len(files) > 0 {
+ b["files"] = files
+ }
+
+ if opts.Tags != nil {
+ b["tags"] = strings.Join(opts.Tags, ",")
+ }
+
+ return b, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new stack using the values
+// provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToStackCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+ return
+}
+
+// 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 {
+ // Existing resources data represented as a string to add to the
+ // new stack. Data returned by Abandon could be provided as AdoptsStackData.
+ AdoptStackData string `json:"adopt_stack_data" required:"true"`
+ // The name of the stack. It must start with an alphabetic character.
+ Name string `json:"stack_name" required:"true"`
+ // 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 `json:"-" required:"true"`
+ // The timeout for stack creation in minutes.
+ Timeout int `json:"timeout_mins,omitempty"`
+ // 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 `json:"-" required:"true"`
+ // 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 *bool `json:"disable_rollback,omitempty"`
+ // A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment `json:"-"`
+ // User-defined parameters to pass to the template.
+ Parameters map[string]string `json:"parameters,omitempty"`
+}
+
+// ToStackAdoptMap casts a CreateOpts struct to a map.
+func (opts AdoptOpts) ToStackAdoptMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ 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()
+ b["template"] = string(opts.TemplateOpts.Bin)
+
+ files := make(map[string]string)
+ 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
+ }
+ b["environment"] = string(opts.EnvironmentOpts.Bin)
+ }
+
+ if len(files) > 0 {
+ b["files"] = files
+ }
+
+ return b, nil
+}
+
+// Adopt accepts an AdoptOpts struct and creates a new stack using the resources
+// from another stack.
+func Adopt(c *gophercloud.ServiceClient, opts AdoptOptsBuilder) (r AdoptResult) {
+ b, err := opts.ToStackAdoptMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(adoptURL(c), b, &r.Body, nil)
+ return
+}
+
+// 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) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, stackName, stackID), &r.Body, nil)
+ return
+}
+
+// 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 {
+ // 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 `json:"-" required:"true"`
+ // A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment `json:"-"`
+ // User-defined parameters to pass to the template.
+ Parameters map[string]string `json:"parameters,omitempty"`
+ // The timeout for stack creation in minutes.
+ Timeout int `json:"timeout_mins,omitempty"`
+ // A list of tags to assosciate with the Stack
+ Tags []string `json:"-"`
+}
+
+// ToStackUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToStackUpdateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ 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()
+ b["template"] = string(opts.TemplateOpts.Bin)
+
+ files := make(map[string]string)
+ 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
+ }
+ b["environment"] = string(opts.EnvironmentOpts.Bin)
+ }
+
+ if len(files) > 0 {
+ b["files"] = files
+ }
+
+ if opts.Tags != nil {
+ b["tags"] = strings.Join(opts.Tags, ",")
+ }
+
+ return b, 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) (r UpdateResult) {
+ b, err := opts.ToStackUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Put(updateURL(c, stackName, stackID), b, nil, nil)
+ return
+}
+
+// Delete deletes a stack based on the stack name and stack ID.
+func Delete(c *gophercloud.ServiceClient, stackName, stackID string) (r DeleteResult) {
+ _, r.Err = c.Delete(deleteURL(c, stackName, stackID), nil)
+ return
+}
+
+// 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 {
+ // The name of the stack. It must start with an alphabetic character.
+ Name string `json:"stack_name" required:"true"`
+ // The timeout for stack creation in minutes.
+ Timeout int `json:"timeout_mins" required:"true"`
+ // 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 `json:"-" required:"true"`
+ // 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 *bool `json:"disable_rollback,omitempty"`
+ // A structure that contains details for the environment of the stack.
+ EnvironmentOpts *Environment `json:"-"`
+ // User-defined parameters to pass to the template.
+ Parameters map[string]string `json:"parameters,omitempty"`
+}
+
+// ToStackPreviewMap casts a PreviewOpts struct to a map.
+func (opts PreviewOpts) ToStackPreviewMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ 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()
+ b["template"] = string(opts.TemplateOpts.Bin)
+
+ files := make(map[string]string)
+ 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
+ }
+ b["environment"] = string(opts.EnvironmentOpts.Bin)
+ }
+
+ if len(files) > 0 {
+ b["files"] = files
+ }
+
+ return b, nil
+}
+
+// Preview accepts a PreviewOptsBuilder interface and creates a preview of a stack using the values
+// provided.
+func Preview(c *gophercloud.ServiceClient, opts PreviewOptsBuilder) (r PreviewResult) {
+ b, err := opts.ToStackPreviewMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(previewURL(c), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// 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) (r AbandonResult) {
+ _, r.Err = c.Delete(abandonURL(c, stackName, stackID), &gophercloud.RequestOpts{
+ JSONResponse: &r.Body,
+ OkCodes: []int{200},
+ })
+ return
+}
diff --git a/openstack/orchestration/v1/stacks/results.go b/openstack/orchestration/v1/stacks/results.go
new file mode 100644
index 0000000..8df5419
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/results.go
@@ -0,0 +1,238 @@
+package stacks
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreatedStack represents the object extracted from a Create operation.
+type CreatedStack struct {
+ ID string `json:"id"`
+ Links []gophercloud.Link `json:"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) {
+ var s struct {
+ CreatedStack *CreatedStack `json:"stack"`
+ }
+ err := r.ExtractInto(&s)
+ return s.CreatedStack, err
+}
+
+// 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)
+ return len(stacks) == 0, err
+}
+
+// ListedStack represents an element in the slice extracted from a List operation.
+type ListedStack struct {
+ CreationTime time.Time `json:"-"`
+ Description string `json:"description"`
+ ID string `json:"id"`
+ Links []gophercloud.Link `json:"links"`
+ Name string `json:"stack_name"`
+ Status string `json:"stack_status"`
+ StatusReason string `json:"stack_status_reason"`
+ Tags []string `json:"tags"`
+ UpdatedTime time.Time `json:"-"`
+}
+
+func (r *ListedStack) UnmarshalJSON(b []byte) error {
+ type tmp ListedStack
+ var s struct {
+ tmp
+ CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+ UpdatedTime gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = ListedStack(s.tmp)
+
+ r.CreationTime = time.Time(s.CreationTime)
+ r.UpdatedTime = time.Time(s.UpdatedTime)
+
+ return nil
+}
+
+// ExtractStacks extracts and returns a slice of ListedStack. It is used while iterating
+// over a stacks.List call.
+func ExtractStacks(r pagination.Page) ([]ListedStack, error) {
+ var s struct {
+ ListedStacks []ListedStack `json:"stacks"`
+ }
+ err := (r.(StackPage)).ExtractInto(&s)
+ return s.ListedStacks, err
+}
+
+// RetrievedStack represents the object extracted from a Get operation.
+type RetrievedStack struct {
+ Capabilities []interface{} `json:"capabilities"`
+ CreationTime time.Time `json:"-"`
+ Description string `json:"description"`
+ DisableRollback bool `json:"disable_rollback"`
+ ID string `json:"id"`
+ Links []gophercloud.Link `json:"links"`
+ NotificationTopics []interface{} `json:"notification_topics"`
+ Outputs []map[string]interface{} `json:"outputs"`
+ Parameters map[string]string `json:"parameters"`
+ Name string `json:"stack_name"`
+ Status string `json:"stack_status"`
+ StatusReason string `json:"stack_status_reason"`
+ Tags []string `json:"tags"`
+ TemplateDescription string `json:"template_description"`
+ Timeout int `json:"timeout_mins"`
+ UpdatedTime time.Time `json:"-"`
+}
+
+func (r *RetrievedStack) UnmarshalJSON(b []byte) error {
+ type tmp RetrievedStack
+ var s struct {
+ tmp
+ CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+ UpdatedTime gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = RetrievedStack(s.tmp)
+
+ r.CreationTime = time.Time(s.CreationTime)
+ r.UpdatedTime = time.Time(s.UpdatedTime)
+
+ return nil
+}
+
+// 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) {
+ var s struct {
+ Stack *RetrievedStack `json:"stack"`
+ }
+ err := r.ExtractInto(&s)
+ return s.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{} `json:"capabilities"`
+ CreationTime time.Time `json:"-"`
+ Description string `json:"description"`
+ DisableRollback bool `json:"disable_rollback"`
+ ID string `json:"id"`
+ Links []gophercloud.Link `json:"links"`
+ Name string `json:"stack_name"`
+ NotificationTopics []interface{} `json:"notification_topics"`
+ Parameters map[string]string `json:"parameters"`
+ Resources []interface{} `json:"resources"`
+ TemplateDescription string `json:"template_description"`
+ Timeout int `json:"timeout_mins"`
+ UpdatedTime time.Time `json:"-"`
+}
+
+func (r *PreviewedStack) UnmarshalJSON(b []byte) error {
+ type tmp PreviewedStack
+ var s struct {
+ tmp
+ CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+ UpdatedTime gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = PreviewedStack(s.tmp)
+
+ r.CreationTime = time.Time(s.CreationTime)
+ r.UpdatedTime = time.Time(s.UpdatedTime)
+
+ return nil
+}
+
+// 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) {
+ var s struct {
+ PreviewedStack *PreviewedStack `json:"stack"`
+ }
+ err := r.ExtractInto(&s)
+ return s.PreviewedStack, err
+}
+
+// AbandonedStack represents the result of an Abandon operation.
+type AbandonedStack struct {
+ Status string `json:"status"`
+ Name string `json:"name"`
+ Template map[string]interface{} `json:"template"`
+ Action string `json:"action"`
+ ID string `json:"id"`
+ Resources map[string]interface{} `json:"resources"`
+ Files map[string]string `json:"files"`
+ StackUserProjectID string `json:"stack_user_project_id"`
+ ProjectID string `json:"project_id"`
+ Environment map[string]interface{} `json:"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) {
+ var s *AbandonedStack
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// 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)
+ return string(out), err
+}
diff --git a/openstack/orchestration/v1/stacks/template.go b/openstack/orchestration/v1/stacks/template.go
new file mode 100644
index 0000000..4cf5aae
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/template.go
@@ -0,0 +1,141 @@
+package stacks
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// 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
+ }
+ }
+ var invalid string
+ for key := range t.Parsed {
+ if _, ok := TemplateFormatVersions[key]; ok {
+ return nil
+ }
+ invalid = key
+ }
+ return ErrInvalidTemplateFormatVersion{Version: invalid}
+}
+
+// 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 gophercloud.ErrUnexpectedType{Actual: fmt.Sprintf("%v", 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..cbe99ed
--- /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/gophercloud/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/testing/doc.go b/openstack/orchestration/v1/stacks/testing/doc.go
new file mode 100644
index 0000000..5b3703e
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_stacks_v1
+package testing
diff --git a/openstack/orchestration/v1/stacks/testing/fixtures.go b/openstack/orchestration/v1/stacks/testing/fixtures.go
new file mode 100644
index 0000000..f3e3b57
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/testing/fixtures.go
@@ -0,0 +1,407 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// CreateExpected represents the expected object from a Create request.
+var CreateExpected = &stacks.CreatedStack{
+ ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+ Links: []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 = []stacks.ListedStack{
+ {
+ Description: "Simple template to test heat commands",
+ Links: []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"},
+ },
+ {
+ Description: "Simple template to test heat commands",
+ Links: []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 = &stacks.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{
+ {
+ 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 = &stacks.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{
+ {
+ 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 = &stacks.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)
+ })
+}
diff --git a/openstack/orchestration/v1/stacks/testing/requests_test.go b/openstack/orchestration/v1/stacks/testing/requests_test.go
new file mode 100644
index 0000000..bdc6229
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/testing/requests_test.go
@@ -0,0 +1,192 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateStack(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleCreateSuccessfully(t, CreateOutput)
+ template := new(stacks.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 := stacks.CreateOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: gophercloud.Disabled,
+ }
+ actual, err := stacks.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)
+ template := new(stacks.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 := stacks.AdoptOpts{
+ AdoptStackData: `{environment{parameters{}}}`,
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: gophercloud.Disabled,
+ }
+ actual, err := stacks.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 := stacks.List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := stacks.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 := stacks.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)
+
+ template := new(stacks.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 := stacks.UpdateOpts{
+ TemplateOpts: template,
+ }
+ err := stacks.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 := stacks.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)
+
+ template := new(stacks.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 := stacks.PreviewOpts{
+ Name: "stackcreated",
+ Timeout: 60,
+ TemplateOpts: template,
+ DisableRollback: gophercloud.Disabled,
+ }
+ actual, err := stacks.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 := stacks.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/urls.go b/openstack/orchestration/v1/stacks/urls.go
new file mode 100644
index 0000000..b00be54
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/urls.go
@@ -0,0 +1,35 @@
+package stacks
+
+import "github.com/gophercloud/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..71d9e35
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/utils.go
@@ -0,0 +1,160 @@
+package stacks
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "path/filepath"
+ "reflect"
+ "strings"
+
+ "github.com/gophercloud/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 ErrInvalidDataFormat{}
+ }
+ }
+ 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, gophercloud.ErrUnexpectedType{Expected: "map[string]interface{}/map[interface{}]interface{}", Actual: fmt.Sprintf("%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..4d5cc73
--- /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/gophercloud/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/requests.go b/openstack/orchestration/v1/stacktemplates/requests.go
new file mode 100644
index 0000000..d248c24
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/requests.go
@@ -0,0 +1,39 @@
+package stacktemplates
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) (r GetResult) {
+ _, r.Err = c.Get(getURL(c, stackName, stackID), &r.Body, nil)
+ return
+}
+
+// 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 `json:"template" or:"TemplateURL"`
+ TemplateURL string `json:"template_url" or:"Template"`
+}
+
+// ToStackTemplateValidateMap assembles a request body based on the contents of a ValidateOpts.
+func (opts ValidateOpts) ToStackTemplateValidateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Validate validates the given stack template.
+func Validate(c *gophercloud.ServiceClient, opts ValidateOptsBuilder) (r ValidateResult) {
+ b, err := opts.ToStackTemplateValidateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = c.Post(validateURL(c), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
diff --git a/openstack/orchestration/v1/stacktemplates/results.go b/openstack/orchestration/v1/stacktemplates/results.go
new file mode 100644
index 0000000..bca959b
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/results.go
@@ -0,0 +1,44 @@
+package stacktemplates
+
+import (
+ "encoding/json"
+
+ "github.com/gophercloud/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 `json:"Description"`
+ Parameters map[string]interface{} `json:"Parameters"`
+ ParameterGroups map[string]interface{} `json:"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) {
+ var s *ValidatedTemplate
+ err := r.ExtractInto(&s)
+ return s, err
+}
diff --git a/openstack/orchestration/v1/stacktemplates/testing/doc.go b/openstack/orchestration/v1/stacktemplates/testing/doc.go
new file mode 100644
index 0000000..43c6b0f
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/testing/doc.go
@@ -0,0 +1,2 @@
+// orchestration_stacktemplates_v1
+package testing
diff --git a/openstack/orchestration/v1/stacktemplates/testing/fixtures.go b/openstack/orchestration/v1/stacktemplates/testing/fixtures.go
new file mode 100644
index 0000000..23ec579
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/testing/fixtures.go
@@ -0,0 +1,96 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacktemplates"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/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 = &stacktemplates.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/testing/requests_test.go b/openstack/orchestration/v1/stacktemplates/testing/requests_test.go
new file mode 100644
index 0000000..442bcb7
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/testing/requests_test.go
@@ -0,0 +1,58 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacktemplates"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+ HandleGetSuccessfully(t, GetOutput)
+
+ actual, err := stacktemplates.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 := stacktemplates.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 := stacktemplates.Validate(fake.ServiceClient(), opts).Extract()
+ th.AssertNoErr(t, err)
+
+ expected := ValidateExpected
+ th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stacktemplates/urls.go b/openstack/orchestration/v1/stacktemplates/urls.go
new file mode 100644
index 0000000..aed6b4b
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/urls.go
@@ -0,0 +1,11 @@
+package stacktemplates
+
+import "github.com/gophercloud/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/sharedfilesystems/v2/availabilityzones/requests.go b/openstack/sharedfilesystems/v2/availabilityzones/requests.go
new file mode 100644
index 0000000..df10b85
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/availabilityzones/requests.go
@@ -0,0 +1,13 @@
+package availabilityzones
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will return the existing availability zones.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+ return AvailabilityZonePage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/availabilityzones/results.go b/openstack/sharedfilesystems/v2/availabilityzones/results.go
new file mode 100644
index 0000000..83a76c1
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/availabilityzones/results.go
@@ -0,0 +1,59 @@
+package availabilityzones
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// AvailabilityZone contains all the information associated with an OpenStack
+// AvailabilityZone.
+type AvailabilityZone struct {
+ // The availability zone ID.
+ ID string `json:"id"`
+ // The name of the availability zone.
+ Name string `json:"name"`
+ // The date and time stamp when the availability zone was created.
+ CreatedAt time.Time `json:"-"`
+ // The date and time stamp when the availability zone was updated.
+ UpdatedAt time.Time `json:"-"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// ListResult contains the response body and error from a List request.
+type AvailabilityZonePage struct {
+ pagination.SinglePageBase
+}
+
+// ExtractAvailabilityZones will get the AvailabilityZone objects out of the shareTypeAccessResult object.
+func ExtractAvailabilityZones(r pagination.Page) ([]AvailabilityZone, error) {
+ var a struct {
+ AvailabilityZone []AvailabilityZone `json:"availability_zones"`
+ }
+ err := (r.(AvailabilityZonePage)).ExtractInto(&a)
+ return a.AvailabilityZone, err
+}
+
+func (r *AvailabilityZone) UnmarshalJSON(b []byte) error {
+ type tmp AvailabilityZone
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = AvailabilityZone(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+ r.UpdatedAt = time.Time(s.UpdatedAt)
+
+ return nil
+}
diff --git a/openstack/sharedfilesystems/v2/availabilityzones/testing/fixtures.go b/openstack/sharedfilesystems/v2/availabilityzones/testing/fixtures.go
new file mode 100644
index 0000000..e5db8cd
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/availabilityzones/testing/fixtures.go
@@ -0,0 +1,32 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/os-availability-zone", 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, `
+ {
+ "availability_zones": [
+ {
+ "name": "nova",
+ "created_at": "2015-09-18T09:50:55.000000",
+ "updated_at": null,
+ "id": "388c983d-258e-4a0e-b1ba-10da37d766db"
+ }
+ ]
+ }`)
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/availabilityzones/testing/requests_test.go b/openstack/sharedfilesystems/v2/availabilityzones/testing/requests_test.go
new file mode 100644
index 0000000..76c8574
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/availabilityzones/testing/requests_test.go
@@ -0,0 +1,34 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/availabilityzones"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// Verifies that availability zones can be listed correctly
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ allPages, err := availabilityzones.List(client.ServiceClient()).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := availabilityzones.ExtractAvailabilityZones(allPages)
+ th.AssertNoErr(t, err)
+ var nilTime time.Time
+ expected := []availabilityzones.AvailabilityZone{
+ {
+ Name: "nova",
+ CreatedAt: time.Date(2015, 9, 18, 9, 50, 55, 0, time.UTC),
+ UpdatedAt: nilTime,
+ ID: "388c983d-258e-4a0e-b1ba-10da37d766db",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/sharedfilesystems/v2/availabilityzones/urls.go b/openstack/sharedfilesystems/v2/availabilityzones/urls.go
new file mode 100644
index 0000000..fb4cdcf
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/availabilityzones/urls.go
@@ -0,0 +1,7 @@
+package availabilityzones
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("os-availability-zone")
+}
diff --git a/openstack/sharedfilesystems/v2/securityservices/requests.go b/openstack/sharedfilesystems/v2/securityservices/requests.go
new file mode 100644
index 0000000..9de58b9
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/securityservices/requests.go
@@ -0,0 +1,121 @@
+package securityservices
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+type SecurityServiceType string
+
+// Valid security service types
+const (
+ LDAP SecurityServiceType = "ldap"
+ Kerberos SecurityServiceType = "kerberos"
+ ActiveDirectory SecurityServiceType = "active_directory"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToSecurityServiceCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a SecurityService. This object is
+// passed to the securityservices.Create function. For more information about
+// these parameters, see the SecurityService object.
+type CreateOpts struct {
+ // The security service type. A valid value is ldap, kerberos, or active_directory
+ Type SecurityServiceType `json:"type" required:"true"`
+ // The security service name
+ Name string `json:"name,omitempty"`
+ // The security service description
+ Description string `json:"description,omitempty"`
+ // The DNS IP address that is used inside the tenant network
+ DNSIP string `json:"dns_ip,omitempty"`
+ // The security service user or group name that is used by the tenant
+ User string `json:"user,omitempty"`
+ // The user password, if you specify a user
+ Password string `json:"password,omitempty"`
+ // The security service domain
+ Domain string `json:"domain,omitempty"`
+ // The security service host name or IP address
+ Server string `json:"server,omitempty"`
+}
+
+// ToSecurityServicesCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToSecurityServiceCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "security_service")
+}
+
+// Create will create a new SecurityService based on the values in CreateOpts. To
+// extract the SecurityService object from the response, call the Extract method
+// on the CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToSecurityServiceCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
+
+// Delete will delete the existing SecurityService with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToSecurityServiceListQuery() (string, error)
+}
+
+// ListOpts holds options for listing SecurityServices. It is passed to the
+// securityservices.List function.
+type ListOpts struct {
+ // admin-only option. Set it to true to see all tenant security services.
+ AllTenants bool `q:"all_tenants"`
+ // The security service ID
+ ID string `q:"id"`
+ // The security service domain
+ Domain string `q:"domain"`
+ // The security service type. A valid value is ldap, kerberos, or active_directory
+ Type SecurityServiceType `q:"type"`
+ // The security service name
+ Name string `q:"name"`
+ // The DNS IP address that is used inside the tenant network
+ DNSIP string `q:"dns_ip"`
+ // The security service user or group name that is used by the tenant
+ User string `q:"user"`
+ // The security service host name or IP address
+ Server string `q:"server"`
+ // The ID of the share network using security services
+ ShareNetworkID string `q:"share_network_id"`
+}
+
+// ToSecurityServiceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToSecurityServiceListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List returns SecurityServices 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.ToSecurityServiceListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return SecurityServicePage{pagination.SinglePageBase(r)}
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/securityservices/results.go b/openstack/sharedfilesystems/v2/securityservices/results.go
new file mode 100644
index 0000000..ab9da7d
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/securityservices/results.go
@@ -0,0 +1,103 @@
+package securityservices
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// SecurityService contains all the information associated with an OpenStack
+// SecurityService.
+type SecurityService struct {
+ // The security service ID
+ ID string `json:"id"`
+ // The UUID of the project where the security service was created
+ ProjectID string `json:"project_id"`
+ // The security service domain
+ Domain string `json:"domain"`
+ // The security service status
+ Status string `json:"status"`
+ // The security service type. A valid value is ldap, kerberos, or active_directory
+ Type string `json:"type"`
+ // The security service name
+ Name string `json:"name"`
+ // The security service description
+ Description string `json:"description"`
+ // The DNS IP address that is used inside the tenant network
+ DNSIP string `json:"dns_ip"`
+ // The security service user or group name that is used by the tenant
+ User string `json:"user"`
+ // The user password, if you specify a user
+ Password string `json:"password"`
+ // The security service host name or IP address
+ Server string `json:"server"`
+ // The date and time stamp when the security service was created
+ CreatedAt time.Time `json:"-"`
+ // The date and time stamp when the security service was updated
+ UpdatedAt time.Time `json:"-"`
+}
+
+func (r *SecurityService) UnmarshalJSON(b []byte) error {
+ type tmp SecurityService
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = SecurityService(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+ r.UpdatedAt = time.Time(s.UpdatedAt)
+
+ return nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// SecurityServicePage is a pagination.pager that is returned from a call to the List function.
+type SecurityServicePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no SecurityServices.
+func (r SecurityServicePage) IsEmpty() (bool, error) {
+ securityServices, err := ExtractSecurityServices(r)
+ return len(securityServices) == 0, err
+}
+
+// ExtractSecurityServices extracts and returns SecurityServices. It is used while
+// iterating over a securityservices.List call.
+func ExtractSecurityServices(r pagination.Page) ([]SecurityService, error) {
+ var s struct {
+ SecurityServices []SecurityService `json:"security_services"`
+ }
+ err := (r.(SecurityServicePage)).ExtractInto(&s)
+ return s.SecurityServices, err
+}
+
+// Extract will get the SecurityService object out of the commonResult object.
+func (r commonResult) Extract() (*SecurityService, error) {
+ var s struct {
+ SecurityService *SecurityService `json:"security_service"`
+ }
+ err := r.ExtractInto(&s)
+ return s.SecurityService, err
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/sharedfilesystems/v2/securityservices/testing/fixtures.go b/openstack/sharedfilesystems/v2/securityservices/testing/fixtures.go
new file mode 100644
index 0000000..c6b5f70
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/securityservices/testing/fixtures.go
@@ -0,0 +1,137 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/security-services", 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_service": {
+ "description": "Creating my first Security Service",
+ "dns_ip": "10.0.0.0/24",
+ "user": "demo",
+ "password": "***",
+ "type": "kerberos",
+ "name": "SecServ1"
+ }
+ }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+ {
+ "security_service": {
+ "status": "new",
+ "domain": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "name": "SecServ1",
+ "created_at": "2015-09-07T12:19:10.695211",
+ "updated_at": null,
+ "server": null,
+ "dns_ip": "10.0.0.0/24",
+ "user": "demo",
+ "password": "supersecret",
+ "type": "kerberos",
+ "id": "3c829734-0679-4c17-9637-801da48c0d5f",
+ "description": "Creating my first Security Service"
+ }
+ }`)
+ })
+}
+
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc("/security-services/securityServiceID", 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 MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/security-services/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")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+ {
+ "security_services": [
+ {
+ "status": "new",
+ "domain": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "name": "SecServ1",
+ "created_at": "2015-09-07T12:19:10.000000",
+ "description": "Creating my first Security Service",
+ "updated_at": null,
+ "server": null,
+ "dns_ip": "10.0.0.0/24",
+ "user": "demo",
+ "password": "supersecret",
+ "type": "kerberos",
+ "id": "3c829734-0679-4c17-9637-801da48c0d5f"
+ },
+ {
+ "status": "new",
+ "domain": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "name": "SecServ2",
+ "created_at": "2015-09-07T12:25:03.000000",
+ "description": "Creating my second Security Service",
+ "updated_at": null,
+ "server": null,
+ "dns_ip": "10.0.0.0/24",
+ "user": null,
+ "password": null,
+ "type": "ldap",
+ "id": "5a1d3a12-34a7-4087-8983-50e9ed03509a"
+ }
+ ]
+ }`)
+ })
+}
+
+func MockFilteredListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/security-services/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")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+ {
+ "security_services": [
+ {
+ "status": "new",
+ "domain": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "name": "SecServ1",
+ "created_at": "2015-09-07T12:19:10.000000",
+ "description": "Creating my first Security Service",
+ "updated_at": null,
+ "server": null,
+ "dns_ip": "10.0.0.0/24",
+ "user": "demo",
+ "password": "supersecret",
+ "type": "kerberos",
+ "id": "3c829734-0679-4c17-9637-801da48c0d5f"
+ }
+ ]
+ }`)
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/securityservices/testing/requests_test.go b/openstack/sharedfilesystems/v2/securityservices/testing/requests_test.go
new file mode 100644
index 0000000..9feff53
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/securityservices/testing/requests_test.go
@@ -0,0 +1,150 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/securityservices"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// Verifies that a security service can be created correctly
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := &securityservices.CreateOpts{
+ Name: "SecServ1",
+ Description: "Creating my first Security Service",
+ DNSIP: "10.0.0.0/24",
+ User: "demo",
+ Password: "***",
+ Type: "kerberos",
+ }
+
+ s, err := securityservices.Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, s.Name, "SecServ1")
+ th.AssertEquals(t, s.Description, "Creating my first Security Service")
+ th.AssertEquals(t, s.User, "demo")
+ th.AssertEquals(t, s.DNSIP, "10.0.0.0/24")
+ th.AssertEquals(t, s.Password, "supersecret")
+ th.AssertEquals(t, s.Type, "kerberos")
+}
+
+// Verifies that a security service cannot be created without a type
+func TestCreateFails(t *testing.T) {
+ options := &securityservices.CreateOpts{
+ Name: "SecServ1",
+ Description: "Creating my first Security Service",
+ DNSIP: "10.0.0.0/24",
+ User: "demo",
+ Password: "***",
+ }
+
+ _, err := securityservices.Create(client.ServiceClient(), options).Extract()
+ if _, ok := err.(gophercloud.ErrMissingInput); !ok {
+ t.Fatal("ErrMissingInput was expected to occur")
+ }
+}
+
+// Verifies that security service deletion works
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+
+ res := securityservices.Delete(client.ServiceClient(), "securityServiceID")
+ th.AssertNoErr(t, res.Err)
+}
+
+// Verifies that security services can be listed correctly
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ allPages, err := securityservices.List(client.ServiceClient(), &securityservices.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := securityservices.ExtractSecurityServices(allPages)
+ th.AssertNoErr(t, err)
+ var nilTime time.Time
+ expected := []securityservices.SecurityService{
+ {
+ Status: "new",
+ Domain: "",
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ Name: "SecServ1",
+ CreatedAt: time.Date(2015, 9, 7, 12, 19, 10, 0, time.UTC),
+ Description: "Creating my first Security Service",
+ UpdatedAt: nilTime,
+ Server: "",
+ DNSIP: "10.0.0.0/24",
+ User: "demo",
+ Password: "supersecret",
+ Type: "kerberos",
+ ID: "3c829734-0679-4c17-9637-801da48c0d5f",
+ },
+ {
+ Status: "new",
+ Domain: "",
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ Name: "SecServ2",
+ CreatedAt: time.Date(2015, 9, 7, 12, 25, 03, 0, time.UTC),
+ Description: "Creating my second Security Service",
+ UpdatedAt: nilTime,
+ Server: "",
+ DNSIP: "10.0.0.0/24",
+ User: "",
+ Password: "",
+ Type: "ldap",
+ ID: "5a1d3a12-34a7-4087-8983-50e9ed03509a",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+// Verifies that security services list can be called with query parameters
+func TestFilteredList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockFilteredListResponse(t)
+
+ options := &securityservices.ListOpts{
+ Type: "kerberos",
+ }
+
+ allPages, err := securityservices.List(client.ServiceClient(), options).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := securityservices.ExtractSecurityServices(allPages)
+ th.AssertNoErr(t, err)
+ var nilTime time.Time
+ expected := []securityservices.SecurityService{
+ {
+ Status: "new",
+ Domain: "",
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ Name: "SecServ1",
+ CreatedAt: time.Date(2015, 9, 7, 12, 19, 10, 0, time.UTC),
+ Description: "Creating my first Security Service",
+ UpdatedAt: nilTime,
+ Server: "",
+ DNSIP: "10.0.0.0/24",
+ User: "demo",
+ Password: "supersecret",
+ Type: "kerberos",
+ ID: "3c829734-0679-4c17-9637-801da48c0d5f",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/sharedfilesystems/v2/securityservices/urls.go b/openstack/sharedfilesystems/v2/securityservices/urls.go
new file mode 100644
index 0000000..e4deb5f
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/securityservices/urls.go
@@ -0,0 +1,15 @@
+package securityservices
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("security-services")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("security-services", id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("security-services", "detail")
+}
diff --git a/openstack/sharedfilesystems/v2/sharenetworks/requests.go b/openstack/sharedfilesystems/v2/sharenetworks/requests.go
new file mode 100644
index 0000000..ebaad70
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharenetworks/requests.go
@@ -0,0 +1,167 @@
+package sharenetworks
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToShareNetworkCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a ShareNetwork. This object is
+// passed to the sharenetworks.Create function. For more information about
+// these parameters, see the ShareNetwork object.
+type CreateOpts struct {
+ // The UUID of the Neutron network to set up for share servers
+ NeutronNetID string `json:"neutron_net_id,omitempty"`
+ // The UUID of the Neutron subnet to set up for share servers
+ NeutronSubnetID string `json:"neutron_subnet_id,omitempty"`
+ // The UUID of the nova network to set up for share servers
+ NovaNetID string `json:"nova_net_id,omitempty"`
+ // The share network name
+ Name string `json:"name"`
+ // The share network description
+ Description string `json:"description"`
+}
+
+// ToShareNetworkCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToShareNetworkCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "share_network")
+}
+
+// Create will create a new ShareNetwork based on the values in CreateOpts. To
+// extract the ShareNetwork object from the response, call the Extract method
+// on the CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToShareNetworkCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will delete the existing ShareNetwork with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToShareNetworkListQuery() (string, error)
+}
+
+// ListOpts holds options for listing ShareNetworks. It is passed to the
+// sharenetworks.List function.
+type ListOpts struct {
+ // admin-only option. Set it to true to see all tenant share networks.
+ AllTenants bool `q:"all_tenants"`
+ // The UUID of the project where the share network was created
+ ProjectID string `q:"project_id"`
+ // The neutron network ID
+ NeutronNetID string `q:"neutron_net_id"`
+ // The neutron subnet ID
+ NeutronSubnetID string `q:"neutron_subnet_id"`
+ // The nova network ID
+ NovaNetID string `q:"nova_net_id"`
+ // The network type. A valid value is VLAN, VXLAN, GRE or flat
+ NetworkType string `q:"network_type"`
+ // The Share Network name
+ Name string `q:"name"`
+ // The Share Network description
+ Description string `q:"description"`
+ // The Share Network IP version
+ IPVersion gophercloud.IPVersion `q:"ip_version"`
+ // The Share Network segmentation ID
+ SegmentationID int `q:"segmentation_id"`
+ // List all share networks created after the given date
+ CreatedSince string `q:"created_since"`
+ // List all share networks created before the given date
+ CreatedBefore string `q:"created_before"`
+ // Limit specifies the page size.
+ Limit int `q:"limit"`
+ // Limit specifies the page number.
+ Offset int `q:"offset"`
+}
+
+// ToShareNetworkListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToShareNetworkListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// ListDetail returns ShareNetworks optionally limited by the conditions provided in ListOpts.
+func ListDetail(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listDetailURL(client)
+ if opts != nil {
+ query, err := opts.ToShareNetworkListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ p := ShareNetworkPage{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ })
+}
+
+// Get retrieves the ShareNetwork with the provided ID. To extract the ShareNetwork
+// object from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+ ToShareNetworkUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contain options for updating an existing ShareNetwork. This object is passed
+// to the sharenetworks.Update function. For more information about the parameters, see
+// the ShareNetwork object.
+type UpdateOpts struct {
+ // The share network name
+ Name string `json:"name,omitempty"`
+ // The share network description
+ Description string `json:"description,omitempty"`
+ // The UUID of the Neutron network to set up for share servers
+ NeutronNetID string `json:"neutron_net_id,omitempty"`
+ // The UUID of the Neutron subnet to set up for share servers
+ NeutronSubnetID string `json:"neutron_subnet_id,omitempty"`
+ // The UUID of the nova network to set up for share servers
+ NovaNetID string `json:"nova_net_id,omitempty"`
+}
+
+// ToShareNetworkUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToShareNetworkUpdateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "share_network")
+}
+
+// Update will update the ShareNetwork with provided information. To extract the updated
+// ShareNetwork from the response, call the Extract method on the UpdateResult.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToShareNetworkUpdateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ })
+ return
+}
diff --git a/openstack/sharedfilesystems/v2/sharenetworks/results.go b/openstack/sharedfilesystems/v2/sharenetworks/results.go
new file mode 100644
index 0000000..6762ef2
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharenetworks/results.go
@@ -0,0 +1,170 @@
+package sharenetworks
+
+import (
+ "encoding/json"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ShareNetwork contains all the information associated with an OpenStack
+// ShareNetwork.
+type ShareNetwork struct {
+ // The Share Network ID
+ ID string `json:"id"`
+ // The UUID of the project where the share network was created
+ ProjectID string `json:"project_id"`
+ // The neutron network ID
+ NeutronNetID string `json:"neutron_net_id"`
+ // The neutron subnet ID
+ NeutronSubnetID string `json:"neutron_subnet_id"`
+ // The nova network ID
+ NovaNetID string `json:"nova_net_id"`
+ // The network type. A valid value is VLAN, VXLAN, GRE or flat
+ NetworkType string `json:"network_type"`
+ // The segmentation ID
+ SegmentationID int `json:"segmentation_id"`
+ // The IP block from which to allocate the network, in CIDR notation
+ CIDR string `json:"cidr"`
+ // The IP version of the network. A valid value is 4 or 6
+ IPVersion int `json:"ip_version"`
+ // The Share Network name
+ Name string `json:"name"`
+ // The Share Network description
+ Description string `json:"description"`
+ // The date and time stamp when the Share Network was created
+ CreatedAt time.Time `json:"-"`
+ // The date and time stamp when the Share Network was updated
+ UpdatedAt time.Time `json:"-"`
+}
+
+func (r *ShareNetwork) UnmarshalJSON(b []byte) error {
+ type tmp ShareNetwork
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = ShareNetwork(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+ r.UpdatedAt = time.Time(s.UpdatedAt)
+
+ return nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// ShareNetworkPage is a pagination.pager that is returned from a call to the List function.
+type ShareNetworkPage struct {
+ pagination.MarkerPageBase
+}
+
+// NextPageURL generates the URL for the page of results after this one.
+func (r ShareNetworkPage) NextPageURL() (string, error) {
+ currentURL := r.URL
+ mark, err := r.Owner.LastMarker()
+ if err != nil {
+ return "", err
+ }
+
+ q := currentURL.Query()
+ q.Set("offset", mark)
+ currentURL.RawQuery = q.Encode()
+ return currentURL.String(), nil
+}
+
+// LastMarker returns the last offset in a ListResult.
+func (r ShareNetworkPage) LastMarker() (string, error) {
+ maxInt := strconv.Itoa(int(^uint(0) >> 1))
+ shareNetworks, err := ExtractShareNetworks(r)
+ if err != nil {
+ return maxInt, err
+ }
+ if len(shareNetworks) == 0 {
+ return maxInt, nil
+ }
+
+ u, err := url.Parse(r.URL.String())
+ if err != nil {
+ return maxInt, err
+ }
+ queryParams := u.Query()
+ offset := queryParams.Get("offset")
+ limit := queryParams.Get("limit")
+
+ // Limit is not present, only one page required
+ if limit == "" {
+ return maxInt, nil
+ }
+
+ iOffset := 0
+ if offset != "" {
+ iOffset, err = strconv.Atoi(offset)
+ if err != nil {
+ return maxInt, err
+ }
+ }
+ iLimit, err := strconv.Atoi(limit)
+ if err != nil {
+ return maxInt, err
+ }
+ iOffset = iOffset + iLimit
+ offset = strconv.Itoa(iOffset)
+
+ return offset, nil
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (r ShareNetworkPage) IsEmpty() (bool, error) {
+ shareNetworks, err := ExtractShareNetworks(r)
+ return len(shareNetworks) == 0, err
+}
+
+// ExtractShareNetworks extracts and returns ShareNetworks. It is used while
+// iterating over a sharenetworks.List call.
+func ExtractShareNetworks(r pagination.Page) ([]ShareNetwork, error) {
+ var s struct {
+ ShareNetworks []ShareNetwork `json:"share_networks"`
+ }
+ err := (r.(ShareNetworkPage)).ExtractInto(&s)
+ return s.ShareNetworks, err
+}
+
+// Extract will get the ShareNetwork object out of the commonResult object.
+func (r commonResult) Extract() (*ShareNetwork, error) {
+ var s struct {
+ ShareNetwork *ShareNetwork `json:"share_network"`
+ }
+ err := r.ExtractInto(&s)
+ return s.ShareNetwork, err
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult contains the response body and error from an Update request.
+type UpdateResult struct {
+ commonResult
+}
diff --git a/openstack/sharedfilesystems/v2/sharenetworks/testing/fixtures.go b/openstack/sharedfilesystems/v2/sharenetworks/testing/fixtures.go
new file mode 100644
index 0000000..8b753e9
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharenetworks/testing/fixtures.go
@@ -0,0 +1,307 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func createReq(name, description, network, subnetwork string) string {
+ return fmt.Sprintf(`{
+ "share_network": {
+ "name": "%s",
+ "description": "%s",
+ "neutron_net_id": "%s",
+ "neutron_subnet_id": "%s"
+ }
+ }`, name, description, network, subnetwork)
+}
+
+func createResp(name, description, network, subnetwork string) string {
+ return fmt.Sprintf(`
+ {
+ "share_network": {
+ "name": "%s",
+ "description": "%s",
+ "segmentation_id": null,
+ "created_at": "2015-09-07T14:37:00.583656",
+ "updated_at": null,
+ "id": "77eb3421-4549-4789-ac39-0d5185d68c29",
+ "neutron_net_id": "%s",
+ "neutron_subnet_id": "%s",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "e10a683c20da41248cfd5e1ab3d88c62",
+ "network_type": null
+ }
+ }`, name, description, network, subnetwork)
+}
+
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-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, createReq("my_network",
+ "This is my share network",
+ "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "53482b62-2c84-4a53-b6ab-30d9d9800d06"))
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, createResp("my_network",
+ "This is my share network",
+ "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "53482b62-2c84-4a53-b6ab-30d9d9800d06"))
+ })
+}
+
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/fa158a3d-6d9f-4187-9ca5-abbb82646eb2", 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 MockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/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")
+ w.WriteHeader(http.StatusOK)
+
+ r.ParseForm()
+ marker := r.Form.Get("offset")
+
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `{
+ "share_networks": [
+ {
+ "name": "net_my1",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:57:13.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "32763294-e3d4-456a-998d-60047677c2fb",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "descr"
+ },
+ {
+ "name": "net_my",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:54:25.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "713df749-aac0-4a54-af52-10f6c991e80c",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "desecr"
+ },
+ {
+ "name": null,
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:51:41.000000",
+ "neutron_subnet_id": null,
+ "updated_at": null,
+ "id": "fa158a3d-6d9f-4187-9ca5-abbb82646eb2",
+ "neutron_net_id": null,
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": null
+ }
+ ]
+ }`)
+ default:
+ fmt.Fprintf(w, `
+ {
+ "share_networks": []
+ }`)
+ }
+ })
+}
+
+func MockFilteredListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/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")
+ w.WriteHeader(http.StatusOK)
+
+ r.ParseForm()
+ marker := r.Form.Get("offset")
+ switch marker {
+ case "":
+ fmt.Fprintf(w, `
+ {
+ "share_networks": [
+ {
+ "name": "net_my1",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:57:13.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "32763294-e3d4-456a-998d-60047677c2fb",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "descr"
+ }
+ ]
+ }`)
+ case "1":
+ fmt.Fprintf(w, `
+ {
+ "share_networks": [
+ {
+ "name": "net_my1",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:57:13.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "32763294-e3d4-456a-998d-60047677c2fb",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "descr"
+ }
+ ]
+ }`)
+ case "2":
+ fmt.Fprintf(w, `
+ {
+ "share_networks": [
+ {
+ "name": "net_my1",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:57:13.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "32763294-e3d4-456a-998d-60047677c2fb",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "descr"
+ }
+ ]
+ }`)
+ default:
+ fmt.Fprintf(w, `
+ {
+ "share_networks": []
+ }`)
+ }
+ })
+}
+
+func MockGetResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/7f950b52-6141-4a08-bbb5-bb7ffa3ea5fd", 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, `
+ {
+ "share_network": {
+ "name": "net_my1",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:56:45.000000",
+ "neutron_subnet_id": "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ "updated_at": null,
+ "id": "7f950b52-6141-4a08-bbb5-bb7ffa3ea5fd",
+ "neutron_net_id": "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ "ip_version": null,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "descr"
+ }
+ }`)
+ })
+}
+
+func MockUpdateNeutronResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/713df749-aac0-4a54-af52-10f6c991e80c", 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, `
+ {
+ "share_network": {
+ "name": "net_my2",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:54:25.000000",
+ "neutron_subnet_id": "new-neutron-subnet-id",
+ "updated_at": "2015-09-07T08:02:53.512184",
+ "id": "713df749-aac0-4a54-af52-10f6c991e80c",
+ "neutron_net_id": "new-neutron-id",
+ "ip_version": 4,
+ "nova_net_id": null,
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "new description"
+ }
+ }
+ `)
+ })
+}
+
+func MockUpdateNovaResponse(t *testing.T) {
+ th.Mux.HandleFunc("/share-networks/713df749-aac0-4a54-af52-10f6c991e80c", 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, `
+ {
+ "share_network": {
+ "name": "net_my2",
+ "segmentation_id": null,
+ "created_at": "2015-09-04T14:54:25.000000",
+ "neutron_subnet_id": null,
+ "updated_at": "2015-09-07T08:02:53.512184",
+ "id": "713df749-aac0-4a54-af52-10f6c991e80c",
+ "neutron_net_id": null,
+ "ip_version": 4,
+ "nova_net_id": "new-nova-id",
+ "cidr": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "network_type": null,
+ "description": "new description"
+ }
+ }
+ `)
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/sharenetworks/testing/requests_test.go b/openstack/sharedfilesystems/v2/sharenetworks/testing/requests_test.go
new file mode 100644
index 0000000..46c2d92
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharenetworks/testing/requests_test.go
@@ -0,0 +1,238 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharenetworks"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// Verifies that a share network can be created correctly
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := &sharenetworks.CreateOpts{
+ Name: "my_network",
+ Description: "This is my share network",
+ NeutronNetID: "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ NeutronSubnetID: "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ }
+
+ n, err := sharenetworks.Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, n.Name, "my_network")
+ th.AssertEquals(t, n.Description, "This is my share network")
+ th.AssertEquals(t, n.NeutronNetID, "998b42ee-2cee-4d36-8b95-67b5ca1f2109")
+ th.AssertEquals(t, n.NeutronSubnetID, "53482b62-2c84-4a53-b6ab-30d9d9800d06")
+}
+
+// Verifies that share network deletion works
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+
+ res := sharenetworks.Delete(client.ServiceClient(), "fa158a3d-6d9f-4187-9ca5-abbb82646eb2")
+ th.AssertNoErr(t, res.Err)
+}
+
+// Verifies that share networks can be listed correctly
+func TestListDetail(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ allPages, err := sharenetworks.ListDetail(client.ServiceClient(), &sharenetworks.ListOpts{}).AllPages()
+
+ th.AssertNoErr(t, err)
+ actual, err := sharenetworks.ExtractShareNetworks(allPages)
+ th.AssertNoErr(t, err)
+
+ var nilTime time.Time
+ expected := []sharenetworks.ShareNetwork{
+ {
+ ID: "32763294-e3d4-456a-998d-60047677c2fb",
+ Name: "net_my1",
+ CreatedAt: time.Date(2015, 9, 4, 14, 57, 13, 0, time.UTC),
+ Description: "descr",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "",
+ NeutronNetID: "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ NeutronSubnetID: "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ IPVersion: 0,
+ SegmentationID: 0,
+ UpdatedAt: nilTime,
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ },
+ {
+ ID: "713df749-aac0-4a54-af52-10f6c991e80c",
+ Name: "net_my",
+ CreatedAt: time.Date(2015, 9, 4, 14, 54, 25, 0, time.UTC),
+ Description: "desecr",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "",
+ NeutronNetID: "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ NeutronSubnetID: "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ IPVersion: 0,
+ SegmentationID: 0,
+ UpdatedAt: nilTime,
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ },
+ {
+ ID: "fa158a3d-6d9f-4187-9ca5-abbb82646eb2",
+ Name: "",
+ CreatedAt: time.Date(2015, 9, 4, 14, 51, 41, 0, time.UTC),
+ Description: "",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "",
+ NeutronNetID: "",
+ NeutronSubnetID: "",
+ IPVersion: 0,
+ SegmentationID: 0,
+ UpdatedAt: nilTime,
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+// Verifies that share networks list can be called with query parameters
+func TestPaginatedListDetail(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockFilteredListResponse(t)
+
+ options := &sharenetworks.ListOpts{
+ Offset: 0,
+ Limit: 1,
+ }
+
+ count := 0
+
+ err := sharenetworks.ListDetail(client.ServiceClient(), options).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ _, err := sharenetworks.ExtractShareNetworks(page)
+ if err != nil {
+ t.Errorf("Failed to extract share networks: %v", err)
+ return false, err
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, count, 3)
+}
+
+// Verifies that it is possible to get a share network
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ var nilTime time.Time
+ expected := sharenetworks.ShareNetwork{
+ ID: "7f950b52-6141-4a08-bbb5-bb7ffa3ea5fd",
+ Name: "net_my1",
+ CreatedAt: time.Date(2015, 9, 4, 14, 56, 45, 0, time.UTC),
+ Description: "descr",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "",
+ NeutronNetID: "998b42ee-2cee-4d36-8b95-67b5ca1f2109",
+ NeutronSubnetID: "53482b62-2c84-4a53-b6ab-30d9d9800d06",
+ IPVersion: 0,
+ SegmentationID: 0,
+ UpdatedAt: nilTime,
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ }
+
+ n, err := sharenetworks.Get(client.ServiceClient(), "7f950b52-6141-4a08-bbb5-bb7ffa3ea5fd").Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, &expected, n)
+}
+
+// Verifies that it is possible to update a share network using neutron network
+func TestUpdateNeutron(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUpdateNeutronResponse(t)
+
+ expected := sharenetworks.ShareNetwork{
+ ID: "713df749-aac0-4a54-af52-10f6c991e80c",
+ Name: "net_my2",
+ CreatedAt: time.Date(2015, 9, 4, 14, 54, 25, 0, time.UTC),
+ Description: "new description",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "",
+ NeutronNetID: "new-neutron-id",
+ NeutronSubnetID: "new-neutron-subnet-id",
+ IPVersion: 4,
+ SegmentationID: 0,
+ UpdatedAt: time.Date(2015, 9, 7, 8, 2, 53, 512184000, time.UTC),
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ }
+
+ options := sharenetworks.UpdateOpts{
+ Name: "net_my2",
+ Description: "new description",
+ NeutronNetID: "new-neutron-id",
+ NeutronSubnetID: "new-neutron-subnet-id",
+ }
+
+ v, err := sharenetworks.Update(client.ServiceClient(), "713df749-aac0-4a54-af52-10f6c991e80c", options).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &expected, v)
+}
+
+// Verifies that it is possible to update a share network using nova network
+func TestUpdateNova(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUpdateNovaResponse(t)
+
+ expected := sharenetworks.ShareNetwork{
+ ID: "713df749-aac0-4a54-af52-10f6c991e80c",
+ Name: "net_my2",
+ CreatedAt: time.Date(2015, 9, 4, 14, 54, 25, 0, time.UTC),
+ Description: "new description",
+ NetworkType: "",
+ CIDR: "",
+ NovaNetID: "new-nova-id",
+ NeutronNetID: "",
+ NeutronSubnetID: "",
+ IPVersion: 4,
+ SegmentationID: 0,
+ UpdatedAt: time.Date(2015, 9, 7, 8, 2, 53, 512184000, time.UTC),
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ }
+
+ options := sharenetworks.UpdateOpts{
+ Name: "net_my2",
+ Description: "new description",
+ NovaNetID: "new-nova-id",
+ }
+
+ v, err := sharenetworks.Update(client.ServiceClient(), "713df749-aac0-4a54-af52-10f6c991e80c", options).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &expected, v)
+}
diff --git a/openstack/sharedfilesystems/v2/sharenetworks/urls.go b/openstack/sharedfilesystems/v2/sharenetworks/urls.go
new file mode 100644
index 0000000..6198c54
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharenetworks/urls.go
@@ -0,0 +1,23 @@
+package sharenetworks
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("share-networks")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("share-networks", id)
+}
+
+func listDetailURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("share-networks", "detail")
+}
+
+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/sharedfilesystems/v2/shares/requests.go b/openstack/sharedfilesystems/v2/shares/requests.go
new file mode 100644
index 0000000..cfa8460
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/shares/requests.go
@@ -0,0 +1,81 @@
+package shares
+
+import (
+ "github.com/gophercloud/gophercloud"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToShareCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains the options for create a Share. This object is
+// passed to shares.Create(). For more information about these parameters,
+// please refer to the Share object, or the shared file systems API v2
+// documentation
+type CreateOpts struct {
+ // Defines the share protocol to use
+ ShareProto string `json:"share_proto" required:"true"`
+ // Size in GB
+ Size int `json:"size" required:"true"`
+ // Defines the share name
+ Name string `json:"name,omitempty"`
+ // Share description
+ Description string `json:"description,omitempty"`
+ // DisplayName is equivalent to Name. The API supports using both
+ // This is an inherited attribute from the block storage API
+ DisplayName string `json:"display_name,omitempty"`
+ // DisplayDescription is equivalent to Description. The API supports using bot
+ // This is an inherited attribute from the block storage API
+ DisplayDescription string `json:"display_description,omitempty"`
+ // ShareType defines the sharetype. If omitted, a default share type is used
+ ShareType string `json:"share_type,omitempty"`
+ // VolumeType is deprecated but supported. Either ShareType or VolumeType can be used
+ VolumeType string `json:"volume_type,omitempty"`
+ // The UUID from which to create a share
+ SnapshotID string `json:"snapshot_id,omitempty"`
+ // Determines whether or not the share is public
+ IsPublic *bool `json:"is_public,omitempty"`
+ // Key value pairs of user defined metadata
+ Metadata map[string]string `json:"metadata,omitempty"`
+ // The UUID of the share network to which the share belongs to
+ ShareNetworkID string `json:"share_network_id,omitempty"`
+ // The UUID of the consistency group to which the share belongs to
+ ConsistencyGroupID string `json:"consistency_group_id,omitempty"`
+ // The availability zone of the share
+ AvailabilityZone string `json:"availability_zone,omitempty"`
+}
+
+// ToShareCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToShareCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "share")
+}
+
+// Create will create a new Share based on the values in CreateOpts. To extract
+// the Share object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToShareCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 201},
+ })
+ return
+}
+
+// Delete will delete an existing Share with the given UUID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// Get will get a single share with given UUID
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/sharedfilesystems/v2/shares/results.go b/openstack/sharedfilesystems/v2/shares/results.go
new file mode 100644
index 0000000..224d1df
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/shares/results.go
@@ -0,0 +1,112 @@
+package shares
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// Share contains all information associated with an OpenStack Share
+type Share struct {
+ // The availability zone of the share
+ AvailabilityZone string `json:"availability_zone"`
+ // A description of the share
+ Description string `json:"description,omitempty"`
+ // DisplayDescription is inherited from BlockStorage API.
+ // Both Description and DisplayDescription can be used
+ DisplayDescription string `json:"display_description,omitempty"`
+ // DisplayName is inherited from BlockStorage API
+ // Both DisplayName and Name can be used
+ DisplayName string `json:"display_name,omitempty"`
+ // Indicates whether a share has replicas or not.
+ HasReplicas bool `json:"has_replicas"`
+ // The host name of the share
+ Host string `json:"host"`
+ // The UUID of the share
+ ID string `json:"id"`
+ // Indicates the visibility of the share
+ IsPublic bool `json:"is_public,omitempty"`
+ // Share links for pagination
+ Links []map[string]string `json:"links"`
+ // Key, value -pairs of custom metadata
+ Metadata map[string]string `json:"metadata,omitempty"`
+ // The name of the share
+ Name string `json:"name,omitempty"`
+ // The UUID of the project to which this share belongs to
+ ProjectID string `json:"project_id"`
+ // The share replication type
+ ReplicationType string `json:"replication_type,omitempty"`
+ // The UUID of the share network
+ ShareNetworkID string `json:"share_network_id"`
+ // The shared file system protocol
+ ShareProto string `json:"share_proto"`
+ // The UUID of the share server
+ ShareServerID string `json:"share_server_id"`
+ // The UUID of the share type.
+ ShareType string `json:"share_type"`
+ // The name of the share type.
+ ShareTypeName string `json:"share_type_name"`
+ // Size of the share in GB
+ Size int `json:"size"`
+ // UUID of the snapshot from which to create the share
+ SnapshotID string `json:"snapshot_id"`
+ // The share status
+ Status string `json:"status"`
+ // The task state, used for share migration
+ TaskState string `json:"task_state"`
+ // The type of the volume
+ VolumeType string `json:"volume_type,omitempty"`
+ // The UUID of the consistency group this share belongs to
+ ConsistencyGroupID string `json:"consistency_group_id"`
+ // Used for filtering backends which either support or do not support share snapshots
+ SnapshotSupport bool `json:"snapshot_support"`
+ SourceCgsnapshotMemberID string `json:"source_cgsnapshot_member_id"`
+ // Timestamp when the share was created
+ CreatedAt time.Time `json:"-"`
+}
+
+func (r *Share) UnmarshalJSON(b []byte) error {
+ type tmp Share
+ var s struct {
+ tmp
+ CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"`
+ }
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+ *r = Share(s.tmp)
+
+ r.CreatedAt = time.Time(s.CreatedAt)
+
+ return nil
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the Share object from the commonResult
+func (r commonResult) Extract() (*Share, error) {
+ var s struct {
+ Share *Share `json:"share"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Share, err
+}
+
+// CreateResult contains the result..
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult contains the delete results
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// GetResult contains the get result
+type GetResult struct {
+ commonResult
+}
diff --git a/openstack/sharedfilesystems/v2/shares/testing/fixtures.go b/openstack/sharedfilesystems/v2/shares/testing/fixtures.go
new file mode 100644
index 0000000..83b174f
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/shares/testing/fixtures.go
@@ -0,0 +1,142 @@
+package testing
+
+import (
+ "fmt"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+ "net/http"
+ "testing"
+)
+
+const (
+ shareEndpoint = "/shares"
+ shareID = "011d21e2-fbc3-4e4a-9993-9ea223f73264"
+)
+
+var createRequest = `{
+ "share": {
+ "name": "my_test_share",
+ "size": 1,
+ "share_proto": "NFS"
+ }
+ }`
+
+var createResponse = `{
+ "share": {
+ "name": "my_test_share",
+ "share_proto": "NFS",
+ "size": 1,
+ "status": null,
+ "share_server_id": null,
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "share_type": "25747776-08e5-494f-ab40-a64b9d20d8f7",
+ "share_type_name": "default",
+ "availability_zone": null,
+ "created_at": "2015-09-18T10:25:24.533287",
+ "export_location": null,
+ "links": [
+ {
+ "href": "http://172.18.198.54:8786/v1/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "self"
+ },
+ {
+ "href": "http://172.18.198.54:8786/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "bookmark"
+ }
+ ],
+ "share_network_id": null,
+ "export_locations": [],
+ "host": null,
+ "access_rules_status": "active",
+ "has_replicas": false,
+ "replication_type": null,
+ "task_state": null,
+ "snapshot_support": true,
+ "consistency_group_id": "9397c191-8427-4661-a2e8-b23820dc01d4",
+ "source_cgsnapshot_member_id": null,
+ "volume_type": "default",
+ "snapshot_id": null,
+ "is_public": true,
+ "metadata": {
+ "project": "my_app",
+ "aim": "doc"
+ },
+ "id": "011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "description": "My custom share London"
+ }
+ }`
+
+// MockCreateResponse creates a mock response
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc(shareEndpoint, 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, createRequest)
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, createResponse)
+ })
+}
+
+// MockDeleteResponse creates a mock delete response
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc(shareEndpoint+"/"+shareID, 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)
+ })
+}
+
+var getResponse = `{
+ "share": {
+ "links": [
+ {
+ "href": "http://172.18.198.54:8786/v2/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "self"
+ },
+ {
+ "href": "http://172.18.198.54:8786/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "bookmark"
+ }
+ ],
+ "availability_zone": "nova",
+ "share_network_id": "713df749-aac0-4a54-af52-10f6c991e80c",
+ "share_server_id": "e268f4aa-d571-43dd-9ab3-f49ad06ffaef",
+ "snapshot_id": null,
+ "id": "011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "size": 1,
+ "share_type": "25747776-08e5-494f-ab40-a64b9d20d8f7",
+ "share_type_name": "default",
+ "consistency_group_id": "9397c191-8427-4661-a2e8-b23820dc01d4",
+ "project_id": "16e1ab15c35a457e9c2b2aa189f544e1",
+ "metadata": {
+ "project": "my_app",
+ "aim": "doc"
+ },
+ "status": "available",
+ "description": "My custom share London",
+ "host": "manila2@generic1#GENERIC1",
+ "has_replicas": false,
+ "replication_type": null,
+ "task_state": null,
+ "is_public": true,
+ "snapshot_support": true,
+ "name": "my_test_share",
+ "created_at": "2015-09-18T10:25:24.000000",
+ "share_proto": "NFS",
+ "volume_type": "default",
+ "source_cgsnapshot_member_id": null
+ }
+}`
+
+// MockGetResponse creates a mock get response
+func MockGetResponse(t *testing.T) {
+ th.Mux.HandleFunc(shareEndpoint+"/"+shareID, func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, getResponse)
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/shares/testing/request_test.go b/openstack/sharedfilesystems/v2/shares/testing/request_test.go
new file mode 100644
index 0000000..5b700a6
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/shares/testing/request_test.go
@@ -0,0 +1,84 @@
+package testing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/shares"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ options := &shares.CreateOpts{Size: 1, Name: "my_test_share", ShareProto: "NFS"}
+ n, err := shares.Create(client.ServiceClient(), options).Extract()
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, n.Name, "my_test_share")
+ th.AssertEquals(t, n.Size, 1)
+ th.AssertEquals(t, n.ShareProto, "NFS")
+}
+
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+
+ result := shares.Delete(client.ServiceClient(), shareID)
+ th.AssertNoErr(t, result.Err)
+}
+
+func TestGet(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetResponse(t)
+
+ s, err := shares.Get(client.ServiceClient(), shareID).Extract()
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, s, &shares.Share{
+ AvailabilityZone: "nova",
+ ShareNetworkID: "713df749-aac0-4a54-af52-10f6c991e80c",
+ ShareServerID: "e268f4aa-d571-43dd-9ab3-f49ad06ffaef",
+ SnapshotID: "",
+ ID: shareID,
+ Size: 1,
+ ShareType: "25747776-08e5-494f-ab40-a64b9d20d8f7",
+ ShareTypeName: "default",
+ ConsistencyGroupID: "9397c191-8427-4661-a2e8-b23820dc01d4",
+ ProjectID: "16e1ab15c35a457e9c2b2aa189f544e1",
+ Metadata: map[string]string{
+ "project": "my_app",
+ "aim": "doc",
+ },
+ Status: "available",
+ Description: "My custom share London",
+ Host: "manila2@generic1#GENERIC1",
+ HasReplicas: false,
+ ReplicationType: "",
+ TaskState: "",
+ SnapshotSupport: true,
+ Name: "my_test_share",
+ CreatedAt: time.Date(2015, time.September, 18, 10, 25, 24, 0, time.UTC),
+ ShareProto: "NFS",
+ VolumeType: "default",
+ SourceCgsnapshotMemberID: "",
+ IsPublic: true,
+ Links: []map[string]string{
+ {
+ "href": "http://172.18.198.54:8786/v2/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "self",
+ },
+ {
+ "href": "http://172.18.198.54:8786/16e1ab15c35a457e9c2b2aa189f544e1/shares/011d21e2-fbc3-4e4a-9993-9ea223f73264",
+ "rel": "bookmark",
+ },
+ },
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/shares/urls.go b/openstack/sharedfilesystems/v2/shares/urls.go
new file mode 100644
index 0000000..309f071
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/shares/urls.go
@@ -0,0 +1,15 @@
+package shares
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("shares")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("shares", id)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("shares", id)
+}
diff --git a/openstack/sharedfilesystems/v2/sharetypes/requests.go b/openstack/sharedfilesystems/v2/sharetypes/requests.go
new file mode 100644
index 0000000..8bfc632
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharetypes/requests.go
@@ -0,0 +1,151 @@
+package sharetypes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+ ToShareTypeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a ShareType. This object is
+// passed to the sharetypes.Create function. For more information about
+// these parameters, see the ShareType object.
+type CreateOpts struct {
+ // The share type name
+ Name string `json:"name" required:"true"`
+ // Indicates whether a share type is publicly accessible
+ IsPublic bool `json:"os-share-type-access:is_public"`
+ // The extra specifications for the share type
+ ExtraSpecs ExtraSpecsOpts `json:"extra_specs" required:"true"`
+}
+
+// ExtraSpecsOpts represent the extra specifications that can be selected for a share type
+type ExtraSpecsOpts struct {
+ // An extra specification that defines the driver mode for share server, or storage, life cycle management
+ DriverHandlesShareServers bool `json:"driver_handles_share_servers" required:"true"`
+ // An extra specification that filters back ends by whether they do or do not support share snapshots
+ SnapshotSupport *bool `json:"snapshot_support,omitempty"`
+}
+
+// ToShareTypeCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToShareTypeCreateMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "share_type")
+}
+
+// Create will create a new ShareType based on the values in CreateOpts. To
+// extract the ShareType object from the response, call the Extract method
+// on the CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToShareTypeCreateMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// Delete will delete the existing ShareType with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+ ToShareTypeListQuery() (string, error)
+}
+
+// ListOpts holds options for listing ShareTypes. It is passed to the
+// sharetypes.List function.
+type ListOpts struct {
+ // Select if public types, private types, or both should be listed
+ IsPublic string `q:"is_public"`
+}
+
+// ToShareTypeListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToShareTypeListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List returns ShareTypes 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.ToShareTypeListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+
+ return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+ return ShareTypePage{pagination.SinglePageBase(r)}
+ })
+}
+
+// GetDefault will retrieve the default ShareType.
+func GetDefault(client *gophercloud.ServiceClient) (r GetDefaultResult) {
+ _, r.Err = client.Get(getDefaultURL(client), &r.Body, nil)
+ return
+}
+
+// GetExtraSpecs will retrieve the extra specifications for a given ShareType.
+func GetExtraSpecs(client *gophercloud.ServiceClient, id string) (r GetExtraSpecsResult) {
+ _, r.Err = client.Get(getExtraSpecsURL(client, id), &r.Body, nil)
+ return
+}
+
+// SetExtraSpecsOptsBuilder allows extensions to add additional parameters to the
+// SetExtraSpecs request.
+type SetExtraSpecsOptsBuilder interface {
+ ToShareTypeSetExtraSpecsMap() (map[string]interface{}, error)
+}
+
+type SetExtraSpecsOpts struct {
+ // A list of all extra specifications to be added to a ShareType
+ Specs map[string]interface{} `json:"extra_specs"`
+}
+
+// ToShareTypeSetExtraSpecsMap assembles a request body based on the contents of a
+// SetExtraSpecsOpts.
+func (opts SetExtraSpecsOpts) ToShareTypeSetExtraSpecsMap() (map[string]interface{}, error) {
+ return gophercloud.BuildRequestBody(opts, "")
+}
+
+// SetExtraSpecs will set new specifications for a ShareType based on the values
+// in SetExtraSpecsOpts. To extract the extra specifications object from the response,
+// call the Extract method on the SetExtraSpecsResult.
+func SetExtraSpecs(client *gophercloud.ServiceClient, id string, opts SetExtraSpecsOptsBuilder) (r SetExtraSpecsResult) {
+ b, err := opts.ToShareTypeSetExtraSpecsMap()
+ if err != nil {
+ r.Err = err
+ return
+ }
+
+ _, r.Err = client.Post(setExtraSpecsURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200, 202},
+ })
+ return
+}
+
+// UnsetExtraSpecs will unset an extra specification for an existing ShareType.
+func UnsetExtraSpecs(client *gophercloud.ServiceClient, id string, key string) (r UnsetExtraSpecsResult) {
+ _, r.Err = client.Delete(unsetExtraSpecsURL(client, id, key), nil)
+ return
+}
+
+// ShowAccess will show access details for an existing ShareType.
+func ShowAccess(client *gophercloud.ServiceClient, id string) (r ShowAccessResult) {
+ _, r.Err = client.Get(showAccessURL(client, id), &r.Body, nil)
+ return
+}
diff --git a/openstack/sharedfilesystems/v2/sharetypes/results.go b/openstack/sharedfilesystems/v2/sharetypes/results.go
new file mode 100644
index 0000000..92a8fab
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharetypes/results.go
@@ -0,0 +1,129 @@
+package sharetypes
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ShareType contains all the information associated with an OpenStack
+// ShareType.
+type ShareType struct {
+ // The Share Type ID
+ ID string `json:"id"`
+ // The Share Type name
+ Name string `json:"name"`
+ // Indicates whether a share type is publicly accessible
+ IsPublic bool `json:"os-share-type-access:is_public"`
+ // The required extra specifications for the share type
+ RequiredExtraSpecs map[string]interface{} `json:"required_extra_specs"`
+ // The extra specifications for the share type
+ ExtraSpecs map[string]interface{} `json:"extra_specs"`
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the ShareType object out of the commonResult object.
+func (r commonResult) Extract() (*ShareType, error) {
+ var s struct {
+ ShareType *ShareType `json:"share_type"`
+ }
+ err := r.ExtractInto(&s)
+ return s.ShareType, err
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+ commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ShareTypePage is a pagination.pager that is returned from a call to the List function.
+type ShareTypePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no ShareTypes.
+func (r ShareTypePage) IsEmpty() (bool, error) {
+ shareTypes, err := ExtractShareTypes(r)
+ return len(shareTypes) == 0, err
+}
+
+// ExtractShareTypes extracts and returns ShareTypes. It is used while
+// iterating over a sharetypes.List call.
+func ExtractShareTypes(r pagination.Page) ([]ShareType, error) {
+ var s struct {
+ ShareTypes []ShareType `json:"share_types"`
+ }
+ err := (r.(ShareTypePage)).ExtractInto(&s)
+ return s.ShareTypes, err
+}
+
+// GetDefaultResult contains the response body and error from a Get Default request.
+type GetDefaultResult struct {
+ commonResult
+}
+
+// ExtraSpecs contains all the information associated with extra specifications
+// for an Openstack ShareType.
+type ExtraSpecs map[string]interface{}
+
+type extraSpecsResult struct {
+ gophercloud.Result
+}
+
+// Extract will get the ExtraSpecs object out of the commonResult object.
+func (r extraSpecsResult) Extract() (ExtraSpecs, error) {
+ var s struct {
+ Specs ExtraSpecs `json:"extra_specs"`
+ }
+ err := r.ExtractInto(&s)
+ return s.Specs, err
+}
+
+// GetExtraSpecsResult contains the response body and error from a Get Extra Specs request.
+type GetExtraSpecsResult struct {
+ extraSpecsResult
+}
+
+// SetExtraSpecsResult contains the response body and error from a Set Extra Specs request.
+type SetExtraSpecsResult struct {
+ extraSpecsResult
+}
+
+// UnsetExtraSpecsResult contains the response body and error from a Unset Extra Specs request.
+type UnsetExtraSpecsResult struct {
+ gophercloud.ErrResult
+}
+
+// ShareTypeAccess contains all the information associated with an OpenStack
+// ShareTypeAccess.
+type ShareTypeAccess struct {
+ // The share type ID of the member.
+ ShareTypeID string `json:"share_type_id"`
+ // The UUID of the project for which access to the share type is granted.
+ ProjectID string `json:"project_id"`
+}
+
+type shareTypeAccessResult struct {
+ gophercloud.Result
+}
+
+// ShowAccessResult contains the response body and error from a Show access request.
+type ShowAccessResult struct {
+ shareTypeAccessResult
+}
+
+// Extract will get the ShareTypeAccess objects out of the shareTypeAccessResult object.
+func (r ShowAccessResult) Extract() ([]ShareTypeAccess, error) {
+ var s struct {
+ ShareTypeAccess []ShareTypeAccess `json:"share_type_access"`
+ }
+ err := r.ExtractInto(&s)
+ return s.ShareTypeAccess, err
+}
diff --git a/openstack/sharedfilesystems/v2/sharetypes/testing/fixtures.go b/openstack/sharedfilesystems/v2/sharetypes/testing/fixtures.go
new file mode 100644
index 0000000..03fc83b
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharetypes/testing/fixtures.go
@@ -0,0 +1,240 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockCreateResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types", 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, `
+ {
+ "share_type": {
+ "os-share-type-access:is_public": true,
+ "extra_specs": {
+ "driver_handles_share_servers": true,
+ "snapshot_support": true
+ },
+ "name": "my_new_share_type"
+ }
+ }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+ {
+ "volume_type": {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": true
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "my_new_share_type",
+ "id": "1d600d02-26a7-4b23-af3d-7d51860fe858"
+ },
+ "share_type": {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": true
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "my_new_share_type",
+ "id": "1d600d02-26a7-4b23-af3d-7d51860fe858"
+ }
+ }`)
+ })
+}
+
+func MockDeleteResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/shareTypeID", 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 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": [
+ {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": "True"
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "default",
+ "id": "be27425c-f807-4500-a056-d00721db45cf"
+ },
+ {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": "false"
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "false"
+ },
+ "name": "d",
+ "id": "f015bebe-c38b-4c49-8832-00143b10253b"
+ }
+ ],
+ "share_types": [
+ {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": "True"
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "default",
+ "id": "be27425c-f807-4500-a056-d00721db45cf"
+ },
+ {
+ "os-share-type-access:is_public": true,
+ "required_extra_specs": {
+ "driver_handles_share_servers": "false"
+ },
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "false"
+ },
+ "name": "d",
+ "id": "f015bebe-c38b-4c49-8832-00143b10253b"
+ }
+ ]
+ }`)
+ })
+}
+
+func MockGetDefaultResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/default", 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": {
+ "required_extra_specs": null,
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "default",
+ "id": "be27425c-f807-4500-a056-d00721db45cf"
+ },
+ "share_type": {
+ "required_extra_specs": null,
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True"
+ },
+ "name": "default",
+ "id": "be27425c-f807-4500-a056-d00721db45cf"
+ }
+ }`)
+ })
+}
+
+func MockGetExtraSpecsResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/shareTypeID/extra_specs", 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, `
+ {
+ "extra_specs": {
+ "snapshot_support": "True",
+ "driver_handles_share_servers": "True",
+ "my_custom_extra_spec": "False"
+ }
+ }`)
+ })
+}
+
+func MockSetExtraSpecsResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/shareTypeID/extra_specs", 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, `
+ {
+ "extra_specs": {
+ "my_key": "my_value"
+ }
+ }`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+
+ fmt.Fprintf(w, `
+ {
+ "extra_specs": {
+ "my_key": "my_value"
+ }
+ }`)
+ })
+}
+
+func MockUnsetExtraSpecsResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/shareTypeID/extra_specs/my_key", 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 MockShowAccessResponse(t *testing.T) {
+ th.Mux.HandleFunc("/types/shareTypeID/share_type_access", 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, `
+ {
+ "share_type_access": [
+ {
+ "share_type_id": "1732f284-401d-41d9-a494-425451e8b4b8",
+ "project_id": "818a3f48dcd644909b3fa2e45a399a27"
+ },
+ {
+ "share_type_id": "1732f284-401d-41d9-a494-425451e8b4b8",
+ "project_id": "e1284adea3ee4d2482af5ed214f3ad90"
+ }
+ ]
+ }`)
+ })
+}
diff --git a/openstack/sharedfilesystems/v2/sharetypes/testing/requests_test.go b/openstack/sharedfilesystems/v2/sharetypes/testing/requests_test.go
new file mode 100644
index 0000000..bcc1b16
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharetypes/testing/requests_test.go
@@ -0,0 +1,187 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/sharedfilesystems/v2/sharetypes"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// Verifies that a share type can be created correctly
+func TestCreate(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockCreateResponse(t)
+
+ snapshotSupport := true
+ extraSpecs := sharetypes.ExtraSpecsOpts{
+ DriverHandlesShareServers: true,
+ SnapshotSupport: &snapshotSupport,
+ }
+
+ options := &sharetypes.CreateOpts{
+ Name: "my_new_share_type",
+ IsPublic: true,
+ ExtraSpecs: extraSpecs,
+ }
+
+ st, err := sharetypes.Create(client.ServiceClient(), options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, st.Name, "my_new_share_type")
+ th.AssertEquals(t, st.IsPublic, true)
+}
+
+// Verifies that a share type can't be created if the required parameters are missing
+func TestCreateFails(t *testing.T) {
+ options := &sharetypes.CreateOpts{
+ Name: "my_new_share_type",
+ }
+
+ _, err := sharetypes.Create(client.ServiceClient(), options).Extract()
+ if _, ok := err.(gophercloud.ErrMissingInput); !ok {
+ t.Fatal("ErrMissingInput was expected to occur")
+ }
+
+ extraSpecs := sharetypes.ExtraSpecsOpts{
+ DriverHandlesShareServers: true,
+ }
+
+ options = &sharetypes.CreateOpts{
+ ExtraSpecs: extraSpecs,
+ }
+
+ _, err = sharetypes.Create(client.ServiceClient(), options).Extract()
+ if _, ok := err.(gophercloud.ErrMissingInput); !ok {
+ t.Fatal("ErrMissingInput was expected to occur")
+ }
+}
+
+// Verifies that share type deletion works
+func TestDelete(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteResponse(t)
+ res := sharetypes.Delete(client.ServiceClient(), "shareTypeID")
+ th.AssertNoErr(t, res.Err)
+}
+
+// Verifies that share types can be listed correctly
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListResponse(t)
+
+ allPages, err := sharetypes.List(client.ServiceClient(), &sharetypes.ListOpts{}).AllPages()
+ th.AssertNoErr(t, err)
+ actual, err := sharetypes.ExtractShareTypes(allPages)
+ th.AssertNoErr(t, err)
+ expected := []sharetypes.ShareType{
+ {
+ ID: "be27425c-f807-4500-a056-d00721db45cf",
+ Name: "default",
+ IsPublic: true,
+ ExtraSpecs: map[string]interface{}{"snapshot_support": "True", "driver_handles_share_servers": "True"},
+ RequiredExtraSpecs: map[string]interface{}{"driver_handles_share_servers": "True"},
+ },
+ {
+ ID: "f015bebe-c38b-4c49-8832-00143b10253b",
+ Name: "d",
+ IsPublic: true,
+ ExtraSpecs: map[string]interface{}{"driver_handles_share_servers": "false", "snapshot_support": "True"},
+ RequiredExtraSpecs: map[string]interface{}{"driver_handles_share_servers": "True"},
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+// Verifies that it is possible to get the default share type
+func TestGetDefault(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetDefaultResponse(t)
+
+ expected := sharetypes.ShareType{
+ ID: "be27425c-f807-4500-a056-d00721db45cf",
+ Name: "default",
+ ExtraSpecs: map[string]interface{}{"snapshot_support": "True", "driver_handles_share_servers": "True"},
+ RequiredExtraSpecs: map[string]interface{}(nil),
+ }
+
+ actual, err := sharetypes.GetDefault(client.ServiceClient()).Extract()
+ th.AssertNoErr(t, err)
+ th.CheckDeepEquals(t, &expected, actual)
+}
+
+// Verifies that it is possible to get the extra specifications for a share type
+func TestGetExtraSpecs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockGetExtraSpecsResponse(t)
+
+ st, err := sharetypes.GetExtraSpecs(client.ServiceClient(), "shareTypeID").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, st["snapshot_support"], "True")
+ th.AssertEquals(t, st["driver_handles_share_servers"], "True")
+ th.AssertEquals(t, st["my_custom_extra_spec"], "False")
+}
+
+// Verifies that an extra specs can be added to a share type
+func TestSetExtraSpecs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockSetExtraSpecsResponse(t)
+
+ options := &sharetypes.SetExtraSpecsOpts{
+ Specs: map[string]interface{}{"my_key": "my_value"},
+ }
+
+ es, err := sharetypes.SetExtraSpecs(client.ServiceClient(), "shareTypeID", options).Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, es["my_key"], "my_value")
+}
+
+// Verifies that an extra specification can be unset for a share type
+func TestUnsetExtraSpecs(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockUnsetExtraSpecsResponse(t)
+ res := sharetypes.UnsetExtraSpecs(client.ServiceClient(), "shareTypeID", "my_key")
+ th.AssertNoErr(t, res.Err)
+}
+
+// Verifies that it is possible to see the access for a share type
+func TestShowAccess(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockShowAccessResponse(t)
+
+ expected := []sharetypes.ShareTypeAccess{
+ {
+ ShareTypeID: "1732f284-401d-41d9-a494-425451e8b4b8",
+ ProjectID: "818a3f48dcd644909b3fa2e45a399a27",
+ },
+ {
+ ShareTypeID: "1732f284-401d-41d9-a494-425451e8b4b8",
+ ProjectID: "e1284adea3ee4d2482af5ed214f3ad90",
+ },
+ }
+
+ shareType, err := sharetypes.ShowAccess(client.ServiceClient(), "shareTypeID").Extract()
+ th.AssertNoErr(t, err)
+
+ th.CheckDeepEquals(t, expected, shareType)
+}
diff --git a/openstack/sharedfilesystems/v2/sharetypes/urls.go b/openstack/sharedfilesystems/v2/sharetypes/urls.go
new file mode 100644
index 0000000..b963c7b
--- /dev/null
+++ b/openstack/sharedfilesystems/v2/sharetypes/urls.go
@@ -0,0 +1,35 @@
+package sharetypes
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("types")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("types", id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+ return createURL(c)
+}
+
+func getDefaultURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("types", "default")
+}
+
+func getExtraSpecsURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("types", id, "extra_specs")
+}
+
+func setExtraSpecsURL(c *gophercloud.ServiceClient, id string) string {
+ return getExtraSpecsURL(c, id)
+}
+
+func unsetExtraSpecsURL(c *gophercloud.ServiceClient, id string, key string) string {
+ return c.ServiceURL("types", id, "extra_specs", key)
+}
+
+func showAccessURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL("types", id, "share_type_access")
+}
diff --git a/openstack/testing/client_test.go b/openstack/testing/client_test.go
new file mode 100644
index 0000000..3fe768f
--- /dev/null
+++ b/openstack/testing/client_test.go
@@ -0,0 +1,315 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+const ID = "0123456789"
+
+func TestAuthenticatedClientV3(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ 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{
+ Username: "me",
+ Password: "secret",
+ DomainName: "default",
+ TenantName: "project",
+ IdentityEndpoint: th.Endpoint(),
+ }
+ client, err := openstack.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 := openstack.AuthenticatedClient(options)
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "01234567890", client.TokenID)
+}
+
+func TestIdentityAdminV3Client(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ 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": {
+ "audit_ids": ["VcxU2JYqT8OzfUVvrjEITQ", "qNUTIJntTzO1-XUk5STybw"],
+ "catalog": [
+ {
+ "endpoints": [
+ {
+ "id": "39dc322ce86c4111b4f06c2eeae0841b",
+ "interface": "public",
+ "region": "RegionOne",
+ "url": "http://localhost:5000"
+ },
+ {
+ "id": "ec642f27474842e78bf059f6c48f4e99",
+ "interface": "internal",
+ "region": "RegionOne",
+ "url": "http://localhost:5000"
+ },
+ {
+ "id": "c609fc430175452290b62a4242e8a7e8",
+ "interface": "admin",
+ "region": "RegionOne",
+ "url": "http://localhost:35357"
+ }
+ ],
+ "id": "4363ae44bdf34a3981fde3b823cb9aa2",
+ "type": "identity",
+ "name": "keystone"
+ }
+ ],
+ "expires_at": "2013-02-27T18:30:59.999999Z",
+ "is_domain": false,
+ "issued_at": "2013-02-27T16:30:59.999999Z",
+ "methods": [
+ "password"
+ ],
+ "project": {
+ "domain": {
+ "id": "1789d1",
+ "name": "example.com"
+ },
+ "id": "263fd9",
+ "name": "project-x"
+ },
+ "roles": [
+ {
+ "id": "76e72a",
+ "name": "admin"
+ },
+ {
+ "id": "f4f392",
+ "name": "member"
+ }
+ ],
+ "service_providers": [
+ {
+ "auth_url":"https://example.com:5000/v3/OS-FEDERATION/identity_providers/acme/protocols/saml2/auth",
+ "id": "sp1",
+ "sp_url": "https://example.com:5000/Shibboleth.sso/SAML2/ECP"
+ },
+ {
+ "auth_url":"https://other.example.com:5000/v3/OS-FEDERATION/identity_providers/acme/protocols/saml2/auth",
+ "id": "sp2",
+ "sp_url": "https://other.example.com:5000/Shibboleth.sso/SAML2/ECP"
+ }
+ ],
+ "user": {
+ "domain": {
+ "id": "1789d1",
+ "name": "example.com"
+ },
+ "id": "0ca8f6",
+ "name": "Joe",
+ "password_expires_at": "2016-11-06T15:32:17.000000"
+ }
+ }
+}
+ `)
+ })
+
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "secret",
+ DomainID: "12345",
+ IdentityEndpoint: th.Endpoint(),
+ }
+ pc, err := openstack.AuthenticatedClient(options)
+ th.AssertNoErr(t, err)
+ sc, err := openstack.NewIdentityV3(pc, gophercloud.EndpointOpts{
+ Availability: gophercloud.AvailabilityAdmin,
+ })
+ th.AssertNoErr(t, err)
+ th.CheckEquals(t, "http://localhost:35357/", sc.Endpoint)
+}
+
+func testAuthenticatedClientFails(t *testing.T, endpoint string) {
+ options := gophercloud.AuthOptions{
+ Username: "me",
+ Password: "secret",
+ DomainName: "default",
+ TenantName: "project",
+ IdentityEndpoint: endpoint,
+ }
+ _, err := openstack.AuthenticatedClient(options)
+ if err == nil {
+ t.Fatal("expected error but call succeeded")
+ }
+}
+
+func TestAuthenticatedClientV3Fails(t *testing.T) {
+ testAuthenticatedClientFails(t, "http://bad-address.example.com/v3")
+}
+
+func TestAuthenticatedClientV2Fails(t *testing.T) {
+ testAuthenticatedClientFails(t, "http://bad-address.example.com/v2.0")
+}
diff --git a/openstack/testing/doc.go b/openstack/testing/doc.go
new file mode 100644
index 0000000..34cfe7a
--- /dev/null
+++ b/openstack/testing/doc.go
@@ -0,0 +1,2 @@
+// openstack
+package testing
diff --git a/openstack/testing/endpoint_location_test.go b/openstack/testing/endpoint_location_test.go
new file mode 100644
index 0000000..ea7bdd2
--- /dev/null
+++ b/openstack/testing/endpoint_location_test.go
@@ -0,0 +1,231 @@
+package testing
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack"
+ tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+ tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+ th "github.com/gophercloud/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 := openstack.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) {
+ _, actual := openstack.V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+ Type: "nope",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ expected := &gophercloud.ErrEndpointNotFound{}
+ th.CheckEquals(t, expected.Error(), actual.Error())
+}
+
+func TestV2EndpointMultiple(t *testing.T) {
+ _, err := openstack.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 := openstack.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 := openstack.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) {
+ _, actual := openstack.V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+ Type: "nope",
+ Availability: gophercloud.AvailabilityPublic,
+ })
+ expected := &gophercloud.ErrEndpointNotFound{}
+ th.CheckEquals(t, expected.Error(), actual.Error())
+}
+
+func TestV3EndpointMultiple(t *testing.T) {
+ _, err := openstack.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 := openstack.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/utils/choose_version.go b/openstack/utils/choose_version.go
new file mode 100644
index 0000000..c605d08
--- /dev/null
+++ b/openstack/utils/choose_version.go
@@ -0,0 +1,114 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gophercloud/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/testing/choose_version_test.go b/openstack/utils/testing/choose_version_test.go
new file mode 100644
index 0000000..9c0119c
--- /dev/null
+++ b/openstack/utils/testing/choose_version_test.go
@@ -0,0 +1,119 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/openstack/utils"
+ "github.com/gophercloud/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 := &utils.Version{ID: "v2.0", Priority: 2, Suffix: "blarg"}
+ v3 := &utils.Version{ID: "v3.0", Priority: 3, Suffix: "hargl"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: "",
+ }
+ v, endpoint, err := utils.ChooseVersion(c, []*utils.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 := &utils.Version{ID: "v2.0", Priority: 2, Suffix: "nope"}
+ v3 := &utils.Version{ID: "v3.0", Priority: 3, Suffix: "northis"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+ }
+ v, endpoint, err := utils.ChooseVersion(c, []*utils.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 := &utils.Version{ID: "v2.0", Priority: 2, Suffix: "/v2.0/"}
+ v3 := &utils.Version{ID: "v3.0", Priority: 3, Suffix: "/v3.0/"}
+
+ c := &gophercloud.ProviderClient{
+ IdentityBase: testhelper.Endpoint(),
+ IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+ }
+ v, endpoint, err := utils.ChooseVersion(c, []*utils.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/openstack/utils/testing/doc.go b/openstack/utils/testing/doc.go
new file mode 100644
index 0000000..66ecc07
--- /dev/null
+++ b/openstack/utils/testing/doc.go
@@ -0,0 +1,2 @@
+//utils
+package testing
diff --git a/pagination/http.go b/pagination/http.go
new file mode 100644
index 0000000..cb4b4ae
--- /dev/null
+++ b/pagination/http.go
@@ -0,0 +1,60 @@
+package pagination
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gophercloud/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.Get(url, nil, &gophercloud.RequestOpts{
+ MoreHeaders: headers,
+ OkCodes: []int{200, 204},
+ })
+}
diff --git a/pagination/linked.go b/pagination/linked.go
new file mode 100644
index 0000000..3656fb7
--- /dev/null
+++ b/pagination/linked.go
@@ -0,0 +1,92 @@
+package pagination
+
+import (
+ "fmt"
+ "reflect"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// 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 {
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "map[string]interface{}"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+ return "", err
+ }
+
+ 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 {
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "map[string]interface{}"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(value))
+ return "", err
+ }
+ } else {
+ if value == nil {
+ // Actual null element.
+ return "", nil
+ }
+
+ url, ok := value.(string)
+ if !ok {
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "string"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(value))
+ return "", err
+ }
+
+ return url, nil
+ }
+ }
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current LinkedPageBase) IsEmpty() (bool, error) {
+ if b, ok := current.Body.([]interface{}); ok {
+ return len(b) == 0, nil
+ }
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "[]interface{}"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+ return true, err
+}
+
+// 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/marker.go b/pagination/marker.go
new file mode 100644
index 0000000..52e53ba
--- /dev/null
+++ b/pagination/marker.go
@@ -0,0 +1,58 @@
+package pagination
+
+import (
+ "fmt"
+ "reflect"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// 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
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current MarkerPageBase) IsEmpty() (bool, error) {
+ if b, ok := current.Body.([]interface{}); ok {
+ return len(b) == 0, nil
+ }
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "[]interface{}"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+ return true, err
+}
+
+// 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/pager.go b/pagination/pager.go
new file mode 100644
index 0000000..6f1609e
--- /dev/null
+++ b/pagination/pager.go
@@ -0,0 +1,238 @@
+package pagination
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+
+ "github.com/gophercloud/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)
+
+ // if it's a single page, just return the testPage (first page)
+ if _, found := pageType.FieldByName("SinglePageBase"); found {
+ return testPage, nil
+ }
+
+ // Switch on the page body type. Recognized types are `map[string]interface{}`,
+ // `[]byte`, and `[]interface{}`.
+ switch pb := 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, v := range b {
+ // If it's a linked page, we don't want the `links`, we want the other one.
+ if !strings.HasSuffix(k, "links") {
+ // check the field's type. we only want []interface{} (which is really []map[string]interface{})
+ switch vt := v.(type) {
+ case []interface{}:
+ key = k
+ pagesSlice = append(pagesSlice, vt...)
+ }
+ }
+ }
+ 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:
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "map[string]interface{}/[]byte/[]interface{}"
+ err.Actual = fmt.Sprintf("%T", pb)
+ return nil, err
+ }
+
+ // 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/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..4251d64
--- /dev/null
+++ b/pagination/single.go
@@ -0,0 +1,33 @@
+package pagination
+
+import (
+ "fmt"
+ "reflect"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// 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
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current SinglePageBase) IsEmpty() (bool, error) {
+ if b, ok := current.Body.([]interface{}); ok {
+ return len(b) == 0, nil
+ }
+ err := gophercloud.ErrUnexpectedType{}
+ err.Expected = "[]interface{}"
+ err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+ return true, err
+}
+
+// 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/testing/doc.go b/pagination/testing/doc.go
new file mode 100644
index 0000000..0bc1eb3
--- /dev/null
+++ b/pagination/testing/doc.go
@@ -0,0 +1,2 @@
+// pagination
+package testing
diff --git a/pagination/testing/linked_test.go b/pagination/testing/linked_test.go
new file mode 100644
index 0000000..3533e44
--- /dev/null
+++ b/pagination/testing/linked_test.go
@@ -0,0 +1,112 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/pagination"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// LinkedPager sample and test cases.
+
+type LinkedPageResult struct {
+ pagination.LinkedPageBase
+}
+
+func (r LinkedPageResult) IsEmpty() (bool, error) {
+ is, err := ExtractLinkedInts(r)
+ return len(is) == 0, err
+}
+
+func ExtractLinkedInts(r pagination.Page) ([]int, error) {
+ var s struct {
+ Ints []int `json:"ints"`
+ }
+ err := (r.(LinkedPageResult)).ExtractInto(&s)
+ return s.Ints, err
+}
+
+func createLinked(t *testing.T) pagination.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 pagination.PageResult) pagination.Page {
+ return LinkedPageResult{pagination.LinkedPageBase{PageResult: r}}
+ }
+
+ return pagination.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 pagination.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/testing/marker_test.go b/pagination/testing/marker_test.go
new file mode 100644
index 0000000..7b1a6da
--- /dev/null
+++ b/pagination/testing/marker_test.go
@@ -0,0 +1,127 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/pagination"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// MarkerPager sample and test cases.
+
+type MarkerPageResult struct {
+ pagination.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) pagination.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 pagination.PageResult) pagination.Page {
+ p := MarkerPageResult{pagination.MarkerPageBase{PageResult: r}}
+ p.MarkerPageBase.Owner = p
+ return p
+ }
+
+ return pagination.NewPager(client, testhelper.Server.URL+"/page", createPage)
+}
+
+func ExtractMarkerStrings(page pagination.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 pagination.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/testing/pagination_test.go b/pagination/testing/pagination_test.go
new file mode 100644
index 0000000..170dca4
--- /dev/null
+++ b/pagination/testing/pagination_test.go
@@ -0,0 +1,13 @@
+package testing
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func createClient() *gophercloud.ServiceClient {
+ return &gophercloud.ServiceClient{
+ ProviderClient: &gophercloud.ProviderClient{TokenID: "abc123"},
+ Endpoint: testhelper.Endpoint(),
+ }
+}
diff --git a/pagination/testing/single_test.go b/pagination/testing/single_test.go
new file mode 100644
index 0000000..8d95e94
--- /dev/null
+++ b/pagination/testing/single_test.go
@@ -0,0 +1,79 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/gophercloud/gophercloud/pagination"
+ "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// SinglePage sample and test cases.
+
+type SinglePageResult struct {
+ pagination.SinglePageBase
+}
+
+func (r SinglePageResult) IsEmpty() (bool, error) {
+ is, err := ExtractSingleInts(r)
+ if err != nil {
+ return true, err
+ }
+ return len(is) == 0, nil
+}
+
+func ExtractSingleInts(r pagination.Page) ([]int, error) {
+ var s struct {
+ Ints []int `json:"ints"`
+ }
+ err := (r.(SinglePageResult)).ExtractInto(&s)
+ return s.Ints, err
+}
+
+func setupSinglePaged() pagination.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 pagination.PageResult) pagination.Page {
+ return SinglePageResult{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, testhelper.Server.URL+"/only", createPage)
+}
+
+func TestEnumerateSinglePaged(t *testing.T) {
+ callCount := 0
+ pager := setupSinglePaged()
+ defer testhelper.TeardownHTTP()
+
+ err := pager.EachPage(func(page pagination.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..e484fe1
--- /dev/null
+++ b/params.go
@@ -0,0 +1,445 @@
+package gophercloud
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// BuildRequestBody builds a map[string]interface from the given `struct`. If
+// parent is not the empty string, the final map[string]interface returned will
+// encapsulate the built one
+//
+func BuildRequestBody(opts interface{}, parent string) (map[string]interface{}, 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]interface{})
+ if optsValue.Kind() == reflect.Struct {
+ //fmt.Printf("optsValue.Kind() is a reflect.Struct: %+v\n", optsValue.Kind())
+ for i := 0; i < optsValue.NumField(); i++ {
+ v := optsValue.Field(i)
+ f := optsType.Field(i)
+
+ if f.Name != strings.Title(f.Name) {
+ //fmt.Printf("Skipping field: %s...\n", f.Name)
+ continue
+ }
+
+ //fmt.Printf("Starting on field: %s...\n", f.Name)
+
+ zero := isZero(v)
+ //fmt.Printf("v is zero?: %v\n", zero)
+
+ // if the field has a required tag that's set to "true"
+ if requiredTag := f.Tag.Get("required"); requiredTag == "true" {
+ //fmt.Printf("Checking required field [%s]:\n\tv: %+v\n\tisZero:%v\n", f.Name, v.Interface(), zero)
+ // if the field's value is zero, return a missing-argument error
+ if zero {
+ // if the field has a 'required' tag, it can't have a zero-value
+ err := ErrMissingInput{}
+ err.Argument = f.Name
+ return nil, err
+ }
+ }
+
+ if xorTag := f.Tag.Get("xor"); xorTag != "" {
+ //fmt.Printf("Checking `xor` tag for field [%s] with value %+v:\n\txorTag: %s\n", f.Name, v, xorTag)
+ xorField := optsValue.FieldByName(xorTag)
+ var xorFieldIsZero bool
+ if reflect.ValueOf(xorField.Interface()) == reflect.Zero(xorField.Type()) {
+ xorFieldIsZero = true
+ } else {
+ if xorField.Kind() == reflect.Ptr {
+ xorField = xorField.Elem()
+ }
+ xorFieldIsZero = isZero(xorField)
+ }
+ if !(zero != xorFieldIsZero) {
+ err := ErrMissingInput{}
+ err.Argument = fmt.Sprintf("%s/%s", f.Name, xorTag)
+ err.Info = fmt.Sprintf("Exactly one of %s and %s must be provided", f.Name, xorTag)
+ return nil, err
+ }
+ }
+
+ if orTag := f.Tag.Get("or"); orTag != "" {
+ //fmt.Printf("Checking `or` tag for field with:\n\tname: %+v\n\torTag:%s\n", f.Name, orTag)
+ //fmt.Printf("field is zero?: %v\n", zero)
+ if zero {
+ orField := optsValue.FieldByName(orTag)
+ var orFieldIsZero bool
+ if reflect.ValueOf(orField.Interface()) == reflect.Zero(orField.Type()) {
+ orFieldIsZero = true
+ } else {
+ if orField.Kind() == reflect.Ptr {
+ orField = orField.Elem()
+ }
+ orFieldIsZero = isZero(orField)
+ }
+ if orFieldIsZero {
+ err := ErrMissingInput{}
+ err.Argument = fmt.Sprintf("%s/%s", f.Name, orTag)
+ err.Info = fmt.Sprintf("At least one of %s and %s must be provided", f.Name, orTag)
+ return nil, err
+ }
+ }
+ }
+
+ if v.Kind() == reflect.Struct || (v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Struct) {
+ if zero {
+ //fmt.Printf("value before change: %+v\n", optsValue.Field(i))
+ if jsonTag := f.Tag.Get("json"); jsonTag != "" {
+ jsonTagPieces := strings.Split(jsonTag, ",")
+ if len(jsonTagPieces) > 1 && jsonTagPieces[1] == "omitempty" {
+ if v.CanSet() {
+ if !v.IsNil() {
+ if v.Kind() == reflect.Ptr {
+ v.Set(reflect.Zero(v.Type()))
+ }
+ }
+ //fmt.Printf("value after change: %+v\n", optsValue.Field(i))
+ }
+ }
+ }
+ continue
+ }
+
+ //fmt.Printf("Calling BuildRequestBody with:\n\tv: %+v\n\tf.Name:%s\n", v.Interface(), f.Name)
+ _, err := BuildRequestBody(v.Interface(), f.Name)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ //fmt.Printf("opts: %+v \n", opts)
+
+ b, err := json.Marshal(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ //fmt.Printf("string(b): %s\n", string(b))
+
+ err = json.Unmarshal(b, &optsMap)
+ if err != nil {
+ return nil, err
+ }
+
+ //fmt.Printf("optsMap: %+v\n", optsMap)
+
+ if parent != "" {
+ optsMap = map[string]interface{}{parent: optsMap}
+ }
+ //fmt.Printf("optsMap after parent added: %+v\n", optsMap)
+ return optsMap, nil
+ }
+ // Return an error if the underlying type of 'opts' isn't a struct.
+ return nil, fmt.Errorf("Options type is not a struct.")
+}
+
+// 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
+)
+
+// IPVersion is a type for the possible IP address versions. Valid instances
+// are IPv4 and IPv6
+type IPVersion int
+
+const (
+ // IPv4 is used for IP version 4 addresses
+ IPv4 IPVersion = 4
+ // IPv6 is used for IP version 6 addresses
+ IPv6 IPVersion = 6
+)
+
+// 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
+}
+
+/*
+func isUnderlyingStructZero(v reflect.Value) bool {
+ switch v.Kind() {
+ case reflect.Ptr:
+ return isUnderlyingStructZero(v.Elem())
+ default:
+ return isZero(v)
+ }
+}
+*/
+
+var t time.Time
+
+func isZero(v reflect.Value) bool {
+ //fmt.Printf("\n\nchecking isZero for value: %+v\n", v)
+ switch v.Kind() {
+ case reflect.Ptr:
+ if v.IsNil() {
+ return true
+ }
+ return false
+ 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())
+ //fmt.Printf("zero type for value: %+v\n\n\n", z)
+ 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) {
+ loop:
+ switch v.Kind() {
+ case reflect.Ptr:
+ v = v.Elem()
+ goto loop
+ 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/provider_client.go b/provider_client.go
new file mode 100644
index 0000000..f886823
--- /dev/null
+++ b/provider_client.go
@@ -0,0 +1,307 @@
+package gophercloud
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+)
+
+// DefaultUserAgent is the default User-Agent string set in the request header.
+const DefaultUserAgent = "gophercloud/2.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
+
+ Debug bool
+}
+
+// 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.Reader that will be consumed by the request directly. No content-type
+ // will be set unless one is provided explicitly by MoreHeaders.
+ RawBody io.Reader
+ // 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
+ // ErrorContext specifies the resource error type to return if an error is encountered.
+ // This lets resources override default error messages based on the response status code.
+ ErrorContext error
+}
+
+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.Reader
+ 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)
+ }
+ }
+ }
+
+ // Set connection parameter to close the connection immediately when we've got the response
+ req.Close = true
+
+ // Issue the request.
+ resp, err := client.HTTPClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // 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()
+ //pc := make([]uintptr, 1)
+ //runtime.Callers(2, pc)
+ //f := runtime.FuncForPC(pc[0])
+ respErr := ErrUnexpectedResponseCode{
+ URL: url,
+ Method: method,
+ Expected: options.OkCodes,
+ Actual: resp.StatusCode,
+ Body: body,
+ }
+ //respErr.Function = "gophercloud.ProviderClient.Request"
+
+ errType := options.ErrorContext
+ switch resp.StatusCode {
+ case http.StatusBadRequest:
+ err = ErrDefault400{respErr}
+ if error400er, ok := errType.(Err400er); ok {
+ err = error400er.Error400(respErr)
+ }
+ case http.StatusUnauthorized:
+ if client.ReauthFunc != nil {
+ err = client.ReauthFunc()
+ if err != nil {
+ e := &ErrUnableToReauthenticate{}
+ e.ErrOriginal = respErr
+ return nil, e
+ }
+ if options.RawBody != nil {
+ if seeker, ok := options.RawBody.(io.Seeker); ok {
+ seeker.Seek(0, 0)
+ }
+ }
+ resp, err = client.Request(method, url, options)
+ if err != nil {
+ switch err.(type) {
+ case *ErrUnexpectedResponseCode:
+ e := &ErrErrorAfterReauthentication{}
+ e.ErrOriginal = err.(*ErrUnexpectedResponseCode)
+ return nil, e
+ default:
+ e := &ErrErrorAfterReauthentication{}
+ e.ErrOriginal = err
+ return nil, e
+ }
+ }
+ return resp, nil
+ }
+ err = ErrDefault401{respErr}
+ if error401er, ok := errType.(Err401er); ok {
+ err = error401er.Error401(respErr)
+ }
+ case http.StatusNotFound:
+ err = ErrDefault404{respErr}
+ if error404er, ok := errType.(Err404er); ok {
+ err = error404er.Error404(respErr)
+ }
+ case http.StatusMethodNotAllowed:
+ err = ErrDefault405{respErr}
+ if error405er, ok := errType.(Err405er); ok {
+ err = error405er.Error405(respErr)
+ }
+ case http.StatusRequestTimeout:
+ err = ErrDefault408{respErr}
+ if error408er, ok := errType.(Err408er); ok {
+ err = error408er.Error408(respErr)
+ }
+ case 429:
+ err = ErrDefault429{respErr}
+ if error429er, ok := errType.(Err429er); ok {
+ err = error429er.Error429(respErr)
+ }
+ case http.StatusInternalServerError:
+ err = ErrDefault500{respErr}
+ if error500er, ok := errType.(Err500er); ok {
+ err = error500er.Error500(respErr)
+ }
+ case http.StatusServiceUnavailable:
+ err = ErrDefault503{respErr}
+ if error503er, ok := errType.(Err503er); ok {
+ err = error503er.Error503(respErr)
+ }
+ }
+
+ if err == nil {
+ err = respErr
+ }
+
+ return resp, err
+ }
+
+ // 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 == "PATCH":
+ return []int{200, 204}
+ case method == "DELETE":
+ return []int{202, 204}
+ }
+
+ return []int{}
+}
diff --git a/results.go b/results.go
new file mode 100644
index 0000000..76c16ef
--- /dev/null
+++ b/results.go
@@ -0,0 +1,336 @@
+package gophercloud
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "reflect"
+ "strconv"
+ "time"
+)
+
+/*
+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
+}
+
+// ExtractInto allows users to provide an object into which `Extract` will extract
+// the `Result.Body`. This would be useful for OpenStack providers that have
+// different fields in the response object than OpenStack proper.
+func (r Result) ExtractInto(to interface{}) error {
+ if r.Err != nil {
+ return r.Err
+ }
+
+ if reader, ok := r.Body.(io.Reader); ok {
+ if readCloser, ok := reader.(io.Closer); ok {
+ defer readCloser.Close()
+ }
+ return json.NewDecoder(reader).Decode(to)
+ }
+
+ b, err := json.Marshal(r.Body)
+ if err != nil {
+ return err
+ }
+ err = json.Unmarshal(b, to)
+
+ return err
+}
+
+func (r Result) extractIntoPtr(to interface{}, label string) error {
+ if label == "" {
+ return r.ExtractInto(&to)
+ }
+
+ var m map[string]interface{}
+ err := r.ExtractInto(&m)
+ if err != nil {
+ return err
+ }
+
+ b, err := json.Marshal(m[label])
+ if err != nil {
+ return err
+ }
+
+ err = json.Unmarshal(b, &to)
+ return err
+}
+
+// ExtractIntoStructPtr will unmarshal the Result (r) into the provided
+// interface{} (to).
+//
+// NOTE: For internal use only
+//
+// `to` must be a pointer to an underlying struct type
+//
+// If provided, `label` will be filtered out of the response
+// body prior to `r` being unmarshalled into `to`.
+func (r Result) ExtractIntoStructPtr(to interface{}, label string) error {
+ if r.Err != nil {
+ return r.Err
+ }
+
+ t := reflect.TypeOf(to)
+ if k := t.Kind(); k != reflect.Ptr {
+ return fmt.Errorf("Expected pointer, got %v", k)
+ }
+ switch t.Elem().Kind() {
+ case reflect.Struct:
+ return r.extractIntoPtr(to, label)
+ default:
+ return fmt.Errorf("Expected pointer to struct, got: %v", t)
+ }
+}
+
+// ExtractIntoSlicePtr will unmarshal the Result (r) into the provided
+// interface{} (to).
+//
+// NOTE: For internal use only
+//
+// `to` must be a pointer to an underlying slice type
+//
+// If provided, `label` will be filtered out of the response
+// body prior to `r` being unmarshalled into `to`.
+func (r Result) ExtractIntoSlicePtr(to interface{}, label string) error {
+ if r.Err != nil {
+ return r.Err
+ }
+
+ t := reflect.TypeOf(to)
+ if k := t.Kind(); k != reflect.Ptr {
+ return fmt.Errorf("Expected pointer, got %v", k)
+ }
+ switch t.Elem().Kind() {
+ case reflect.Slice:
+ return r.extractIntoPtr(to, label)
+ default:
+ return fmt.Errorf("Expected pointer to slice, got: %v", t)
+ }
+}
+
+// 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 (r HeaderResult) ExtractInto(to interface{}) error {
+ if r.Err != nil {
+ return r.Err
+ }
+
+ tmpHeaderMap := map[string]string{}
+ for k, v := range r.Header {
+ if len(v) > 0 {
+ tmpHeaderMap[k] = v[0]
+ }
+ }
+
+ b, err := json.Marshal(tmpHeaderMap)
+ if err != nil {
+ return err
+ }
+ err = json.Unmarshal(b, to)
+
+ return err
+}
+
+// RFC3339Milli describes a common time format used by some API responses.
+const RFC3339Milli = "2006-01-02T15:04:05.999999Z"
+
+type JSONRFC3339Milli time.Time
+
+func (jt *JSONRFC3339Milli) UnmarshalJSON(data []byte) error {
+ b := bytes.NewBuffer(data)
+ dec := json.NewDecoder(b)
+ var s string
+ if err := dec.Decode(&s); err != nil {
+ return err
+ }
+ t, err := time.Parse(RFC3339Milli, s)
+ if err != nil {
+ return err
+ }
+ *jt = JSONRFC3339Milli(t)
+ return nil
+}
+
+const RFC3339MilliNoZ = "2006-01-02T15:04:05.999999"
+
+type JSONRFC3339MilliNoZ time.Time
+
+func (jt *JSONRFC3339MilliNoZ) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return err
+ }
+ if s == "" {
+ return nil
+ }
+ t, err := time.Parse(RFC3339MilliNoZ, s)
+ if err != nil {
+ return err
+ }
+ *jt = JSONRFC3339MilliNoZ(t)
+ return nil
+}
+
+type JSONRFC1123 time.Time
+
+func (jt *JSONRFC1123) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return err
+ }
+ if s == "" {
+ return nil
+ }
+ t, err := time.Parse(time.RFC1123, s)
+ if err != nil {
+ return err
+ }
+ *jt = JSONRFC1123(t)
+ return nil
+}
+
+type JSONUnix time.Time
+
+func (jt *JSONUnix) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return err
+ }
+ if s == "" {
+ return nil
+ }
+ unix, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return err
+ }
+ t = time.Unix(unix, 0)
+ *jt = JSONUnix(t)
+ return nil
+}
+
+// RFC3339NoZ is the time format used in Heat (Orchestration).
+const RFC3339NoZ = "2006-01-02T15:04:05"
+
+type JSONRFC3339NoZ time.Time
+
+func (jt *JSONRFC3339NoZ) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return err
+ }
+ if s == "" {
+ return nil
+ }
+ t, err := time.Parse(RFC3339NoZ, s)
+ if err != nil {
+ return err
+ }
+ *jt = JSONRFC3339NoZ(t)
+ return nil
+}
+
+/*
+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 `json:"href"`
+ Rel string `json:"rel"`
+}
+
+/*
+ExtractNextURL is an internal function useful for packages of collection
+resources that are paginated in a certain way.
+
+It 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..9debd48
--- /dev/null
+++ b/script/acceptancetest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the acceptance tests.
+
+exec go test -p=1 github.com/gophercloud/gophercloud/acceptance/... $@
diff --git a/script/acceptancetest_environments/keystonev2-lbaasv1.sh b/script/acceptancetest_environments/keystonev2-lbaasv1.sh
new file mode 100644
index 0000000..c74db62
--- /dev/null
+++ b/script/acceptancetest_environments/keystonev2-lbaasv1.sh
@@ -0,0 +1,194 @@
+#!/bin/bash
+#
+# This script is useful for creating a devstack environment to run gophercloud
+# acceptance tests on.
+#
+# This can be considered a "legacy" devstack environment since it uses
+# Keystone v2 and LBaaS v1.
+#
+# To run, simply execute this script within a virtual machine.
+#
+# The following OpenStack versions are installed:
+# * OpenStack Mitaka
+# * Keystone v2
+# * Glance v1 and v2
+# * Nova v2 and v2.1
+# * Cinder v1 and v2
+# * Trove v1
+# * Swift v1
+# * Neutron v2
+# * Neutron LBaaS v1.0
+# * Neutron FWaaS v2.0
+# * Manila v2
+#
+# Go 1.6 is also installed.
+
+set -e
+
+cd
+sudo apt-get update
+sudo apt-get install -y git make mercurial
+
+sudo wget -O /usr/local/bin/gimme https://raw.githubusercontent.com/travis-ci/gimme/master/gimme
+sudo chmod +x /usr/local/bin/gimme
+gimme 1.6 >> .bashrc
+
+mkdir ~/go
+eval "$(/usr/local/bin/gimme 1.6)"
+echo 'export GOPATH=$HOME/go' >> .bashrc
+export GOPATH=$HOME/go
+source .bashrc
+
+go get golang.org/x/crypto/ssh
+go get github.com/gophercloud/gophercloud
+
+git clone https://git.openstack.org/openstack-dev/devstack -b stable/mitaka
+cd devstack
+cat >local.conf <<EOF
+[[local|localrc]]
+# OpenStack version
+OPENSTACK_VERSION="mitaka"
+
+# devstack password
+DEVSTACK_PASSWORD="password"
+
+# Configure passwords and the Swift Hash
+MYSQL_PASSWORD=\$DEVSTACK_PASSWORD
+RABBIT_PASSWORD=\$DEVSTACK_PASSWORD
+SERVICE_TOKEN=\$DEVSTACK_PASSWORD
+ADMIN_PASSWORD=\$DEVSTACK_PASSWORD
+SERVICE_PASSWORD=\$DEVSTACK_PASSWORD
+SWIFT_HASH=\$DEVSTACK_PASSWORD
+
+# Configure the stable OpenStack branches used by DevStack
+# For stable branches see
+# http://git.openstack.org/cgit/openstack-dev/devstack/refs/
+CINDER_BRANCH=stable/\$OPENSTACK_VERSION
+CEILOMETER_BRANCH=stable/\$OPENSTACK_VERSION
+GLANCE_BRANCH=stable/\$OPENSTACK_VERSION
+HEAT_BRANCH=stable/\$OPENSTACK_VERSION
+HORIZON_BRANCH=stable/\$OPENSTACK_VERSION
+KEYSTONE_BRANCH=stable/\$OPENSTACK_VERSION
+NEUTRON_BRANCH=stable/\$OPENSTACK_VERSION
+NOVA_BRANCH=stable/\$OPENSTACK_VERSION
+SWIFT_BRANCH=stable/\$OPENSTACK_VERSION
+ZAQAR_BRANCH=stable/\$OPENSTACK_VERSION
+
+# Enable Swift
+enable_service s-proxy
+enable_service s-object
+enable_service s-container
+enable_service s-account
+
+# Disable Nova Network and enable Neutron
+disable_service n-net
+enable_service q-svc
+enable_service q-agt
+enable_service q-dhcp
+enable_service q-l3
+enable_service q-meta
+#enable_service q-flavors
+
+# Disable Neutron metering
+disable_service q-metering
+
+# Enable LBaaS V1
+enable_service q-lbaas
+
+# Enable FWaaS
+enable_service q-fwaas
+
+# Enable LBaaS v2
+#enable_plugin neutron-lbaas https://git.openstack.org/openstack/neutron-lbaas stable/\$OPENSTACK_VERSION
+#enable_plugin octavia https://git.openstack.org/openstack/octavia stable/\$OPENSTACK_VERSION
+#enable_service q-lbaasv2
+#enable_service octavia
+#enable_service o-cw
+#enable_service o-hk
+#enable_service o-hm
+#enable_service o-api
+
+# Enable Trove
+enable_plugin trove git://git.openstack.org/openstack/trove.git stable/\$OPENSTACK_VERSION
+enable_service trove,tr-api,tr-tmgr,tr-cond
+
+# Disable Temptest
+disable_service tempest
+
+# Disable Horizon
+disable_service horizon
+
+# Disable Keystone v2
+#ENABLE_IDENTITY_V2=False
+
+# Enable SSL/tls
+#enable_service tls-proxy
+#USE_SSL=True
+
+# Enable Ceilometer
+#enable_service ceilometer-acompute
+#enable_service ceilometer-acentral
+#enable_service ceilometer-anotification
+#enable_service ceilometer-collector
+#enable_service ceilometer-alarm-evaluator
+#enable_service ceilometer-alarm-notifier
+#enable_service ceilometer-api
+
+# Enable Zaqar
+#enable_plugin zaqar https://github.com/openstack/zaqar
+#enable_service zaqar-server
+
+# Enable Manila
+enable_plugin manila https://github.com/openstack/manila
+
+# Automatically download and register a VM image that Heat can launch
+# For more information on Heat and DevStack see
+# http://docs.openstack.org/developer/heat/getting_started/on_devstack.html
+#IMAGE_URLS+=",http://cloud.fedoraproject.org/fedora-20.x86_64.qcow2"
+#IMAGE_URLS+=",https://cloud-images.ubuntu.com/trusty/current/trusty-server-cloudimg-amd64-disk1.img"
+
+# Logging
+LOGDAYS=1
+LOGFILE=/opt/stack/logs/stack.sh.log
+LOGDIR=/opt/stack/logs
+EOF
+./stack.sh
+
+# Prep the testing environment by creating the required testing resources and environment variables
+source openrc admin
+wget http://download.cirros-cloud.net/0.3.4/cirros-0.3.4-x86_64-disk.img
+glance image-create --name CirrOS --disk-format qcow2 --container-format bare < cirros-0.3.4-x86_64-disk.img
+nova flavor-create m1.acctest 99 512 5 1 --ephemeral 10
+nova flavor-create m1.resize 98 512 6 1 --ephemeral 10
+_NETWORK_ID=$(nova net-list | grep private | awk -F\| '{print $2}' | tr -d ' ')
+_EXTGW_ID=$(nova net-list | grep public | awk -F\| '{print $2}' | tr -d ' ')
+_IMAGE_ID=$(nova image-list | grep CirrOS | awk -F\| '{print $2}' | tr -d ' ' | head -1)
+echo export OS_IMAGE_NAME="cirros-0.3.4-x86_64-uec" >> openrc
+echo export OS_IMAGE_ID="$_IMAGE_ID" >> openrc
+echo export OS_NETWORK_ID=$_NETWORK_ID >> openrc
+echo export OS_EXTGW_ID=$_EXTGW_ID >> openrc
+echo export OS_POOL_NAME="public" >> openrc
+echo export OS_FLAVOR_ID=99 >> openrc
+echo export OS_FLAVOR_ID_RESIZE=98 >> openrc
+
+# Manila share-network needs to be created
+_IDTOVALUE="-F id -f value"
+_NEUTRON_NET_ID=$(neutron net-list --name private $_IDTOVALUE)
+_NEUTRON_IPV4_SUB=$(neutron subnet-list \
+ --ip_version 4 \
+ --network_id "$_NEUTRON_NET_ID" \
+ $_IDTOVALUE)
+
+manila share-network-create \
+ --neutron-net-id "$_NEUTRON_NET_ID" \
+ --neutron-subnet-id "$_NEUTRON_IPV4_SUB" \
+ --name "acc_share_nw"
+
+_SHARE_NETWORK=$(manila share-network-list \
+ --neutron-net-id "$_NEUTRON_NET_ID" \
+ --neutron-subnet-id "$_NEUTRON_IPV4_SUB" \
+ --name "acc_share_nw" \
+ | awk 'FNR == 4 {print $2}')
+
+echo export OS_SHARE_NETWORK_ID="$_SHARE_NETWORK" >> openrc
+source openrc demo
diff --git a/script/acceptancetest_environments/keystonev3-lbaasv2.sh b/script/acceptancetest_environments/keystonev3-lbaasv2.sh
new file mode 100644
index 0000000..5cc9212
--- /dev/null
+++ b/script/acceptancetest_environments/keystonev3-lbaasv2.sh
@@ -0,0 +1,208 @@
+#!/bin/bash
+#
+# This script is useful for creating a devstack environment to run gophercloud
+# acceptance tests on.
+#
+# To run, simply execute this script within a virtual machine.
+#
+# The following OpenStack versions are installed:
+# * OpenStack Mitaka
+# * Keystone v3
+# * Glance v1 and v2
+# * Nova v2 and v2.1
+# * Cinder v1 and v2
+# * Trove v1
+# * Swift v1
+# * Neutron v2
+# * Neutron LBaaS v2.0
+# * Neutron FWaaS v2.0
+#
+# Go 1.6 is also installed.
+
+set -e
+
+cd
+sudo apt-get update
+sudo apt-get install -y git make mercurial
+
+sudo wget -O /usr/local/bin/gimme https://raw.githubusercontent.com/travis-ci/gimme/master/gimme
+sudo chmod +x /usr/local/bin/gimme
+gimme 1.6 >> .bashrc
+
+mkdir ~/go
+eval "$(/usr/local/bin/gimme 1.6)"
+echo 'export GOPATH=$HOME/go' >> .bashrc
+export GOPATH=$HOME/go
+
+export PATH=$PATH:$HOME/terraform:$HOME/go/bin
+echo 'export PATH=$PATH:$HOME/terraform:$HOME/go/bin' >> .bashrc
+source .bashrc
+
+go get golang.org/x/crypto/ssh
+go get github.com/gophercloud/gophercloud
+
+git clone https://git.openstack.org/openstack-dev/devstack -b stable/mitaka
+cd devstack
+cat >local.conf <<EOF
+[[local|localrc]]
+# OpenStack version
+OPENSTACK_VERSION="mitaka"
+
+# devstack password
+DEVSTACK_PASSWORD="password"
+
+# Configure passwords and the Swift Hash
+MYSQL_PASSWORD=\$DEVSTACK_PASSWORD
+RABBIT_PASSWORD=\$DEVSTACK_PASSWORD
+SERVICE_TOKEN=\$DEVSTACK_PASSWORD
+ADMIN_PASSWORD=\$DEVSTACK_PASSWORD
+SERVICE_PASSWORD=\$DEVSTACK_PASSWORD
+SWIFT_HASH=\$DEVSTACK_PASSWORD
+
+# Configure the stable OpenStack branches used by DevStack
+# For stable branches see
+# http://git.openstack.org/cgit/openstack-dev/devstack/refs/
+CINDER_BRANCH=stable/\$OPENSTACK_VERSION
+CEILOMETER_BRANCH=stable/\$OPENSTACK_VERSION
+GLANCE_BRANCH=stable/\$OPENSTACK_VERSION
+HEAT_BRANCH=stable/\$OPENSTACK_VERSION
+HORIZON_BRANCH=stable/\$OPENSTACK_VERSION
+KEYSTONE_BRANCH=stable/\$OPENSTACK_VERSION
+NEUTRON_BRANCH=stable/\$OPENSTACK_VERSION
+NOVA_BRANCH=stable/\$OPENSTACK_VERSION
+SWIFT_BRANCH=stable/\$OPENSTACK_VERSION
+ZAQAR_BRANCH=stable/\$OPENSTACK_VERSION
+
+# Enable Swift
+enable_service s-proxy
+enable_service s-object
+enable_service s-container
+enable_service s-account
+
+# Disable Nova Network and enable Neutron
+disable_service n-net
+enable_service q-svc
+enable_service q-agt
+enable_service q-dhcp
+enable_service q-l3
+enable_service q-meta
+enable_service q-flavors
+
+# Disable Neutron metering
+disable_service q-metering
+
+# Enable LBaaS V1
+#enable_service q-lbaas
+
+# Enable FWaaS
+enable_service q-fwaas
+
+# Enable LBaaS v2
+enable_plugin neutron-lbaas https://git.openstack.org/openstack/neutron-lbaas stable/\$OPENSTACK_VERSION
+#enable_plugin octavia https://git.openstack.org/openstack/octavia stable/\$OPENSTACK_VERSION
+enable_plugin octavia https://git.openstack.org/openstack/octavia
+enable_service q-lbaasv2
+enable_service octavia
+enable_service o-cw
+enable_service o-hm
+enable_service o-hk
+enable_service o-api
+
+# Octavia
+OCTAVIA_AUTH_VERSION=3
+
+# Enable Trove
+#enable_plugin trove git://git.openstack.org/openstack/trove.git stable/\$OPENSTACK_VERSION
+#enable_service trove,tr-api,tr-tmgr,tr-cond
+
+# Disable Temptest
+disable_service tempest
+
+# Disable Horizon
+disable_service horizon
+
+# Disable Keystone v2
+ENABLE_IDENTITY_V2=False
+
+# Enable SSL/tls
+#enable_service tls-proxy
+#USE_SSL=True
+
+# Enable Ceilometer
+#enable_service ceilometer-acompute
+#enable_service ceilometer-acentral
+#enable_service ceilometer-anotification
+#enable_service ceilometer-collector
+#enable_service ceilometer-alarm-evaluator
+#enable_service ceilometer-alarm-notifier
+#enable_service ceilometer-api
+
+# Enable Zaqar
+#enable_plugin zaqar https://github.com/openstack/zaqar
+#enable_service zaqar-server
+
+# Automatically download and register a VM image that Heat can launch
+# For more information on Heat and DevStack see
+# http://docs.openstack.org/developer/heat/getting_started/on_devstack.html
+#IMAGE_URLS+=",http://cloud.fedoraproject.org/fedora-20.x86_64.qcow2"
+#IMAGE_URLS+=",https://cloud-images.ubuntu.com/trusty/current/trusty-server-cloudimg-amd64-disk1.img"
+
+# Logging
+LOGDAYS=1
+LOGFILE=/opt/stack/logs/stack.sh.log
+LOGDIR=/opt/stack/logs
+
+[[post-config|\$OCTAVIA_CONF]]
+[default]
+debug = true
+verbose = true
+[keystone_authtoken]
+auth_uri = http://localhost:5000/v3
+auth_url = http://localhost:35357/v3
+user_domain_id = default
+project_name = default
+auth_type = password
+[keystone_authtoken_v3]
+admin_user_domain = default
+admin_project_domain = default
+[[post-config|\$NEUTRON_CONF]]
+[service_auth]
+auth_version = 3
+auth_uri = http://localhost:5000/v3
+auth_url = http://localhost:35357/v3
+admin_user_domain = default
+admin_project_domain = default
+[[post-config|\$NEUTRON_LBAAS_CONF]]
+[service_auth]
+auth_version = 3
+auth_uri = http://localhost:5000/v3
+auth_url = http://localhost:35357/v3
+admin_user_domain = default
+admin_project_domain = default
+EOF
+./stack.sh
+
+# Patch openrc
+cat >> openrc <<EOF
+if [ "\$OS_IDENTITY_API_VERSION" = "3" ]; then
+ export OS_DOMAIN_NAME=\${OS_DOMAIN_NAME:-"default"}
+fi
+EOF
+
+# Prep the testing environment by creating the required testing resources and environment variables
+source openrc admin
+wget http://download.cirros-cloud.net/0.3.4/cirros-0.3.4-x86_64-disk.img
+glance image-create --name CirrOS --disk-format qcow2 --container-format bare < cirros-0.3.4-x86_64-disk.img
+nova flavor-create m1.acctest 99 512 5 1 --ephemeral 10
+nova flavor-create m1.resize 98 512 6 1 --ephemeral 10
+_NETWORK_ID=$(nova net-list | grep private | awk -F\| '{print $2}' | tr -d ' ')
+_EXTGW_ID=$(nova net-list | grep public | awk -F\| '{print $2}' | tr -d ' ')
+_IMAGE_ID=$(nova image-list | grep CirrOS | awk -F\| '{print $2}' | tr -d ' ' | head -1)
+echo export OS_IMAGE_NAME="cirros-0.3.4-x86_64-uec" >> openrc
+echo export OS_IMAGE_ID="$_IMAGE_ID" >> openrc
+echo export OS_NETWORK_ID=$_NETWORK_ID >> openrc
+echo export OS_EXTGW_ID=$_EXTGW_ID >> openrc
+echo export OS_POOL_NAME="public" >> openrc
+echo export OS_FLAVOR_ID=99 >> openrc
+echo export OS_FLAVOR_ID_RESIZE=98 >> openrc
+source openrc demo
diff --git a/script/bootstrap b/script/bootstrap
new file mode 100755
index 0000000..78a195d
--- /dev/null
+++ b/script/bootstrap
@@ -0,0 +1,25 @@
+#!/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/gophercloud/gophercloud
+cd $GOPATH/src/github.com/gophercloud/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/gophercloud/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/coverage b/script/coverage
new file mode 100755
index 0000000..3efa81b
--- /dev/null
+++ b/script/coverage
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+set -e
+
+n=1
+for testpkg in $(go list ./testing ./.../testing); do
+ covpkg="${testpkg/"/testing"/}"
+ go test -covermode count -coverprofile "testing_"$n.coverprofile -coverpkg $covpkg $testpkg 2>/dev/null
+ n=$((n+1))
+done
+gocovmerge `ls *.coverprofile` > cover.out
+rm *.coverprofile
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..2c65d06
--- /dev/null
+++ b/script/unittest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the unit tests.
+
+exec go test ./testing ./.../testing $@
diff --git a/service_client.go b/service_client.go
new file mode 100644
index 0000000..7484c67
--- /dev/null
+++ b/service_client.go
@@ -0,0 +1,141 @@
+package gophercloud
+
+import (
+ "io"
+ "net/http"
+ "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
+
+ Microversion 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, "/")
+}
+
+// Get calls `Request` with the "GET" HTTP verb.
+func (client *ServiceClient) Get(url string, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ if opts.MoreHeaders == nil {
+ opts.MoreHeaders = make(map[string]string)
+ }
+ opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+ return client.Request("GET", url, opts)
+}
+
+// Post calls `Request` with the "POST" HTTP verb.
+func (client *ServiceClient) Post(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if v, ok := (JSONBody).(io.Reader); ok {
+ opts.RawBody = v
+ } else if JSONBody != nil {
+ opts.JSONBody = JSONBody
+ }
+
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ if opts.MoreHeaders == nil {
+ opts.MoreHeaders = make(map[string]string)
+ }
+ opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+ return client.Request("POST", url, opts)
+}
+
+// Put calls `Request` with the "PUT" HTTP verb.
+func (client *ServiceClient) Put(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if v, ok := (JSONBody).(io.Reader); ok {
+ opts.RawBody = v
+ } else if JSONBody != nil {
+ opts.JSONBody = JSONBody
+ }
+
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ if opts.MoreHeaders == nil {
+ opts.MoreHeaders = make(map[string]string)
+ }
+ opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+ return client.Request("PUT", url, opts)
+}
+
+// Patch calls `Request` with the "PATCH" HTTP verb.
+func (client *ServiceClient) Patch(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if v, ok := (JSONBody).(io.Reader); ok {
+ opts.RawBody = v
+ } else if JSONBody != nil {
+ opts.JSONBody = JSONBody
+ }
+
+ if JSONResponse != nil {
+ opts.JSONResponse = JSONResponse
+ }
+
+ if opts.MoreHeaders == nil {
+ opts.MoreHeaders = make(map[string]string)
+ }
+ opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+ return client.Request("PATCH", url, opts)
+}
+
+// Delete calls `Request` with the "DELETE" HTTP verb.
+func (client *ServiceClient) Delete(url string, opts *RequestOpts) (*http.Response, error) {
+ if opts == nil {
+ opts = &RequestOpts{}
+ }
+
+ if opts.MoreHeaders == nil {
+ opts.MoreHeaders = make(map[string]string)
+ }
+ opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+ return client.Request("DELETE", url, opts)
+}
diff --git a/testhelper/client/fake.go b/testhelper/client/fake.go
new file mode 100644
index 0000000..3d81cc9
--- /dev/null
+++ b/testhelper/client/fake.go
@@ -0,0 +1,17 @@
+package client
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/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..f21c3f9
--- /dev/null
+++ b/testhelper/convenience.go
@@ -0,0 +1,348 @@
+package testhelper
+
+import (
+ "bytes"
+ "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))
+ })
+}
+
+func isByteArrayEquals(t *testing.T, expectedBytes []byte, actualBytes []byte) bool {
+ return bytes.Equal(expectedBytes, actualBytes)
+}
+
+// AssertByteArrayEquals a convenience function for checking whether two byte arrays are equal
+func AssertByteArrayEquals(t *testing.T, expectedBytes []byte, actualBytes []byte) {
+ if !isByteArrayEquals(t, expectedBytes, actualBytes) {
+ logFatal(t, "The bytes differed.")
+ }
+}
+
+// CheckByteArrayEquals a convenience function for silent checking whether two byte arrays are equal
+func CheckByteArrayEquals(t *testing.T, expectedBytes []byte, actualBytes []byte) {
+ if !isByteArrayEquals(t, expectedBytes, actualBytes) {
+ logError(t, "The bytes differed.")
+ }
+}
+
+// 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..fe98c86
--- /dev/null
+++ b/testhelper/fixture/helper.go
@@ -0,0 +1,31 @@
+package fixture
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ "github.com/gophercloud/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/testing/doc.go b/testing/doc.go
new file mode 100644
index 0000000..244a62e
--- /dev/null
+++ b/testing/doc.go
@@ -0,0 +1,2 @@
+// gophercloud
+package testing
diff --git a/testing/endpoint_search_test.go b/testing/endpoint_search_test.go
new file mode 100644
index 0000000..22476cb
--- /dev/null
+++ b/testing/endpoint_search_test.go
@@ -0,0 +1,20 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestApplyDefaultsToEndpointOpts(t *testing.T) {
+ eo := gophercloud.EndpointOpts{Availability: gophercloud.AvailabilityPublic}
+ eo.ApplyDefaults("compute")
+ expected := gophercloud.EndpointOpts{Availability: gophercloud.AvailabilityPublic, Type: "compute"}
+ th.CheckDeepEquals(t, expected, eo)
+
+ eo = gophercloud.EndpointOpts{Type: "compute"}
+ eo.ApplyDefaults("object-store")
+ expected = gophercloud.EndpointOpts{Availability: gophercloud.AvailabilityPublic, Type: "compute"}
+ th.CheckDeepEquals(t, expected, eo)
+}
diff --git a/testing/params_test.go b/testing/params_test.go
new file mode 100644
index 0000000..937ff8b
--- /dev/null
+++ b/testing/params_test.go
@@ -0,0 +1,254 @@
+package testing
+
+import (
+ "net/url"
+ "reflect"
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestMaybeString(t *testing.T) {
+ testString := ""
+ var expected *string
+ actual := gophercloud.MaybeString(testString)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testString = "carol"
+ expected = &testString
+ actual = gophercloud.MaybeString(testString)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestMaybeInt(t *testing.T) {
+ testInt := 0
+ var expected *int
+ actual := gophercloud.MaybeInt(testInt)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testInt = 4
+ expected = &testInt
+ actual = gophercloud.MaybeInt(testInt)
+ th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestBuildQueryString(t *testing.T) {
+ type testVar string
+ iFalse := false
+ 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"`
+ F *bool `q:"f"`
+ }{
+ J: 2,
+ R: "red",
+ C: true,
+ S: []string{"one", "two", "three"},
+ TS: []testVar{"a", "b"},
+ TI: []int{1, 2},
+ F: &iFalse,
+ }
+ expected := &url.URL{RawQuery: "c=true&f=false&j=2&r=red&s=one&s=two&s=three&ti=1&ti=2&ts=a&ts=b"}
+ actual, err := gophercloud.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"`
+ F *bool `q:"f"`
+ }{
+ J: 2,
+ C: true,
+ }
+ _, err = gophercloud.BuildQueryString(&opts)
+ if err == nil {
+ t.Errorf("Expected error: 'Required field not set'")
+ }
+ th.CheckDeepEquals(t, expected, actual)
+
+ _, err = gophercloud.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 := gophercloud.BuildHeaders(&testStruct)
+ th.CheckNoErr(t, err)
+ th.CheckDeepEquals(t, expected, actual)
+
+ testStruct.Num = 0
+ _, err = gophercloud.BuildHeaders(&testStruct)
+ if err == nil {
+ t.Errorf("Expected error: 'Required header not set'")
+ }
+
+ _, err = gophercloud.BuildHeaders(map[string]interface{}{"Number": 4})
+ if err == nil {
+ t.Errorf("Expected error: 'Options type is not a struct'")
+ }
+}
+
+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 := gophercloud.BuildQueryString(foo{Name: "blah+?!!foo", Shape: "Triangl e"})
+ th.AssertNoErr(t, err)
+
+ th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestBuildRequestBody(t *testing.T) {
+ type PasswordCredentials struct {
+ Username string `json:"username" required:"true"`
+ Password string `json:"password" required:"true"`
+ }
+
+ type TokenCredentials struct {
+ ID string `json:"id,omitempty" required:"true"`
+ }
+
+ type orFields struct {
+ Filler int `json:"filler,omitempty"`
+ F1 int `json:"f1,omitempty" or:"F2"`
+ F2 int `json:"f2,omitempty" or:"F1"`
+ }
+
+ // AuthOptions wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+ // interface.
+ type AuthOptions struct {
+ PasswordCredentials `json:"passwordCredentials,omitempty" xor:"TokenCredentials"`
+
+ // 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 string `json:"tenantId,omitempty"`
+ TenantName string `json:"tenantName,omitempty"`
+
+ // TokenCredentials allows users to authenticate (possibly as another user) with an
+ // authentication token ID.
+ TokenCredentials `json:"token,omitempty" xor:"PasswordCredentials"`
+
+ OrFields orFields `json:"or_fields,omitempty"`
+ }
+
+ var successCases = []struct {
+ opts AuthOptions
+ expected map[string]interface{}
+ }{
+ {
+ AuthOptions{
+ PasswordCredentials: PasswordCredentials{
+ Username: "me",
+ Password: "swordfish",
+ },
+ },
+ map[string]interface{}{
+ "auth": map[string]interface{}{
+ "passwordCredentials": map[string]interface{}{
+ "password": "swordfish",
+ "username": "me",
+ },
+ },
+ },
+ },
+ {
+ AuthOptions{
+ TokenCredentials: TokenCredentials{
+ ID: "1234567",
+ },
+ },
+ map[string]interface{}{
+ "auth": map[string]interface{}{
+ "token": map[string]interface{}{
+ "id": "1234567",
+ },
+ },
+ },
+ },
+ }
+
+ for _, successCase := range successCases {
+ actual, err := gophercloud.BuildRequestBody(successCase.opts, "auth")
+ th.AssertNoErr(t, err)
+ th.AssertDeepEquals(t, successCase.expected, actual)
+ }
+
+ var failCases = []struct {
+ opts AuthOptions
+ expected error
+ }{
+ {
+ AuthOptions{
+ TenantID: "987654321",
+ TenantName: "me",
+ },
+ gophercloud.ErrMissingInput{},
+ },
+ {
+ AuthOptions{
+ TokenCredentials: TokenCredentials{
+ ID: "1234567",
+ },
+ PasswordCredentials: PasswordCredentials{
+ Username: "me",
+ Password: "swordfish",
+ },
+ },
+ gophercloud.ErrMissingInput{},
+ },
+ {
+ AuthOptions{
+ PasswordCredentials: PasswordCredentials{
+ Password: "swordfish",
+ },
+ },
+ gophercloud.ErrMissingInput{},
+ },
+ {
+ AuthOptions{
+ PasswordCredentials: PasswordCredentials{
+ Username: "me",
+ Password: "swordfish",
+ },
+ OrFields: orFields{
+ Filler: 2,
+ },
+ },
+ gophercloud.ErrMissingInput{},
+ },
+ }
+
+ for _, failCase := range failCases {
+ _, err := gophercloud.BuildRequestBody(failCase.opts, "auth")
+ th.AssertDeepEquals(t, reflect.TypeOf(failCase.expected), reflect.TypeOf(err))
+ }
+}
diff --git a/testing/provider_client_test.go b/testing/provider_client_test.go
new file mode 100644
index 0000000..7c0e84e
--- /dev/null
+++ b/testing/provider_client_test.go
@@ -0,0 +1,36 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAuthenticatedHeaders(t *testing.T) {
+ p := &gophercloud.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 := &gophercloud.ProviderClient{}
+
+ p.UserAgent.Prepend("custom-user-agent/2.4.0")
+ expected := "custom-user-agent/2.4.0 gophercloud/2.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/2.0.0"
+ actual = p.UserAgent.Join()
+ th.CheckEquals(t, expected, actual)
+
+ p.UserAgent = gophercloud.UserAgent{}
+ expected = "gophercloud/2.0.0"
+ actual = p.UserAgent.Join()
+ th.CheckEquals(t, expected, actual)
+}
diff --git a/testing/service_client_test.go b/testing/service_client_test.go
new file mode 100644
index 0000000..904b303
--- /dev/null
+++ b/testing/service_client_test.go
@@ -0,0 +1,15 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestServiceURL(t *testing.T) {
+ c := &gophercloud.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/testing/util_test.go b/testing/util_test.go
new file mode 100644
index 0000000..ae3e448
--- /dev/null
+++ b/testing/util_test.go
@@ -0,0 +1,122 @@
+package testing
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestWaitFor(t *testing.T) {
+ err := gophercloud.WaitFor(2, func() (bool, error) {
+ return true, nil
+ })
+ th.CheckNoErr(t, err)
+}
+
+func TestWaitForTimeout(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping test in short mode.")
+ }
+
+ err := gophercloud.WaitFor(1, func() (bool, error) {
+ return false, nil
+ })
+ th.AssertEquals(t, "A timeout occurred", err.Error())
+}
+
+func TestWaitForError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping test in short mode.")
+ }
+
+ err := gophercloud.WaitFor(2, func() (bool, error) {
+ return false, errors.New("Error has occurred")
+ })
+ th.AssertEquals(t, "Error has occurred", err.Error())
+}
+
+func TestWaitForPredicateExceed(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping test in short mode.")
+ }
+
+ err := gophercloud.WaitFor(1, func() (bool, error) {
+ time.Sleep(4 * time.Second)
+ return false, errors.New("Just wasting time")
+ })
+ th.AssertEquals(t, "A timeout occurred", err.Error())
+}
+
+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], gophercloud.NormalizeURL(urls[i]))
+ }
+
+}
+
+func TestNormalizePathURL(t *testing.T) {
+ baseDir, _ := os.Getwd()
+
+ rawPath := "template.yaml"
+ basePath, _ := filepath.Abs(".")
+ result, _ := gophercloud.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, _ = gophercloud.NormalizePathURL(basePath, rawPath)
+ expected = "http://www.google.com"
+ th.CheckEquals(t, expected, result)
+
+ rawPath = "very/nested/file.yaml"
+ basePath, _ = filepath.Abs(".")
+ result, _ = gophercloud.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, _ = gophercloud.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, _ = gophercloud.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, _ = gophercloud.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, _ = gophercloud.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, _ = gophercloud.NormalizePathURL(basePath, rawPath)
+ expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more/very/nested/file.yaml"}, "/")
+ th.CheckEquals(t, expected, result)
+
+}
diff --git a/util.go b/util.go
new file mode 100644
index 0000000..68f9a5d
--- /dev/null
+++ b/util.go
@@ -0,0 +1,102 @@
+package gophercloud
+
+import (
+ "fmt"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// WaitFor polls a predicate function, once per second, up to a timeout limit.
+// This is useful to wait for a resource to transition to a certain state.
+// To handle situations when the predicate might hang indefinitely, the
+// predicate will be prematurely cancelled after the timeout.
+// 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 {
+ type WaitForResult struct {
+ Success bool
+ Error error
+ }
+
+ start := time.Now().Unix()
+
+ for {
+ // If a timeout is set, and that's been exceeded, shut it down.
+ if timeout >= 0 && time.Now().Unix()-start >= int64(timeout) {
+ return fmt.Errorf("A timeout occurred")
+ }
+
+ time.Sleep(1 * time.Second)
+
+ var result WaitForResult
+ ch := make(chan bool, 1)
+ go func() {
+ defer close(ch)
+ satisfied, err := predicate()
+ result.Success = satisfied
+ result.Error = err
+ }()
+
+ select {
+ case <-ch:
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.Success {
+ return nil
+ }
+ // If the predicate has not finished by the timeout, cancel it.
+ case <-time.After(time.Duration(timeout) * time.Second):
+ return fmt.Errorf("A timeout occurred")
+ }
+ }
+}
+
+// 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
+
+}