405 lines
10 KiB
Go
405 lines
10 KiB
Go
package egoscale
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Error formats a CloudStack error into a standard error
|
|
func (e ErrorResponse) Error() string {
|
|
return fmt.Sprintf("API error %s %d (%s %d): %s", e.ErrorCode, e.ErrorCode, e.CSErrorCode, e.CSErrorCode, e.ErrorText)
|
|
}
|
|
|
|
// Error formats a CloudStack job response into a standard error
|
|
func (e BooleanResponse) Error() error {
|
|
if !e.Success {
|
|
return fmt.Errorf("API error: %s", e.DisplayText)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func responseKey(key string) (string, bool) {
|
|
// XXX: addIpToNic, activateIp6, restorevmresponse are kind of special
|
|
var responseKeys = map[string]string{
|
|
"addiptonicresponse": "addiptovmnicresponse",
|
|
"activateip6response": "activateip6nicresponse",
|
|
"restorevirtualmachineresponse": "restorevmresponse",
|
|
"updatevmaffinitygroupresponse": "updatevirtualmachineresponse",
|
|
}
|
|
|
|
k, ok := responseKeys[key]
|
|
return k, ok
|
|
}
|
|
|
|
func (client *Client) parseResponse(resp *http.Response, apiName string) (json.RawMessage, error) {
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key := fmt.Sprintf("%sresponse", strings.ToLower(apiName))
|
|
response, ok := m[key]
|
|
if !ok {
|
|
if resp.StatusCode >= 400 {
|
|
response, ok = m["errorresponse"]
|
|
}
|
|
|
|
if !ok {
|
|
// try again with the special keys
|
|
value, ok := responseKey(key)
|
|
if ok {
|
|
key = value
|
|
}
|
|
|
|
response, ok = m[key]
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("malformed JSON response %d, %q was expected.\n%s", resp.StatusCode, key, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
errorResponse := new(ErrorResponse)
|
|
if e := json.Unmarshal(response, errorResponse); e != nil && errorResponse.ErrorCode <= 0 {
|
|
return nil, fmt.Errorf("%d %s", resp.StatusCode, b)
|
|
}
|
|
return nil, errorResponse
|
|
}
|
|
|
|
n := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(response, &n); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// list response may contain only one key
|
|
if len(n) > 1 || strings.HasPrefix(key, "list") {
|
|
return response, nil
|
|
}
|
|
|
|
if len(n) == 1 {
|
|
for k := range n {
|
|
// boolean response and asyncjob result may also contain
|
|
// only one key
|
|
if k == "success" || k == "jobid" {
|
|
return response, nil
|
|
}
|
|
return n[k], nil
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// asyncRequest perform an asynchronous job with a context
|
|
func (client *Client) asyncRequest(ctx context.Context, asyncCommand AsyncCommand) (interface{}, error) {
|
|
var err error
|
|
|
|
resp := asyncCommand.AsyncResponse()
|
|
client.AsyncRequestWithContext(
|
|
ctx,
|
|
asyncCommand,
|
|
func(j *AsyncJobResult, e error) bool {
|
|
if e != nil {
|
|
err = e
|
|
return false
|
|
}
|
|
if j.JobStatus != Pending {
|
|
if r := j.Result(resp); r != nil {
|
|
err = r
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
)
|
|
return resp, err
|
|
}
|
|
|
|
// SyncRequestWithContext performs a sync request with a context
|
|
func (client *Client) SyncRequestWithContext(ctx context.Context, command Command) (interface{}, error) {
|
|
body, err := client.request(ctx, command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := command.Response()
|
|
b, ok := response.(*BooleanResponse)
|
|
if ok {
|
|
m := make(map[string]interface{})
|
|
if errUnmarshal := json.Unmarshal(body, &m); errUnmarshal != nil {
|
|
return nil, errUnmarshal
|
|
}
|
|
|
|
b.DisplayText, _ = m["displaytext"].(string)
|
|
|
|
if success, okSuccess := m["success"].(string); okSuccess {
|
|
b.Success = success == "true"
|
|
}
|
|
|
|
if success, okSuccess := m["success"].(bool); okSuccess {
|
|
b.Success = success
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
if err := json.Unmarshal(body, response); err != nil {
|
|
errResponse := new(ErrorResponse)
|
|
if e := json.Unmarshal(body, errResponse); e == nil && errResponse.ErrorCode > 0 {
|
|
return errResponse, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// BooleanRequest performs the given boolean command
|
|
func (client *Client) BooleanRequest(command Command) error {
|
|
resp, err := client.Request(command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if b, ok := resp.(*BooleanResponse); ok {
|
|
return b.Error()
|
|
}
|
|
|
|
panic(fmt.Errorf("command %q is not a proper boolean response. %#v", client.APIName(command), resp))
|
|
}
|
|
|
|
// BooleanRequestWithContext performs the given boolean command
|
|
func (client *Client) BooleanRequestWithContext(ctx context.Context, command Command) error {
|
|
resp, err := client.RequestWithContext(ctx, command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if b, ok := resp.(*BooleanResponse); ok {
|
|
return b.Error()
|
|
}
|
|
|
|
panic(fmt.Errorf("command %q is not a proper boolean response. %#v", client.APIName(command), resp))
|
|
}
|
|
|
|
// Request performs the given command
|
|
func (client *Client) Request(command Command) (interface{}, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), client.Timeout)
|
|
defer cancel()
|
|
|
|
return client.RequestWithContext(ctx, command)
|
|
}
|
|
|
|
// RequestWithContext preforms a command with a context
|
|
func (client *Client) RequestWithContext(ctx context.Context, command Command) (interface{}, error) {
|
|
switch c := command.(type) {
|
|
case AsyncCommand:
|
|
return client.asyncRequest(ctx, c)
|
|
default:
|
|
return client.SyncRequestWithContext(ctx, command)
|
|
}
|
|
}
|
|
|
|
// SyncRequest performs the command as is
|
|
func (client *Client) SyncRequest(command Command) (interface{}, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), client.Timeout)
|
|
defer cancel()
|
|
|
|
return client.SyncRequestWithContext(ctx, command)
|
|
}
|
|
|
|
// AsyncRequest performs the given command
|
|
func (client *Client) AsyncRequest(asyncCommand AsyncCommand, callback WaitAsyncJobResultFunc) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), client.Timeout)
|
|
defer cancel()
|
|
|
|
client.AsyncRequestWithContext(ctx, asyncCommand, callback)
|
|
}
|
|
|
|
// AsyncRequestWithContext preforms a request with a context
|
|
func (client *Client) AsyncRequestWithContext(ctx context.Context, asyncCommand AsyncCommand, callback WaitAsyncJobResultFunc) {
|
|
result, err := client.SyncRequestWithContext(ctx, asyncCommand)
|
|
if err != nil {
|
|
if !callback(nil, err) {
|
|
return
|
|
}
|
|
}
|
|
|
|
jobResult, ok := result.(*AsyncJobResult)
|
|
if !ok {
|
|
callback(nil, fmt.Errorf("wrong type, AsyncJobResult was expected instead of %T", result))
|
|
}
|
|
|
|
// Successful response
|
|
if jobResult.JobID == nil || jobResult.JobStatus != Pending {
|
|
callback(jobResult, nil)
|
|
// without a JobID, the next requests will only fail
|
|
return
|
|
}
|
|
|
|
for iteration := 0; ; iteration++ {
|
|
time.Sleep(client.RetryStrategy(int64(iteration)))
|
|
|
|
req := &QueryAsyncJobResult{JobID: jobResult.JobID}
|
|
resp, err := client.SyncRequestWithContext(ctx, req)
|
|
if err != nil && !callback(nil, err) {
|
|
return
|
|
}
|
|
|
|
result, ok := resp.(*AsyncJobResult)
|
|
if !ok {
|
|
if !callback(nil, fmt.Errorf("wrong type. AsyncJobResult expected, got %T", resp)) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if !callback(result, nil) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Payload builds the HTTP request params from the given command
|
|
func (client *Client) Payload(command Command) (url.Values, error) {
|
|
params, err := prepareValues("", command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hookReq, ok := command.(onBeforeHook); ok {
|
|
if err := hookReq.onBeforeSend(params); err != nil {
|
|
return params, err
|
|
}
|
|
}
|
|
params.Set("apikey", client.APIKey)
|
|
params.Set("command", client.APIName(command))
|
|
params.Set("response", "json")
|
|
|
|
if params.Get("expires") == "" && client.Expiration >= 0 {
|
|
params.Set("signatureversion", "3")
|
|
params.Set("expires", time.Now().Add(client.Expiration).Local().Format("2006-01-02T15:04:05-0700"))
|
|
}
|
|
|
|
return params, nil
|
|
}
|
|
|
|
// Sign signs the HTTP request and returns the signature as as base64 encoding
|
|
func (client *Client) Sign(params url.Values) (string, error) {
|
|
query := encodeValues(params)
|
|
query = strings.ToLower(query)
|
|
mac := hmac.New(sha1.New, []byte(client.apiSecret))
|
|
_, err := mac.Write([]byte(query))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
return signature, nil
|
|
}
|
|
|
|
// request makes a Request while being close to the metal
|
|
func (client *Client) request(ctx context.Context, command Command) (json.RawMessage, error) {
|
|
params, err := client.Payload(command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
signature, err := client.Sign(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
params.Add("signature", signature)
|
|
|
|
method := "GET"
|
|
query := params.Encode()
|
|
url := fmt.Sprintf("%s?%s", client.Endpoint, query)
|
|
|
|
var body io.Reader
|
|
// respect Internet Explorer limit of 2048
|
|
if len(url) > 2048 {
|
|
url = client.Endpoint
|
|
method = "POST"
|
|
body = strings.NewReader(query)
|
|
}
|
|
|
|
request, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request = request.WithContext(ctx)
|
|
request.Header.Add("User-Agent", fmt.Sprintf("exoscale/egoscale (%v)", Version))
|
|
|
|
if method == "POST" {
|
|
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
request.Header.Add("Content-Length", strconv.Itoa(len(query)))
|
|
}
|
|
|
|
resp, err := client.HTTPClient.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close() // nolint: errcheck
|
|
|
|
contentType := resp.Header.Get("content-type")
|
|
|
|
if !strings.Contains(contentType, "application/json") {
|
|
return nil, fmt.Errorf(`body content-type response expected "application/json", got %q`, contentType)
|
|
}
|
|
|
|
text, err := client.parseResponse(resp, client.APIName(command))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return text, nil
|
|
}
|
|
|
|
func encodeValues(params url.Values) string {
|
|
// This code is borrowed from net/url/url.go
|
|
// The way it's encoded by net/url doesn't match
|
|
// how CloudStack works to determine the signature.
|
|
//
|
|
// CloudStack only encodes the values of the query parameters
|
|
// and furthermore doesn't use '+' for whitespaces. Therefore
|
|
// after encoding the values all '+' are replaced with '%20'.
|
|
if params == nil {
|
|
return ""
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
keys := make([]string, 0, len(params))
|
|
for k := range params {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
prefix := k + "="
|
|
for _, v := range params[k] {
|
|
if buf.Len() > 0 {
|
|
buf.WriteByte('&')
|
|
}
|
|
buf.WriteString(prefix)
|
|
buf.WriteString(csEncode(v))
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|