remote: Moving to flexible factory + interface model

This commit is contained in:
Armon Dadgar 2014-12-03 20:05:29 -08:00 committed by Mitchell Hashimoto
parent d821f7aaa6
commit c659a8305e
8 changed files with 534 additions and 180 deletions

223
remote/atlas.go Normal file
View File

@ -0,0 +1,223 @@
package remote
import (
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
)
const (
// defaultAtlasServer is used when no address is given
defaultAtlasServer = "https://atlas.hashicorp.com/"
)
// AtlasRemoteClient implements the RemoteClient interface
// for an Atlas compatible server.
type AtlasRemoteClient struct {
server string
serverURL *url.URL
user string
name string
accessToken string
}
func NewAtlasRemoteClient(conf map[string]string) (*AtlasRemoteClient, error) {
client := &AtlasRemoteClient{}
if err := client.validateConfig(conf); err != nil {
return nil, err
}
return nil, nil
}
func (c *AtlasRemoteClient) validateConfig(conf map[string]string) error {
server, ok := conf["server"]
if !ok || server == "" {
server = defaultAtlasServer
}
url, err := url.Parse(server)
if err != nil {
return err
}
c.server = server
c.serverURL = url
token, ok := conf["access_token"]
if !ok || token == "" {
return fmt.Errorf("missing 'access_token' configuration")
}
c.accessToken = token
name, ok := conf["access_token"]
if !ok || name == "" {
return fmt.Errorf("missing 'name' configuration")
}
parts := strings.Split(name, "/")
if len(parts) != 2 {
return fmt.Errorf("malformed slug '%s'", name)
}
c.user = parts[0]
c.name = parts[1]
return nil
}
func (c *AtlasRemoteClient) GetState() (*RemoteStatePayload, error) {
// Make the HTTP request
req, err := http.NewRequest("GET", c.url("show").String(), nil)
if err != nil {
return nil, fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Request the url
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Handle the common status codes
switch resp.StatusCode {
case http.StatusOK:
// Handled after
case http.StatusNoContent:
return nil, nil
case http.StatusNotFound:
return nil, nil
case http.StatusUnauthorized:
return nil, ErrRequireAuth
case http.StatusForbidden:
return nil, ErrInvalidAuth
case http.StatusInternalServerError:
return nil, ErrRemoteInternal
default:
return nil, fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
// Read in the body
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, resp.Body); err != nil {
return nil, fmt.Errorf("Failed to read remote state: %v", err)
}
// Create the payload
payload := &RemoteStatePayload{
State: buf.Bytes(),
}
// Check for the MD5
if raw := resp.Header.Get("Content-MD5"); raw != "" {
md5, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("Failed to decode Content-MD5 '%s': %v", raw, err)
}
payload.MD5 = md5
} else {
// Generate the MD5
hash := md5.Sum(payload.State)
payload.MD5 = hash[:md5.Size]
}
return payload, nil
}
func (c *AtlasRemoteClient) PutState(state []byte, force bool) error {
// Get the target URL
base := c.url("update")
// Generate the MD5
hash := md5.Sum(state)
b64 := base64.StdEncoding.EncodeToString(hash[:md5.Size])
// Set the force query parameter if needed
if force {
values := base.Query()
values.Set("force", "true")
base.RawQuery = values.Encode()
}
// Make the HTTP client and request
req, err := http.NewRequest("PUT", base.String(), bytes.NewReader(state))
if err != nil {
return fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Prepare the request
req.Header.Set("Content-MD5", b64)
req.Header.Set("Content-Type", "application/json")
req.ContentLength = int64(len(state))
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Failed to upload state: %v", err)
}
defer resp.Body.Close()
// Handle the error codes
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusConflict:
return ErrConflict
case http.StatusPreconditionFailed:
return ErrServerNewer
case http.StatusUnauthorized:
return ErrRequireAuth
case http.StatusForbidden:
return ErrInvalidAuth
case http.StatusInternalServerError:
return ErrRemoteInternal
default:
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
}
func (c *AtlasRemoteClient) DeleteState() error {
// Make the HTTP request
req, err := http.NewRequest("DELETE", c.url("destroy").String(), nil)
if err != nil {
return fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Failed to delete state: %v", err)
}
defer resp.Body.Close()
// Handle the error codes
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusNoContent:
return nil
case http.StatusNotFound:
return nil
case http.StatusUnauthorized:
return ErrRequireAuth
case http.StatusForbidden:
return ErrInvalidAuth
case http.StatusInternalServerError:
return ErrRemoteInternal
default:
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
return nil
}
func (c *AtlasRemoteClient) url(route string) *url.URL {
return &url.URL{
Scheme: c.serverURL.Scheme,
Host: c.serverURL.Host,
Path: path.Join("api/v1/state", c.user, c.name, route),
RawQuery: fmt.Sprintf("access_token=%s", c.accessToken),
}
}

14
remote/atlas_test.go Normal file
View File

@ -0,0 +1,14 @@
package remote
import "testing"
func TestAtlasRemote_Interface(t *testing.T) {
var client interface{} = &AtlasRemoteClient{}
if _, ok := client.(RemoteClient); !ok {
t.Fatalf("does not implement interface")
}
}
func TestAtlasRemote(t *testing.T) {
// TODO
}

View File

@ -1,17 +1,8 @@
package remote
import (
"bytes"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"github.com/hashicorp/terraform/terraform"
"strings"
)
var (
@ -36,6 +27,12 @@ var (
ErrRemoteInternal = fmt.Errorf("Remote server reporting internal error")
)
type RemoteClient interface {
GetState() (*RemoteStatePayload, error)
PutState(state []byte, force bool) error
DeleteState() error
}
// RemoteStatePayload is used to return the remote state
// along with associated meta data when we do a remote fetch.
type RemoteStatePayload struct {
@ -43,163 +40,18 @@ type RemoteStatePayload struct {
State []byte
}
// remoteStateClient is used to interact with a remote state store
// using the API
type remoteStateClient struct {
conf *terraform.RemoteState
}
// URL is used to return an appropriate URL to hit for the
// given server and remote name
func (r *remoteStateClient) URL() (*url.URL, error) {
// Get the base URL configuration
base, err := url.Parse(r.conf.Server)
if err != nil {
return nil, fmt.Errorf("Failed to parse remote server '%s': %v", r.conf.Server, err)
}
// Compute the full path by just appending the name
base.Path = path.Join(base.Path, r.conf.Name)
// Add the request token if any
if r.conf.AuthToken != "" {
values := base.Query()
values.Set("access_token", r.conf.AuthToken)
base.RawQuery = values.Encode()
}
return base, nil
}
// GetState is used to read the remote state
func (r *remoteStateClient) GetState() (*RemoteStatePayload, error) {
// Get the target URL
base, err := r.URL()
if err != nil {
return nil, err
}
// Request the url
resp, err := http.Get(base.String())
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Handle the common status codes
switch resp.StatusCode {
case http.StatusOK:
// Handled after
case http.StatusNoContent:
return nil, nil
case http.StatusNotFound:
return nil, nil
case http.StatusUnauthorized:
return nil, ErrRequireAuth
case http.StatusForbidden:
return nil, ErrInvalidAuth
case http.StatusInternalServerError:
return nil, ErrRemoteInternal
// NewClientByType is used to construct a RemoteClient
// based on the configured type.
func NewClientByType(ctype string, conf map[string]string) (RemoteClient, error) {
ctype = strings.ToLower(ctype)
switch ctype {
case "atlas":
return NewAtlasRemoteClient(conf)
case "consul":
return NewConsulRemoteClient(conf)
case "http":
return NewHTTPRemoteClient(conf)
default:
return nil, fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
// Read in the body
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, resp.Body); err != nil {
return nil, fmt.Errorf("Failed to read remote state: %v", err)
}
// Create the payload
payload := &RemoteStatePayload{
State: buf.Bytes(),
}
// Check if this is Consul
if raw := resp.Header.Get("X-Consul-Index"); raw != "" {
// Check if we used the ?raw query param, otherwise decode
if _, ok := base.Query()["raw"]; !ok {
type kv struct {
Value []byte
}
var values []*kv
if err := json.Unmarshal(buf.Bytes(), &values); err != nil {
return nil, fmt.Errorf("Failed to decode Consul response: %v", err)
}
// Setup the reader to pull the value from Consul
payload.State = values[0].Value
}
}
// Check for the MD5
if raw := resp.Header.Get("Content-MD5"); raw != "" {
md5, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("Failed to decode Content-MD5 '%s': %v", raw, err)
}
payload.MD5 = md5
} else {
// Generate the MD5
hash := md5.Sum(payload.State)
payload.MD5 = hash[:md5.Size]
}
return payload, nil
}
// Put is used to update the remote state
func (r *remoteStateClient) PutState(state []byte, force bool) error {
// Get the target URL
base, err := r.URL()
if err != nil {
return err
}
// Generate the MD5
hash := md5.Sum(state)
b64 := base64.StdEncoding.EncodeToString(hash[:md5.Size])
// Set the force query parameter if needed
if force {
values := base.Query()
values.Set("force", "true")
base.RawQuery = values.Encode()
}
// Make the HTTP client and request
client := http.Client{}
req, err := http.NewRequest("PUT", base.String(), bytes.NewReader(state))
if err != nil {
return fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Prepare the request
req.Header.Set("Content-MD5", b64)
req.ContentLength = int64(len(state))
// Make the request
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Failed to upload state: %v", err)
}
defer resp.Body.Close()
// Handle the error codes
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusConflict:
return ErrConflict
case http.StatusPreconditionFailed:
return ErrServerNewer
case http.StatusUnauthorized:
return ErrRequireAuth
case http.StatusForbidden:
return ErrInvalidAuth
case http.StatusInternalServerError:
return ErrRemoteInternal
default:
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
return nil, fmt.Errorf("Unknown remote client type '%s'", ctype)
}
}

77
remote/consul.go Normal file
View File

@ -0,0 +1,77 @@
package remote
import (
"crypto/md5"
"fmt"
"github.com/armon/consul-api"
)
// ConsulRemoteClient implements the RemoteClient interface
// for an Consul compatible server.
type ConsulRemoteClient struct {
client *consulapi.Client
path string // KV path
}
func NewConsulRemoteClient(conf map[string]string) (*ConsulRemoteClient, error) {
client := &ConsulRemoteClient{}
if err := client.validateConfig(conf); err != nil {
return nil, err
}
return client, nil
}
func (c *ConsulRemoteClient) validateConfig(conf map[string]string) (err error) {
config := consulapi.DefaultConfig()
if token, ok := conf["token"]; ok && token != "" {
config.Token = token
}
if addr, ok := conf["address"]; ok && addr != "" {
config.Address = addr
}
path, ok := conf["path"]
if !ok || path == "" {
return fmt.Errorf("missing 'path' configuration")
}
c.path = path
c.client, err = consulapi.NewClient(config)
return err
}
func (c *ConsulRemoteClient) GetState() (*RemoteStatePayload, error) {
kv := c.client.KV()
pair, _, err := kv.Get(c.path, nil)
if err != nil {
return nil, err
}
if pair == nil {
return nil, nil
}
// Create the payload
payload := &RemoteStatePayload{
State: pair.Value,
}
// Generate the MD5
hash := md5.Sum(payload.State)
payload.MD5 = hash[:md5.Size]
return payload, nil
}
func (c *ConsulRemoteClient) PutState(state []byte, force bool) error {
pair := &consulapi.KVPair{
Key: c.path,
Value: state,
}
kv := c.client.KV()
_, err := kv.Put(pair, nil)
return err
}
func (c *ConsulRemoteClient) DeleteState() error {
kv := c.client.KV()
_, err := kv.Delete(c.path, nil)
return err
}

14
remote/consul_test.go Normal file
View File

@ -0,0 +1,14 @@
package remote
import "testing"
func TestConsulRemote_Interface(t *testing.T) {
var client interface{} = &ConsulRemoteClient{}
if _, ok := client.(RemoteClient); !ok {
t.Fatalf("does not implement interface")
}
}
func TestConsulRemote(t *testing.T) {
// TODO
}

157
remote/http.go Normal file
View File

@ -0,0 +1,157 @@
package remote
import (
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
)
// HTTPRemoteClient implements the RemoteClient interface
// for an HTTP compatible server.
type HTTPRemoteClient struct {
// url is the URL that we GET / POST / DELETE to
url *url.URL
}
func NewHTTPRemoteClient(conf map[string]string) (*HTTPRemoteClient, error) {
client := &HTTPRemoteClient{}
if err := client.validateConfig(conf); err != nil {
return nil, err
}
return client, nil
}
func (c *HTTPRemoteClient) validateConfig(conf map[string]string) error {
urlRaw, ok := conf["url"]
if !ok || urlRaw == "" {
return fmt.Errorf("missing 'url' configuration")
}
url, err := url.Parse(urlRaw)
if err != nil {
return fmt.Errorf("failed to parse url: %v", err)
}
c.url = url
return nil
}
func (c *HTTPRemoteClient) GetState() (*RemoteStatePayload, error) {
// Request the url
resp, err := http.Get(c.url.String())
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Handle the common status codes
switch resp.StatusCode {
case http.StatusOK:
// Handled after
case http.StatusNoContent:
return nil, nil
case http.StatusNotFound:
return nil, nil
default:
return nil, fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
// Read in the body
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, resp.Body); err != nil {
return nil, fmt.Errorf("Failed to read remote state: %v", err)
}
// Create the payload
payload := &RemoteStatePayload{
State: buf.Bytes(),
}
// Check for the MD5
if raw := resp.Header.Get("Content-MD5"); raw != "" {
md5, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("Failed to decode Content-MD5 '%s': %v", raw, err)
}
payload.MD5 = md5
} else {
// Generate the MD5
hash := md5.Sum(payload.State)
payload.MD5 = hash[:md5.Size]
}
return payload, nil
}
func (c *HTTPRemoteClient) PutState(state []byte, force bool) error {
// Copy the target URL
base := new(url.URL)
*base = *c.url
// Generate the MD5
hash := md5.Sum(state)
b64 := base64.StdEncoding.EncodeToString(hash[:md5.Size])
// Set the force query parameter if needed
if force {
values := base.Query()
values.Set("force", "true")
base.RawQuery = values.Encode()
}
// Make the HTTP client and request
req, err := http.NewRequest("POST", base.String(), bytes.NewReader(state))
if err != nil {
return fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Prepare the request
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-MD5", b64)
req.ContentLength = int64(len(state))
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Failed to upload state: %v", err)
}
defer resp.Body.Close()
// Handle the error codes
switch resp.StatusCode {
case http.StatusOK:
return nil
default:
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
}
func (c *HTTPRemoteClient) DeleteState() error {
// Make the HTTP request
req, err := http.NewRequest("DELETE", c.url.String(), nil)
if err != nil {
return fmt.Errorf("Failed to make HTTP request: %v", err)
}
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Failed to delete state: %v", err)
}
defer resp.Body.Close()
// Handle the error codes
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusNoContent:
return nil
case http.StatusNotFound:
return nil
default:
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
}
}

14
remote/http_test.go Normal file
View File

@ -0,0 +1,14 @@
package remote
import "testing"
func TestHTTPRemote_Interface(t *testing.T) {
var client interface{} = &HTTPRemoteClient{}
if _, ok := client.(RemoteClient); !ok {
t.Fatalf("does not implement interface")
}
}
func TestHTTPRemote(t *testing.T) {
// TODO
}

View File

@ -6,7 +6,6 @@ import (
"fmt"
"io"
"io/ioutil"
"net/url"
"os"
"path/filepath"
@ -185,17 +184,13 @@ func ExistsFile(path string) (bool, error) {
// ValidConfig does a purely logical validation of the remote config
func ValidConfig(conf *terraform.RemoteState) error {
// Verify the remote server configuration is sane
if conf.Name == "" {
return fmt.Errorf("Name must be provided for remote state storage")
// Default the type to Atlas
if conf.Type == "" {
conf.Type = "atlas"
}
if conf.Server != "" {
if _, err := url.Parse(conf.Server); err != nil {
return fmt.Errorf("Remote Server URL invalid: %v", err)
}
} else {
// Fill in the default server
conf.Server = DefaultServer
_, err := NewClientByType(conf.Type, conf.Config)
if err != nil {
return err
}
return nil
}
@ -233,7 +228,11 @@ func RefreshState(conf *terraform.RemoteState) (StateChangeResult, error) {
}
// Read the state from the server
client := &remoteStateClient{conf: conf}
client, err := NewClientByType(conf.Type, conf.Config)
if err != nil {
return StateChangeNoop,
fmt.Errorf("Failed to create remote client: %v", err)
}
payload, err := client.GetState()
if err != nil {
return StateChangeNoop,
@ -335,7 +334,11 @@ func PushState(conf *terraform.RemoteState, force bool) (StateChangeResult, erro
}
// Push the state to the server
client := &remoteStateClient{conf: conf}
client, err := NewClientByType(conf.Type, conf.Config)
if err != nil {
return StateChangeNoop,
fmt.Errorf("Failed to create remote client: %v", err)
}
err = client.PutState(raw, force)
// Handle the various edge cases