Skip to content

Commit

Permalink
WIP: Add liquid nova report usage endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Varsius committed Nov 25, 2024
1 parent 05df297 commit ce060c8
Show file tree
Hide file tree
Showing 4 changed files with 477 additions and 8 deletions.
95 changes: 87 additions & 8 deletions internal/liquids/nova/liquid.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ package nova
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"time"

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

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

// Init implements the liquidapi.Logic interface.
Expand All @@ -41,8 +53,64 @@ 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)

// 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])

return nil
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 @@ -74,7 +142,7 @@ func (l *Logic) BuildServiceInfo(ctx context.Context) (liquid.ServiceInfo, error
}

err := FlavorSelection{}.ForeachFlavor(ctx, l.NovaV2, func(f flavors.Flavor) error {
if f.ExtraSpecs["capabilities:hypervisor_type"] == "ironic" {
if IsIronicFlavor(f) {
return nil
}
if f.ExtraSpecs["quota:separate"] == "true" {
Expand All @@ -93,6 +161,18 @@ func (l *Logic) BuildServiceInfo(ctx context.Context) (liquid.ServiceInfo, error
return liquid.ServiceInfo{
Version: time.Now().Unix(),
Resources: resources,
UsageMetricFamilies: map[liquid.MetricName]liquid.MetricFamilyInfo{
"liquid_nova_instance_counts_by_hypervisor": {
Type: liquid.MetricTypeCounter, // TODO: Counter or Gauge?
Help: "Total number of instances, grouped by hypervisor type.", // TODO: Is this correct? Liquid nova only has one hypervisor type if I understand correctly
LabelKeys: []string{"hypervisor_type"},
},
"liquid_nova_instance_counts_bvy_hypervisor_and_az": {
Type: liquid.MetricTypeCounter, // TODO: Same as above
Help: "Total number of instances in each availability zone, grouped by hypervisor type.",
LabelKeys: []string{"hypervisor_type", "availability_zone"},
},
},
}, nil
}

Expand All @@ -101,12 +181,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)
}
139 changes: 139 additions & 0 deletions internal/liquids/nova/subresources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*******************************************************************************
*
* 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"`
HypervisorType string `json:"hypervisor,omitempty"`
// 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 flavor data (NOTE: deprecated, only here for backwards compatibility)
attrs.HypervisorType = l.HypervisorType

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

Please sign in to comment.