// Package rundeck provides a client for interacting with a Rundeck instance // via its HTTP API. // // Instantiate a Client with the NewClient function to get started. // // At present this package uses Rundeck API version 13. package rundeck import ( "bytes" "crypto/tls" "encoding/xml" "fmt" "io/ioutil" "net/http" "net/url" "mime/multipart" "strings" ) // ClientConfig is used with NewClient to specify initialization settings. type ClientConfig struct { // The base URL of the Rundeck instance. BaseURL string // The API auth token generated from user settings in the Rundeck UI. AuthToken string // Don't fail if the server uses SSL with an un-verifiable certificate. // This is not recommended except during development/debugging. AllowUnverifiedSSL bool } // Client is a Rundeck API client interface. type Client struct { httpClient *http.Client apiURL *url.URL authToken string } type request struct { Method string PathParts []string QueryArgs map[string]string Headers map[string]string BodyBytes []byte } // NewClient returns a configured Rundeck client. func NewClient(config *ClientConfig) (*Client, error) { t := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: config.AllowUnverifiedSSL, }, } httpClient := &http.Client{ Transport: t, } apiPath, _ := url.Parse("api/13/") baseURL, err := url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("invalid base URL: %s", err.Error()) } apiURL := baseURL.ResolveReference(apiPath) return &Client{ httpClient: httpClient, apiURL: apiURL, authToken: config.AuthToken, }, nil } func (c *Client) rawRequest(req *request) ([]byte, error) { res, err := c.httpClient.Do(req.MakeHTTPRequest(c)) if err != nil { return nil, err } resBodyBytes, err := ioutil.ReadAll(res.Body) if err != nil { return nil, err } if res.StatusCode == 404 { return nil, &NotFoundError{} } if res.StatusCode < 200 || res.StatusCode >= 300 { if strings.HasPrefix(res.Header.Get("Content-Type"), "text/xml") { var richErr Error err = xml.Unmarshal(resBodyBytes, &richErr) if err != nil { return nil, fmt.Errorf("HTTP Error %i with error decoding XML body: %s", res.StatusCode, err.Error()) } return nil, richErr } return nil, fmt.Errorf("HTTP Error %i", res.StatusCode) } if res.StatusCode != 200 && res.StatusCode != 201 { return nil, nil } return resBodyBytes, nil } func (c *Client) xmlRequest(method string, pathParts []string, query map[string]string, reqBody interface{}, result interface{}) error { var err error var reqBodyBytes []byte reqBodyBytes = nil if reqBody != nil { reqBodyBytes, err = xml.Marshal(reqBody) if err != nil { return err } } req := &request{ Method: method, PathParts: pathParts, QueryArgs: query, BodyBytes: reqBodyBytes, Headers: map[string]string{ "Accept": "application/xml", }, } if reqBody != nil { req.Headers["Content-Type"] = "application/xml" } resBodyBytes, err := c.rawRequest(req) if err != nil { return err } if result != nil { if resBodyBytes == nil { return fmt.Errorf("server did not return an XML payload") } err = xml.Unmarshal(resBodyBytes, result) if err != nil { return fmt.Errorf("error decoding response XML payload: %s", err.Error()) } } return nil } func (c *Client) get(pathParts []string, query map[string]string, result interface{}) error { return c.xmlRequest("GET", pathParts, query, nil, result) } func (c *Client) rawGet(pathParts []string, query map[string]string, accept string) (string, error) { req := &request{ Method: "GET", PathParts: pathParts, QueryArgs: query, Headers: map[string]string{ "Accept": accept, }, } resBodyBytes, err := c.rawRequest(req) if err != nil { return "", err } return string(resBodyBytes), nil } func (c *Client) post(pathParts []string, query map[string]string, reqBody interface{}, result interface{}) error { return c.xmlRequest("POST", pathParts, query, reqBody, result) } func (c *Client) put(pathParts []string, reqBody interface{}, result interface{}) error { return c.xmlRequest("PUT", pathParts, nil, reqBody, result) } func (c *Client) delete(pathParts []string) error { return c.xmlRequest("DELETE", pathParts, nil, nil, nil) } func (c *Client) postXMLBatch(pathParts []string, args map[string]string, xmlBatch interface{}, result interface{}) error { req := &http.Request{ Method: "POST", Header: http.Header{}, } req.Header.Add("User-Agent", "Go-Rundeck-API") req.Header.Add("X-Rundeck-Auth-Token", c.authToken) urlPath := &url.URL{ Path: strings.Join(pathParts, "/"), } reqURL := c.apiURL.ResolveReference(urlPath) req.URL = reqURL buf := bytes.Buffer{} writer := multipart.NewWriter(&buf) for k, v := range args { err := writer.WriteField(k, v) if err != nil { return err } } partWriter, err := writer.CreateFormFile("xmlBatch", "batch.xml") if err != nil { return err } reqBodyBytes, err := xml.Marshal(xmlBatch) if err != nil { return err } _, err = partWriter.Write(reqBodyBytes) if err != nil { return err } writer.Close() reqBodyReader := bytes.NewReader(buf.Bytes()) req.Body = ioutil.NopCloser(reqBodyReader) req.ContentLength = int64(buf.Len()) req.Header.Add("Content-Type", writer.FormDataContentType()) res, err := c.httpClient.Do(req) if err != nil { return err } resBodyBytes, err := ioutil.ReadAll(res.Body) if err != nil { return err } if res.StatusCode < 200 || res.StatusCode >= 300 { if strings.HasPrefix(res.Header.Get("Content-Type"), "text/xml") { var richErr Error err = xml.Unmarshal(resBodyBytes, &richErr) if err != nil { return fmt.Errorf("HTTP Error %i with error decoding XML body: %s", res.StatusCode, err.Error()) } return richErr } return fmt.Errorf("HTTP Error %i", res.StatusCode) } if result != nil { if res.StatusCode != 200 && res.StatusCode != 201 { return fmt.Errorf("server did not return an XML payload") } err = xml.Unmarshal(resBodyBytes, result) if err != nil { return fmt.Errorf("error decoding response XML payload: %s", err.Error()) } } return nil } func (r *request) MakeHTTPRequest(client *Client) *http.Request { req := &http.Request{ Method: r.Method, Header: http.Header{}, } // Automatic/mandatory HTTP headers first req.Header.Add("User-Agent", "Go-Rundeck-API") req.Header.Add("X-Rundeck-Auth-Token", client.authToken) for k, v := range r.Headers { req.Header.Add(k, v) } urlPath := &url.URL{ Path: strings.Join(r.PathParts, "/"), } reqURL := client.apiURL.ResolveReference(urlPath) req.URL = reqURL if len(r.QueryArgs) > 0 { urlQuery := url.Values{} for k, v := range r.QueryArgs { urlQuery.Add(k, v) } reqURL.RawQuery = urlQuery.Encode() } if r.BodyBytes != nil { req.Body = ioutil.NopCloser(bytes.NewReader(r.BodyBytes)) req.ContentLength = int64(len(r.BodyBytes)) } return req }