Skip to content

Commit

Permalink
Add liquid nove report usage endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Varsius committed Nov 26, 2024
1 parent aa90648 commit 1a17672
Show file tree
Hide file tree
Showing 4 changed files with 434 additions and 10 deletions.
99 changes: 90 additions & 9 deletions internal/liquids/nova/liquid.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ package nova
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"

"github.com/gophercloud/gophercloud/v2"
Expand All @@ -31,7 +35,16 @@ import (
)

type Logic struct {
NovaV2 *gophercloud.ServiceClient `yaml:"-"`
// configuration
WithSubresources bool `json:"with_subresources"`
// connections
NovaV2 *gophercloud.ServiceClient `json:"-"`
OSTypeProber *OSTypeProber `json:"-"`
ServerGroupProber *ServerGroupProber `json:"-"`
// computed state
ignoredFlavorNames []string `json:"-"`
hasPooledResource map[string]map[liquid.ResourceName]bool `json:"-"`
hwVersionResources []liquid.ResourceName `json:"-"`
}

// Init implements the liquidapi.Logic interface.
Expand All @@ -41,8 +54,66 @@ func (l *Logic) Init(ctx context.Context, provider *gophercloud.ProviderClient,
return err
}
l.NovaV2.Microversion = "2.61" // to include extra specs in flavors.ListDetail()
cinderV3, err := openstack.NewBlockStorageV3(provider, eo)
if err != nil {
return err
}
glanceV2, err := openstack.NewImageV2(provider, eo)
if err != nil {
return err
}
l.OSTypeProber = NewOSTypeProber(l.NovaV2, cinderV3, glanceV2)
l.ServerGroupProber = NewServerGroupProber(l.NovaV2)

return nil
// SAPCC extension: Nova may report quotas with this name pattern in its quota sets and quota class sets.
// If it does, instances with flavors that have the extra spec `quota:hw_version` set to the first match
// group of this regexp will count towards those quotas instead of the regular `cores/instances/ram` quotas.
//
// This initialization enumerates which such pooled resources exist.
defaultQuotaClassSet, err := getDefaultQuotaClassSet(ctx, l.NovaV2)
if err != nil {
return fmt.Errorf("while enumerating default quotas: %w", err)
}
l.hasPooledResource = make(map[string]map[liquid.ResourceName]bool)
hwVersionResourceRx := regexp.MustCompile(`^hw_version_(\S+)_(cores|instances|ram)$`)
for resourceName := range defaultQuotaClassSet {
match := hwVersionResourceRx.FindStringSubmatch(resourceName)
if match == nil {
continue
}
hwVersion, baseResourceName := match[1], liquid.ResourceName(match[2])

l.hwVersionResources = append(l.hwVersionResources, liquid.ResourceName(resourceName))

if l.hasPooledResource[hwVersion] == nil {
l.hasPooledResource[hwVersion] = make(map[liquid.ResourceName]bool)
}
l.hasPooledResource[hwVersion][baseResourceName] = true
}

return FlavorSelection{}.ForeachFlavor(ctx, l.NovaV2, func(f flavors.Flavor) error {
if IsIronicFlavor(f) {
l.ignoredFlavorNames = append(l.ignoredFlavorNames, f.Name)
}
return nil
})
}

func getDefaultQuotaClassSet(ctx context.Context, novaV2 *gophercloud.ServiceClient) (map[string]any, error) {
url := novaV2.ServiceURL("os-quota-class-sets", "default")
var result gophercloud.Result
_, err := novaV2.Get(ctx, url, &result.Body, nil) //nolint:bodyclose
if err != nil {
return nil, err
}

var body struct {
//NOTE: cannot use map[string]int64 here because this object contains the
// field "id": "default" (curse you, untyped JSON)
QuotaClassSet map[string]any `json:"quota_class_set"`
}
err = result.ExtractInto(&body)
return body.QuotaClassSet, err
}

// BuildServiceInfo implements the liquidapi.Logic interface.
Expand Down Expand Up @@ -77,8 +148,8 @@ func (l *Logic) BuildServiceInfo(ctx context.Context) (liquid.ServiceInfo, error
if IsIronicFlavor(f) {
return nil
}
if f.ExtraSpecs["quota:separate"] == "true" {
resources[liquid.ResourceName("instances_"+f.Name)] = liquid.ResourceInfo{
if IsSplitFlavor(f) {
resources[ResourceNameForFlavor(f.Name)] = liquid.ResourceInfo{
Unit: liquid.UnitNone,
HasCapacity: true,
HasQuota: true,
Expand All @@ -90,6 +161,17 @@ func (l *Logic) BuildServiceInfo(ctx context.Context) (liquid.ServiceInfo, error
return liquid.ServiceInfo{}, err
}

for _, resourceName := range l.hwVersionResources {
unit := liquid.UnitNone
if strings.HasSuffix(string(resourceName), "ram") {
unit = liquid.UnitMebibytes
}
resources[resourceName] = liquid.ResourceInfo{
Unit: unit,
HasQuota: true,
}
}

return liquid.ServiceInfo{
Version: time.Now().Unix(),
Resources: resources,
Expand All @@ -101,12 +183,11 @@ func (l *Logic) ScanCapacity(ctx context.Context, req liquid.ServiceCapacityRequ
return liquid.ServiceCapacityReport{}, errors.New("TODO")
}

// ScanUsage implements the liquidapi.Logic interface.
func (l *Logic) ScanUsage(ctx context.Context, projectUUID string, req liquid.ServiceUsageRequest, serviceInfo liquid.ServiceInfo) (liquid.ServiceUsageReport, error) {
return liquid.ServiceUsageReport{}, errors.New("TODO")
}

// SetQuota implements the liquidapi.Logic interface.
func (l *Logic) SetQuota(ctx context.Context, projectUUID string, req liquid.ServiceQuotaRequest, serviceInfo liquid.ServiceInfo) error {
return errors.New("TODO")
}

func (l *Logic) IgnoreFlavor(flavorName string) bool {
return slices.Contains(l.ignoredFlavorNames, flavorName)
}
135 changes: 135 additions & 0 deletions internal/liquids/nova/subresources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*******************************************************************************
*
* Copyright 2024 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You should have received a copy of the License along with this
* program. If not, you may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*******************************************************************************/

package nova

import (
"context"
"encoding/json"
"fmt"
"strconv"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/sapcc/go-api-declarations/liquid"
)

type SubresourceAttributes struct {
// base metadata
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
Tags []string `json:"tags"`
// placement information
AZ liquid.AvailabilityZone `json:"availability_zone"`
// information from flavor
FlavorName string `json:"flavor"`
VCPUs uint64 `json:"vcpu"`
MemoryMiB uint64 `json:"ram"`
DiskGiB uint64 `json:"disk"`
VideoMemoryMiB *uint64 `json:"video_ram,omitempty"`
HWVersion string `json:"-"` // this is only used for sorting the subresource into the right resource
// information from image
OSType string `json:"os_type"`
}

func (l *Logic) buildInstanceSubresource(ctx context.Context, instance servers.Server) (res liquid.Subresource, err error) {
// copy base attributes
res.ID = instance.ID
res.Name = instance.Name

attrs := SubresourceAttributes{
Status: instance.Status,
AZ: liquid.AvailabilityZone(instance.AvailabilityZone),
Metadata: instance.Metadata,
}
if instance.Tags != nil {
attrs.Tags = *instance.Tags
}

// flavor data is given to us as a map[string]any, but we want something more structured
buf, err := json.Marshal(instance.Flavor)
if err != nil {
return res, fmt.Errorf("could not reserialize flavor data for instance %s: %w", instance.ID, err)
}
var flavorInfo FlavorInfo
err = json.Unmarshal(buf, &flavorInfo)
if err != nil {
return res, fmt.Errorf("could not parse flavor data for instance %s: %w", instance.ID, err)
}

// copy attributes from flavor data
attrs.FlavorName = flavorInfo.OriginalName
attrs.VCPUs = flavorInfo.VCPUs
attrs.MemoryMiB = flavorInfo.MemoryMiB
attrs.DiskGiB = flavorInfo.DiskGiB
if videoRAMStr, exists := flavorInfo.ExtraSpecs["hw_video:ram_max_mb"]; exists {
videoRAMVal, err := strconv.ParseUint(videoRAMStr, 10, 64)
if err == nil {
attrs.VideoMemoryMiB = &videoRAMVal
}
}
attrs.HWVersion = flavorInfo.ExtraSpecs["quota:hw_version"]

// calculate classifications based on image data
attrs.OSType = l.OSTypeProber.Get(ctx, instance)

buf, err = json.Marshal(attrs)
if err != nil {
return res, fmt.Errorf("while serializing Subresource Attributes: %w", err)
}
res.Attributes = json.RawMessage(buf)
return res, nil
}

func (l *Logic) buildInstanceSubresources(ctx context.Context, projectUUID string) ([]liquid.Subresource, error) {
opts := novaServerListOpts{
AllTenants: true,
TenantID: projectUUID,
}

var result []liquid.Subresource
err := servers.List(l.NovaV2, opts).EachPage(ctx, func(ctx context.Context, page pagination.Page) (bool, error) {
var instances []servers.Server
err := servers.ExtractServersInto(page, &instances)
if err != nil {
return false, err
}

for _, instance := range instances {
res, err := l.buildInstanceSubresource(ctx, instance)
if err != nil {
return false, err
}
result = append(result, res)
}
return true, nil
})
return result, err
}

type novaServerListOpts struct {
AllTenants bool `q:"all_tenants"`
TenantID string `q:"tenant_id"`
}

func (opts novaServerListOpts) ToServerListQuery() (string, error) {
q, err := gophercloud.BuildQueryString(opts)
return q.String(), err
}
Loading

0 comments on commit 1a17672

Please sign in to comment.