Merge #15793: Locking support in HTTP remote backend
This commit is contained in:
commit
2e0c1d07ae
|
@ -5,11 +5,15 @@ import (
|
|||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/hashicorp/terraform/state"
|
||||
)
|
||||
|
||||
func httpFactory(conf map[string]string) (Client, error) {
|
||||
|
@ -18,13 +22,53 @@ func httpFactory(conf map[string]string) (Client, error) {
|
|||
return nil, fmt.Errorf("missing 'address' configuration")
|
||||
}
|
||||
|
||||
url, err := url.Parse(address)
|
||||
updateURL, err := url.Parse(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HTTP URL: %s", err)
|
||||
return nil, fmt.Errorf("failed to parse address URL: %s", err)
|
||||
}
|
||||
if url.Scheme != "http" && url.Scheme != "https" {
|
||||
if updateURL.Scheme != "http" && updateURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("address must be HTTP or HTTPS")
|
||||
}
|
||||
updateMethod, ok := conf["update_method"]
|
||||
if !ok {
|
||||
updateMethod = "POST"
|
||||
}
|
||||
|
||||
var lockURL *url.URL
|
||||
if lockAddress, ok := conf["lock_address"]; ok {
|
||||
var err error
|
||||
lockURL, err = url.Parse(lockAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse lockAddress URL: %s", err)
|
||||
}
|
||||
if lockURL.Scheme != "http" && lockURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("lockAddress must be HTTP or HTTPS")
|
||||
}
|
||||
} else {
|
||||
lockURL = nil
|
||||
}
|
||||
lockMethod, ok := conf["lock_method"]
|
||||
if !ok {
|
||||
lockMethod = "LOCK"
|
||||
}
|
||||
|
||||
var unlockURL *url.URL
|
||||
if unlockAddress, ok := conf["unlock_address"]; ok {
|
||||
var err error
|
||||
unlockURL, err = url.Parse(unlockAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse unlockAddress URL: %s", err)
|
||||
}
|
||||
if unlockURL.Scheme != "http" && unlockURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unlockAddress must be HTTP or HTTPS")
|
||||
}
|
||||
} else {
|
||||
unlockURL = nil
|
||||
}
|
||||
unlockMethod, ok := conf["unlock_method"]
|
||||
if !ok {
|
||||
unlockMethod = "UNLOCK"
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
if skipRaw, ok := conf["skip_cert_verification"]; ok {
|
||||
|
@ -45,39 +89,142 @@ func httpFactory(conf map[string]string) (Client, error) {
|
|||
}
|
||||
|
||||
ret := &HTTPClient{
|
||||
URL: url,
|
||||
URL: updateURL,
|
||||
UpdateMethod: updateMethod,
|
||||
|
||||
LockURL: lockURL,
|
||||
LockMethod: lockMethod,
|
||||
UnlockURL: unlockURL,
|
||||
UnlockMethod: unlockMethod,
|
||||
|
||||
Username: conf["username"],
|
||||
Password: conf["password"],
|
||||
|
||||
// accessible only for testing use
|
||||
Client: client,
|
||||
}
|
||||
if username, ok := conf["username"]; ok && username != "" {
|
||||
ret.Username = username
|
||||
}
|
||||
if password, ok := conf["password"]; ok && password != "" {
|
||||
ret.Password = password
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// HTTPClient is a remote client that stores data in Consul or HTTP REST.
|
||||
type HTTPClient struct {
|
||||
URL *url.URL
|
||||
// Update & Retrieve
|
||||
URL *url.URL
|
||||
UpdateMethod string
|
||||
|
||||
// Locking
|
||||
LockURL *url.URL
|
||||
LockMethod string
|
||||
UnlockURL *url.URL
|
||||
UnlockMethod string
|
||||
|
||||
// HTTP
|
||||
Client *http.Client
|
||||
Username string
|
||||
Password string
|
||||
|
||||
lockID string
|
||||
jsonLockInfo []byte
|
||||
}
|
||||
|
||||
func (c *HTTPClient) Get() (*Payload, error) {
|
||||
req, err := http.NewRequest("GET", c.URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (c *HTTPClient) httpRequest(method string, url *url.URL, data *[]byte, what string) (*http.Response, error) {
|
||||
// If we have data we need a reader
|
||||
var reader io.Reader = nil
|
||||
if data != nil {
|
||||
reader = bytes.NewReader(*data)
|
||||
}
|
||||
|
||||
// Prepare the request
|
||||
// Create the request
|
||||
req, err := http.NewRequest(method, url.String(), reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to make %s HTTP request: %s", what, err)
|
||||
}
|
||||
// Setup basic auth
|
||||
if c.Username != "" {
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
}
|
||||
|
||||
// Work with data/body
|
||||
if data != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.ContentLength = int64(len(*data))
|
||||
|
||||
// Generate the MD5
|
||||
hash := md5.Sum(*data)
|
||||
b64 := base64.StdEncoding.EncodeToString(hash[:])
|
||||
req.Header.Set("Content-MD5", b64)
|
||||
}
|
||||
|
||||
// Make the request
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to %s: %v", what, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) Lock(info *state.LockInfo) (string, error) {
|
||||
if c.LockURL == nil {
|
||||
return "", nil
|
||||
}
|
||||
c.lockID = ""
|
||||
|
||||
jsonLockInfo := info.Marshal()
|
||||
resp, err := c.httpRequest(c.LockMethod, c.LockURL, &jsonLockInfo, "lock")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
c.lockID = info.ID
|
||||
c.jsonLockInfo = jsonLockInfo
|
||||
return info.ID, nil
|
||||
case http.StatusUnauthorized:
|
||||
return "", fmt.Errorf("HTTP remote state endpoint requires auth")
|
||||
case http.StatusForbidden:
|
||||
return "", fmt.Errorf("HTTP remote state endpoint invalid auth")
|
||||
case http.StatusConflict, http.StatusLocked:
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTTP remote state already locked, failed to read body")
|
||||
}
|
||||
existing := state.LockInfo{}
|
||||
err = json.Unmarshal(body, &existing)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTTP remote state already locked, failed to unmarshal body")
|
||||
}
|
||||
return "", fmt.Errorf("HTTP remote state already locked: ID=%s", existing.ID)
|
||||
default:
|
||||
return "", fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) Unlock(id string) error {
|
||||
if c.UnlockURL == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := c.httpRequest(c.UnlockMethod, c.UnlockURL, &c.jsonLockInfo, "unlock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) Get() (*Payload, error) {
|
||||
resp, err := c.httpRequest("GET", c.URL, nil, "get state")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -139,9 +286,11 @@ func (c *HTTPClient) Put(data []byte) error {
|
|||
// Copy the target URL
|
||||
base := *c.URL
|
||||
|
||||
// Generate the MD5
|
||||
hash := md5.Sum(data)
|
||||
b64 := base64.StdEncoding.EncodeToString(hash[:])
|
||||
if c.lockID != "" {
|
||||
query := base.Query()
|
||||
query.Set("ID", c.lockID)
|
||||
base.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
/*
|
||||
// Set the force query parameter if needed
|
||||
|
@ -152,23 +301,13 @@ func (c *HTTPClient) Put(data []byte) error {
|
|||
}
|
||||
*/
|
||||
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to make HTTP request: %s", err)
|
||||
var method string = "POST"
|
||||
if c.UpdateMethod != "" {
|
||||
method = c.UpdateMethod
|
||||
}
|
||||
|
||||
// Prepare the request
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-MD5", b64)
|
||||
req.ContentLength = int64(len(data))
|
||||
if c.Username != "" {
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
}
|
||||
|
||||
// Make the request
|
||||
resp, err := c.Client.Do(req)
|
||||
resp, err := c.httpRequest(method, &base, &data, "upload state")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to upload state: %v", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
@ -182,20 +321,10 @@ func (c *HTTPClient) Put(data []byte) error {
|
|||
}
|
||||
|
||||
func (c *HTTPClient) Delete() error {
|
||||
req, err := http.NewRequest("DELETE", c.URL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to make HTTP request: %s", err)
|
||||
}
|
||||
|
||||
// Prepare the request
|
||||
if c.Username != "" {
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
}
|
||||
|
||||
// Make the request
|
||||
resp, err := c.Client.Do(req)
|
||||
resp, err := c.httpRequest("DELETE", c.URL, nil, "delete state")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to delete state: %s", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
func TestHTTPClient_impl(t *testing.T) {
|
||||
var _ Client = new(HTTPClient)
|
||||
var _ ClientLocker = new(HTTPClient)
|
||||
}
|
||||
|
||||
func TestHTTPClient(t *testing.T) {
|
||||
|
@ -26,25 +27,128 @@ func TestHTTPClient(t *testing.T) {
|
|||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
// Test basic get/update
|
||||
client := &HTTPClient{URL: url, Client: cleanhttp.DefaultClient()}
|
||||
testClient(t, client)
|
||||
|
||||
// Test locking and alternative UpdateMethod
|
||||
a := &HTTPClient{
|
||||
URL: url,
|
||||
UpdateMethod: "PUT",
|
||||
LockURL: url,
|
||||
LockMethod: "LOCK",
|
||||
UnlockURL: url,
|
||||
UnlockMethod: "UNLOCK",
|
||||
Client: cleanhttp.DefaultClient(),
|
||||
}
|
||||
b := &HTTPClient{
|
||||
URL: url,
|
||||
UpdateMethod: "PUT",
|
||||
LockURL: url,
|
||||
LockMethod: "LOCK",
|
||||
UnlockURL: url,
|
||||
UnlockMethod: "UNLOCK",
|
||||
Client: cleanhttp.DefaultClient(),
|
||||
}
|
||||
TestRemoteLocks(t, a, b)
|
||||
|
||||
}
|
||||
|
||||
func assertError(t *testing.T, err error, expected string) {
|
||||
if err == nil {
|
||||
t.Fatalf("Expected empty config to err")
|
||||
} else if err.Error() != expected {
|
||||
t.Fatalf("Expected err.Error() to be \"%s\", got \"%s\"", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientFactory(t *testing.T) {
|
||||
// missing address
|
||||
_, err := httpFactory(map[string]string{})
|
||||
assertError(t, err, "missing 'address' configuration")
|
||||
|
||||
// defaults
|
||||
conf := map[string]string{
|
||||
"address": "http://127.0.0.1:8888/foo",
|
||||
}
|
||||
c, err := httpFactory(conf)
|
||||
client, _ := c.(*HTTPClient)
|
||||
if client == nil || err != nil {
|
||||
t.Fatal("Unexpected failure, address")
|
||||
}
|
||||
if client.URL.String() != conf["address"] {
|
||||
t.Fatalf("Expected address \"%s\", got \"%s\"", conf["address"], client.URL.String())
|
||||
}
|
||||
if client.UpdateMethod != "POST" {
|
||||
t.Fatalf("Expected update_method \"%s\", got \"%s\"", "POST", client.UpdateMethod)
|
||||
}
|
||||
if client.LockURL != nil || client.LockMethod != "LOCK" {
|
||||
t.Fatal("Unexpected lock_address or lock_method")
|
||||
}
|
||||
if client.UnlockURL != nil || client.UnlockMethod != "UNLOCK" {
|
||||
t.Fatal("Unexpected unlock_address or unlock_method")
|
||||
}
|
||||
if client.Username != "" || client.Password != "" {
|
||||
t.Fatal("Unexpected username or password")
|
||||
}
|
||||
|
||||
// custom
|
||||
conf = map[string]string{
|
||||
"address": "http://127.0.0.1:8888/foo",
|
||||
"update_method": "BLAH",
|
||||
"lock_address": "http://127.0.0.1:8888/bar",
|
||||
"lock_method": "BLIP",
|
||||
"unlock_address": "http://127.0.0.1:8888/baz",
|
||||
"unlock_method": "BLOOP",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
}
|
||||
c, err = httpFactory(conf)
|
||||
client, _ = c.(*HTTPClient)
|
||||
if client == nil || err != nil {
|
||||
t.Fatal("Unexpected failure, update_method")
|
||||
}
|
||||
if client.UpdateMethod != "BLAH" {
|
||||
t.Fatalf("Expected update_method \"%s\", got \"%s\"", "BLAH", client.UpdateMethod)
|
||||
}
|
||||
if client.LockURL.String() != conf["lock_address"] || client.LockMethod != "BLIP" {
|
||||
t.Fatalf("Unexpected lock_address \"%s\" vs \"%s\" or lock_method \"%s\" vs \"%s\"", client.LockURL.String(),
|
||||
conf["lock_address"], client.LockMethod, conf["lock_method"])
|
||||
}
|
||||
if client.UnlockURL.String() != conf["unlock_address"] || client.UnlockMethod != "BLOOP" {
|
||||
t.Fatalf("Unexpected unlock_address \"%s\" vs \"%s\" or unlock_method \"%s\" vs \"%s\"", client.UnlockURL.String(),
|
||||
conf["unlock_address"], client.UnlockMethod, conf["unlock_method"])
|
||||
}
|
||||
if client.Username != "user" || client.Password != "pass" {
|
||||
t.Fatalf("Unexpected username \"%s\" vs \"%s\" or password \"%s\" vs \"%s\"", client.Username, conf["username"],
|
||||
client.Password, conf["password"])
|
||||
}
|
||||
}
|
||||
|
||||
type testHTTPHandler struct {
|
||||
Data []byte
|
||||
Data []byte
|
||||
Locked bool
|
||||
}
|
||||
|
||||
func (h *testHTTPHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Write(h.Data)
|
||||
case "POST":
|
||||
case "POST", "PUT":
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := io.Copy(buf, r.Body); err != nil {
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
|
||||
h.Data = buf.Bytes()
|
||||
case "LOCK":
|
||||
if h.Locked {
|
||||
w.WriteHeader(423)
|
||||
} else {
|
||||
h.Locked = true
|
||||
}
|
||||
case "UNLOCK":
|
||||
h.Locked = false
|
||||
case "DELETE":
|
||||
h.Data = nil
|
||||
w.WriteHeader(200)
|
||||
|
|
|
@ -8,18 +8,24 @@ description: |-
|
|||
|
||||
# http
|
||||
|
||||
**Kind: Standard (with no locking)**
|
||||
**Kind: Standard (with optional locking)**
|
||||
|
||||
Stores the state using a simple [REST](https://en.wikipedia.org/wiki/Representational_state_transfer) client.
|
||||
|
||||
State will be fetched via GET, updated via POST, and purged with DELETE.
|
||||
State will be fetched via GET, updated via POST, and purged with DELETE. The method used for updating is configurable.
|
||||
|
||||
When locking support is enabled it will use LOCK and UNLOCK requests providing the lock info in the body. The endpoint should
|
||||
return a 423: Locked or 409: Conflict with the holding lock info when it's already taken, 200: OK for success. Any other status
|
||||
will be considered an error. The ID of the holding lock info will be added as a query parameter to state updates requests.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
backend "http" {
|
||||
address = "http://myrest.api.com"
|
||||
address = "http://myrest.api.com/foo"
|
||||
lock_address = "http://myrest.api.com/foo"
|
||||
unlock_address = "http://myrest.api.com/foo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -40,6 +46,16 @@ data "terraform_remote_state" "foo" {
|
|||
The following configuration options are supported:
|
||||
|
||||
* `address` - (Required) The address of the REST endpoint
|
||||
* `update_method` - (Optional) HTTP method to use when updating state.
|
||||
Defaults to `POST`.
|
||||
* `lock_address` - (Optional) The address of the lock REST endpoint.
|
||||
Defaults to disabled.
|
||||
* `lock_method` - (Optional) The HTTP method to use when locking.
|
||||
Defaults to `LOCK`.
|
||||
* `unlock_address` - (Optional) The address of the unlock REST endpoint.
|
||||
Defaults to disabled.
|
||||
* `unlock_method` - (Optional) The HTTP method to use when unlocking.
|
||||
Defaults to `UNLOCK`.
|
||||
* `username` - (Optional) The username for HTTP basic authentication
|
||||
* `password` - (Optional) The password for HTTP basic authentication
|
||||
* `skip_cert_verification` - (Optional) Whether to skip TLS verification.
|
||||
|
|
Loading…
Reference in New Issue