// 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 }