Merge pull request #9 from rackspace/authentication

Add Authentication functionality.

Now that I have the time to write this up, here's the description that should have gone into the git commit message.

gophercloud needs to authenticate against a provider. However, gorax's API isn't ideal from a multi-provider perspective. Thus, instead of requiring the user to instantiate Identity objects, configuring them, and then authenticating in a 3-step process, I create a single public function, Authenticate(), which performs (essentially) these tasks.

I cannot predict the future, and cannot guarantee Identity V3 compatibility in its current form. However, in an attempt to anticipate the future, the Authenticate function is designed to automatically guess which Identity API you intend on using based on which set of credentials you provide it. The underlying assumption is that a V3 token is compatible with a V2 token; once we have the token, it should be usable with other V2 and V3 APIs as appropriate.

Unlike Ruby or Python, Go lacks support for keyword arguments. There are two ways to overcome this deficiency: (1) Make a function that accepts one or more interface{} types, and rely on type-checks to disambiguate meaning from supplied parameters; and, (2) use a structure and rely upon Go's automatic initialization of unspecified fields to well-known "zero" values. Here's a comparison of the two approaches from the point of view of the caller:

// option 1 -- use list of interface{} types
acc, err := gophercloud.Authenticate("rackspace-us", gophercloud.Username("sfalvo"), gophercloud.Password("my-pass-here"), gophercloud.TenantId("blah"))

// option 2 -- use of a dedicated options structure
accRackspace, err := gophercloud.Authenticate("rackspace-us", gophercloud.AuthOptions{
    Username: "sfalvo",
    Password: "my-pass-here",
    TenantId: "blah",
})
As can be seen, the latter requires much less physical typing (assuming one doesn't rename the gophercloud package to just 'g' in the import statement), and thus less chance for error. That's why I decided to use an options structure instead. It also impacts the design of the callee as well; with option (1), I'd have to manually loop through all the parameters, using a type-case statement to decode the supplied parameters and fill in variables as they're discovered, while in (2) I just inspect the options structure directly. Less code means fewer bugs.

Since the method of authentication remains the same across all providers, assuming universal use of V2 APIs, I associate an AuthEndpoint field with each Provider instance. That's the only per-provider piece of information defined at the moment.

Most other SDKs hard-wire their providers; however, this is grossly inconvenient for unit-testing purposes. Therefore, I wrap what would otherwise be global state into a Context structure. TestContext exists to create a blank context, which can be used by unit tests at will. You'll notice that the init() function (in the api.go file) uses it to create the one, true, global context, and pre-populates it with the otherwise statically defined list of providers. Through this mechanism, users of the library needn't concern themselves with contexts and their proper initialization. Instead, they can just use the package-global functions, and they should "just work."

Note that the result of Authenticate() is a structure instance, allowing the client access to the service catalog, tenant ID information, and user information. As we flesh out additional APIs for the Go SDK, we will add methods to this Access structure, allowing more convenient access to various APIs. For example, one hypothetical approach to working with Cloud Compute services would involve using Access as a factory:

compute, err := accRackspace.CloudComputeApi()
This conforms to the first two levels of the desired <organization.service.entity.method> SDK organization, and provides the appropriate propegation of state that allows for token re-auth in a fully transparent manner, if necessary.
diff --git a/acceptance/01-authentication.go b/acceptance/01-authentication.go
new file mode 100644
index 0000000..643ad48
--- /dev/null
+++ b/acceptance/01-authentication.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+	"os"
+	"fmt"
+	"github.com/rackspace/gophercloud"
+)
+
+func main() {
+	provider := os.Getenv("SDK_PROVIDER")
+	username := os.Getenv("SDK_USERNAME")
+	password := os.Getenv("SDK_PASSWORD")
+
+	if (provider == "") || (username == "") || (password == "") {
+		fmt.Fprintf(os.Stderr, "One or more of the following environment variables aren't set:\n")
+		fmt.Fprintf(os.Stderr, "  SDK_PROVIDER=\"%s\"\n", provider)
+		fmt.Fprintf(os.Stderr, "  SDK_USERNAME=\"%s\"\n", username)
+		fmt.Fprintf(os.Stderr, "  SDK_PASSWORD=\"%s\"\n", password)
+		os.Exit(1)
+	}
+
+	_, err := gophercloud.Authenticate(
+		provider,
+		gophercloud.AuthOptions{
+			Username: username,
+			Password: password,
+		},
+	)
+	if err != nil {
+		panic(err)
+	}
+}
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..73b3a8c
--- /dev/null
+++ b/api.go
@@ -0,0 +1,43 @@
+package gophercloud
+
+// globalContext is the, well, "global context."
+// Most of this SDK is written in a manner to facilitate easier testing,
+// which doesn't require all the configuration a real-world application would require.
+// However, for real-world deployments, applications should be able to rely on a consistent configuration of providers, etc.
+var globalContext *Context
+
+// providers is the set of supported providers.
+var providers = map[string]Provider{
+	"rackspace-us": Provider{
+		AuthEndpoint: "https://identity.api.rackspacecloud.com/v2.0/tokens",
+	},
+	"rackspace-uk": Provider{
+		AuthEndpoint: "https://lon.identity.api.rackspacecloud.com/v2.0/tokens",
+	},
+}
+
+// Initialize the global context to sane configuration.
+// The Go runtime ensures this function is called before main(),
+// thus guaranteeing proper configuration before your application ever runs.
+func init() {
+	globalContext = TestContext()
+	for name, descriptor := range providers {
+		globalContext.RegisterProvider(name, descriptor)
+	}
+}
+
+// Authenticate() grants access to the OpenStack-compatible provider API.
+//
+// Providers are identified through a unique key string.
+// Specifying an unsupported provider will result in an ErrProvider error.
+//
+// The supplied AuthOptions instance allows the client to specify only those credentials
+// relevant for the authentication request.  At present, support exists for OpenStack
+// Identity V2 API only; support for V3 will become available as soon as documentation for it
+// becomes readily available.
+//
+// For Identity V2 API requirements, you must provide at least the Username and Password
+// options.  The TenantId field is optional, and defaults to "".
+func Authenticate(provider string, options AuthOptions) (*Access, error) {
+	return globalContext.Authenticate(provider, options)
+}
diff --git a/authenticate.go b/authenticate.go
new file mode 100644
index 0000000..836c21b
--- /dev/null
+++ b/authenticate.go
@@ -0,0 +1,131 @@
+package gophercloud
+
+import (
+	"github.com/racker/perigee"
+)
+
+// AuthOptions lets anyone calling Authenticate() supply the required access credentials.
+// At present, only Identity V2 API support exists; therefore, only Username, Password,
+// and optionally, TenantId are provided.  If future Identity API versions become available,
+// alternative fields unique to those versions may appear here.
+type AuthOptions struct {
+	// Username and Password are required if using Identity V2 API.
+	// Consult with your provider's control panel to discover your
+	// account's username and password.
+	Username, Password string
+
+	// The TenantId field is optional for the Identity V2 API.
+	TenantId string
+}
+
+// AuthContainer provides a JSON encoding wrapper for passing credentials to the Identity
+// service.  You will not work with this structure directly.
+type AuthContainer struct {
+	Auth Auth `json:"auth"`
+}
+
+// Auth provides a JSON encoding wrapper for passing credentials to the Identity
+// service.  You will not work with this structure directly.
+type Auth struct {
+	PasswordCredentials PasswordCredentials `json:"passwordCredentials"`
+	TenantId            string              `json:"tenantId,omitempty"`
+}
+
+// PasswordCredentials provides a JSON encoding wrapper for passing credentials to the Identity
+// service.  You will not work with this structure directly.
+type PasswordCredentials struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+// Access encapsulates the API token and its relevant fields, as well as the
+// services catalog that Identity API returns once authenticated.  You'll probably
+// rarely use this record directly, unless you intend on marshalling or unmarshalling
+// Identity API JSON records yourself.
+type Access struct {
+	Token          Token
+	ServiceCatalog []CatalogEntry
+	User           User
+}
+
+// Token encapsulates an authentication token and when it expires.  It also includes
+// tenant information if available.
+type Token struct {
+	Id, Expires string
+	Tenant      Tenant
+}
+
+// Tenant encapsulates tenant authentication information.  If, after authentication,
+// no tenant information is supplied, both Id and Name will be "".
+type Tenant struct {
+	Id, Name string
+}
+
+// User encapsulates the user credentials, and provides visibility in what
+// the user can do through its role assignments.
+type User struct {
+	Id, Name          string
+	XRaxDefaultRegion string `json:"RAX-AUTH:defaultRegion"`
+	Roles             []Role
+}
+
+// Role encapsulates a permission that a user can rely on.
+type Role struct {
+	Description, Id, Name string
+}
+
+// CatalogEntry encapsulates a service catalog record.
+type CatalogEntry struct {
+	Name, Type string
+	Endpoints  []EntryEndpoint
+}
+
+// EntryEndpoint encapsulates how to get to the API of some service.
+type EntryEndpoint struct {
+	Region, TenantId                    string
+	PublicURL, InternalURL              string
+	VersionId, VersionInfo, VersionList string
+}
+
+// Authenticate() grants access to the OpenStack-compatible provider API.
+//
+// Providers are identified through a unique key string.
+// See the RegisterProvider() method for more details.
+//
+// The supplied AuthOptions instance allows the client to specify only those credentials
+// relevant for the authentication request.  At present, support exists for OpenStack
+// Identity V2 API only; support for V3 will become available as soon as documentation for it
+// becomes readily available.
+//
+// For Identity V2 API requirements, you must provide at least the Username and Password
+// options.  The TenantId field is optional, and defaults to "".
+func (c *Context) Authenticate(provider string, options AuthOptions) (*Access, error) {
+	var access *Access
+
+	p, err := c.ProviderByName(provider)
+	if err != nil {
+		return nil, err
+	}
+	if (options.Username == "") || (options.Password == "") {
+		return nil, ErrCredentials
+	}
+
+	err = perigee.Post(p.AuthEndpoint, perigee.Options{
+		CustomClient: c.httpClient,
+		ReqBody: &AuthContainer{
+			Auth: Auth{
+				PasswordCredentials: PasswordCredentials{
+					Username: options.Username,
+					Password: options.Password,
+				},
+				TenantId: options.TenantId,
+			},
+		},
+		Results: &struct {
+			Access **Access `json:"access"`
+		}{
+			&access,
+		},
+	})
+	return access, err
+}
diff --git a/authenticate_test.go b/authenticate_test.go
new file mode 100644
index 0000000..f077dab
--- /dev/null
+++ b/authenticate_test.go
@@ -0,0 +1,299 @@
+package gophercloud
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+const SUCCESSFUL_RESPONSE = `{
+	"access": {
+		"serviceCatalog": [{
+			"endpoints": [{
+				"publicURL": "https://ord.servers.api.rackspacecloud.com/v2/12345",
+				"region": "ORD",
+				"tenantId": "12345",
+				"versionId": "2",
+				"versionInfo": "https://ord.servers.api.rackspacecloud.com/v2",
+				"versionList": "https://ord.servers.api.rackspacecloud.com/"
+			},{
+				"publicURL": "https://dfw.servers.api.rackspacecloud.com/v2/12345",
+				"region": "DFW",
+				"tenantId": "12345",
+				"versionId": "2",
+				"versionInfo": "https://dfw.servers.api.rackspacecloud.com/v2",
+				"versionList": "https://dfw.servers.api.rackspacecloud.com/"
+			}],
+			"name": "cloudServersOpenStack",
+			"type": "compute"
+		},{
+			"endpoints": [{
+				"publicURL": "https://ord.databases.api.rackspacecloud.com/v1.0/12345",
+				"region": "ORD",
+				"tenantId": "12345"
+			}],
+			"name": "cloudDatabases",
+			"type": "rax:database"
+		}],
+		"token": {
+			"expires": "2012-04-13T13:15:00.000-05:00",
+			"id": "aaaaa-bbbbb-ccccc-dddd"
+		},
+		"user": {
+			"RAX-AUTH:defaultRegion": "DFW",
+			"id": "161418",
+			"name": "demoauthor",
+			"roles": [{
+				"description": "User Admin Role.",
+				"id": "3",
+				"name": "identity:user-admin"
+			}]
+		}
+	}
+}
+`
+
+type testTransport struct {
+	called   int
+	response string
+}
+
+func (t *testTransport) RoundTrip(req *http.Request) (rsp *http.Response, err error) {
+	t.called++
+
+	headers := make(http.Header)
+	headers.Add("Content-Type", "application/xml; charset=UTF-8")
+
+	body := ioutil.NopCloser(strings.NewReader(t.response))
+
+	rsp = &http.Response{
+		Status:           "200 OK",
+		StatusCode:       200,
+		Proto:            "HTTP/1.1",
+		ProtoMajor:       1,
+		ProtoMinor:       1,
+		Header:           headers,
+		Body:             body,
+		ContentLength:    -1,
+		TransferEncoding: nil,
+		Close:            true,
+		Trailer:          nil,
+		Request:          req,
+	}
+	return
+}
+
+type tenantIdCheckTransport struct {
+	expectTenantId bool
+	tenantIdFound  bool
+}
+
+func (t *tenantIdCheckTransport) RoundTrip(req *http.Request) (rsp *http.Response, err error) {
+	var authContainer *AuthContainer
+
+	headers := make(http.Header)
+	headers.Add("Content-Type", "application/xml; charset=UTF-8")
+
+	body := ioutil.NopCloser(strings.NewReader("t.response"))
+
+	rsp = &http.Response{
+		Status:           "200 OK",
+		StatusCode:       200,
+		Proto:            "HTTP/1.1",
+		ProtoMajor:       1,
+		ProtoMinor:       1,
+		Header:           headers,
+		Body:             body,
+		ContentLength:    -1,
+		TransferEncoding: nil,
+		Close:            true,
+		Trailer:          nil,
+		Request:          req,
+	}
+
+	bytes, err := ioutil.ReadAll(req.Body)
+	if err != nil {
+		return nil, err
+	}
+	err = json.Unmarshal(bytes, &authContainer)
+	if err != nil {
+		return nil, err
+	}
+	t.tenantIdFound = (authContainer.Auth.TenantId != "")
+
+	if t.tenantIdFound != t.expectTenantId {
+		rsp.Status = "500 Internal Server Error"
+		rsp.StatusCode = 500
+	}
+	return
+}
+
+func TestAuthProvider(t *testing.T) {
+	c := TestContext()
+	tt := &testTransport{}
+	c.UseCustomClient(&http.Client{
+		Transport: tt,
+	})
+
+	_, err := c.Authenticate("", AuthOptions{})
+	if err == nil {
+		t.Error("Expected error for empty provider string")
+		return
+	}
+	_, err = c.Authenticate("unknown-provider", AuthOptions{Username: "u", Password: "p"})
+	if err == nil {
+		t.Error("Expected error for unknown service provider")
+		return
+	}
+
+	err = c.RegisterProvider("provider", Provider{AuthEndpoint: "/"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	_, err = c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if tt.called != 1 {
+		t.Error("Expected transport to be called once.")
+		return
+	}
+}
+
+func TestTenantIdEncoding(t *testing.T) {
+	c := TestContext()
+	tt := &tenantIdCheckTransport{}
+	c.UseCustomClient(&http.Client{
+		Transport: tt,
+	})
+	c.RegisterProvider("provider", Provider{AuthEndpoint: "/"})
+
+	tt.expectTenantId = false
+	_, err := c.Authenticate("provider", AuthOptions{
+		Username: "u",
+		Password: "p",
+	})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if tt.tenantIdFound {
+		t.Error("Tenant ID should not have been encoded")
+		return
+	}
+
+	tt.expectTenantId = true
+	_, err = c.Authenticate("provider", AuthOptions{
+		Username: "u",
+		Password: "p",
+		TenantId: "t",
+	})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if !tt.tenantIdFound {
+		t.Error("Tenant ID should have been encoded")
+		return
+	}
+}
+
+func TestUserNameAndPassword(t *testing.T) {
+	c := TestContext()
+	c.UseCustomClient(&http.Client{Transport: &testTransport{}})
+	c.RegisterProvider("provider", Provider{AuthEndpoint: "http://localhost/"})
+
+	credentials := []AuthOptions{
+		AuthOptions{},
+		AuthOptions{Username: "u"},
+		AuthOptions{Password: "p"},
+	}
+	for i, auth := range credentials {
+		_, err := c.Authenticate("provider", auth)
+		if err == nil {
+			t.Error("Expected error from missing credentials (%d)", i)
+			return
+		}
+	}
+
+	_, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
+
+func TestTokenAcquisition(t *testing.T) {
+	c := TestContext()
+	tt := &testTransport{}
+	tt.response = SUCCESSFUL_RESPONSE
+	c.UseCustomClient(&http.Client{Transport: tt})
+	c.RegisterProvider("provider", Provider{AuthEndpoint: "http://localhost"})
+
+	acc, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	tok := acc.Token
+	if (tok.Id == "") || (tok.Expires == "") {
+		t.Error("Expected a valid token for successful login; got %s, %s", tok.Id, tok.Expires)
+		return
+	}
+}
+
+func TestServiceCatalogAcquisition(t *testing.T) {
+	c := TestContext()
+	tt := &testTransport{}
+	tt.response = SUCCESSFUL_RESPONSE
+	c.UseCustomClient(&http.Client{Transport: tt})
+	c.RegisterProvider("provider", Provider{AuthEndpoint: "http://localhost"})
+
+	acc, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	svcs := acc.ServiceCatalog
+	if len(svcs) < 2 {
+		t.Error("Expected 2 service catalog entries; got %d", len(svcs))
+		return
+	}
+
+	types := map[string]bool{
+		"compute":      true,
+		"rax:database": true,
+	}
+	for _, entry := range svcs {
+		if !types[entry.Type] {
+			t.Error("Expected to find type %s.", entry.Type)
+			return
+		}
+	}
+}
+
+func TestUserAcquisition(t *testing.T) {
+	c := TestContext()
+	tt := &testTransport{}
+	tt.response = SUCCESSFUL_RESPONSE
+	c.UseCustomClient(&http.Client{Transport: tt})
+	c.RegisterProvider("provider", Provider{AuthEndpoint: "http://localhost"})
+
+	acc, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	u := acc.User
+	if u.Id != "161418" {
+		t.Error("Expected user ID of 16148; got", u.Id)
+		return
+	}
+}
diff --git a/context.go b/context.go
new file mode 100644
index 0000000..70d70a7
--- /dev/null
+++ b/context.go
@@ -0,0 +1,37 @@
+package gophercloud
+
+import (
+	"net/http"
+)
+
+// Context structures encapsulate Gophercloud-global state in a manner which
+// facilitates easier unit testing.  As a user of this SDK, you'll never
+// have to use this structure, except when contributing new code to the SDK.
+type Context struct {
+	// providerMap serves as a directory of supported providers.
+	providerMap map[string]Provider
+
+	// httpClient refers to the current HTTP client interface to use.
+	httpClient *http.Client
+}
+
+// TestContext yields a new Context instance, pre-initialized with a barren
+// state suitable for per-unit-test customization.  This configuration consists
+// of:
+//
+// * An empty provider map.
+//
+// * An HTTP client built by the net/http package (see http://godoc.org/net/http#Client).
+func TestContext() *Context {
+	return &Context{
+		providerMap: make(map[string]Provider),
+		httpClient:  &http.Client{},
+	}
+}
+
+// UseCustomClient configures the context to use a customized HTTP client
+// instance.  By default, TestContext() will return a Context which uses
+// the net/http package's default client instance.  
+func (c *Context) UseCustomClient(hc *http.Client) {
+	c.httpClient = hc
+}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..58a898c
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,27 @@
+package gophercloud
+
+import (
+	"fmt"
+)
+
+// ErrNotImplemented should be used only while developing new SDK features.
+// No established function or method will ever produce this error.
+var ErrNotImplemented = fmt.Errorf("Not implemented")
+
+// ErrProvider errors occur when attempting to reference an unsupported
+// provider.  More often than not, this error happens due to a typo in
+// the name.
+var ErrProvider = fmt.Errorf("Missing or incorrect provider")
+
+// ErrCredentials errors happen when attempting to authenticate using a
+// set of credentials not recognized by the Authenticate() method.
+// For example, not providing a username or password when attempting to
+// authenticate against an Identity V2 API.
+var ErrCredentials = fmt.Errorf("Missing or incomplete credentials")
+
+// ErrConfiguration errors happen when attempting to add a new provider, and
+// the provider added lacks a correct or consistent configuration.
+// For example, all providers must expose at least an Identity V2 API
+// for authentication; if this endpoint isn't specified, you may receive
+// this error when attempting to register it against a context.
+var ErrConfiguration = fmt.Errorf("Missing or incomplete configuration")
diff --git a/package.go b/package.go
new file mode 100644
index 0000000..396e523
--- /dev/null
+++ b/package.go
@@ -0,0 +1,7 @@
+// Gophercloud provides a multi-vendor interface to OpenStack-compatible clouds which attempts to follow
+// established Go community coding standards and social norms.
+//
+// Unless you intend on contributing code to the SDK, you will almost certainly never have to use any
+// Context structures or any of its methods.  Contextual methods exist for easier unit testing only.
+// Stick with the global functions unless you know exactly what you're doing, and why.
+package gophercloud
diff --git a/provider.go b/provider.go
new file mode 100644
index 0000000..c741c4c
--- /dev/null
+++ b/provider.go
@@ -0,0 +1,32 @@
+package gophercloud
+
+// Provider structures exist for each tangible provider of OpenStack service.
+// For example, Rackspace, Hewlett-Packard, and NASA might have their own instance of this structure.
+//
+// At a minimum, a provider must expose an authentication endpoint.
+type Provider struct {
+	AuthEndpoint string
+}
+
+// RegisterProvider allows a unit test to register a mythical provider convenient for testing.
+// If the provider structure lacks adequate configuration, or the configuration given has some
+// detectable error, an ErrConfiguration error will result.
+func (c *Context) RegisterProvider(name string, p Provider) error {
+	if p.AuthEndpoint == "" {
+		return ErrConfiguration
+	}
+
+	c.providerMap[name] = p
+	return nil
+}
+
+// ProviderByName will locate a provider amongst those previously registered, if it exists.
+// If the named provider has not been registered, an ErrProvider error will result.
+func (c *Context) ProviderByName(name string) (p Provider, err error) {
+	for provider, descriptor := range c.providerMap {
+		if name == provider {
+			return descriptor, nil
+		}
+	}
+	return Provider{}, ErrProvider
+}
diff --git a/provider_test.go b/provider_test.go
new file mode 100644
index 0000000..2936526
--- /dev/null
+++ b/provider_test.go
@@ -0,0 +1,28 @@
+package gophercloud
+
+import (
+	"testing"
+)
+
+func TestProviderRegistry(t *testing.T) {
+	c := TestContext()
+
+	_, err := c.ProviderByName("aProvider")
+	if err == nil {
+		t.Error("Expected error when looking for a provider by non-existant name")
+		return
+	}
+
+	err = c.RegisterProvider("aProvider", Provider{})
+	if err != ErrConfiguration {
+		t.Error("Unexpected error/nil when registering a provider w/out an auth endpoint\n  %s", err)
+		return
+	}
+
+	_ = c.RegisterProvider("aProvider", Provider{AuthEndpoint: "http://localhost/auth"})
+	_, err = c.ProviderByName("aProvider")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
diff --git a/scripts/test-all.sh b/scripts/test-all.sh
new file mode 100755
index 0000000..b78e606
--- /dev/null
+++ b/scripts/test-all.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+#
+# This script is responsible for executing all the acceptance tests found in
+# the acceptance/ directory.
+
+# Find where _this_ script is running from.
+SCRIPTS=$(dirname $0)
+SCRIPTS=$(cd $SCRIPTS; pwd)
+
+# Locate the acceptance test / examples directory.
+ACCEPTANCE=$(cd $SCRIPTS/../acceptance; pwd)
+
+# Go workspace path
+WS=$(cd $SCRIPTS/..; pwd)
+
+# In order to run Go code interactively, we need the GOPATH environment
+# to be set.
+if [ "x$GOPATH" == "x" ]; then
+  export GOPATH=$WS
+  echo "WARNING: You didn't have your GOPATH environment variable set."
+  echo "         I'm assuming $GOPATH as its value."
+fi
+
+# Run all acceptance tests sequentially.
+# If any test fails, we fail fast.
+for T in $(ls -1 $ACCEPTANCE/[0-9][0-9]*.go); do
+  if ! [ -x $T ]; then
+    echo "go run $T -quiet ..."
+    if ! go run $T -quiet ; then
+      echo "- FAILED.  Try re-running w/out the -quiet option to see output."
+      exit 1
+    fi
+  fi
+done
+