Add a Rackspace Cloud backend based on Gophercloud https://github.com/rackspace/gophercloud.

Signed-off-by: John Hopper john.hopper@jpserver.net
[solomon@docker.com: manually resolved conflicts]
Signed-off-by: Solomon Hykes <solomon@docker.com>
This commit is contained in:
John Hopper 2014-06-05 00:12:45 -05:00 committed by Solomon Hykes
parent 032972586b
commit b44dba9051
2 changed files with 852 additions and 0 deletions

848
backends/rax.go Normal file
View File

@ -0,0 +1,848 @@
//
// Copyright (C) 2014 Rackspace Hosting Ltd.
//
// 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.
package backends
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/codegangsta/cli"
"github.com/dotcloud/docker/engine"
"github.com/dotcloud/docker/runconfig"
"github.com/rackspace/gophercloud"
)
const (
DASS_TARGET_PREFIX = "rdas_target_"
)
var (
nilOptions = gophercloud.AuthOptions{}
// ErrNoAuthUrl errors occur when the value of the OS_AUTH_URL environment variable cannot be determined.
ErrNoAuthUrl = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
// ErrNoUsername errors occur when the value of the OS_USERNAME environment variable cannot be determined.
ErrNoUsername = fmt.Errorf("Environment variable OS_USERNAME needs to be set.")
// ErrNoPassword errors occur when the value of the OS_PASSWORD environment variable cannot be determined.
ErrNoPassword = fmt.Errorf("Environment variable OS_PASSWORD or OS_API_KEY needs to be set.")
)
// On status callback for when waiting on cloud actions
type onStatus func(details *gophercloud.Server) error
// As the name implies, this function dumbly waits for the desired host status
func doNothing(details *gophercloud.Server) error {
return nil
}
// Shamelessly taken from docker core
func RandomString() string {
id := make([]byte, 32)
_, err := io.ReadFull(rand.Reader, id)
if err != nil {
panic(err) // This shouldn't happen
}
return hex.EncodeToString(id)
}
// Gets the user's auth options from the openstack env variables
func getAuthOptions() (string, gophercloud.AuthOptions, error) {
provider := os.Getenv("OS_AUTH_URL")
username := os.Getenv("OS_USERNAME")
apiKey := os.Getenv("OS_API_KEY")
password := os.Getenv("OS_PASSWORD")
tenantId := os.Getenv("OS_TENANT_ID")
tenantName := os.Getenv("OS_TENANT_NAME")
if provider == "" {
return "", nilOptions, ErrNoAuthUrl
}
if username == "" {
return "", nilOptions, ErrNoUsername
}
if password == "" && apiKey == "" {
return "", nilOptions, ErrNoPassword
}
ao := gophercloud.AuthOptions{
Username: username,
Password: password,
ApiKey: apiKey,
TenantId: tenantId,
TenantName: tenantName,
}
return provider, ao, nil
}
// HTTP Client with some syntax sugar
type HttpClient interface {
Call(method, path, body string) (*http.Response, error)
Get(path, data string) (resp *http.Response, err error)
Delete(path, data string) (resp *http.Response, err error)
Post(path, data string) (resp *http.Response, err error)
Put(path, data string) (resp *http.Response, err error)
}
type DockerHttpClient struct {
Url *url.URL
proto string
addr string
dockerVersion string
}
// Returns a new docker http client
func newDockerHttpClient(peer, dockerVersion string) (client HttpClient, err error) {
targetUrl, err := url.Parse(peer)
if err != nil {
return nil, err
}
targetUrl.Scheme = "http"
return &DockerHttpClient{
Url: targetUrl,
dockerVersion: dockerVersion,
}, nil
}
func (client *DockerHttpClient) Call(method, path, body string) (*http.Response, error) {
targetUrl, err := url.Parse(path)
if err != nil {
return nil, err
}
targetUrl.Host = client.Url.Host
targetUrl.Scheme = client.Url.Scheme
req, err := http.NewRequest(method, targetUrl.String(), strings.NewReader(body))
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (client *DockerHttpClient) Get(path, data string) (resp *http.Response, err error) {
return client.Call("GET", path, data)
}
func (client *DockerHttpClient) Post(path, data string) (resp *http.Response, err error) {
return client.Call("POST", path, data)
}
func (client *DockerHttpClient) Put(path, data string) (resp *http.Response, err error) {
return client.Call("PUT", path, data)
}
func (client *DockerHttpClient) Delete(path, data string) (resp *http.Response, err error) {
return client.Call("DELETE", path, data)
}
// A hostbound action is an action that is supplied with an active client to a desired cloud host
type hostbound func(client HttpClient) engine.Status
// A HostContext represents an active session with a cloud host
type HostContextCache struct {
contexts map[string]*HostContext
}
func NewHostContextCache() (contextCache *HostContextCache) {
return &HostContextCache{
contexts: make(map[string]*HostContext),
}
}
func (hcc *HostContextCache) Get(id, name string, rax *RaxCloud) (context *HostContext, err error) {
return NewHostContext(id, name, rax)
}
func (hcc *HostContextCache) GetCached(id, name string, rax *RaxCloud) (context *HostContext, err error) {
var found bool
if context, found = hcc.contexts[id]; !found {
if context, err = NewHostContext(id, name, rax); err == nil {
hcc.contexts[id] = context
}
}
return
}
func (hcc *HostContextCache) Close() {
for _, context := range hcc.contexts {
context.Close()
}
}
type HostContext struct {
id string
name string
rax *RaxCloud
tunnel *os.Process
}
func NewHostContext(id, name string, rax *RaxCloud) (hc *HostContext, err error) {
if tunnelProcess, err := rax.openTunnel(id, 8000, 8000); err != nil {
return nil, fmt.Errorf("Unable to tunnel to host %s(id:%s): %v", name, id, err)
} else {
return &HostContext{
id: id,
name: name,
rax: rax,
tunnel: tunnelProcess,
}, nil
}
}
func (hc *HostContext) Close() {
if hc.tunnel != nil {
hc.tunnel.Kill()
if state, err := hc.tunnel.Wait(); err != nil {
fmt.Printf("Wait result: state:%v, err:%s\n", state, err)
}
hc.tunnel = nil
}
}
func (hc *HostContext) exec(job *engine.Job, action hostbound) (status engine.Status) {
if hc.tunnel == nil {
return job.Errorf("Tunnel not open to host %s(id:%s)", hc.name, hc.id)
}
if client, err := newDockerHttpClient("tcp://localhost:8000", "v1.10"); err == nil {
return action(client)
} else {
return job.Errorf("Unable to init http client: %v", err)
}
}
// A Rackspace impl of the cloud provider interface
type RaxCloud struct {
remoteUser string
keyFile string
regionOverride string
image string
flavor string
instance string
scalingStrategy string
hostCtxCache *HostContextCache
gopher gophercloud.CloudServersProvider
}
// Returns a pointer to a new RaxCloud instance
func RaxCloudBackend() *RaxCloud {
return &RaxCloud{
hostCtxCache: NewHostContextCache(),
}
}
// Installs the RaxCloud backend into an engine
func (rax *RaxCloud) Install(eng *engine.Engine) (err error) {
eng.Register("rax", func(job *engine.Job) (status engine.Status) {
return rax.init(job)
})
return nil
}
// Initializes the plugin by handling job arguments
func (rax *RaxCloud) init(job *engine.Job) (status engine.Status) {
app := cli.NewApp()
app.Name = "rax"
app.Usage = "Deploy Docker containers onto your Rackspace Cloud account. Accepts environment variables as listed in: https://github.com/openstack/python-novaclient/#command-line-api"
app.Version = "0.0.1"
app.Flags = []cli.Flag{
cli.StringFlag{"instance", "", "Sets the server instance to target for specific commands such as version."},
cli.StringFlag{"region", "DFW", "Sets the Rackspace region to target. This overrides any environment variables for region."},
cli.StringFlag{"key", "/etc/swarmd/rax.id_rsa", "Sets SSH keys file to use when initializing secure connections."},
cli.StringFlag{"user", "root", "Username to use when initializing secure connections."},
cli.StringFlag{"image", "", "Image to use when provisioning new hosts."},
cli.StringFlag{"flavor", "", "Flavor to use when provisioning new hosts."},
cli.StringFlag{"auto-scaling", "none", "Flag that when set determines the mode used for auto-scaling."},
}
var err error
app.Action = func(ctx *cli.Context) {
err = rax.run(ctx, job.Eng)
}
if len(job.Args) > 1 {
app.Run(append([]string{job.Name}, job.Args...))
}
// Failed to init?
if err != nil || rax.gopher == nil {
app.Run([]string{"rax", "help"})
if err == nil {
return engine.StatusErr
} else {
return job.Error(err)
}
}
return engine.StatusOK
}
func (rax *RaxCloud) configure(ctx *cli.Context) (err error) {
if keyFile := ctx.String("key"); keyFile != "" {
rax.keyFile = keyFile
if regionOverride := ctx.String("region"); regionOverride != "" {
rax.regionOverride = regionOverride
}
if remoteUser := ctx.String("user"); remoteUser != "" {
rax.remoteUser = remoteUser
}
if instance := ctx.String("instance"); instance != "" {
rax.instance = instance
}
if image := ctx.String("image"); image != "" {
rax.image = image
} else {
return fmt.Errorf("Please set an image ID with --image <id>")
}
if flavor := ctx.String("flavor"); flavor != "" {
rax.flavor = flavor
} else {
return fmt.Errorf("Please set a flavor ID with --flavor <id>")
}
if scalingStrategy := ctx.String("auto-scaling"); scalingStrategy != "" {
rax.scalingStrategy = scalingStrategy
}
return rax.createGopherCtx()
} else {
return fmt.Errorf("Please set a SSH key with --key <keyfile>")
}
}
func (rax *RaxCloud) run(ctx *cli.Context, eng *engine.Engine) (err error) {
if err = rax.configure(ctx); err == nil {
if _, name, err := rax.findTargetHost(); err == nil {
if name == "" {
rax.createHost(DASS_TARGET_PREFIX + RandomString()[:12])
}
} else {
return err
}
eng.Register("create", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := "/containers/create"
config := runconfig.ContainerConfigFromJob(job)
if data, err := json.Marshal(config); err != nil {
return job.Errorf("marshaling failure : %v", err)
} else if resp, err := client.Post(path, string(data)); err != nil {
return job.Error(err)
} else {
var container struct {
Id string
Warnings []string
}
if body, err := ioutil.ReadAll(resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
} else if err := json.Unmarshal([]byte(body), &container); err != nil {
return job.Errorf("Failed to read container info from body: %v", err)
} else {
job.Printf("%s\n", container.Id)
}
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("container_delete", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s?force=%s", job.Args[0], url.QueryEscape(job.Getenv("forceRemove")))
if _, err := client.Delete(path, ""); err != nil {
return job.Error(err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("containers", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/json?all=%s&limit=%s",
url.QueryEscape(job.Getenv("all")), url.QueryEscape(job.Getenv("limit")))
if resp, err := client.Get(path, ""); err != nil {
return job.Error(err)
} else {
created := engine.NewTable("Created", 0)
if body, err := ioutil.ReadAll(resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
} else if created.ReadListFrom(body); err != nil {
return job.Errorf("Failed to read list from body: %v", err)
} else {
created.WriteListTo(job.Stdout)
}
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("version", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := "/version"
if resp, err := client.Get(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
} else if _, err := io.Copy(job.Stdout, resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("start", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/start", job.Args[0])
config := runconfig.ContainerConfigFromJob(job)
if data, err := json.Marshal(config); err != nil {
return job.Errorf("marshaling failure : %v", err)
} else if _, err := client.Post(path, string(data)); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("stop", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/stop?t=%s", job.Args[0], url.QueryEscape(job.Getenv("t")))
if _, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("kill", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/kill?signal=%s", job.Args[0], job.Args[1])
if _, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("restart", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/restart?t=%s", url.QueryEscape(job.Getenv("t")))
if _, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("inspect", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/json", job.Args[0])
if resp, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
} else if _, err := io.Copy(job.Stdout, resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("attach", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/attach?stream=%s&stdout=%s&stderr=%s", job.Args[0],
url.QueryEscape(job.Getenv("stream")),
url.QueryEscape(job.Getenv("stdout")),
url.QueryEscape(job.Getenv("stderr")))
if resp, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
} else if _, err := io.Copy(job.Stdout, resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("pull", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/images/create?fromImage=%s&tag=%s", job.Args[0], url.QueryEscape(job.Getenv("tag")))
if resp, err := client.Post(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
} else if _, err := io.Copy(job.Stdout, resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.Register("logs", func(job *engine.Job) (status engine.Status) {
if ctx, err := rax.getHostContext(); err == nil {
defer ctx.Close()
return ctx.exec(job, func(client HttpClient) (status engine.Status) {
path := fmt.Sprintf("/containers/%s/logs?stdout=%s&stderr=%s", job.Args[0], url.QueryEscape(job.Getenv("stdout")), url.QueryEscape(job.Getenv("stderr")))
if resp, err := client.Get(path, ""); err != nil {
return job.Errorf("Failed call %s(path:%s): %v", job.Name, path, err)
} else if _, err := io.Copy(job.Stdout, resp.Body); err != nil {
return job.Errorf("Failed to copy response body: %v", err)
}
return engine.StatusOK
})
} else {
return job.Errorf("Failed to create host context: %v", err)
}
})
eng.RegisterCatchall(func(job *engine.Job) engine.Status {
log.Printf("[UNIMPLEMENTED] %s %#v %#v %#v", job.Name, *job, job.Env(), job.Args)
return engine.StatusOK
})
}
return err
}
func (rax *RaxCloud) Close() {
rax.hostCtxCache.Close()
}
func (rax *RaxCloud) getHostContext() (hc *HostContext, err error) {
if id, name, err := rax.findTargetHost(); err != nil {
return nil, err
} else {
return rax.hostCtxCache.Get(id, name, rax)
}
}
func (rax *RaxCloud) addressFor(id string) (address string, err error) {
var server *gophercloud.Server
if server, err = rax.gopher.ServerById(id); err == nil {
address = server.AccessIPv4
}
return
}
func (rax *RaxCloud) createHost(name string) (err error) {
createServerInfo := gophercloud.NewServer{
Name: name,
ImageRef: rax.image,
FlavorRef: rax.flavor,
}
if newServer, err := rax.gopher.CreateServer(createServerInfo); err == nil {
log.Printf("Waiting for host build to complete: %s\n", name)
return rax.waitForStatusById(newServer.Id, "ACTIVE", doNothing)
} else {
return err
}
}
func (rax *RaxCloud) openTunnel(id string, localPort, remotePort int) (*os.Process, error) {
ip, err := rax.addressFor(id)
if err != nil {
return nil, err
}
options := []string{
"-o", "PasswordAuthentication=no",
"-o", "LogLevel=quiet",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "CheckHostIP=no",
"-o", "StrictHostKeyChecking=no",
"-i", rax.keyFile,
"-A",
"-p", "22",
fmt.Sprintf("%s@%s", rax.remoteUser, ip),
"-N",
"-f",
"-L", fmt.Sprintf("%d:localhost:%d", localPort, remotePort),
}
cmd := exec.Command("ssh", options...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, err
}
return cmd.Process, nil
}
func (rax *RaxCloud) createGopherCtx() (err error) {
var (
provider string
authOptions gophercloud.AuthOptions
apiCriteria gophercloud.ApiCriteria
access *gophercloud.Access
csp gophercloud.CloudServersProvider
)
// Create our auth options set by the user's environment
provider, authOptions, err = getAuthOptions()
if err != nil {
return err
}
// Set our API criteria
apiCriteria, err = gophercloud.PopulateApi("rackspace-us")
if err != nil {
return err
}
// Set the region
apiCriteria.Type = "compute"
apiCriteria.Region = os.Getenv("OS_REGION_NAME")
if rax.regionOverride != "" {
log.Printf("Overriding region set in env with %s", rax.regionOverride)
apiCriteria.Region = rax.regionOverride
}
if apiCriteria.Region == "" {
return fmt.Errorf("No region set. Please set the OS_REGION_NAME environment variable or use the --region flag.")
}
// Attempt to authenticate
access, err = gophercloud.Authenticate(provider, authOptions)
if err != nil {
fmt.Printf("ERROR: %v\n\n\n", err)
return err
}
// Get a CSP ref!
csp, err = gophercloud.ServersApi(access, apiCriteria)
if err != nil {
return err
}
rax.gopher = csp
return nil
}
func (rax *RaxCloud) findDockerHosts() (ids map[string]string, err error) {
var servers []gophercloud.Server
if servers, err = rax.gopher.ListServersLinksOnly(); err == nil {
ids = make(map[string]string)
for _, server := range servers {
if strings.HasPrefix(server.Name, DASS_TARGET_PREFIX) {
ids[server.Id] = server.Name
}
}
}
return
}
func (rax *RaxCloud) findDeploymentHosts() (ids map[string]string, err error) {
var servers map[string]string
if servers, err = rax.findDockerHosts(); err == nil {
ids = make(map[string]string)
for id, name := range servers {
var serverDetails *gophercloud.Server
if serverDetails, err = rax.gopher.ServerById(id); err == nil {
if serverDetails.Status == "ACTIVE" {
ids[id] = name
}
} else {
break
}
}
}
return
}
func (rax *RaxCloud) findTargetHost() (id, name string, err error) {
var deploymentHosts map[string]string
if deploymentHosts, err = rax.findDeploymentHosts(); err == nil {
for id, name = range deploymentHosts {
break
}
}
return
}
func (rax *RaxCloud) serverIdByName(name string) (string, error) {
if servers, err := rax.gopher.ListServersLinksOnly(); err == nil {
for _, server := range servers {
if server.Name == name {
return server.Id, nil
}
}
return "", fmt.Errorf("Unable to locate server by name: %s", name)
} else {
return "", err
}
}
func (rax *RaxCloud) waitForStatusByName(name, status string, action onStatus) error {
id, err := rax.serverIdByName(name)
for err == nil {
return rax.waitForStatusById(id, status, action)
}
return err
}
func (rax *RaxCloud) waitForStatusById(id, status string, action onStatus) (err error) {
var details *gophercloud.Server
for err == nil {
if details, err = rax.gopher.ServerById(id); err == nil {
log.Printf("Status: %s\n", details.Status)
if details.Status == status {
action(details)
break
}
time.Sleep(10 * time.Second)
}
}
return
}

View File

@ -151,6 +151,10 @@
{ {
"ImportPath": "github.com/tutumcloud/go-tutum", "ImportPath": "github.com/tutumcloud/go-tutum",
"Rev": "d826286d2e5882428c8163ab44261987bea85a44" "Rev": "d826286d2e5882428c8163ab44261987bea85a44"
},
{
"ImportPath": "github.com/rackspace/gophercloud",
"Rev": "2285a429874c1365ef6c6d3ceb08b1d428e26aca"
} }
] ]
} }