400 lines
11 KiB
Go
400 lines
11 KiB
Go
// Package cloudstack provides utilities for interacting with a CloudStack
|
|
// management server.
|
|
package cloudstack
|
|
|
|
/*
|
|
* See https://cloudstack.apache.org/api/apidocs-4.16/ for API docs
|
|
*/
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
|
|
)
|
|
|
|
// A CloudstackClient interacts with a CloudStack management server via the
|
|
// REST API.
|
|
type CloudstackClient struct {
|
|
cfg *config.Config
|
|
cachedZoneId string
|
|
cachedServiceOfferingId string
|
|
}
|
|
|
|
// New returns a CloudStack client with the given config values.
|
|
func New(cfg *config.Config) *CloudstackClient {
|
|
return &CloudstackClient{cfg: cfg}
|
|
}
|
|
|
|
// CloudStack uses java.net.URLEncoder when calculating signatures:
|
|
// https://github.com/apache/cloudstack/blob/main/server/src/main/java/com/cloud/api/ApiServer.java
|
|
// Unfortunately java.net.URLEncoder transforms "~" into "%7E".
|
|
// This is a bug: https://bugs.openjdk.org/browse/JDK-8204530
|
|
// So, we need to match Java's behaviour so that the server does not
|
|
// reject our signature.
|
|
// (I noticed this problem when passing a `url` parameter to registerTemplate
|
|
// which contained a tilde.)
|
|
// TODO: file a GitHub issue in the cloudmonkey repo
|
|
func urlQueryEscape(value string) string {
|
|
return strings.ReplaceAll(url.QueryEscape(value), "~", "%7E")
|
|
}
|
|
|
|
// Adapted from https://github.com/apache/cloudstack-cloudmonkey/blob/main/cmd/network.go
|
|
func encodeRequestParams(params map[string]string) string {
|
|
if params == nil {
|
|
return ""
|
|
}
|
|
|
|
keys := make([]string, 0, len(params))
|
|
for key := range params {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var buf bytes.Buffer
|
|
for _, key := range keys {
|
|
value := params[key]
|
|
if buf.Len() > 0 {
|
|
buf.WriteByte('&')
|
|
}
|
|
buf.WriteString(key)
|
|
buf.WriteString("=")
|
|
buf.WriteString(urlQueryEscape(value))
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// See http://docs.cloudstack.apache.org/en/4.16.0.0/developersguide/dev.html#how-to-sign-an-api-call-with-python
|
|
func (client *CloudstackClient) createURL(params map[string]string) string {
|
|
cfg := client.cfg
|
|
params["apiKey"] = cfg.CloudstackApiKey
|
|
params["response"] = "json"
|
|
|
|
// adapted from https://github.com/apache/cloudstack-cloudmonkey/blob/main/cmd/network.go
|
|
encodedParams := encodeRequestParams(params)
|
|
mac := hmac.New(sha1.New, []byte(cfg.CloudstackSecretKey))
|
|
mac.Write([]byte(strings.Replace(strings.ToLower(encodedParams), "+", "%20", -1)))
|
|
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
encodedParams += fmt.Sprintf("&signature=%s", urlQueryEscape(signature))
|
|
|
|
return fmt.Sprintf("%s?%s", cfg.CloudstackApiBaseUrl, encodedParams)
|
|
}
|
|
|
|
func getDeserializedResponse(url string, unmarshaledValue interface{}) {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
buf, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err = json.Unmarshal(buf, unmarshaledValue); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
type ErrorInfo struct {
|
|
ErrorCode int `json:"errorcode"`
|
|
ErrorText string `json:"errortext"`
|
|
}
|
|
|
|
func checkErrorInfo(data *ErrorInfo) {
|
|
if data.ErrorCode != 0 {
|
|
panic(fmt.Sprintf("Error %d: %s", data.ErrorCode, data.ErrorText))
|
|
}
|
|
}
|
|
|
|
type ListDomainsResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
Domain []struct {
|
|
Name string `json:"name"`
|
|
Id string `json:"id"`
|
|
} `json:"domain"`
|
|
}
|
|
|
|
// GetDomainId returns the ID of the domain with the given name.
|
|
func (client *CloudstackClient) GetDomainId(domainName string) string {
|
|
url := client.createURL(map[string]string{
|
|
"command": "listDomains",
|
|
"details": "min",
|
|
"name": domainName,
|
|
})
|
|
responseWrapper := struct {
|
|
Response ListDomainsResponse `json:"listdomainsresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
if data.Count != 1 {
|
|
panic("there should be one domain found")
|
|
}
|
|
return data.Domain[0].Id
|
|
}
|
|
|
|
type Template struct {
|
|
Created string `json:"created"`
|
|
Domain string `json:"domain"`
|
|
DomainId string `json:"domainid"`
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
IsReady bool `json:"isready"`
|
|
IsFeatured bool `json:"isfeatured"`
|
|
IsPublic bool `json:"ispublic"`
|
|
Status string `json:"status"`
|
|
DownloadDetails []struct {
|
|
DownloadPercent string `json:"downloadPercent"`
|
|
DownloadState string `json:"downloadState"`
|
|
} `json:"downloaddetails"`
|
|
}
|
|
|
|
type ListTemplatesResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
Template []Template `json:"template"`
|
|
}
|
|
|
|
func (client *CloudstackClient) listTemplates(templateID string) []Template {
|
|
params := map[string]string{
|
|
"command": "listTemplates",
|
|
"details": "min",
|
|
"templatefilter": "self",
|
|
}
|
|
if templateID != "" {
|
|
params["id"] = templateID
|
|
}
|
|
url := client.createURL(params)
|
|
responseWrapper := struct {
|
|
Response ListTemplatesResponse `json:"listtemplatesresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
return data.Template
|
|
}
|
|
|
|
func (client *CloudstackClient) ListTemplates() []Template {
|
|
return client.listTemplates("")
|
|
}
|
|
|
|
func (client *CloudstackClient) GetTemplate(templateID string) (*Template, error) {
|
|
templates := client.listTemplates(templateID)
|
|
if len(templates) == 0 {
|
|
return nil, errors.New("Template not found")
|
|
}
|
|
return &templates[0], nil
|
|
}
|
|
|
|
type ListZonesResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
Zone []struct {
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"zone"`
|
|
}
|
|
|
|
func (client *CloudstackClient) getZoneId() (string, error) {
|
|
if client.cachedZoneId != "" {
|
|
return client.cachedZoneId, nil
|
|
}
|
|
zoneName := client.cfg.CloudstackZoneName
|
|
url := client.createURL(map[string]string{
|
|
"command": "listZones",
|
|
"name": zoneName,
|
|
})
|
|
responseWrapper := struct {
|
|
Response ListZonesResponse `json:"listzonesresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
if data.Count != 1 {
|
|
return "", errors.New(fmt.Sprintf("Expected 1 zone for '%s'; got %d", zoneName, data.Count))
|
|
}
|
|
zoneId := data.Zone[0].Id
|
|
client.cachedZoneId = zoneId
|
|
return zoneId, nil
|
|
}
|
|
|
|
type ListServiceOfferingsResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
ServiceOffering []struct {
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"serviceoffering"`
|
|
}
|
|
|
|
func (client *CloudstackClient) getServiceOfferingId() (string, error) {
|
|
if client.cachedServiceOfferingId != "" {
|
|
return client.cachedServiceOfferingId, nil
|
|
}
|
|
serviceOfferingName := client.cfg.CloudstackServiceOfferingName
|
|
url := client.createURL(map[string]string{
|
|
"command": "listServiceOfferings",
|
|
"name": serviceOfferingName,
|
|
})
|
|
responseWrapper := struct {
|
|
Response ListServiceOfferingsResponse `json:"listserviceofferingsresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
if data.Count != 1 {
|
|
return "", errors.New(fmt.Sprintf("Expected 1 service offering for '%s'; got %d", serviceOfferingName, data.Count))
|
|
}
|
|
serviceOfferingId := data.ServiceOffering[0].Id
|
|
client.cachedServiceOfferingId = serviceOfferingId
|
|
return serviceOfferingId, nil
|
|
}
|
|
|
|
// Note: if an OS type you need isn't available in CloudStack, you can
|
|
// create a new one with addGuestOs
|
|
|
|
type OsType struct {
|
|
Id string `json:"id"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type ListOsTypesResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
OsType []OsType `json:"ostype"`
|
|
}
|
|
|
|
func (client *CloudstackClient) GetOsTypeId(osDescription string) (string, error) {
|
|
url := client.createURL(map[string]string{
|
|
"command": "listOsTypes",
|
|
"description": osDescription,
|
|
})
|
|
responseWrapper := struct {
|
|
Response ListOsTypesResponse `json:"listostypesresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
if data.Count != 1 {
|
|
return "", errors.New(fmt.Sprintf("Expected 1 OS type for '%s'; got %d", osDescription, data.Count))
|
|
}
|
|
return data.OsType[0].Id, nil
|
|
}
|
|
|
|
type RegisterTemplateResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
Template []Template `json:"template"`
|
|
}
|
|
|
|
func (client *CloudstackClient) RegisterTemplate(name string, downloadUrl string, osTypeId string) (*Template, error) {
|
|
zoneId, err := client.getZoneId()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
url := client.createURL(map[string]string{
|
|
"command": "registerTemplate",
|
|
"displaytext": name,
|
|
"format": "QCOW2",
|
|
"hypervisor": "KVM",
|
|
"name": name,
|
|
"url": downloadUrl,
|
|
"isextractable": "true",
|
|
"isfeatured": "false",
|
|
"ispublic": "false",
|
|
"ostypeid": osTypeId,
|
|
"requireshvm": "true",
|
|
"zoneid": zoneId,
|
|
})
|
|
responseWrapper := struct {
|
|
Response RegisterTemplateResponse `json:"registertemplateresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
return &data.Template[0], nil
|
|
}
|
|
|
|
type VirtualMachine struct {
|
|
Id string `json:"id"`
|
|
DisplayName string `json:"displayname"`
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
Nic []struct {
|
|
Id string `json:"id"`
|
|
IpAddress string `json:"ipaddress"`
|
|
IsDefault bool `json:"isdefault"`
|
|
} `json:"nic"`
|
|
}
|
|
|
|
type DeployVirtualMachineResponse struct {
|
|
ErrorInfo
|
|
Id string `json:"id"`
|
|
JobId string `json:"jobid"`
|
|
}
|
|
|
|
func (client *CloudstackClient) DeployVirtualMachine(
|
|
name string, templateID string,
|
|
) (vmID string, err error) {
|
|
serviceOfferingId, err := client.getServiceOfferingId()
|
|
if err != nil {
|
|
return
|
|
}
|
|
zoneId, err := client.getZoneId()
|
|
if err != nil {
|
|
return
|
|
}
|
|
url := client.createURL(map[string]string{
|
|
"command": "deployVirtualMachine",
|
|
"serviceofferingid": serviceOfferingId,
|
|
"templateid": templateID,
|
|
"zoneid": zoneId,
|
|
"name": name,
|
|
"displayname": name,
|
|
"keypair": client.cfg.CloudstackKeypairName,
|
|
})
|
|
responseWrapper := struct {
|
|
Response DeployVirtualMachineResponse `json:"deployvirtualmachineresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
return data.Id, nil
|
|
}
|
|
|
|
type ListVirtualMachinesResponse struct {
|
|
ErrorInfo
|
|
Count int `json:"count"`
|
|
VirtualMachine []VirtualMachine `json:"virtualmachine"`
|
|
}
|
|
|
|
func (client *CloudstackClient) GetVirtualMachine(vmID string) (virtualMachine *VirtualMachine, err error) {
|
|
url := client.createURL(map[string]string{
|
|
"command": "listVirtualMachines",
|
|
"id": vmID,
|
|
})
|
|
responseWrapper := struct {
|
|
Response ListVirtualMachinesResponse `json:"listvirtualmachinesresponse"`
|
|
}{}
|
|
getDeserializedResponse(url, &responseWrapper)
|
|
data := &responseWrapper.Response
|
|
checkErrorInfo(&data.ErrorInfo)
|
|
virtualMachine = &data.VirtualMachine[0]
|
|
return
|
|
}
|