cloudbuild/pkg/cloudstack/cloudstack.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
}